Initial import

This commit is contained in:
how2ice
2026-04-21 03:33:23 +09:00
commit 9e4b70f1f1
495 changed files with 94680 additions and 0 deletions

View File

@@ -0,0 +1,403 @@
import { useCallback } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
type PendingChatRequest = {
sessionId: string;
requestId: string;
text: string;
mode: 'queue' | 'direct';
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};
type PendingContextConfirm = {
mode: 'queue' | 'direct';
text: string;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
includedContextCount: number;
omittedContextCount: number;
};
type SelectedChatType = {
id: string;
name: string;
description: string;
isTemplate: boolean;
} | null;
type RecentContextSummary = {
includedCount: number;
omittedCount: number;
};
type UseConversationComposerControllerOptions = {
activeSessionId: string;
appConfigChat: {
maxContextMessages: number;
maxContextChars: number;
};
draft: string;
composerAttachments: ChatComposerAttachment[];
isComposerAttachmentUploading: boolean;
selectedChatType: SelectedChatType;
socketRef: { current: WebSocket | null };
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
messagesRef: { current: ChatMessage[] };
pendingRequestsRef: { current: PendingChatRequest[] };
shouldStickToBottomRef: { current: boolean };
setDraft: (value: string) => void;
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
setIsComposerAttachmentUploading: (value: boolean) => void;
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
setActiveSystemStatus: (value: string | null) => void;
setIsSystemStatusPending: (value: boolean) => void;
setShowScrollToBottom: (value: boolean) => void;
setPendingContextConfirm: (value: PendingContextConfirm | null) => void;
setStoredChatSessionLastTypeId: (sessionId: string, chatTypeId: string) => void;
upsertRequestItem: (request: ChatConversationRequest) => void;
syncConversationPreviewForRequest: (sessionId: string, text: string, requestedAt?: string) => void;
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
createLocalMessage: (text: string) => ChatMessage;
createChatMessage: (author: 'user' | 'codex' | 'system', text: string, requestId?: string | null) => ChatMessage;
createActivityLogPlaceholder: (requestId: string, lines: string[]) => ChatMessage | null;
buildOutgoingMessageText: (text: string, attachments: ChatComposerAttachment[]) => string;
summarizeRecentContext: (messages: ChatMessage[], maxMessages: number, maxChars: number) => RecentContextSummary;
mergeComposerAttachments: (
previous: ChatComposerAttachment[],
next: ChatComposerAttachment[],
) => ChatComposerAttachment[];
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
scrollViewportToBottom: () => void;
};
export function useConversationComposerController({
activeSessionId,
appConfigChat,
draft,
composerAttachments,
isComposerAttachmentUploading,
selectedChatType,
socketRef,
composerRef,
messagesRef,
pendingRequestsRef,
shouldStickToBottomRef,
setDraft,
setComposerAttachments,
setIsComposerAttachmentUploading,
setMessages,
setActiveSystemStatus,
setIsSystemStatusPending,
setShowScrollToBottom,
setPendingContextConfirm,
setStoredChatSessionLastTypeId,
upsertRequestItem,
syncConversationPreviewForRequest,
updatePendingMessageStatus,
createLocalMessage,
createChatMessage,
createActivityLogPlaceholder,
buildOutgoingMessageText,
summarizeRecentContext,
mergeComposerAttachments,
sendChatRequest,
scrollViewportToBottom,
}: UseConversationComposerControllerOptions) {
const handleComposerFilesPicked = useCallback(
async (files: File[]) => {
if (files.length === 0 || isComposerAttachmentUploading) {
return;
}
setIsComposerAttachmentUploading(true);
const uploadResults = await Promise.allSettled(
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedFileNames: string[] = [];
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
}
failedFileNames.push(files[index]?.name || `파일 ${index + 1}`);
});
if (uploadedItems.length > 0) {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
}
if (failedFileNames.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(`파일 업로드에 실패했습니다: ${failedFileNames.join(', ')}`),
]);
}
setIsComposerAttachmentUploading(false);
},
[
activeSessionId,
createLocalMessage,
isComposerAttachmentUploading,
mergeComposerAttachments,
setComposerAttachments,
setIsComposerAttachmentUploading,
setMessages,
setShowScrollToBottom,
shouldStickToBottomRef,
],
);
const focusComposerAfterSend = useCallback(() => {
window.setTimeout(() => {
composerRef.current?.focus({ cursor: 'end' });
scrollViewportToBottom();
}, 0);
}, [composerRef, scrollViewportToBottom]);
const executeSendMessage = useCallback(
(request: PendingContextConfirm) => {
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, chatTypeIsTemplate } = request;
const requestId = `client-${Date.now().toString(36)}`;
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
requestId,
text,
mode,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeIsTemplate,
retryCount: 0,
failed: false,
};
setStoredChatSessionLastTypeId(activeSessionId, chatTypeId);
if (mode === 'queue') {
const queuedAt = new Date().toISOString();
const optimisticUserMessage: ChatMessage = {
...createChatMessage('user', text, requestId),
deliveryStatus: null,
retryCount: 0,
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 요청을 접수했습니다.',
'# 진행: 대기열 등록을 준비하고 있습니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
status: 'queued',
statusMessage: '대기열 등록',
userMessageId: optimisticUserMessage.id,
userText: text,
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: false,
createdAt: queuedAt,
updatedAt: queuedAt,
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(activeSessionId, text, queuedAt);
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
setActiveSystemStatus('대기열 등록 중...');
setIsSystemStatusPending(true);
} else {
const optimisticUserMessage: ChatMessage = {
...createChatMessage('user', text, requestId),
deliveryStatus: null,
retryCount: 0,
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 즉시 요청을 접수했습니다.',
'# 진행: 즉시 실행 대기 중입니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
status: 'accepted',
statusMessage: '요청을 접수했습니다.',
userMessageId: optimisticUserMessage.id,
userText: text,
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
answeredAt: null,
terminalAt: null,
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
setActiveSystemStatus('즉시 응답 준비 중...');
setIsSystemStatusPending(true);
}
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN) {
setActiveSystemStatus('전송 재시도 중...');
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
outgoingRequest,
];
if (mode === 'direct') {
updatePendingMessageStatus(requestId, 'retrying', 0);
}
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
return;
}
try {
sendChatRequest(socketRef.current, outgoingRequest);
} catch {
setActiveSystemStatus('전송 재시도 중...');
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
outgoingRequest,
];
if (mode === 'direct') {
updatePendingMessageStatus(requestId, 'retrying', 0);
}
}
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
},
[
activeSessionId,
createActivityLogPlaceholder,
createChatMessage,
focusComposerAfterSend,
pendingRequestsRef,
sendChatRequest,
setActiveSystemStatus,
setComposerAttachments,
setDraft,
setIsSystemStatusPending,
setMessages,
setShowScrollToBottom,
setStoredChatSessionLastTypeId,
shouldStickToBottomRef,
socketRef,
syncConversationPreviewForRequest,
updatePendingMessageStatus,
upsertRequestItem,
],
);
const sendMessage = useCallback(
(mode: 'queue' | 'direct') => {
if (isComposerAttachmentUploading) {
return;
}
const trimmed = buildOutgoingMessageText(draft, composerAttachments).trim();
if (!trimmed) {
return;
}
if (!selectedChatType) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
]);
return;
}
if (!selectedChatType.isTemplate) {
const recentContext = summarizeRecentContext(
messagesRef.current,
appConfigChat.maxContextMessages,
appConfigChat.maxContextChars,
);
if (recentContext.omittedCount > 0) {
setPendingContextConfirm({
mode,
text: trimmed,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeIsTemplate: false,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
return;
}
}
executeSendMessage({
mode,
text: trimmed,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeIsTemplate: selectedChatType.isTemplate,
includedContextCount: 0,
omittedContextCount: 0,
});
},
[
appConfigChat.maxContextChars,
appConfigChat.maxContextMessages,
buildOutgoingMessageText,
composerAttachments,
createLocalMessage,
draft,
executeSendMessage,
isComposerAttachmentUploading,
messagesRef,
selectedChatType,
setMessages,
setPendingContextConfirm,
summarizeRecentContext,
],
);
const handleSend = useCallback(() => {
sendMessage('queue');
}, [sendMessage]);
const handleSendImmediate = useCallback(() => {
sendMessage('direct');
}, [sendMessage]);
return {
executeSendMessage,
handleComposerFilesPicked,
handleSend,
handleSendImmediate,
sendMessage,
};
}

