feat: update main chat and system chat UI

This commit is contained in:
2026-05-25 17:26:37 +09:00
parent fb5ec649cd
commit f59522ffc4
120 changed files with 43262 additions and 3325 deletions

View File

@@ -1,7 +1,8 @@
import { useCallback, useRef } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage, ChatPromptContextRef } from '../../mainChatPanel/types';
import { buildComposerFilePickKey } from '../../mainChatPanel/composerFilePickKey';
import { shouldSkipContextConfirmForSessionToday } from '../../mainChatPanel/contextConfirmPreference';
export type ComposerFilePickResult = {
items: {
@@ -19,7 +20,9 @@ type PendingChatRequest = {
mode: 'queue' | 'direct';
origin?: 'composer' | 'prompt';
parentRequestId?: string | null;
promptContextRef?: ChatPromptContextRef | null;
omitPromptHistory?: boolean;
codexModel: string;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
@@ -37,10 +40,13 @@ type PendingChatRequest = {
};
type PendingContextConfirm = {
sessionId: string;
mode: 'queue' | 'direct';
text: string;
origin?: 'composer' | 'prompt';
parentRequestId?: string | null;
promptContextRef?: ChatPromptContextRef | null;
codexModel: string;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
@@ -87,6 +93,7 @@ type UseConversationComposerControllerOptions = {
getDraft: () => string;
composerAttachments: ChatComposerAttachment[];
isComposerAttachmentUploading: boolean;
selectedCodexModel: string;
selectedChatType: SelectedChatType;
socketRef: { current: WebSocket | null };
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
@@ -128,13 +135,23 @@ type UseConversationComposerControllerOptions = {
ensureSessionReady?: (sessionId: string) => Promise<boolean>;
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
scrollViewportToBottom: () => void;
releaseAutoScrollSuspension: () => void;
};
type SendMessageOptions = {
sessionId?: string;
mode: 'queue' | 'direct';
draftText?: string;
};
export type SendMessageResult = 'sent' | 'pending' | 'blocked';
const COMPOSER_SUBMISSION_DEDUP_WINDOW_MS = 1200;
type RecentComposerSubmission = {
key: string;
submittedAt: number;
};
function createClientRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `client-${crypto.randomUUID()}`;
@@ -149,6 +166,7 @@ export function useConversationComposerController({
getDraft,
composerAttachments,
isComposerAttachmentUploading,
selectedCodexModel,
selectedChatType,
socketRef,
composerRef,
@@ -176,10 +194,47 @@ export function useConversationComposerController({
ensureSessionReady,
sendChatRequest,
scrollViewportToBottom,
releaseAutoScrollSuspension,
}: UseConversationComposerControllerOptions) {
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0);
const latestComposerUploadAttemptByKeyRef = useRef(new Map<string, string>());
const activeComposerSubmissionKeyRef = useRef<string | null>(null);
const recentComposerSubmissionRef = useRef<RecentComposerSubmission | null>(null);
const isSocketOpen = useCallback(() => {
return Boolean(socketRef.current && socketRef.current.readyState === WebSocket.OPEN);
}, [socketRef]);
const buildComposerSubmissionKey = useCallback(
({
sessionId,
mode,
text,
codexModel,
chatTypeId,
parentRequestId,
omitPromptHistory,
}: {
sessionId: string;
mode: 'queue' | 'direct';
text: string;
codexModel: string;
chatTypeId: string;
parentRequestId?: string | null;
omitPromptHistory?: boolean;
}) =>
JSON.stringify({
sessionId: sessionId.trim(),
mode,
text: text.trim(),
codexModel: codexModel.trim(),
chatTypeId: chatTypeId.trim(),
parentRequestId: parentRequestId?.trim() || null,
omitPromptHistory: omitPromptHistory === true,
}),
[],
);
const handleComposerFilesPicked = useCallback(
async (files: File[]): Promise<ComposerFilePickResult> => {
@@ -286,18 +341,47 @@ export function useConversationComposerController({
const focusComposerAfterSend = useCallback(() => {
window.setTimeout(() => {
composerRef.current?.focus({ cursor: 'end' });
scrollViewportToBottom();
}, 0);
}, [composerRef, scrollViewportToBottom]);
}, [composerRef]);
const scheduleViewportBottomSyncAfterSend = useCallback(() => {
releaseAutoScrollSuspension();
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
window.requestAnimationFrame(() => {
scrollViewportToBottom();
window.requestAnimationFrame(() => {
scrollViewportToBottom();
});
});
}, [releaseAutoScrollSuspension, scrollViewportToBottom, setShowScrollToBottom, shouldStickToBottomRef]);
const handleExecuteSendError = useCallback(
(error: unknown) => {
const reason =
error instanceof Error && error.message.trim()
? error.message.trim()
: '요청 전송 중 오류가 발생했습니다.';
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
setMessages((previous) => [...previous.slice(-39), createLocalMessage(reason)]);
},
[createLocalMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages],
);
const executeSendMessage = useCallback(
async (request: PendingContextConfirm) => {
const {
sessionId,
mode,
text,
origin,
parentRequestId,
promptContextRef,
chatTypeId,
codexModel,
chatTypeLabel,
chatTypeDescription,
chatTypeBaseDescription,
@@ -307,176 +391,280 @@ export function useConversationComposerController({
customContextContent,
omitPromptHistory,
} = request;
const requestChatTypeId = chatTypeId.trim();
const requestChatTypeLabel = chatTypeLabel.trim() || requestChatTypeId || '기본 요청';
const targetSessionId = sessionId.trim() || activeSessionId.trim();
const submissionKey = buildComposerSubmissionKey({
mode,
text,
codexModel,
chatTypeId: requestChatTypeId,
parentRequestId,
omitPromptHistory,
sessionId: targetSessionId,
});
if (ensureSessionReady) {
setActiveSystemStatus('새 채팅방 준비 중...');
setIsSystemStatusPending(true);
const isSessionReady = await ensureSessionReady(activeSessionId);
if (origin !== 'prompt' && activeComposerSubmissionKeyRef.current === submissionKey) {
return false;
}
if (!isSessionReady) {
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
if (origin !== 'prompt') {
const recentComposerSubmission = recentComposerSubmissionRef.current;
if (
recentComposerSubmission &&
recentComposerSubmission.key === submissionKey &&
Date.now() - recentComposerSubmission.submittedAt < COMPOSER_SUBMISSION_DEDUP_WINDOW_MS
) {
return false;
}
}
const requestId = createClientRequestId();
const outgoingRequest: PendingChatRequest = {
sessionId: activeSessionId,
requestId,
text,
mode,
origin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
omitPromptHistory: omitPromptHistory === true,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeBaseDescription,
defaultContextIds,
defaultContexts,
customContextTitle,
customContextContent,
retryCount: 0,
failed: false,
if (origin !== 'prompt') {
activeComposerSubmissionKeyRef.current = submissionKey;
recentComposerSubmissionRef.current = {
key: submissionKey,
submittedAt: Date.now(),
};
}
const shouldOptimisticallyClearComposer = origin !== 'prompt';
const previousDraft = shouldOptimisticallyClearComposer ? getDraft() : '';
const previousAttachments = shouldOptimisticallyClearComposer ? composerAttachments : [];
let composerRestoreNeeded = shouldOptimisticallyClearComposer;
const restoreComposerOnFailure = () => {
if (!composerRestoreNeeded) {
return;
}
composerRestoreNeeded = false;
if (!getDraft().trim()) {
setDraft(previousDraft);
}
setComposerAttachments((current) => {
if (current.length > 0 || previousAttachments.length === 0) {
return current;
}
return previousAttachments;
});
};
if (origin === 'prompt') {
promptRequestIdsRef?.current.add(requestId);
}
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,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
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, {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'queue',
queueSize: 1,
jobMessage: '대기열 등록 중',
});
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,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
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,
});
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'direct',
queueSize: 0,
jobMessage: '즉시 요청 실행 대기 중',
});
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);
if (shouldOptimisticallyClearComposer) {
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
}
if (!targetSessionId) {
restoreComposerOnFailure();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
return false;
}
if (ensureSessionReady) {
setActiveSystemStatus('새 채팅방 준비 중...');
setIsSystemStatusPending(true);
const isSessionReady = await ensureSessionReady(targetSessionId);
if (!isSessionReady) {
restoreComposerOnFailure();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
return false;
}
}
if (!isSocketOpen()) {
restoreComposerOnFailure();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('워크서버 연결이 아직 준비되지 않았습니다. 연결이 복구된 뒤 다시 전송해 주세요.'),
]);
return false;
}
const requestId = createClientRequestId();
const outgoingRequest: PendingChatRequest = {
sessionId: targetSessionId,
requestId,
text,
mode,
origin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
promptContextRef: promptContextRef ?? null,
omitPromptHistory: omitPromptHistory === true,
codexModel,
chatTypeId: requestChatTypeId,
chatTypeLabel: requestChatTypeLabel,
chatTypeDescription,
chatTypeBaseDescription,
defaultContextIds,
defaultContexts,
customContextTitle,
customContextContent,
retryCount: 0,
failed: false,
};
if (origin === 'prompt') {
promptRequestIdsRef?.current.add(requestId);
}
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: targetSessionId,
requestId,
chatTypeId: requestChatTypeId,
chatTypeLabel: requestChatTypeLabel,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'queued',
statusMessage: '대기열 등록',
userMessageId: optimisticUserMessage.id,
userText: text,
responseMessageId: null,
responseText: '',
hasResponse: false,
canDelete: false,
createdAt: queuedAt,
updatedAt: queuedAt,
answeredAt: null,
terminalAt: null,
});
syncConversationPreviewForRequest(targetSessionId, text, queuedAt, {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'queue',
queueSize: 1,
jobMessage: '대기열 등록 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
scheduleViewportBottomSyncAfterSend();
setActiveSystemStatus('대기열 등록 중...');
setIsSystemStatusPending(true);
} else {
const optimisticUserMessage: ChatMessage = {
...createChatMessage('user', text, requestId),
deliveryStatus: null,
retryCount: 0,
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 즉시 요청을 접수했습니다.',
'# 진행: 요청 내용을 정리한 뒤 답변을 준비합니다.',
]);
upsertRequestItem({
sessionId: targetSessionId,
requestId,
chatTypeId: requestChatTypeId,
chatTypeLabel: requestChatTypeLabel,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
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,
});
syncConversationPreviewForRequest(targetSessionId, text, new Date().toISOString(), {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'direct',
queueSize: 0,
jobMessage: '즉시 요청 실행 대기 중',
});
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
setMessages((previous) => {
const nextMessages = [...previous, optimisticUserMessage];
return optimisticActivityMessage ? [...nextMessages, optimisticActivityMessage] : nextMessages;
});
scheduleViewportBottomSyncAfterSend();
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);
}
composerRestoreNeeded = false;
return true;
}
try {
sendChatRequest(socketRef.current, outgoingRequest);
} catch {
setActiveSystemStatus('전송 재시도 중...');
pendingRequestsRef.current = [
...pendingRequestsRef.current.filter((pending) => pending.requestId !== requestId),
outgoingRequest,
];
if (mode === 'direct') {
updatePendingMessageStatus(requestId, 'retrying', 0);
}
}
composerRestoreNeeded = false;
return true;
} catch (error) {
restoreComposerOnFailure();
throw error;
} finally {
if (origin !== 'prompt' && activeComposerSubmissionKeyRef.current === submissionKey) {
activeComposerSubmissionKeyRef.current = null;
}
}
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
return true;
},
[
activeSessionId,
buildComposerSubmissionKey,
composerAttachments,
createActivityLogPlaceholder,
createChatMessage,
createLocalMessage,
ensureSessionReady,
focusComposerAfterSend,
getDraft,
isSocketOpen,
pendingRequestsRef,
promptRequestIdsRef,
scheduleViewportBottomSyncAfterSend,
sendChatRequest,
setActiveSystemStatus,
setComposerAttachments,
@@ -493,15 +681,15 @@ export function useConversationComposerController({
);
const sendMessage = useCallback(
({ mode, draftText }: SendMessageOptions) => {
({ sessionId, mode, draftText }: SendMessageOptions): SendMessageResult => {
if (isComposerAttachmentUploading) {
return;
return 'blocked';
}
const trimmed = buildOutgoingMessageText(draftText ?? getDraft(), composerAttachments).trim();
if (!trimmed) {
return;
return 'blocked';
}
if (!selectedChatType) {
@@ -509,7 +697,15 @@ export function useConversationComposerController({
...previous.slice(-39),
createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'),
]);
return;
return 'blocked';
}
if (!isSocketOpen()) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage('워크서버 연결이 아직 준비되지 않았습니다. 연결이 복구된 뒤 다시 전송해 주세요.'),
]);
return 'blocked';
}
const recentContext = summarizeRecentContext(
@@ -519,9 +715,34 @@ export function useConversationComposerController({
);
if (recentContext.omittedCount > 0) {
setPendingContextConfirm({
const targetSessionId = sessionId?.trim() || activeSessionId.trim();
const nextRequest = {
sessionId: targetSessionId,
mode,
text: trimmed,
codexModel: selectedCodexModel,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
chatTypeBaseDescription: selectedChatType.baseDescription,
defaultContextIds: selectedChatType.defaultContextIds,
defaultContexts: selectedChatType.defaultContexts,
customContextTitle: selectedChatType.customContextTitle,
customContextContent: selectedChatType.customContextContent,
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
} satisfies PendingContextConfirm;
if (shouldSkipContextConfirmForSessionToday(targetSessionId)) {
void executeSendMessage(nextRequest).catch(handleExecuteSendError);
return 'sent';
}
setPendingContextConfirm({
sessionId: targetSessionId,
mode,
text: trimmed,
codexModel: selectedCodexModel,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
@@ -533,12 +754,14 @@ export function useConversationComposerController({
includedContextCount: recentContext.includedCount,
omittedContextCount: recentContext.omittedCount,
});
return;
return 'pending';
}
executeSendMessage({
void executeSendMessage({
sessionId: sessionId?.trim() || activeSessionId.trim(),
mode,
text: trimmed,
codexModel: selectedCodexModel,
chatTypeId: selectedChatType.id,
chatTypeLabel: selectedChatType.name,
chatTypeDescription: selectedChatType.description,
@@ -549,7 +772,8 @@ export function useConversationComposerController({
customContextContent: selectedChatType.customContextContent,
includedContextCount: 0,
omittedContextCount: 0,
});
}).catch(handleExecuteSendError);
return 'sent';
},
[
appConfigChat.maxContextChars,
@@ -558,8 +782,12 @@ export function useConversationComposerController({
composerAttachments,
createLocalMessage,
getDraft,
handleExecuteSendError,
executeSendMessage,
isSocketOpen,
isComposerAttachmentUploading,
selectedCodexModel,
activeSessionId,
messagesRef,
selectedChatType,
setMessages,