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,8 +1,10 @@
import { Button, Empty, Input, List, Spin, Typography } from 'antd';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { shouldShowConversationForMode } from '../../isolatedChatRooms';
const { Text } = Typography;
type ConversationListPaneProps = {
items: ChatConversationSummary[];
isLoading: boolean;
@@ -24,13 +26,15 @@ export function ConversationListPane({
onSelectSession,
onCreateConversation,
}: ConversationListPaneProps) {
const visibleItems = items.filter((item) => shouldShowConversationForMode(item.sessionId, 'live'));
return (
<section className="chat-v2__pane chat-v2__pane--list">
<div className="chat-v2__pane-header">
<div>
<Text strong> </Text>
<br />
<Text type="secondary">{items.length} </Text>
<Text type="secondary">{visibleItems.length} </Text>
</div>
<Button type="primary" onClick={onCreateConversation}>
@@ -54,14 +58,14 @@ export function ConversationListPane({
<div className="chat-v2__state">
<Text type="danger">{errorMessage}</Text>
</div>
) : items.length === 0 ? (
) : visibleItems.length === 0 ? (
<div className="chat-v2__state">
<Empty description="대화가 없습니다." />
</div>
) : (
<List
className="chat-v2__conversation-list"
dataSource={items}
dataSource={visibleItems}
renderItem={(item) => (
<List.Item>
<button

View File

@@ -21,6 +21,14 @@ const GENERAL_REQUEST_OPTION = [
},
];
const GENERAL_CODEX_MODEL_OPTION = [
{
value: 'gpt-5.4',
label: 'GPT-5.4',
description: '기본 모델',
},
];
export function ConversationRoomPane({
sessionId,
messages,
@@ -29,20 +37,25 @@ export function ConversationRoomPane({
loadingLabel,
errorMessage,
}: ConversationRoomPaneProps) {
const normalizedSessionId = typeof sessionId === 'string' ? sessionId : '';
const normalizedMessages = Array.isArray(messages) ? messages : [];
const normalizedRequests = Array.isArray(requests) ? requests : [];
const normalizedLoadingLabel = typeof loadingLabel === 'string' ? loadingLabel : '';
const normalizedErrorMessage = typeof errorMessage === 'string' ? errorMessage : '';
const viewportRef = useRef<HTMLDivElement | null>(null);
const composerRef = useRef<TextAreaRef | null>(null);
const requestStateMap = useMemo(
() => new Map(requests.map((request) => [request.requestId, request])),
[requests],
() => new Map(normalizedRequests.map((request) => [request.requestId, request])),
[normalizedRequests],
);
const activeSystemStatus = errorMessage.trim() || (sessionId ? null : '대화방을 선택해 주세요.');
const activeSystemStatus = normalizedErrorMessage.trim() || (normalizedSessionId ? null : '대화방을 선택해 주세요.');
return (
<ChatConversationView
sessionId={sessionId}
sessionId={normalizedSessionId}
viewportRef={viewportRef}
composerRef={composerRef}
visibleMessages={messages}
visibleMessages={normalizedMessages}
activeSystemStatus={activeSystemStatus}
isSystemStatusPending={false}
showScrollToBottom={false}
@@ -50,20 +63,24 @@ export function ConversationRoomPane({
draft=""
draftVersion={0}
composerAttachments={[]}
lastReadResponseMessageId={null}
requestStateMap={requestStateMap}
isConversationLoading={isLoading}
conversationLoadingLabel={loadingLabel}
conversationLoadingLabel={normalizedLoadingLabel}
hasOlderMessages={false}
isLoadingOlderMessages={false}
isPullToLoadArmed={false}
pullToLoadDistance={0}
selectedChatTypeId="general-request"
selectedCodexModel="gpt-5.4"
queuedRequests={[]}
chatTypeOptions={GENERAL_REQUEST_OPTION}
codexModelOptions={GENERAL_CODEX_MODEL_OPTION}
previewItems={[]}
isResourceStripOpen={false}
isComposerDisabled={true}
isMobileViewport={false}
isIpadLikeViewport={false}
isChatTypeSelectionLocked={true}
isComposerAttachmentUploading={false}
isSendWithoutContextEnabled={false}
@@ -75,21 +92,27 @@ export function ConversationRoomPane({
onPickComposerFiles={async () => ({ items: [] })}
onRemoveComposerAttachment={() => {}}
onSelectChatType={() => {}}
onSend={() => {}}
onSendImmediate={() => {}}
onSelectCodexModel={() => {}}
onSend={() => 'blocked'}
onSendImmediate={() => 'blocked'}
onToggleSendWithoutContext={() => {}}
isImmediateSendPinned={false}
onToggleImmediateSendPinned={() => {}}
onClearDraft={() => {}}
onScrollToBottom={() => {}}
onToggleResourceStrip={() => {}}
onLoadOlderMessages={() => {}}
onOpenPreview={() => {}}
onCopyMessage={() => {}}
onRetryMessage={() => {}}
onRetryFailedRequest={() => {}}
onCancelMessage={() => {}}
onDeleteRequest={() => {}}
onCompleteManualRequestBadge={() => {}}
onRemoveQueuedRequest={() => {}}
onPromoteQueuedRequest={() => {}}
onSubmitPrompt={async () => false}
onSubmitChildRequest={async () => false}
/>
);
}

View File

@@ -1,11 +1,16 @@
import type { ChatConversationSummary } from '../../mainChatPanel/types';
export const CHAT_CONVERSATIONS_UPDATED_EVENT = 'work-server.chat-conversations-updated';
export const CHAT_CONVERSATION_CLEARED_EVENT = 'work-server.chat-conversation-cleared';
type ChatConversationsUpdatedDetail = {
items: ChatConversationSummary[];
};
type ChatConversationClearedDetail = {
item: ChatConversationSummary;
};
export function emitChatConversationsUpdated(items: ChatConversationSummary[]) {
if (typeof window === 'undefined') {
return;
@@ -32,3 +37,30 @@ export function readChatConversationsUpdatedEvent(
return detail as ChatConversationsUpdatedDetail;
}
export function emitChatConversationCleared(item: ChatConversationSummary) {
if (typeof window === 'undefined') {
return;
}
window.dispatchEvent(
new CustomEvent<ChatConversationClearedDetail>(CHAT_CONVERSATION_CLEARED_EVENT, {
detail: { item },
}),
);
}
export function readChatConversationClearedEvent(
event: Event,
): ChatConversationClearedDetail | null {
if (!(event instanceof CustomEvent)) {
return null;
}
const detail = event.detail;
if (!detail || typeof detail !== 'object' || !('item' in (detail as Record<string, unknown>))) {
return null;
}
return detail as ChatConversationClearedDetail;
}

View File

@@ -8,7 +8,6 @@ import {
fetchChatRuntimeJobDetail,
fetchChatRuntimeSnapshot,
markChatConversationResponsesRead,
renameChatConversationRoom,
updateChatConversationRoom,
uploadChatComposerFile,
} from '../../mainChatPanel';
@@ -36,28 +35,33 @@ export type ChatGateway = {
createConversation: (args: {
sessionId: string;
title: string;
draftText?: string | null;
requestBadgeLabel?: string | null;
codexModel?: string | null;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
roomScope?: Record<string, unknown> | null;
}) => Promise<ChatConversationSummary>;
renameConversation: (sessionId: string, title: string) => Promise<ChatConversationSummary>;
updateConversation: (
sessionId: string,
payload: Partial<
Pick<
ChatConversationSummary,
| 'title'
| 'draftText'
| 'requestBadgeLabel'
| 'codexModel'
| 'chatTypeId'
| 'lastChatTypeId'
| 'generalSectionName'
| 'contextLabel'
| 'contextDescription'
| 'notifyOffline'
| 'roomScope'
>
>,
) => Promise<ChatConversationSummary>;
@@ -76,7 +80,6 @@ export const chatGateway: ChatGateway = {
listConversations: fetchChatConversations,
getConversationDetail: fetchChatConversationDetail,
createConversation: createChatConversationRoom,
renameConversation: renameChatConversationRoom,
updateConversation: updateChatConversationRoom,
clearConversation: clearChatConversationRoom,
deleteConversation: async (sessionId) => {

View File

@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mergeConversationItemsPreservingRequestedSession } from './conversationListMerge.js';
import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle.js';
function createConversationSummary(overrides) {
return {
sessionId: overrides.sessionId,
clientId: overrides.clientId ?? null,
isDraftOnly: overrides.isDraftOnly,
draftText: overrides.draftText ?? '',
title: overrides.title,
requestBadgeLabel: overrides.requestBadgeLabel ?? null,
codexModel: overrides.codexModel ?? null,
chatTypeId: overrides.chatTypeId ?? null,
lastChatTypeId: overrides.lastChatTypeId ?? null,
generalSectionName: overrides.generalSectionName ?? null,
contextLabel: overrides.contextLabel ?? null,
contextDescription: overrides.contextDescription ?? null,
roomScope: overrides.roomScope ?? null,
notifyOffline: overrides.notifyOffline ?? true,
hasUnreadResponse: overrides.hasUnreadResponse ?? false,
currentRequestId: overrides.currentRequestId ?? null,
currentJobStatus: overrides.currentJobStatus ?? null,
currentJobMessage: overrides.currentJobMessage ?? null,
currentQueueSize: overrides.currentQueueSize ?? 0,
currentStatusUpdatedAt: overrides.currentStatusUpdatedAt ?? null,
isPendingWork: overrides.isPendingWork ?? false,
pendingWorkReason: overrides.pendingWorkReason ?? null,
lastRequestPreview: overrides.lastRequestPreview ?? '',
lastMessagePreview: overrides.lastMessagePreview ?? '',
lastResponsePreview: overrides.lastResponsePreview ?? '',
createdAt: overrides.createdAt ?? '2026-05-18T00:00:00.000Z',
updatedAt: overrides.updatedAt ?? '2026-05-18T00:00:00.000Z',
lastMessageAt: overrides.lastMessageAt ?? null,
};
}
test('resolveMergedConversationTitle keeps an explicit title when the incoming title is the default placeholder', () => {
assert.equal(resolveMergedConversationTitle('채팅 관리 / 유형 권한 관리', '새 대화'), '채팅 관리 / 유형 권한 관리');
});
test('mergeConversationItemsPreservingRequestedSession does not overwrite an explicit title with the default placeholder', () => {
const previousItems = [
createConversationSummary({
sessionId: 'session-1',
title: '채팅 관리 / 유형 권한 관리',
updatedAt: '2026-05-18T00:00:05.000Z',
}),
];
const nextItems = [
createConversationSummary({
sessionId: 'session-1',
title: '새 대화',
updatedAt: '2026-05-18T00:00:10.000Z',
}),
];
const [mergedItem] = mergeConversationItemsPreservingRequestedSession(nextItems, previousItems, 'session-1');
assert.ok(mergedItem);
assert.equal(mergedItem.title, '채팅 관리 / 유형 권한 관리');
});

View File

@@ -1,5 +1,9 @@
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { resolveConversationUnreadMergeState } from '../../mainChatPanel/conversationUnread';
import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle';
import {
resolveConversationUnreadMergeState,
resolveStoredConversationUnreadState,
} from '../../mainChatPanel/conversationUnread';
function shouldPreserveRequestMetadata(
previousItem: Pick<ChatConversationSummary, 'currentRequestId'>,
@@ -56,13 +60,16 @@ function mergeConversationSummaries(existing: ChatConversationSummary, incoming:
...preferred,
clientId: preferred.clientId ?? fallback.clientId,
isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly,
title: preferred.title.trim() || fallback.title.trim(),
draftText: preferred.draftText ?? fallback.draftText ?? '',
title: resolveMergedConversationTitle(fallback.title, preferred.title),
requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.trim() || null,
codexModel: preferred.codexModel?.trim() || fallback.codexModel?.trim() || null,
chatTypeId: preferred.chatTypeId?.trim() || fallback.chatTypeId?.trim() || null,
lastChatTypeId: preferred.lastChatTypeId?.trim() || fallback.lastChatTypeId?.trim() || null,
generalSectionName: preferred.generalSectionName?.trim() || fallback.generalSectionName?.trim() || null,
contextLabel: preferred.contextLabel?.trim() || fallback.contextLabel?.trim() || null,
contextDescription: preferred.contextDescription?.trim() || fallback.contextDescription?.trim() || null,
roomScope: preferred.roomScope ?? fallback.roomScope ?? null,
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
@@ -82,6 +89,14 @@ function mergeConversationSummaries(existing: ChatConversationSummary, incoming:
};
}
function shouldPreserveMissingConversation(item: ChatConversationSummary | null | undefined) {
if (!item) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
}
function sortChatConversationSummaries(items: ChatConversationSummary[]) {
const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
const sessionId = item.sessionId.trim();
@@ -125,7 +140,10 @@ export function mergeConversationItemsPreservingRequestedSession(
const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) {
return item;
return {
...item,
hasUnreadResponse: resolveStoredConversationUnreadState(item),
};
}
const preserveRequestMetadata = shouldPreserveRequestMetadata(previousItem, item);
@@ -140,12 +158,13 @@ export function mergeConversationItemsPreservingRequestedSession(
return {
...item,
title: preserveRequestMetadata
? previousItem.title.trim() || item.title.trim()
: item.title.trim() || previousItem.title.trim(),
title: resolveMergedConversationTitle(previousItem.title, item.title, {
preservePrevious: preserveRequestMetadata,
}),
requestBadgeLabel: preserveRequestMetadata
? previousItem.requestBadgeLabel?.trim() || item.requestBadgeLabel?.trim() || null
: item.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null,
codexModel: item.codexModel?.trim() || previousItem.codexModel?.trim() || null,
chatTypeId,
lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
@@ -154,32 +173,14 @@ export function mergeConversationItemsPreservingRequestedSession(
lastRequestPreview: item.lastRequestPreview.trim() || previousItem.lastRequestPreview.trim(),
lastMessagePreview: item.lastMessagePreview.trim() || previousItem.lastMessagePreview.trim(),
lastResponsePreview: item.lastResponsePreview.trim() || previousItem.lastResponsePreview.trim(),
currentRequestId:
item.currentRequestId?.trim() ||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentRequestId : null) ||
null,
currentJobStatus:
item.currentJobStatus ??
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentJobStatus
: null),
currentJobMessage:
item.currentJobMessage?.trim() ||
((item.currentJobStatus == null || item.currentJobStatus === 'completed') ? previousItem.currentJobMessage?.trim() : '') ||
null,
// For sessions that still exist in the server list, trust the server's
// current job state instead of reviving stale local queued/started flags.
currentRequestId: item.currentRequestId?.trim() || null,
currentJobStatus: item.currentJobStatus ?? null,
currentJobMessage: item.currentJobMessage?.trim() || null,
currentQueueSize:
item.currentQueueSize > 0
? item.currentQueueSize
: item.currentJobStatus === 'queued'
? Math.max(1, previousItem.currentQueueSize)
: previousItem.currentJobStatus === 'queued' && item.currentJobStatus == null
? Math.max(1, previousItem.currentQueueSize)
: item.currentQueueSize,
currentStatusUpdatedAt:
item.currentStatusUpdatedAt ||
((previousItem.currentJobStatus === 'queued' || previousItem.currentJobStatus === 'started')
? previousItem.currentStatusUpdatedAt
: null),
item.currentJobStatus === 'queued' ? Math.max(1, item.currentQueueSize) : Math.max(0, item.currentQueueSize),
currentStatusUpdatedAt: item.currentStatusUpdatedAt || null,
};
});
const normalizedRequestedSessionId = requestedSessionId.trim();
@@ -205,6 +206,10 @@ export function mergeConversationItemsPreservingRequestedSession(
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!shouldPreserveMissingConversation(preservedRequestedSession)) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
if (!preservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}

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,

View File

@@ -7,6 +7,7 @@ import type {
ChatConversationRequest,
ChatConversationSummary,
ChatMessage,
ChatPromptContextRef,
} from '../../mainChatPanel/types';
type PendingChatRequest = {
@@ -14,7 +15,9 @@ type PendingChatRequest = {
requestId: string;
text: string;
mode: 'queue' | 'direct';
promptContextRef?: ChatPromptContextRef | null;
omitPromptHistory?: boolean;
codexModel: string;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
@@ -33,9 +36,9 @@ type PendingChatRequest = {
type UseConversationRoomActionsControllerOptions = {
activeSessionId: string;
requestedSessionId: string;
handledRequestedSessionIdRef: { current: string };
isClosingConversationRef: { current: boolean };
conversationItems: ChatConversationSummary[];
activeConversation: ChatConversationSummary | null;
editingConversationTitle: string;
isMobileViewport: boolean;
pendingRequestsRef: { current: PendingChatRequest[] };
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
@@ -54,9 +57,7 @@ type UseConversationRoomActionsControllerOptions = {
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;
setPreserveEmptyConversationSelection: (value: boolean) => void;
updatePendingMessageStatus: (requestId: string, status: 'retrying' | 'failed' | null, retryCount?: number) => void;
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
createLocalMessage: (text: string) => ChatMessage;
@@ -69,9 +70,9 @@ type UseConversationRoomActionsControllerOptions = {
export function useConversationRoomActionsController({
activeSessionId,
requestedSessionId,
handledRequestedSessionIdRef,
isClosingConversationRef,
conversationItems,
activeConversation,
editingConversationTitle,
isMobileViewport,
pendingRequestsRef,
sessionMessageCacheRef,
@@ -90,9 +91,7 @@ export function useConversationRoomActionsController({
setIsResourceStripOpen,
setIsConversationPaneClosed,
setIsMobileConversationView,
setRenamingConversationSessionId,
setEditingConversationTitle,
setIsEditingConversationTitle,
setPreserveEmptyConversationSelection,
updatePendingMessageStatus,
sendChatRequest,
createLocalMessage,
@@ -266,62 +265,6 @@ export function useConversationRoomActionsController({
],
);
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 = activeConversation.isDraftOnly
? await chatGateway.createConversation({
sessionId,
title: trimmedTitle,
chatTypeId: activeConversation.chatTypeId,
lastChatTypeId: activeConversation.lastChatTypeId,
generalSectionName: activeConversation.generalSectionName,
contextLabel: activeConversation.contextLabel ?? undefined,
contextDescription: activeConversation.contextDescription ?? undefined,
notifyOffline: activeConversation.notifyOffline,
})
: 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 {
@@ -340,6 +283,9 @@ export function useConversationRoomActionsController({
setConversationItems(remaining);
if (sessionId === activeSessionId) {
isClosingConversationRef.current = true;
handledRequestedSessionIdRef.current = '';
setPreserveEmptyConversationSelection(true);
replaceChatSessionInUrl('');
chatConnectionGateway.resetLastReceivedEventId('');
setActiveSessionId('');
@@ -353,9 +299,10 @@ export function useConversationRoomActionsController({
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
setIsResourceStripOpen(false);
setIsConversationPaneClosed(false);
setIsMobileConversationView(!isMobileViewport);
setIsConversationPaneClosed(true);
setIsMobileConversationView(false);
} else if (requestedSessionId === sessionId) {
handledRequestedSessionIdRef.current = '';
replaceChatSessionInUrl(activeSessionId);
}
} catch (error) {
@@ -365,6 +312,8 @@ export function useConversationRoomActionsController({
[
activeSessionId,
conversationItems,
handledRequestedSessionIdRef,
isClosingConversationRef,
isMobileViewport,
messageApi,
replaceChatSessionInUrl,
@@ -382,6 +331,7 @@ export function useConversationRoomActionsController({
setIsPreviewModalOpen,
setIsResourceStripOpen,
setIsSystemStatusPending,
setPreserveEmptyConversationSelection,
setMessages,
setRequestItems,
],
@@ -434,7 +384,6 @@ export function useConversationRoomActionsController({
handleClearConversation,
deleteStoredRequest,
handleDeleteConversation,
handleRenameConversation,
removeQueuedComposerRequest,
retryPendingRequest,
};

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { mergeConversationRequestStatusMessage, mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import { resolveMergedConversationTitle } from '../../mainChatPanel/conversationTitle';
import { chatGateway } from '../data/chatGateway';
import type {
ChatConversationRequest,
@@ -8,8 +9,8 @@ import type {
ChatMessage,
} from '../../mainChatPanel/types';
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 8;
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 8;
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 10;
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 10;
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
function mergeConversationRequests(
@@ -75,6 +76,7 @@ type UseConversationRoomDataOptions = {
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
viewportRef: MutableRefObject<HTMLDivElement | null>;
onMissingConversation?: (sessionId: string) => void;
shouldPreserveMissingConversation?: (sessionId: string) => boolean;
};
function isMissingConversationError(error: unknown) {
@@ -110,6 +112,7 @@ export function useConversationRoomData({
queueViewportPrependRestore,
viewportRef,
onMissingConversation,
shouldPreserveMissingConversation,
}: UseConversationRoomDataOptions) {
const previousSessionIdRef = useRef('');
@@ -194,13 +197,21 @@ export function useConversationRoomData({
if (!isCancelled && response.item.sessionId === requestedSessionId) {
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
const previousItem = previous.find((item) => item.sessionId === response.item.sessionId) ?? null;
const exists = previousItem != null;
const mergedItem = previousItem
? {
...response.item,
title: resolveMergedConversationTitle(previousItem.title, response.item.title),
}
: response.item;
if (!exists) {
return sortChatConversationSummaries([response.item, ...previous]);
return sortChatConversationSummaries([mergedItem, ...previous]);
}
return sortChatConversationSummaries(
previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)),
previous.map((item) => (item.sessionId === response.item.sessionId ? mergedItem : item)),
);
});
@@ -224,6 +235,14 @@ export function useConversationRoomData({
} catch (error) {
if (!isCancelled) {
if (cachedMessages.length === 0 && isMissingConversationError(error)) {
if (shouldPreserveMissingConversation?.(requestedSessionId)) {
setMessages([]);
setHasOlderMessages(false);
setOldestLoadedMessageId(null);
setConversationLoadingLabel('새 채팅방을 준비하는 중입니다.');
return;
}
sessionMessageCacheRef.current.delete(requestedSessionId);
setConversationItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
setMessages([]);
@@ -275,6 +294,7 @@ export function useConversationRoomData({
setMessages,
setOldestLoadedMessageId,
onMissingConversation,
shouldPreserveMissingConversation,
setRequestItems,
]);

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import type { ChatComposerAttachment, ChatMessage } from '../../mainChatPanel/types';
import type { ChatComposerAttachment } from '../../mainChatPanel/types';
type PreviewItem = {
id: string;
@@ -22,7 +22,6 @@ type UseConversationViewControllerOptions = {
setDraft: React.Dispatch<React.SetStateAction<string>>;
setIsResourceStripOpen: (value: boolean) => void;
setIsSystemStatusPending: (value: boolean) => void;
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>;
};
export function useConversationViewController({
@@ -38,7 +37,6 @@ export function useConversationViewController({
setDraft,
setIsResourceStripOpen,
setIsSystemStatusPending,
setMessages,
}: UseConversationViewControllerOptions) {
const previousSessionIdRef = useRef(activeSessionId);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
@@ -52,7 +50,8 @@ export function useConversationViewController({
const activePreview = activePreviewOverride ?? previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
useEffect(() => {
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
const previousSessionId = previousSessionIdRef.current;
const hasSessionChanged = previousSessionId !== activeSessionId;
if (!hasSessionChanged) {
return;
@@ -60,8 +59,13 @@ export function useConversationViewController({
previousSessionIdRef.current = activeSessionId;
setMessages([]);
setDraft('');
// Draft restoration is handled by the panel layer per session. Keep this
// hook focused on view-only resets so session changes do not wipe a
// restored draft after the panel has reloaded it from storage.
if (previousSessionId.trim() && !activeSessionId.trim()) {
return;
}
setComposerAttachments([]);
setCopiedMessageId(null);
setActivePreviewId(null);
@@ -78,7 +82,6 @@ export function useConversationViewController({
setDraft,
setIsResourceStripOpen,
setIsSystemStatusPending,
setMessages,
]);
useEffect(() => {

View File

@@ -49,7 +49,10 @@ export function useConversationViewportController({
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const systemStatusTimerRef = useRef<number | null>(null);
const restoreAutoScrollFrameRef = useRef<number | null>(null);
const showScrollToBottomRef = useRef(false);
const shouldStickToBottomRef = useRef(true);
const lastViewportScrollTopRef = useRef(0);
const autoScrollSuspendedUntilRef = useRef(0);
const pendingViewportRestoreRef = useRef(false);
const pendingPrependRestoreRef = useRef<{
previousScrollHeight: number;
@@ -71,8 +74,17 @@ export function useConversationViewportController({
}
}, []);
const syncShowScrollToBottom = useCallback((nextValue: boolean) => {
if (showScrollToBottomRef.current === nextValue) {
return;
}
showScrollToBottomRef.current = nextValue;
setShowScrollToBottom(nextValue);
}, []);
const scrollViewportToBottom = useCallback(
(behavior: ScrollBehavior = 'smooth') => {
(behavior: ScrollBehavior = 'auto') => {
const viewport = viewportRef.current;
if (!viewport) {
@@ -87,6 +99,12 @@ export function useConversationViewportController({
[viewportRef],
);
const isAutoScrollSuspended = useCallback(() => autoScrollSuspendedUntilRef.current > Date.now(), []);
const releaseAutoScrollSuspension = useCallback(() => {
autoScrollSuspendedUntilRef.current = 0;
}, []);
const scheduleViewportBottomSync = useCallback(
(frameCount = 6) => {
if (restoreAutoScrollFrameRef.current !== null) {
@@ -96,12 +114,12 @@ export function useConversationViewportController({
const run = (remainingFrames: number) => {
restoreAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
if (!shouldStickToBottomRef.current || isConversationContentLoading) {
if (!shouldStickToBottomRef.current || isConversationContentLoading || isAutoScrollSuspended()) {
restoreAutoScrollFrameRef.current = null;
return;
}
setShowScrollToBottom(false);
syncShowScrollToBottom(false);
scrollViewportToBottom('auto');
if (remainingFrames <= 1) {
@@ -115,7 +133,7 @@ export function useConversationViewportController({
run(frameCount);
},
[isConversationContentLoading, scrollViewportToBottom],
[isAutoScrollSuspended, isConversationContentLoading, scrollViewportToBottom, syncShowScrollToBottom],
);
const handleViewportScroll = useCallback(() => {
@@ -127,10 +145,19 @@ export function useConversationViewportController({
const remainingDistance = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
const isNearBottom = remainingDistance <= 24;
const isScrollingUp = viewport.scrollTop < lastViewportScrollTopRef.current - 2;
shouldStickToBottomRef.current = isNearBottom;
setShowScrollToBottom(!isNearBottom);
}, [viewportRef]);
if (isNearBottom) {
releaseAutoScrollSuspension();
} else if (isScrollingUp) {
autoScrollSuspendedUntilRef.current = Date.now() + 1600;
}
const shouldStickToBottom = isNearBottom && !isAutoScrollSuspended();
shouldStickToBottomRef.current = shouldStickToBottom;
lastViewportScrollTopRef.current = viewport.scrollTop;
syncShowScrollToBottom(!shouldStickToBottom);
}, [isAutoScrollSuspended, releaseAutoScrollSuspension, syncShowScrollToBottom, viewportRef]);
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
if (options?.forceStickToBottom) {
@@ -166,15 +193,15 @@ export function useConversationViewportController({
}, []);
useEffect(() => {
if (!shouldStickToBottomRef.current) {
if (!shouldStickToBottomRef.current || isAutoScrollSuspended()) {
return;
}
scrollViewportToBottom(chatMessageCount > 1 ? 'smooth' : 'auto');
}, [chatMessageCount, chatMessageSyncKey, scrollViewportToBottom]);
scrollViewportToBottom('auto');
}, [chatMessageCount, chatMessageSyncKey, isAutoScrollSuspended, scrollViewportToBottom]);
useEffect(() => {
if (isConversationContentLoading || !shouldStickToBottomRef.current) {
if (isConversationContentLoading || !shouldStickToBottomRef.current || isAutoScrollSuspended()) {
return;
}
@@ -186,7 +213,13 @@ export function useConversationViewportController({
restoreAutoScrollFrameRef.current = null;
}
};
}, [activeConversation?.sessionId, chatMessageSyncKey, isConversationContentLoading, scheduleViewportBottomSync]);
}, [
activeConversation?.sessionId,
chatMessageSyncKey,
isAutoScrollSuspended,
isConversationContentLoading,
scheduleViewportBottomSync,
]);
useEffect(() => {
const pendingPrependRestore = pendingPrependRestoreRef.current;
@@ -233,8 +266,9 @@ export function useConversationViewportController({
}
if (!restoreSnapshot || restoreSnapshot.shouldStickToBottom) {
releaseAutoScrollSuspension();
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
syncShowScrollToBottom(false);
scrollViewportToBottom('auto');
return;
}
@@ -254,7 +288,9 @@ export function useConversationViewportController({
chatMessageCount,
handleViewportScroll,
isConversationContentLoading,
releaseAutoScrollSuspension,
scrollViewportToBottom,
syncShowScrollToBottom,
viewportRef,
]);
@@ -382,7 +418,7 @@ export function useConversationViewportController({
return clearSystemStatusTimer;
}
if (activeConversation?.currentJobStatus && !runtimeSnapshot) {
if (activeConversation?.currentJobStatus) {
clearSystemStatusTimer();
setActiveSystemStatus(mapJobStatusLabel(activeConversation));
setIsSystemStatusPending(
@@ -399,12 +435,18 @@ export function useConversationViewportController({
}
clearSystemStatusTimer();
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
const latestMessage = messages[messages.length - 1];
if (!latestMessage || isActivityLogMessage(latestMessage) || latestMessage.author !== 'system') {
if (activeSystemStatus == null && !isSystemStatusPending) {
return clearSystemStatusTimer;
}
systemStatusTimerRef.current = window.setTimeout(() => {
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
systemStatusTimerRef.current = null;
}, 450);
return clearSystemStatusTimer;
}
@@ -415,6 +457,15 @@ export function useConversationViewportController({
const nextStatus = mapSystemStatusMessage(latestMessage.text);
if (!nextStatus || isTerminalStatus) {
if (activeSystemStatus == null && !isSystemStatusPending) {
return clearSystemStatusTimer;
}
systemStatusTimerRef.current = window.setTimeout(() => {
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
systemStatusTimerRef.current = null;
}, 450);
return clearSystemStatusTimer;
}
@@ -429,10 +480,12 @@ export function useConversationViewportController({
clearSystemStatusTimer,
connectionState,
isActivityLogMessage,
isSystemStatusPending,
mapJobStatusLabel,
mapSystemStatusMessage,
messages,
runtimeSnapshot,
activeSystemStatus,
]);
useEffect(() => {
@@ -454,7 +507,7 @@ export function useConversationViewportController({
scrollViewportToBottom,
setActiveSystemStatus,
setIsSystemStatusPending,
setShowScrollToBottom,
setShowScrollToBottom: syncShowScrollToBottom,
shouldStickToBottomRef,
showScrollToBottom,
handleViewportTouchEnd,
@@ -463,5 +516,6 @@ export function useConversationViewportController({
isPullToLoadArmed,
pullToLoadDistance,
queueViewportPrependRestore,
releaseAutoScrollSuspension,
};
}

View File

@@ -9,8 +9,6 @@ import {
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
const UNREAD_COUNT_REFRESH_INTERVAL_MS = 15_000;
type UseUnreadCountsResult = {
chatUnreadCount: number;
notificationUnreadCount: number;
@@ -150,21 +148,14 @@ export function useUnreadCounts(): UseUnreadCountsResult {
refreshAllUnreadCounts();
};
const intervalId = window.setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
refreshAllUnreadCounts();
}, UNREAD_COUNT_REFRESH_INTERVAL_MS);
window.addEventListener('focus', handleVisibilityOrFocus);
window.addEventListener('online', handleVisibilityOrFocus);
window.addEventListener('pageshow', handleVisibilityOrFocus);
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
return () => {
window.clearInterval(intervalId);
window.removeEventListener('focus', handleVisibilityOrFocus);
window.removeEventListener('online', handleVisibilityOrFocus);
window.removeEventListener('pageshow', handleVisibilityOrFocus);
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
};