View File

@@ -0,0 +1 @@
export { useConversationListData as useConversationListController } from './useConversationListData';

View File

@@ -0,0 +1,135 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import {
CHAT_CONVERSATIONS_UPDATED_EVENT,
readChatConversationsUpdatedEvent,
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = {
requestedSessionId: string;
};
type UseConversationListDataResult = {
conversationItems: ChatConversationSummary[];
setConversationItems: Dispatch<SetStateAction<ChatConversationSummary[]>>;
isConversationListLoading: boolean;
reloadConversationItems: () => Promise<void>;
conversationSearch: string;
setConversationSearch: Dispatch<SetStateAction<string>>;
};
export function useConversationListData({
requestedSessionId,
}: UseConversationListDataOptions): UseConversationListDataResult {
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
const [conversationSearch, setConversationSearch] = useState('');
const loadConversationItems = async () => {
setIsConversationListLoading(true);
try {
const items = await chatGateway.listConversations();
setConversationItems(items);
} catch {
setConversationItems([]);
} finally {
setIsConversationListLoading(false);
}
};
useEffect(() => {
let isCancelled = false;
void chatGateway
.listConversations()
.then((items) => {
if (!isCancelled) {
setConversationItems(items);
}
})
.catch(() => {
if (!isCancelled) {
setConversationItems([]);
}
})
.finally(() => {
if (!isCancelled) {
setIsConversationListLoading(false);
}
});
setIsConversationListLoading(true);
return () => {
isCancelled = true;
};
}, []);
useEffect(() => {
if (!requestedSessionId || isConversationListLoading) {
return;
}
if (conversationItems.some((item) => item.sessionId === requestedSessionId)) {
return;
}
let isCancelled = false;
const loadRequestedConversation = async () => {
try {
const response = await chatGateway.getConversationDetail(requestedSessionId);
if (isCancelled || response.item.sessionId !== requestedSessionId) {
return;
}
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) {
return [response.item, ...previous];
}
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
});
} catch {
// 유효하지 않은 세션은 이후 기본 빈 상태 흐름이 유지된다.
}
};
void loadRequestedConversation();
return () => {
isCancelled = true;
};
}, [conversationItems, isConversationListLoading, requestedSessionId]);
useEffect(() => {
const handleConversationsUpdated = (event: Event) => {
const detail = readChatConversationsUpdatedEvent(event);
if (!detail) {
return;
}
setConversationItems(detail.items);
};
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
return () => {
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
};
}, []);
return {
conversationItems,
setConversationItems,
isConversationListLoading,
reloadConversationItems: loadConversationItems,
conversationSearch,
setConversationSearch,
};
}

View File

