chore: exclude local resource artifacts from main sync

This commit is contained in:
2026-05-15 10:16:45 +09:00
parent 442879313f
commit d38d022872
504 changed files with 17074 additions and 3642 deletions

View File

@@ -39,6 +39,7 @@ export function ConversationRoomPane({
return (
<ChatConversationView
sessionId={sessionId}
viewportRef={viewportRef}
composerRef={composerRef}
visibleMessages={messages}
@@ -77,6 +78,8 @@ export function ConversationRoomPane({
onSend={() => {}}
onSendImmediate={() => {}}
onToggleSendWithoutContext={() => {}}
isImmediateSendPinned={false}
onToggleImmediateSendPinned={() => {}}
onClearDraft={() => {}}
onScrollToBottom={() => {}}
onToggleResourceStrip={() => {}}

View File

@@ -36,8 +36,10 @@ export type ChatGateway = {
createConversation: (args: {
sessionId: string;
title: string;
requestBadgeLabel?: string | null;
chatTypeId?: string | null;
lastChatTypeId?: string | null;
generalSectionName?: string | null;
contextLabel?: string;
contextDescription?: string;
notifyOffline?: boolean;
@@ -49,6 +51,7 @@ export type ChatGateway = {
Pick<
ChatConversationSummary,
| 'title'
| 'requestBadgeLabel'
| 'chatTypeId'
| 'lastChatTypeId'
| 'generalSectionName'

View File

@@ -0,0 +1,221 @@
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { resolveConversationUnreadMergeState } from '../../mainChatPanel/conversationUnread';
function shouldPreserveRequestMetadata(
previousItem: Pick<ChatConversationSummary, 'currentRequestId'>,
nextItem: Pick<ChatConversationSummary, 'currentRequestId'>,
) {
const previousRequestId = previousItem.currentRequestId?.trim() || '';
const nextRequestId = nextItem.currentRequestId?.trim() || '';
return Boolean(previousRequestId && previousRequestId === nextRequestId);
}
function toConversationSortTime(value: string | null | undefined) {
if (typeof value !== 'string' || !value.trim()) {
return 0;
}
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function getConversationLastMessageSortTime(item: ChatConversationSummary) {
const lastMessageTime = toConversationSortTime(item.lastMessageAt);
if (lastMessageTime > 0) {
return lastMessageTime;
}
return Math.max(toConversationSortTime(item.createdAt), toConversationSortTime(item.updatedAt));
}
function pickPreferredConversationSummary(left: ChatConversationSummary, right: ChatConversationSummary) {
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime > leftTime ? right : left;
}
const leftUpdatedAt = toConversationSortTime(left.updatedAt);
const rightUpdatedAt = toConversationSortTime(right.updatedAt);
if (rightUpdatedAt !== leftUpdatedAt) {
return rightUpdatedAt > leftUpdatedAt ? right : left;
}
return right;
}
function mergeConversationSummaries(existing: ChatConversationSummary, incoming: ChatConversationSummary) {
const preferred = pickPreferredConversationSummary(existing, incoming);
const fallback = preferred === existing ? incoming : existing;
return {
...fallback,
...preferred,
clientId: preferred.clientId ?? fallback.clientId,
isDraftOnly: preferred.isDraftOnly ?? fallback.isDraftOnly,
title: preferred.title.trim() || fallback.title.trim(),
requestBadgeLabel: preferred.requestBadgeLabel?.trim() || fallback.requestBadgeLabel?.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,
notifyOffline: preferred.notifyOffline ?? fallback.notifyOffline,
hasUnreadResponse: resolveConversationUnreadMergeState(existing, incoming),
currentRequestId: preferred.currentRequestId?.trim() || fallback.currentRequestId?.trim() || null,
currentJobStatus: preferred.currentJobStatus ?? fallback.currentJobStatus,
currentJobMessage: preferred.currentJobMessage?.trim() || fallback.currentJobMessage?.trim() || null,
currentQueueSize: Math.max(preferred.currentQueueSize ?? 0, fallback.currentQueueSize ?? 0),
currentStatusUpdatedAt:
preferred.currentStatusUpdatedAt?.trim() || fallback.currentStatusUpdatedAt?.trim() || null,
isPendingWork: preferred.isPendingWork ?? fallback.isPendingWork,
pendingWorkReason: preferred.pendingWorkReason ?? fallback.pendingWorkReason,
lastRequestPreview: preferred.lastRequestPreview.trim() || fallback.lastRequestPreview.trim(),
lastMessagePreview: preferred.lastMessagePreview.trim() || fallback.lastMessagePreview.trim(),
lastResponsePreview: preferred.lastResponsePreview.trim() || fallback.lastResponsePreview.trim(),
createdAt: preferred.createdAt.trim() || fallback.createdAt.trim(),
updatedAt: preferred.updatedAt.trim() || fallback.updatedAt.trim(),
lastMessageAt: preferred.lastMessageAt?.trim() || fallback.lastMessageAt?.trim() || null,
};
}
function sortChatConversationSummaries(items: ChatConversationSummary[]) {
const dedupedItems = items.reduce<ChatConversationSummary[]>((result, item) => {
const sessionId = item.sessionId.trim();
if (!sessionId) {
result.push(item);
return result;
}
const existingIndex = result.findIndex((candidate) => candidate.sessionId.trim() === sessionId);
if (existingIndex < 0) {
result.push(item);
return result;
}
const nextItems = [...result];
nextItems[existingIndex] = mergeConversationSummaries(nextItems[existingIndex] as ChatConversationSummary, item);
return nextItems;
}, []);
return dedupedItems.sort((left, right) => {
const leftTime = getConversationLastMessageSortTime(left);
const rightTime = getConversationLastMessageSortTime(right);
if (rightTime !== leftTime) {
return rightTime - leftTime;
}
return left.sessionId.localeCompare(right.sessionId, 'ko-KR');
});
}
export function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
requestedSessionId: string,
) {
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
const normalizedNextItems = nextItems.map((item) => {
const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) {
return item;
}
const preserveRequestMetadata = shouldPreserveRequestMetadata(previousItem, item);
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
const lastChatTypeId =
item.lastChatTypeId?.trim() ||
chatTypeId ||
previousItem.lastChatTypeId?.trim() ||
previousItem.chatTypeId?.trim() ||
null;
return {
...item,
title: preserveRequestMetadata
? previousItem.title.trim() || item.title.trim()
: item.title.trim() || previousItem.title.trim(),
requestBadgeLabel: preserveRequestMetadata
? previousItem.requestBadgeLabel?.trim() || item.requestBadgeLabel?.trim() || null
: item.requestBadgeLabel?.trim() || previousItem.requestBadgeLabel?.trim() || null,
chatTypeId,
lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
contextDescription: item.contextDescription?.trim() || null,
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,
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),
};
});
const normalizedRequestedSessionId = requestedSessionId.trim();
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
const preservedTransientItems = previousItems.filter((item) => {
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
});
if (!normalizedRequestedSessionId) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
if (hasRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!preservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasPreservedRequestedSession = preservedTransientItems.some(
(item) => item.sessionId === preservedRequestedSession.sessionId,
);
if (hasPreservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
import { buildComposerFilePickKey } from '../../mainChatPanel/composerFilePickKey';
export type ComposerFilePickResult = {
items: {
@@ -11,19 +12,26 @@ export type ComposerFilePickResult = {
}[];
};
function buildComposerFilePickKey(file: File) {
return `${file.name}:${file.size}:${file.type}:${file.lastModified}`;
}
type PendingChatRequest = {
sessionId: string;
requestId: string;
text: string;
mode: 'queue' | 'direct';
origin?: 'composer' | 'prompt';
parentRequestId?: string | null;
omitPromptHistory?: boolean;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeBaseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
retryCount: number;
failed: boolean;
};
@@ -31,9 +39,20 @@ type PendingChatRequest = {
type PendingContextConfirm = {
mode: 'queue' | 'direct';
text: string;
origin?: 'composer' | 'prompt';
parentRequestId?: string | null;
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeBaseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
includedContextCount: number;
omittedContextCount: number;
omitPromptHistory?: boolean;
@@ -43,6 +62,15 @@ type SelectedChatType = {
id: string;
name: string;
description: string;
baseDescription?: string;
defaultContextIds?: string[];
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
} | null;
type RecentContextSummary = {
@@ -64,6 +92,7 @@ type UseConversationComposerControllerOptions = {
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
messagesRef: { current: ChatMessage[] };
pendingRequestsRef: { current: PendingChatRequest[] };
promptRequestIdsRef?: { current: Set<string> };
shouldStickToBottomRef: { current: boolean };
setDraft: (value: string) => void;
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
@@ -80,6 +109,7 @@ type UseConversationComposerControllerOptions = {
requestedAt?: string,
options?: {
requestId?: string;
requestOrigin?: 'composer' | 'prompt';
mode?: 'queue' | 'direct';
queueSize?: number;
jobMessage?: string | null;
@@ -95,6 +125,7 @@ type UseConversationComposerControllerOptions = {
previous: ChatComposerAttachment[],
next: ChatComposerAttachment[],
) => ChatComposerAttachment[];
ensureSessionReady?: (sessionId: string) => Promise<boolean>;
sendChatRequest: (socket: WebSocket, request: PendingChatRequest) => void;
scrollViewportToBottom: () => void;
};
@@ -104,6 +135,14 @@ type SendMessageOptions = {
draftText?: string;
};
function createClientRequestId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `client-${crypto.randomUUID()}`;
}
return `client-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function useConversationComposerController({
activeSessionId,
appConfigChat,
@@ -115,6 +154,7 @@ export function useConversationComposerController({
composerRef,
messagesRef,
pendingRequestsRef,
promptRequestIdsRef,
shouldStickToBottomRef,
setDraft,
setComposerAttachments,
@@ -133,11 +173,13 @@ export function useConversationComposerController({
buildOutgoingMessageText,
summarizeRecentContext,
mergeComposerAttachments,
ensureSessionReady,
sendChatRequest,
scrollViewportToBottom,
}: UseConversationComposerControllerOptions) {
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0);
const latestComposerUploadAttemptByKeyRef = useRef(new Map<string, string>());
const handleComposerFilesPicked = useCallback(
async (files: File[]): Promise<ComposerFilePickResult> => {
@@ -145,6 +187,13 @@ export function useConversationComposerController({
return { items: [] };
}
const batchAttemptId = `composer-upload-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const fileKeys = files.map((file) => buildComposerFilePickKey(file));
fileKeys.forEach((key) => {
latestComposerUploadAttemptByKeyRef.current.set(key, batchAttemptId);
});
const uploadBatch = async (): Promise<ComposerFilePickResult> => {
activeComposerUploadCountRef.current += 1;
@@ -160,6 +209,12 @@ export function useConversationComposerController({
const failedItems: Array<{ fileName: string; reason: string }> = [];
uploadResults.forEach((result, index) => {
const fileKey = fileKeys[index];
if (!fileKey || latestComposerUploadAttemptByKeyRef.current.get(fileKey) !== batchAttemptId) {
return;
}
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
@@ -218,6 +273,7 @@ export function useConversationComposerController({
activeSessionId,
composerUploadQueueRef,
createLocalMessage,
latestComposerUploadAttemptByKeyRef,
mergeComposerAttachments,
setComposerAttachments,
setIsComposerAttachmentUploading,
@@ -235,22 +291,60 @@ export function useConversationComposerController({
}, [composerRef, scrollViewportToBottom]);
const executeSendMessage = useCallback(
(request: PendingContextConfirm) => {
const { mode, text, chatTypeId, chatTypeLabel, chatTypeDescription, omitPromptHistory } = request;
const requestId = `client-${Date.now().toString(36)}`;
async (request: PendingContextConfirm) => {
const {
mode,
text,
origin,
parentRequestId,
chatTypeId,
chatTypeLabel,
chatTypeDescription,
chatTypeBaseDescription,
defaultContextIds,
defaultContexts,
customContextTitle,
customContextContent,
omitPromptHistory,
} = request;
if (ensureSessionReady) {
setActiveSystemStatus('새 채팅방 준비 중...');
setIsSystemStatusPending(true);
const isSessionReady = await ensureSessionReady(activeSessionId);
if (!isSessionReady) {
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
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') {
promptRequestIdsRef?.current.add(requestId);
}
if (mode === 'queue') {
const queuedAt = new Date().toISOString();
const optimisticUserMessage: ChatMessage = {
@@ -260,11 +354,13 @@ export function useConversationComposerController({
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 요청을 접수했습니다.',
'# 진행: 대기열 등록을 준비하고 있습니다.',
'# 진행: 순서를 기다리기 전에 요청 내용을 정리하고 있습니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'queued',
statusMessage: '대기열 등록',
userMessageId: optimisticUserMessage.id,
@@ -280,6 +376,7 @@ export function useConversationComposerController({
});
syncConversationPreviewForRequest(activeSessionId, text, queuedAt, {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'queue',
queueSize: 1,
jobMessage: '대기열 등록 중',
@@ -301,11 +398,13 @@ export function useConversationComposerController({
};
const optimisticActivityMessage = createActivityLogPlaceholder(requestId, [
'# 상태: 즉시 요청을 접수했습니다.',
'# 진행: 즉시 실행 대기 중입니다.',
'# 진행: 요청 내용을 정리한 뒤 답변을 준비합니다.',
]);
upsertRequestItem({
sessionId: activeSessionId,
requestId,
requestOrigin: origin ?? 'composer',
parentRequestId: parentRequestId?.trim() || null,
status: 'accepted',
statusMessage: '요청을 접수했습니다.',
userMessageId: optimisticUserMessage.id,
@@ -321,6 +420,7 @@ export function useConversationComposerController({
});
syncConversationPreviewForRequest(activeSessionId, text, new Date().toISOString(), {
requestId,
requestOrigin: origin ?? 'composer',
mode: 'direct',
queueSize: 0,
jobMessage: '즉시 요청 실행 대기 중',
@@ -367,13 +467,16 @@ export function useConversationComposerController({
setDraft('');
setComposerAttachments([]);
focusComposerAfterSend();
return true;
},
[
activeSessionId,
createActivityLogPlaceholder,
createChatMessage,
ensureSessionReady,
focusComposerAfterSend,
pendingRequestsRef,
promptRequestIdsRef,
sendChatRequest,
setActiveSystemStatus,
setComposerAttachments,
@@ -422,6 +525,11 @@ export function useConversationComposerController({
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,
});
@@ -434,6 +542,11 @@ export function useConversationComposerController({
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: 0,
omittedContextCount: 0,
});

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import { emitChatConversationsUpdated } from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
import { mergeConversationItemsPreservingRequestedSession } from './conversationListMerge';
type UseConversationListDataOptions = {
requestedSessionId: string;
@@ -18,94 +18,6 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>;
};
function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
requestedSessionId: string,
) {
const previousBySessionId = new Map(previousItems.map((item) => [item.sessionId, item] as const));
const normalizedNextItems = nextItems.map((item) => {
const previousItem = previousBySessionId.get(item.sessionId);
if (!previousItem) {
return item;
}
const chatTypeId = item.chatTypeId?.trim() || previousItem.chatTypeId?.trim() || null;
const lastChatTypeId =
item.lastChatTypeId?.trim() ||
chatTypeId ||
previousItem.lastChatTypeId?.trim() ||
previousItem.chatTypeId?.trim() ||
null;
return {
...item,
chatTypeId,
lastChatTypeId,
generalSectionName: item.generalSectionName?.trim() || previousItem.generalSectionName?.trim() || null,
contextLabel: item.contextLabel?.trim() || previousItem.contextLabel?.trim() || null,
contextDescription: item.contextDescription?.trim() || previousItem.contextDescription?.trim() || null,
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,
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),
};
});
const normalizedRequestedSessionId = requestedSessionId.trim();
const nextSessionIds = new Set(normalizedNextItems.map((item) => item.sessionId));
const preservedTransientItems = previousItems.filter((item) => {
if (!item.sessionId || nextSessionIds.has(item.sessionId)) {
return false;
}
return item.isDraftOnly || item.currentJobStatus === 'queued' || item.currentJobStatus === 'started';
});
if (!normalizedRequestedSessionId) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const hasRequestedSession = normalizedNextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
if (hasRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!preservedRequestedSession) {
return sortChatConversationSummaries([...preservedTransientItems, ...normalizedNextItems]);
}
return sortChatConversationSummaries([preservedRequestedSession, ...preservedTransientItems, ...normalizedNextItems]);
}
export function useConversationListData({
requestedSessionId,
enabled = true,
@@ -139,7 +51,6 @@ export function useConversationListData({
setConversationItems((previous) => {
const nextItems = mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId);
emitChatConversationsUpdated(nextItems);
return nextItems;
});
} catch {
@@ -177,6 +88,10 @@ export function useConversationListData({
};
}, [enabled, loadConversationItems]);
useEffect(() => {
emitChatConversationsUpdated(conversationItems);
}, [conversationItems]);
return {
conversationItems,
setConversationItems,

View File

@@ -18,6 +18,14 @@ type PendingChatRequest = {
chatTypeId: string;
chatTypeLabel: string;
chatTypeDescription: string;
chatTypeBaseDescription?: string;
defaultContexts?: Array<{
id: string;
title: string;
content: string;
}>;
customContextTitle?: string | null;
customContextContent?: string | null;
retryCount: number;
failed: boolean;
};
@@ -281,7 +289,18 @@ export function useConversationRoomActionsController({
setIsEditingConversationTitle(false);
try {
const item = await chatGateway.renameConversation(sessionId, trimmedTitle);
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) {
@@ -306,7 +325,16 @@ export function useConversationRoomActionsController({
const handleDeleteConversation = useCallback(
async (sessionId: string) => {
try {
await chatGateway.deleteConversation(sessionId);
const targetConversation = conversationItems.find((entry) => entry.sessionId === sessionId) ?? null;
if (!targetConversation) {
return;
}
if (!targetConversation.isDraftOnly) {
await chatGateway.deleteConversation(sessionId);
}
const remaining = conversationItems.filter((entry) => entry.sessionId !== sessionId);
sessionMessageCacheRef.current.delete(sessionId);
setConversationItems(remaining);

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { mergeConversationRequestStatusMessage, mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import { chatGateway } from '../data/chatGateway';
import type {
@@ -29,10 +29,12 @@ function mergeConversationRequests(
const nextUserText = item.userText.trim() || previousItem.userText.trim();
const nextResponseText = item.responseText.trim() || previousItem.responseText.trim();
const nextStatusMessage = item.statusMessage?.trim() || previousItem.statusMessage?.trim() || null;
const nextStatusMessage = mergeConversationRequestStatusMessage(previousItem, item);
return {
...item,
requestOrigin: item.requestOrigin ?? previousItem.requestOrigin ?? null,
parentRequestId: item.parentRequestId?.trim() || previousItem.parentRequestId?.trim() || null,
statusMessage: nextStatusMessage,
userMessageId: item.userMessageId ?? previousItem.userMessageId,
userText: nextUserText,
@@ -72,8 +74,17 @@ type UseConversationRoomDataOptions = {
setIsLoadingOlderMessages: Dispatch<SetStateAction<boolean>>;
queueViewportPrependRestore: (previousScrollHeight: number, previousScrollTop: number) => void;
viewportRef: MutableRefObject<HTMLDivElement | null>;
onMissingConversation?: (sessionId: string) => void;
};
function isMissingConversationError(error: unknown) {
if (!(error instanceof Error)) {
return false;
}
return /채팅방을 찾을 수 없습니다|404\b|not found/i.test(error.message);
}
export function useConversationRoomData({
activeSessionId,
activeConversationIsDraftOnly = false,
@@ -98,6 +109,7 @@ export function useConversationRoomData({
setIsLoadingOlderMessages,
queueViewportPrependRestore,
viewportRef,
onMissingConversation,
}: UseConversationRoomDataOptions) {
const previousSessionIdRef = useRef('');
@@ -209,8 +221,20 @@ export function useConversationRoomData({
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
}
} catch {
} catch (error) {
if (!isCancelled) {
if (cachedMessages.length === 0 && isMissingConversationError(error)) {
sessionMessageCacheRef.current.delete(requestedSessionId);
setConversationItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
setMessages([]);
setRequestItems((previous) => previous.filter((item) => item.sessionId !== requestedSessionId));
setHasOlderMessages(false);
setOldestLoadedMessageId(null);
setConversationLoadingLabel('삭제되었거나 만료된 채팅방입니다.');
onMissingConversation?.(requestedSessionId);
return;
}
setMessages(cachedMessages);
setHasOlderMessages(false);
setOldestLoadedMessageId(cachedMessages[0]?.id ?? null);
@@ -250,6 +274,7 @@ export function useConversationRoomData({
setHasOlderMessages,
setMessages,
setOldestLoadedMessageId,
onMissingConversation,
setRequestItems,
]);

View File

@@ -337,6 +337,36 @@ export function useConversationViewportController({
resetPullToLoad,
]);
useEffect(() => {
const handleViewportInteractionReset = () => {
resetPullToLoad();
};
const handleVisibilityChange = () => {
if (document.visibilityState !== 'visible') {
resetPullToLoad();
return;
}
window.requestAnimationFrame(() => {
resetPullToLoad();
handleViewportScroll();
});
};
window.addEventListener('resize', handleViewportInteractionReset);
window.addEventListener('pageshow', handleViewportInteractionReset);
window.addEventListener('blur', handleViewportInteractionReset);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('resize', handleViewportInteractionReset);
window.removeEventListener('pageshow', handleViewportInteractionReset);
window.removeEventListener('blur', handleViewportInteractionReset);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [handleViewportScroll, resetPullToLoad]);
useEffect(() => {
if (connectionState === 'disconnected') {
clearSystemStatusTimer();

View File

@@ -9,6 +9,8 @@ import {
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
const UNREAD_COUNT_REFRESH_INTERVAL_MS = 15_000;
type UseUnreadCountsResult = {
chatUnreadCount: number;
notificationUnreadCount: number;
@@ -134,6 +136,40 @@ export function useUnreadCounts(): UseUnreadCountsResult {
};
}, []);
useEffect(() => {
const refreshAllUnreadCounts = () => {
void refreshChatUnreadCount();
void refreshNotificationUnreadCount();
};
const handleVisibilityOrFocus = () => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
return;
}
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('pageshow', handleVisibilityOrFocus);
document.addEventListener('visibilitychange', handleVisibilityOrFocus);
return () => {
window.clearInterval(intervalId);
window.removeEventListener('focus', handleVisibilityOrFocus);
window.removeEventListener('pageshow', handleVisibilityOrFocus);
document.removeEventListener('visibilitychange', handleVisibilityOrFocus);
};
}, []);
return {
chatUnreadCount,
notificationUnreadCount,