feat: update main chat and system chat UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
63
src/app/main/chatV2/hooks/conversationListMerge.test.mjs
Normal file
63
src/app/main/chatV2/hooks/conversationListMerge.test.mjs
Normal 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, '채팅 관리 / 유형 권한 관리');
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user