@@ -0,0 +1,370 @@
import { useCallback } from 'react';
import { removeChatRuntimeJob } from '../../mainChatPanel';
import { chatConnectionGateway } from '../data/chatConnectionGateway';
import { chatGateway } from '../data/chatGateway';
import type {
ChatComposerAttachment,
ChatConversationRequest,
ChatConversationSummary,
ChatMessage,
} from '../../mainChatPanel/types';
type PendingChatRequest = {
sessionId: string;
requestId: string;
text: string;
mode: 'queue' | 'direct';
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeIsTemplate: boolean;
retryCount: number;
failed: boolean;
};
type UseConversationRoomActionsControllerOptions = {
activeSessionId: string;
requestedSessionId: string;
conversationItems: ChatConversationSummary[];
activeConversation: ChatConversationSummary | null;
editingConversationTitle: string;
isMobileViewport: boolean;
pendingRequestsRef: { current: PendingChatRequest[] };
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
socketRef: { current: WebSocket | null };
setConversationItems: React.Dispatch<React.SetStateAction<ChatConversationSummary[]>>;
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
setRequestItems: React.Dispatch<React.SetStateAction<ChatConversationRequest[]>>;
setActiveSessionId: (value: string) => void;
setDraft: (value: string) => void;
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
setCopiedMessageId: (value: number | null) => void;
setActivePreviewId: (value: string | null) => void;
setIsPreviewModalOpen: (value: boolean) => void;
setActiveSystemStatus: (value: string | null) => void;
setIsSystemStatusPending: (value: boolean) => void;
setIsResourceStripOpen: (value: boolean) => void;
setIsConversationPaneClosed: (value: boolean) => void;
setIsMobileConversationView: (value: boolean) => void;
setRenamingConversationSessionId: (value: string | null | ((current: string | null) => string | null)) => void;
setEditingConversationTitle: (value: string) => void;
setIsEditingConversationTitle: (value: boolean) => void;
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
createLocalMessage: (text: string) => ChatMessage;
replaceChatSessionInUrl: (sessionId: string) => void;
messageApi: {
error: (content: string) => void;
};
};
export function useConversationRoomActionsController({
activeSessionId,
requestedSessionId,
conversationItems,
activeConversation,
editingConversationTitle,
isMobileViewport,
pendingRequestsRef,
sessionMessageCacheRef,
socketRef,
setConversationItems,
setMessages,
setRequestItems,
setActiveSessionId,
setDraft,
setComposerAttachments,
setCopiedMessageId,
setActivePreviewId,
setIsPreviewModalOpen,
setActiveSystemStatus,
setIsSystemStatusPending,
setIsResourceStripOpen,
setIsConversationPaneClosed,
setIsMobileConversationView,
setRenamingConversationSessionId,
setEditingConversationTitle,
setIsEditingConversationTitle,
updatePendingMessageStatus,
sendChatRequest,
createLocalMessage,
replaceChatSessionInUrl,
messageApi,
}: UseConversationRoomActionsControllerOptions) {
const removeOptimisticRequestMessages = useCallback(
(requestId: string) => {
setMessages((previous) => previous.filter((message) => message.clientRequestId !== requestId));
},
[setMessages],
);
const retryPendingRequest = useCallback(
(requestId: string) => {
const currentRequest = pendingRequestsRef.current.find(
(request) => request.requestId === requestId && request.sessionId === activeSessionId,
);
if (!currentRequest) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('재전송할 요청 정보를 찾지 못했습니다. 같은 내용을 다시 보내 주세요.'),
]);
return;
}
const resetRequest: PendingChatRequest = {
...currentRequest,
retryCount: 0,
failed: false,
};
setActiveSystemStatus('전송 재시도 중...');
setIsSystemStatusPending(true);
const socket = socketRef.current;
if (!socket || socket.readyState !== WebSocket.OPEN) {
updatePendingMessageStatus(requestId, 'retrying', 0);
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((request) => request.requestId !== requestId),
resetRequest,
];
return;
}
try {
sendChatRequest(socket, resetRequest);
updatePendingMessageStatus(requestId, null, 0);
pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== requestId);
} catch {
updatePendingMessageStatus(requestId, 'retrying', 0);
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((request) => request.requestId !== requestId),
resetRequest,
];
}
},
[
activeSessionId,
createLocalMessage,
pendingRequestsRef,
sendChatRequest,
setActiveSystemStatus,
setIsSystemStatusPending,
setMessages,
socketRef,
updatePendingMessageStatus,
],
);
const cancelPendingRequest = useCallback(
(requestId: string) => {
const currentRequest = pendingRequestsRef.current.find(
(request) => request.requestId === requestId && request.sessionId === activeSessionId,
);
if (!currentRequest) {
removeOptimisticRequestMessages(requestId);
setActiveSystemStatus('미접수 요청을 화면에서 제거했습니다.');
setIsSystemStatusPending(false);
return;
}
pendingRequestsRef.current = pendingRequestsRef.current.filter((request) => request.requestId !== requestId);
removeOptimisticRequestMessages(requestId);
setRequestItems((previous) =>
previous.filter((item) => !(item.sessionId === activeSessionId && item.requestId === requestId)),
);
setActiveSystemStatus('실패한 요청을 취소했습니다.');
setIsSystemStatusPending(false);
},
[
activeSessionId,
pendingRequestsRef,
removeOptimisticRequestMessages,
setActiveSystemStatus,
setIsSystemStatusPending,
setRequestItems,
],
);
const removeQueuedComposerRequest = useCallback(
async (requestId: string) => {
try {
await removeChatRuntimeJob(requestId);
setRequestItems((previous) =>
previous.map((item) =>
item.sessionId === activeSessionId && item.requestId === requestId
? {
...item,
status: 'removed',
statusMessage: '대기열에서 제거되었습니다.',
terminalAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
: item,
),
);
setActiveSystemStatus('대기 요청을 삭제했습니다.');
setIsSystemStatusPending(false);
} catch (error) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(error instanceof Error ? error.message : '대기 요청 제거 중 오류가 발생했습니다.'),
]);
}
},
[activeSessionId, createLocalMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages, setRequestItems],
);
const deleteStoredRequest = useCallback(
async (requestId: string) => {
try {
await chatGateway.deleteConversationRequest(activeSessionId, requestId);
setMessages((previous) => previous.filter((message) => message.clientRequestId !== requestId));
setRequestItems((previous) =>
previous.filter((item) => !(item.sessionId === activeSessionId && item.requestId === requestId)),
);
setConversationItems((previous) =>
previous.map((item) =>
item.sessionId === activeSessionId
? {
...item,
currentRequestId: item.currentRequestId === requestId ? null : item.currentRequestId,
currentJobStatus: item.currentRequestId === requestId ? null : item.currentJobStatus,
currentJobMessage: item.currentRequestId === requestId ? null : item.currentJobMessage,
currentQueueSize: item.currentRequestId === requestId ? 0 : item.currentQueueSize,
}
: item,
),
);
setActiveSystemStatus('요청을 삭제했습니다.');
setIsSystemStatusPending(false);
} catch (error) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(error instanceof Error ? error.message : '요청 삭제 중 오류가 발생했습니다.'),
]);
}
},
[
activeSessionId,
createLocalMessage,
setActiveSystemStatus,
setConversationItems,
setIsSystemStatusPending,
setMessages,
setRequestItems,
],
);
const handleRenameConversation = useCallback(async () => {
if (!activeConversation) {
return;
}
const sessionId = activeConversation.sessionId;
const previousTitle = activeConversation.title;
const trimmedTitle = editingConversationTitle.trim();
if (!trimmedTitle || trimmedTitle === previousTitle) {
setIsEditingConversationTitle(false);
setEditingConversationTitle(previousTitle);
return;
}
setRenamingConversationSessionId(sessionId);
setConversationItems((previous) =>
previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, title: trimmedTitle } : entry)),
);
setEditingConversationTitle(trimmedTitle);
setIsEditingConversationTitle(false);
try {
const item = await chatGateway.renameConversation(sessionId, trimmedTitle);
setConversationItems((previous) => previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)));
setEditingConversationTitle(item.title);
} catch (error) {
setConversationItems((previous) =>
previous.map((entry) => (entry.sessionId === sessionId ? { ...entry, title: previousTitle } : entry)),
);
setEditingConversationTitle(previousTitle);
messageApi.error(error instanceof Error ? error.message : '채팅방 이름 변경에 실패했습니다.');
} finally {
setRenamingConversationSessionId((current) => (current === sessionId ? null : current));
}
}, [
activeConversation,
editingConversationTitle,
messageApi,
setConversationItems,
setEditingConversationTitle,
setIsEditingConversationTitle,
setRenamingConversationSessionId,
]);
const handleDeleteConversation = useCallback(
async (sessionId: string) => {
try {
await chatGateway.deleteConversation(sessionId);
const remaining = conversationItems.filter((entry) => entry.sessionId !== sessionId);
sessionMessageCacheRef.current.delete(sessionId);
setConversationItems(remaining);
if (sessionId === activeSessionId) {
replaceChatSessionInUrl('');
chatConnectionGateway.resetLastReceivedEventId('');
setActiveSessionId('');
setMessages([]);
setRequestItems([]);
setDraft('');
setComposerAttachments([]);
setCopiedMessageId(null);
setActivePreviewId(null);
setIsPreviewModalOpen(false);
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
setIsResourceStripOpen(false);
setIsConversationPaneClosed(false);
setIsMobileConversationView(!isMobileViewport);
} else if (requestedSessionId === sessionId) {
replaceChatSessionInUrl(activeSessionId);
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '대화방 삭제 중 오류가 발생했습니다.');
}
},
[
activeSessionId,
conversationItems,
isMobileViewport,
messageApi,
replaceChatSessionInUrl,
requestedSessionId,
sessionMessageCacheRef,
setActivePreviewId,
setActiveSessionId,
setActiveSystemStatus,
setComposerAttachments,
setConversationItems,
setCopiedMessageId,
setDraft,
setIsConversationPaneClosed,
setIsMobileConversationView,
setIsPreviewModalOpen,
setIsResourceStripOpen,
setIsSystemStatusPending,
setMessages,
setRequestItems,
],
);
return {
cancelPendingRequest,
deleteStoredRequest,
handleDeleteConversation,
handleRenameConversation,
removeQueuedComposerRequest,
retryPendingRequest,
};
}

View File

@@ -0,0 +1 @@
export { useConversationRoomData as useConversationRoomController } from './useConversationRoomData';

View File

@@ -0,0 +1,307 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { chatGateway } from '../data/chatGateway';
import type {
ChatConversationRequest,
ChatConversationSummary,
ChatMessage,
} from '../../mainChatPanel/types';
const INITIAL_CONVERSATION_MESSAGE_LIMIT = 3;
const OLDER_CONVERSATION_MESSAGE_PAGE_SIZE = 20;
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return [] as ChatMessage[];
}
return cache.get(normalizedSessionId) ?? [];
}
function getBestAvailableSessionMessages(
cache: Map<string, ChatMessage[]>,
sessionId: string,
currentSessionId: string,
currentMessages: ChatMessage[],
) {
const cachedMessages = getCachedSessionMessages(cache, sessionId);
if (sessionId !== currentSessionId || currentMessages.length === 0) {
return cachedMessages;
}
return mergeRecoveredChatMessages(cachedMessages, currentMessages);
}
type UseConversationRoomDataOptions = {
activeSessionId: string;
connectionState: 'connecting' | 'connected' | 'disconnected';
shouldBlockConversationWhileLoading: (sessionId: string) => boolean;
captureViewportRestoreSnapshot: () => void;
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
messagesRef: MutableRefObject<ChatMessage[]>;
pendingViewportRestoreRef: MutableRefObject<boolean>;
shouldRestoreConversationAfterReconnectRef: MutableRefObject<boolean>;
setConversationItems: Dispatch<SetStateAction<ChatConversationSummary[]>>;
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
setRequestItems: Dispatch<SetStateAction<ChatConversationRequest[]>>;
setConversationLoadingLabel: Dispatch<SetStateAction<string>>;
setIsConversationContentLoading: Dispatch<SetStateAction<boolean>>;
setIsDeferringAuxiliaryChatRequests: Dispatch<SetStateAction<boolean>>;
setHasOlderMessages: Dispatch<SetStateAction<boolean>>;
setOldestLoadedMessageId: Dispatch<SetStateAction<number | null>>;
setIsLoadingOlderMessages: Dispatch<SetStateAction<boolean>>;
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
viewportRef: MutableRefObject<HTMLDivElement | null>;
};
export function useConversationRoomData({
activeSessionId,
connectionState,
shouldBlockConversationWhileLoading,
captureViewportRestoreSnapshot,
sessionMessageCacheRef,
messagesRef,
pendingViewportRestoreRef,
shouldRestoreConversationAfterReconnectRef,
setConversationItems,
setMessages,
setRequestItems,
setConversationLoadingLabel,
setIsConversationContentLoading,
setIsDeferringAuxiliaryChatRequests,
setHasOlderMessages,
setOldestLoadedMessageId,
setIsLoadingOlderMessages,
queueViewportPrependRestore,
viewportRef,
}: UseConversationRoomDataOptions) {
useEffect(() => {
if (!activeSessionId.trim()) {
setMessages([]);
setRequestItems([]);
setIsConversationContentLoading(false);
setIsLoadingOlderMessages(false);
setHasOlderMessages(false);
setOldestLoadedMessageId(null);
return;
}
let isCancelled = false;
const requestedSessionId = activeSessionId;
const loadConversationDetail = async () => {
captureViewportRestoreSnapshot();
pendingViewportRestoreRef.current = true;
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
setIsConversationContentLoading(shouldBlockConversationWhileLoading(requestedSessionId));
setIsDeferringAuxiliaryChatRequests(true);
try {
const response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: INITIAL_CONVERSATION_MESSAGE_LIMIT,
});
if (!isCancelled && response.item.sessionId === requestedSessionId) {
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) {
return [response.item, ...previous];
}
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
});
const baseMessages = getBestAvailableSessionMessages(
sessionMessageCacheRef.current,
requestedSessionId,
activeSessionId,
messagesRef.current,
);
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(nextMessages);
setRequestItems(response.requests);
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
}
} catch {
if (!isCancelled) {
const cachedMessages = getBestAvailableSessionMessages(
sessionMessageCacheRef.current,
requestedSessionId,
activeSessionId,
messagesRef.current,
);
if (cachedMessages.length > 0) {
setMessages(cachedMessages);
}
}
} finally {
if (!isCancelled) {
setIsConversationContentLoading(false);
setIsDeferringAuxiliaryChatRequests(false);
}
}
};
void loadConversationDetail();
return () => {
isCancelled = true;
};
}, [
activeSessionId,
captureViewportRestoreSnapshot,
messagesRef,
pendingViewportRestoreRef,
sessionMessageCacheRef,
setConversationItems,
setConversationLoadingLabel,
setIsConversationContentLoading,
setIsDeferringAuxiliaryChatRequests,
setHasOlderMessages,
setMessages,
setOldestLoadedMessageId,
setRequestItems,
shouldBlockConversationWhileLoading,
]);
useEffect(() => {
if (connectionState !== 'connected' || !shouldRestoreConversationAfterReconnectRef.current) {
return;
}
let isCancelled = false;
const requestedSessionId = activeSessionId;
const restoreConversationAfterReconnect = async () => {
setIsDeferringAuxiliaryChatRequests(true);
try {
const response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: Math.max(INITIAL_CONVERSATION_MESSAGE_LIMIT, messagesRef.current.length || 0),
});
if (!isCancelled && response.item.sessionId === requestedSessionId) {
const baseMessages = getBestAvailableSessionMessages(
sessionMessageCacheRef.current,
requestedSessionId,
activeSessionId,
messagesRef.current,
);
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
const hasMessageDiff = nextMessages !== baseMessages;
if (hasMessageDiff) {
captureViewportRestoreSnapshot();
pendingViewportRestoreRef.current = true;
setConversationLoadingLabel('채팅방을 다시 연결하고 내용을 복구하는 중입니다.');
setIsConversationContentLoading(true);
}
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) {
return [response.item, ...previous];
}
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
});
setRequestItems(response.requests);
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
if (hasMessageDiff) {
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(nextMessages);
window.requestAnimationFrame(() => {
if (!isCancelled) {
setIsConversationContentLoading(false);
}
});
}
}
} catch {
if (!isCancelled) {
setIsConversationContentLoading(false);
}
} finally {
if (!isCancelled) {
shouldRestoreConversationAfterReconnectRef.current = false;
if (!pendingViewportRestoreRef.current) {
setIsConversationContentLoading(false);
}
setIsDeferringAuxiliaryChatRequests(false);
}
}
};
void restoreConversationAfterReconnect();
return () => {
isCancelled = true;
};
}, [
activeSessionId,
captureViewportRestoreSnapshot,
connectionState,
messagesRef,
pendingViewportRestoreRef,
sessionMessageCacheRef,
setConversationItems,
setConversationLoadingLabel,
setIsConversationContentLoading,
setIsDeferringAuxiliaryChatRequests,
setHasOlderMessages,
setMessages,
setOldestLoadedMessageId,
setRequestItems,
shouldRestoreConversationAfterReconnectRef,
]);
const loadOlderMessages = async () => {
const requestedSessionId = activeSessionId.trim();
const oldestVisibleMessageId = messagesRef.current[0]?.id ?? null;
if (!requestedSessionId || oldestVisibleMessageId == null) {
return;
}
setIsLoadingOlderMessages(true);
try {
const response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: OLDER_CONVERSATION_MESSAGE_PAGE_SIZE,
beforeMessageId: oldestVisibleMessageId,
});
if (response.item.sessionId !== requestedSessionId || response.messages.length === 0) {
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
return;
}
const viewport = viewportRef.current;
const previousScrollHeight = viewport?.scrollHeight ?? 0;
const previousScrollTop = viewport?.scrollTop ?? 0;
const nextMessages = mergeRecoveredChatMessages(messagesRef.current, response.messages);
queueViewportPrependRestore(previousScrollHeight, previousScrollTop);
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(nextMessages);
setRequestItems(response.requests);
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
} finally {
setIsLoadingOlderMessages(false);
}
};
return {
loadOlderMessages,
};
}

View File

@@ -0,0 +1,206 @@
import { useEffect, useRef, useState } from 'react';
import type { ChatComposerAttachment, ChatMessage } from '../../mainChatPanel/types';
type PreviewItem = {
id: string;
label: string;
url: string;
kind: 'image' | 'video' | 'markdown' | 'code' | 'document' | 'pdf' | 'file';
source: 'message' | 'context';
};
type UseConversationViewControllerOptions = {
activeSessionId: string;
activeView: 'chat' | 'runtime' | 'errors';
previewItems: PreviewItem[];
selectedChatTypeId: string | null;
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
setActiveSystemStatus: (value: string | null) => void;
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
setCopiedMessageId: (value: number | null) => void;
setDraft: React.Dispatch<React.SetStateAction<string>>;
setIsResourceStripOpen: (value: boolean) => void;
setIsSystemStatusPending: (value: boolean) => void;
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
};
export function useConversationViewController({
activeSessionId,
activeView,
composerRef,
previewItems,
selectedChatTypeId,
sessionMessageCacheRef,
setActiveSystemStatus,
setComposerAttachments,
setCopiedMessageId,
setDraft,
setIsResourceStripOpen,
setIsSystemStatusPending,
setMessages,
}: UseConversationViewControllerOptions) {
const previousSessionIdRef = useRef(activeSessionId);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const [previewText, setPreviewText] = useState('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewContentType, setPreviewContentType] = useState('');
const activePreview = previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
useEffect(() => {
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
if (!hasSessionChanged) {
return;
}
previousSessionIdRef.current = activeSessionId;
setMessages(sessionMessageCacheRef.current.get(activeSessionId)?.slice() ?? []);
setDraft('');
setComposerAttachments([]);
setCopiedMessageId(null);
setActivePreviewId(null);
setIsPreviewModalOpen(false);
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
setIsResourceStripOpen(false);
}, [
activeSessionId,
sessionMessageCacheRef,
setActiveSystemStatus,
setComposerAttachments,
setCopiedMessageId,
setDraft,
setIsResourceStripOpen,
setIsSystemStatusPending,
setMessages,
]);
useEffect(() => {
if (!activePreviewId) {
return;
}
if (previewItems.some((item) => item.id === activePreviewId)) {
return;
}
setActivePreviewId(null);
setIsPreviewModalOpen(false);
}, [activePreviewId, previewItems]);
useEffect(() => {
if (!isPreviewModalOpen || !activePreview) {
setPreviewText('');
setPreviewError('');
setPreviewContentType('');
setIsPreviewLoading(false);
return;
}
if (activePreview.kind === 'image' || activePreview.kind === 'video' || activePreview.kind === 'pdf') {
setPreviewText('');
setPreviewError('');
setPreviewContentType('');
setIsPreviewLoading(false);
return;
}
const controller = new AbortController();
setIsPreviewLoading(true);
setPreviewError('');
setPreviewContentType('');
fetch(activePreview.url, {
cache: 'no-store',
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
setPreviewContentType(response.headers.get('content-type') ?? '');
const text = await response.text();
setPreviewText(text.slice(0, 20000));
})
.catch((error: unknown) => {
if (controller.signal.aborted) {
return;
}
setPreviewText('');
setPreviewContentType('');
setPreviewError(error instanceof Error ? error.message : 'preview를 가져오지 못했습니다.');
})
.finally(() => {
if (!controller.signal.aborted) {
setIsPreviewLoading(false);
}
});
return () => {
controller.abort();
};
}, [activePreview, isPreviewModalOpen]);
useEffect(() => {
if (activeView !== 'chat') {
return;
}
composerRef.current?.focus({ cursor: 'end' });
}, [activeView, composerRef, selectedChatTypeId]);
useEffect(() => {
if (activeView !== 'chat') {
return undefined;
}
const handleWindowKeyDown = (event: KeyboardEvent) => {
const isTextEntryTarget =
event.target instanceof HTMLElement &&
(event.target.isContentEditable ||
['input', 'textarea', 'select'].includes(event.target.tagName.toLowerCase()) ||
event.target.closest('[contenteditable="true"]'));
if (event.metaKey || event.ctrlKey || event.altKey || isTextEntryTarget) {
return;
}
if (event.key === '/') {
event.preventDefault();
composerRef.current?.focus({ cursor: 'end' });
return;
}
if (event.key.length === 1) {
event.preventDefault();
composerRef.current?.focus({ cursor: 'end' });
setDraft((previous) => previous + event.key);
}
};
window.addEventListener('keydown', handleWindowKeyDown);
return () => {
window.removeEventListener('keydown', handleWindowKeyDown);
};
}, [activeView, composerRef, setDraft]);
return {
activePreview,
activePreviewId,
isPreviewLoading,
isPreviewModalOpen,
previewContentType,
previewError,
previewText,
setActivePreviewId,
setIsPreviewModalOpen,
};
}

View File

@@ -0,0 +1,406 @@
import { useCallback, useEffect, useRef, useState, type TouchEvent } from 'react';
import type {
ChatConversationSummary,
ChatMessage,
ChatRuntimeSnapshot,
} from '../../mainChatPanel/types';
type UseConversationViewportControllerOptions = {
activeConversation: ChatConversationSummary | null;
activeQueuedComposerRequestsCount: number;
activeRuntimeStatus: string | null;
chatMessageCount: number;
chatMessageSyncKey: string;
connectionState: 'connecting' | 'connected' | 'disconnected';
isConversationContentLoading: boolean;
messages: ChatMessage[];
runtimeSnapshot: ChatRuntimeSnapshot | null;
viewportRef: { current: HTMLDivElement | null };
hasOlderMessages: boolean;
isLoadingOlderMessages: boolean;
onLoadOlderMessages: () => void | Promise<void>;
mapJobStatusLabel: (
item: Pick<ChatConversationSummary, 'currentJobStatus' | 'currentJobMessage' | 'currentQueueSize'>,
) => string;
mapSystemStatusMessage: (text: string) => string | null;
isActivityLogMessage: (message: ChatMessage) => boolean;
};
export function useConversationViewportController({
activeConversation,
activeQueuedComposerRequestsCount,
activeRuntimeStatus,
chatMessageCount,
chatMessageSyncKey,
connectionState,
isConversationContentLoading,
messages,
runtimeSnapshot,
viewportRef,
hasOlderMessages,
isLoadingOlderMessages,
onLoadOlderMessages,
mapJobStatusLabel,
mapSystemStatusMessage,
isActivityLogMessage,
}: UseConversationViewportControllerOptions) {
const [activeSystemStatus, setActiveSystemStatus] = useState<string | null>(null);
const [isSystemStatusPending, setIsSystemStatusPending] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const systemStatusTimerRef = useRef<number | null>(null);
const restoreAutoScrollFrameRef = useRef<number | null>(null);
const shouldStickToBottomRef = useRef(true);
const pendingViewportRestoreRef = useRef(false);
const pendingPrependRestoreRef = useRef<{
previousScrollHeight: number;
previousScrollTop: number;
} | null>(null);
const viewportRestoreSnapshotRef = useRef<{
shouldStickToBottom: boolean;
offsetFromBottom: number;
} | null>(null);
const touchStartYRef = useRef<number | null>(null);
const touchPullActiveRef = useRef(false);
const [pullToLoadDistance, setPullToLoadDistance] = useState(0);
const [isPullToLoadArmed, setIsPullToLoadArmed] = useState(false);
const clearSystemStatusTimer = useCallback(() => {
if (systemStatusTimerRef.current !== null) {
window.clearTimeout(systemStatusTimerRef.current);
systemStatusTimerRef.current = null;
}
}, []);
const scrollViewportToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
viewport.scrollTo({
top: viewport.scrollHeight,
behavior,
});
},
[viewportRef],
);
const scheduleViewportBottomSync = useCallback(
(frameCount = 6) => {
if (restoreAutoScrollFrameRef.current !== null) {
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
restoreAutoScrollFrameRef.current = null;
}
const run = (remainingFrames: number) => {
restoreAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
if (!shouldStickToBottomRef.current || isConversationContentLoading) {
restoreAutoScrollFrameRef.current = null;
return;
}
setShowScrollToBottom(false);
scrollViewportToBottom('auto');
if (remainingFrames <= 1) {
restoreAutoScrollFrameRef.current = null;
return;
}
run(remainingFrames - 1);
});
};
run(frameCount);
},
[isConversationContentLoading, scrollViewportToBottom],
);
const handleViewportScroll = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
const isNearBottom = remainingDistance <= 24;
shouldStickToBottomRef.current = isNearBottom;
setShowScrollToBottom(!isNearBottom);
}, [viewportRef]);
const captureViewportRestoreSnapshot = useCallback(() => {
const viewport = viewportRef.current;
if (!viewport) {
viewportRestoreSnapshotRef.current = {
shouldStickToBottom: shouldStickToBottomRef.current,
offsetFromBottom: 0,
};
return;
}
viewportRestoreSnapshotRef.current = {
shouldStickToBottom: shouldStickToBottomRef.current,
offsetFromBottom: Math.max(0, viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight),
};
}, [viewportRef]);
const queueViewportPrependRestore = useCallback((previousScrollHeight: number, previousScrollTop: number) => {
pendingPrependRestoreRef.current = {
previousScrollHeight,
previousScrollTop,
};
}, []);
useEffect(() => {
if (!shouldStickToBottomRef.current) {
return;
}
scrollViewportToBottom(chatMessageCount > 1 ? 'smooth' : 'auto');
}, [chatMessageCount, chatMessageSyncKey, scrollViewportToBottom]);
useEffect(() => {
if (isConversationContentLoading || !shouldStickToBottomRef.current) {
return;
}
scheduleViewportBottomSync();
return () => {
if (restoreAutoScrollFrameRef.current !== null) {
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
restoreAutoScrollFrameRef.current = null;
}
};
}, [activeConversation?.sessionId, chatMessageSyncKey, isConversationContentLoading, scheduleViewportBottomSync]);
useEffect(() => {
const pendingPrependRestore = pendingPrependRestoreRef.current;
if (!pendingPrependRestore) {
return;
}
pendingPrependRestoreRef.current = null;
const frameId = window.requestAnimationFrame(() => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
const nextScrollTop =
pendingPrependRestore.previousScrollTop + (viewport.scrollHeight - pendingPrependRestore.previousScrollHeight);
viewport.scrollTop = Math.max(0, nextScrollTop);
handleViewportScroll();
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [chatMessageCount, chatMessageSyncKey, handleViewportScroll, viewportRef]);
useEffect(() => {
if (isConversationContentLoading || !pendingViewportRestoreRef.current) {
return;
}
const restoreSnapshot = viewportRestoreSnapshotRef.current;
pendingViewportRestoreRef.current = false;
viewportRestoreSnapshotRef.current = null;
const frameId = window.requestAnimationFrame(() => {
const viewport = viewportRef.current;
if (!viewport) {
return;
}
if (!restoreSnapshot || restoreSnapshot.shouldStickToBottom) {
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
scrollViewportToBottom('auto');
return;
}
shouldStickToBottomRef.current = false;
viewport.scrollTop = Math.max(
0,
viewport.scrollHeight - viewport.clientHeight - restoreSnapshot.offsetFromBottom,
);
handleViewportScroll();
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, [
chatMessageCount,
handleViewportScroll,
isConversationContentLoading,
scrollViewportToBottom,
viewportRef,
]);
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) {
touchStartYRef.current = null;
touchPullActiveRef.current = false;
return;
}
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchPullActiveRef.current = true;
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
if (!touchPullActiveRef.current || touchStartYRef.current == null) {
return;
}
const viewport = viewportRef.current;
const currentY = event.touches[0]?.clientY ?? null;
if (!viewport || currentY == null || viewport.scrollTop > 0) {
touchPullActiveRef.current = false;
touchStartYRef.current = null;
setPullToLoadDistance(0);
setIsPullToLoadArmed(false);
return;
}
const deltaY = Math.max(0, currentY - touchStartYRef.current);
const nextDistance = Math.min(96, deltaY * 0.45);
if (nextDistance > 0) {
event.preventDefault();
}
setPullToLoadDistance(nextDistance);
setIsPullToLoadArmed(nextDistance >= 52);
}, [viewportRef]);
const resetPullToLoad = useCallback(() => {
touchPullActiveRef.current = false;
touchStartYRef.current = null;
setPullToLoadDistance(0);
setIsPullToLoadArmed(false);
}, []);
const handleViewportTouchEnd = useCallback(() => {
const shouldLoadOlder = touchPullActiveRef.current && isPullToLoadArmed && hasOlderMessages && !isLoadingOlderMessages;
resetPullToLoad();
if (shouldLoadOlder) {
void onLoadOlderMessages();
}
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]);
useEffect(() => {
if (connectionState === 'disconnected') {
clearSystemStatusTimer();
setActiveSystemStatus('워크서버 연결이 끊어졌습니다. 실제 처리 상태를 다시 확인하는 중입니다.');
setIsSystemStatusPending(false);
return clearSystemStatusTimer;
}
if (activeRuntimeStatus) {
clearSystemStatusTimer();
setActiveSystemStatus(activeRuntimeStatus);
setIsSystemStatusPending(true);
return clearSystemStatusTimer;
}
if (activeConversation?.currentJobStatus && !runtimeSnapshot) {
clearSystemStatusTimer();
setActiveSystemStatus(mapJobStatusLabel(activeConversation));
setIsSystemStatusPending(
activeConversation.currentJobStatus === 'queued' || activeConversation.currentJobStatus === 'started',
);
return clearSystemStatusTimer;
}
if (activeQueuedComposerRequestsCount > 0) {
clearSystemStatusTimer();
setActiveSystemStatus(`실제 대기열 ${activeQueuedComposerRequestsCount}`);
setIsSystemStatusPending(true);
return clearSystemStatusTimer;
}
clearSystemStatusTimer();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
const latestMessage = messages[messages.length - 1];
if (!latestMessage || isActivityLogMessage(latestMessage) || latestMessage.author !== 'system') {
return clearSystemStatusTimer;
}
const isTerminalStatus =
latestMessage.text.startsWith('요청 처리가 끝났습니다.') ||
latestMessage.text.startsWith('즉시 요청 처리가 끝났습니다.') ||
latestMessage.text.startsWith('요청 처리 중 오류가 발생했습니다.');
const nextStatus = mapSystemStatusMessage(latestMessage.text);
if (!nextStatus || isTerminalStatus) {
return clearSystemStatusTimer;
}
setActiveSystemStatus(nextStatus);
setIsSystemStatusPending(true);
return clearSystemStatusTimer;
}, [
activeConversation,
activeQueuedComposerRequestsCount,
activeRuntimeStatus,
clearSystemStatusTimer,
connectionState,
isActivityLogMessage,
mapJobStatusLabel,
mapSystemStatusMessage,
messages,
runtimeSnapshot,
]);
useEffect(() => {
return () => {
clearSystemStatusTimer();
if (restoreAutoScrollFrameRef.current !== null) {
window.cancelAnimationFrame(restoreAutoScrollFrameRef.current);
}
};
}, [clearSystemStatusTimer]);
return {
activeSystemStatus,
captureViewportRestoreSnapshot,
handleViewportScroll,
isSystemStatusPending,
pendingViewportRestoreRef,
scrollViewportToBottom,
setActiveSystemStatus,
setIsSystemStatusPending,
setShowScrollToBottom,
shouldStickToBottomRef,
showScrollToBottom,
handleViewportTouchEnd,
handleViewportTouchMove,
handleViewportTouchStart,
isPullToLoadArmed,
pullToLoadDistance,
queueViewportPrependRestore,
};
}

View File

@@ -0,0 +1,171 @@
import { useEffect, useState } from 'react';
import {
NOTIFICATION_MESSAGES_UPDATED_EVENT,
deleteNotificationMessage,
fetchNotificationMessage,
fetchNotificationMessages,
updateNotificationMessageReadState,
type NotificationMessageItem,
} from '../../notificationApi';
import { useUnreadCounts } from './useUnreadCounts';
function mergeMessageItem(items: NotificationMessageItem[], nextItem: NotificationMessageItem) {
const hasItem = items.some((item) => item.id === nextItem.id);
if (!hasItem) {
return [nextItem, ...items];
}
return items.map((item) => (item.id === nextItem.id ? nextItem : item));
}
export function useNotificationCenterData(drawerOpen: boolean) {
const [detailOpen, setDetailOpen] = useState(false);
const [messages, setMessages] = useState<NotificationMessageItem[]>([]);
const [selectedMessage, setSelectedMessage] = useState<NotificationMessageItem | null>(null);
const [listLoading, setListLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [toggleReadLoading, setToggleReadLoading] = useState(false);
const [deletingMessageId, setDeletingMessageId] = useState<number | null>(null);
const [listError, setListError] = useState<string | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const { notificationUnreadCount: unreadCount, refreshNotificationUnreadCount } = useUnreadCounts();
const refreshUnreadCount = async () => {
await refreshNotificationUnreadCount();
};
const loadMessages = async () => {
setListLoading(true);
setListError(null);
try {
const response = await fetchNotificationMessages({ limit: 30 });
setMessages(response.items);
} catch (error) {
setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.');
} finally {
setListLoading(false);
}
};
const applyUpdatedMessage = (nextItem: NotificationMessageItem) => {
setSelectedMessage(nextItem);
setMessages((current) => {
const wasUnread = current.find((item) => item.id === nextItem.id)?.read === false;
const nextItems = mergeMessageItem(current, nextItem);
if (wasUnread !== nextItem.read) {
void refreshNotificationUnreadCount();
}
return nextItems;
});
};
const openMessageDetail = async (id: number) => {
setDetailOpen(true);
setDetailLoading(true);
setDetailError(null);
setSelectedMessage(null);
try {
const detail = await fetchNotificationMessage(id);
setSelectedMessage(detail);
if (!detail.read) {
const updated = await updateNotificationMessageReadState(id, true);
applyUpdatedMessage(updated);
}
} catch (error) {
setDetailError(error instanceof Error ? error.message : '알림 상세를 불러오지 못했습니다.');
} finally {
setDetailLoading(false);
}
};
const handleToggleReadState = async () => {
if (!selectedMessage) {
return;
}
setToggleReadLoading(true);
try {
const updated = await updateNotificationMessageReadState(selectedMessage.id, !selectedMessage.read);
applyUpdatedMessage(updated);
} catch (error) {
setDetailError(error instanceof Error ? error.message : '읽음 상태를 변경하지 못했습니다.');
} finally {
setToggleReadLoading(false);
}
};
const handleDeleteMessage = async (id: number) => {
if (deletingMessageId === id) {
return;
}
setDeletingMessageId(id);
setListError(null);
try {
await deleteNotificationMessage(id);
setMessages((current) => current.filter((item) => item.id !== id));
void refreshNotificationUnreadCount();
if (selectedMessage?.id === id) {
setSelectedMessage(null);
setDetailOpen(false);
}
} catch (error) {
setListError(error instanceof Error ? error.message : '알림을 삭제하지 못했습니다.');
} finally {
setDeletingMessageId((current) => (current === id ? null : current));
}
};
useEffect(() => {
void refreshUnreadCount();
const handleMessagesUpdated = () => {
void refreshUnreadCount();
if (drawerOpen) {
void loadMessages();
}
};
window.addEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleMessagesUpdated);
return () => {
window.removeEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleMessagesUpdated);
};
}, [drawerOpen, refreshNotificationUnreadCount]);
useEffect(() => {
if (!drawerOpen) {
return;
}
void loadMessages();
}, [drawerOpen]);
return {
unreadCount,
detailOpen,
setDetailOpen,
messages,
selectedMessage,
listLoading,
detailLoading,
toggleReadLoading,
deletingMessageId,
listError,
detailError,
loadMessages,
openMessageDetail,
handleToggleReadState,
handleDeleteMessage,
};
}

View File

@@ -0,0 +1 @@
export { useNotificationCenterData as useNotificationController } from './useNotificationCenterData';

View File

@@ -0,0 +1 @@
export { useRuntimeData as useRuntimeController } from './useRuntimeData';

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react';
import { chatConnectionGateway } from '../data/chatConnectionGateway';
import { chatGateway } from '../data/chatGateway';
import type { ChatRuntimeJobDetail, ChatRuntimeSnapshot } from '../../mainChatPanel/types';
type UseRuntimeDataOptions = {
activeSessionId: string;
isDeferringAuxiliaryChatRequests: boolean;
};
export function useRuntimeData({
activeSessionId,
isDeferringAuxiliaryChatRequests,
}: UseRuntimeDataOptions) {
const [runtimeSnapshot, setRuntimeSnapshot] = useState<ChatRuntimeSnapshot | null>(null);
const [runtimeJobDetail, setRuntimeJobDetail] = useState<ChatRuntimeJobDetail | null>(null);
useEffect(() => {
let isCancelled = false;
const loadRuntimeSnapshot = async () => {
try {
const snapshot = await chatGateway.fetchRuntimeSnapshot();
if (!isCancelled) {
setRuntimeSnapshot(snapshot);
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
}
} catch {
if (!isCancelled) {
setRuntimeSnapshot(null);
chatConnectionGateway.setSharedRuntimeSnapshot(null);
}
}
};
void loadRuntimeSnapshot();
return () => {
isCancelled = true;
};
}, []);
useEffect(() => {
if (!activeSessionId.trim()) {
return;
}
if (isDeferringAuxiliaryChatRequests) {
return;
}
let isCancelled = false;
const syncRuntimeSnapshotForActiveSession = async () => {
try {
const snapshot = await chatGateway.fetchRuntimeSnapshot();
if (!isCancelled) {
setRuntimeSnapshot(snapshot);
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
}
} catch {
if (!isCancelled) {
setRuntimeSnapshot(null);
chatConnectionGateway.setSharedRuntimeSnapshot(null);
}
}
};
void syncRuntimeSnapshotForActiveSession();
return () => {
isCancelled = true;
};
}, [activeSessionId, isDeferringAuxiliaryChatRequests]);
const handleRuntimeEvent = (snapshot: ChatRuntimeSnapshot) => {
setRuntimeSnapshot(snapshot);
chatConnectionGateway.setSharedRuntimeSnapshot(snapshot);
};
const handleRuntimeDetailEvent = (detail: ChatRuntimeJobDetail) => {
setRuntimeJobDetail(detail);
};
return {
runtimeSnapshot,
runtimeJobDetail,
setRuntimeSnapshot,
handleRuntimeEvent,
handleRuntimeDetailEvent,
};
}

View File

@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import {
fetchNotificationMessages,
NOTIFICATION_MESSAGES_UPDATED_EVENT,
} from '../../notificationApi';
import {
CHAT_CONVERSATIONS_UPDATED_EVENT,
readChatConversationsUpdatedEvent,
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
type UseUnreadCountsResult = {
chatUnreadCount: number;
notificationUnreadCount: number;
refreshChatUnreadCount: () => Promise<void>;
refreshNotificationUnreadCount: () => Promise<void>;
};
type SharedUnreadCountsState = {
chatUnreadCount: number;
notificationUnreadCount: number;
hasLoaded: boolean;
chatRequestPromise: Promise<void> | null;
notificationRequestPromise: Promise<void> | null;
subscribers: Set<() => void>;
};
const sharedUnreadCountsState: SharedUnreadCountsState = {
chatUnreadCount: 0,
notificationUnreadCount: 0,
hasLoaded: false,
chatRequestPromise: null,
notificationRequestPromise: null,
subscribers: new Set(),
};
function emitUnreadCounts() {
sharedUnreadCountsState.subscribers.forEach((listener) => {
listener();
});
}
function subscribeUnreadCounts(listener: () => void) {
sharedUnreadCountsState.subscribers.add(listener);
return () => {
sharedUnreadCountsState.subscribers.delete(listener);
};
}
async function refreshChatUnreadCount() {
if (sharedUnreadCountsState.chatRequestPromise) {
return sharedUnreadCountsState.chatRequestPromise;
}
sharedUnreadCountsState.chatRequestPromise = (async () => {
try {
const items = await chatGateway.listConversations();
sharedUnreadCountsState.chatUnreadCount = items.filter((item) => item.hasUnreadResponse).length;
} catch {
sharedUnreadCountsState.chatUnreadCount = 0;
} finally {
sharedUnreadCountsState.chatRequestPromise = null;
emitUnreadCounts();
}
})();
return sharedUnreadCountsState.chatRequestPromise;
}
async function refreshNotificationUnreadCount() {
if (sharedUnreadCountsState.notificationRequestPromise) {
return sharedUnreadCountsState.notificationRequestPromise;
}
sharedUnreadCountsState.notificationRequestPromise = (async () => {
try {
const response = await fetchNotificationMessages({ limit: 1 });
sharedUnreadCountsState.notificationUnreadCount = response.unreadCount;
} catch {
// Keep the previous badge count when the server is temporarily unavailable.
} finally {
sharedUnreadCountsState.notificationRequestPromise = null;
emitUnreadCounts();
}
})();
return sharedUnreadCountsState.notificationRequestPromise;
}
export function useUnreadCounts(): UseUnreadCountsResult {
const [chatUnreadCount, setChatUnreadCount] = useState(sharedUnreadCountsState.chatUnreadCount);
const [notificationUnreadCount, setNotificationUnreadCount] = useState(sharedUnreadCountsState.notificationUnreadCount);
useEffect(() => {
return subscribeUnreadCounts(() => {
setChatUnreadCount(sharedUnreadCountsState.chatUnreadCount);
setNotificationUnreadCount(sharedUnreadCountsState.notificationUnreadCount);
});
}, []);
useEffect(() => {
if (sharedUnreadCountsState.hasLoaded) {
return;
}
sharedUnreadCountsState.hasLoaded = true;
void refreshChatUnreadCount();
void refreshNotificationUnreadCount();
}, []);
useEffect(() => {
const handleNotificationMessagesUpdated = () => {
void refreshNotificationUnreadCount();
};
const handleChatConversationsUpdated = (event: Event) => {
const detail = readChatConversationsUpdatedEvent(event);
if (!detail) {
return;
}
sharedUnreadCountsState.chatUnreadCount = detail.items.filter((item) => item.hasUnreadResponse).length;
emitUnreadCounts();
};
window.addEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleNotificationMessagesUpdated);
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleChatConversationsUpdated as EventListener);
return () => {
window.removeEventListener(NOTIFICATION_MESSAGES_UPDATED_EVENT, handleNotificationMessagesUpdated);
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleChatConversationsUpdated as EventListener);
};
}, []);
return {
chatUnreadCount,
notificationUnreadCount,
refreshChatUnreadCount,
refreshNotificationUnreadCount,
};
}