feat: update codex live chat workflow

This commit is contained in:
2026-04-22 20:00:38 +09:00
parent 9e4b70f1f1
commit b0b9980a6c
70 changed files with 5178 additions and 2401 deletions

View File

@@ -1,9 +1,6 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import type { ChatConversationSummary } from '../../mainChatPanel/types';
import {
CHAT_CONVERSATIONS_UPDATED_EVENT,
readChatConversationsUpdatedEvent,
} from '../data/chatClientEvents';
import { chatGateway } from '../data/chatGateway';
type UseConversationListDataOptions = {
@@ -19,6 +16,33 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>;
};
function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
requestedSessionId: string,
) {
const normalizedRequestedSessionId = requestedSessionId.trim();
if (!normalizedRequestedSessionId) {
return sortChatConversationSummaries(nextItems);
}
const hasRequestedSession = nextItems.some((item) => item.sessionId === normalizedRequestedSessionId);
if (hasRequestedSession) {
return sortChatConversationSummaries(nextItems);
}
const preservedRequestedSession =
previousItems.find((item) => item.sessionId === normalizedRequestedSessionId) ?? null;
if (!preservedRequestedSession) {
return sortChatConversationSummaries(nextItems);
}
return sortChatConversationSummaries([preservedRequestedSession, ...nextItems]);
}
export function useConversationListData({
requestedSessionId,
}: UseConversationListDataOptions): UseConversationListDataResult {
@@ -31,9 +55,11 @@ export function useConversationListData({
try {
const items = await chatGateway.listConversations();
setConversationItems(items);
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
} catch {
setConversationItems([]);
setConversationItems((previous) => previous);
} finally {
setIsConversationListLoading(false);
}
@@ -46,12 +72,14 @@ export function useConversationListData({
.listConversations()
.then((items) => {
if (!isCancelled) {
setConversationItems(items);
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
}
})
.catch(() => {
if (!isCancelled) {
setConversationItems([]);
setConversationItems((previous) => previous);
}
})
.finally(() => {
@@ -67,63 +95,6 @@ export function useConversationListData({
};
}, []);
useEffect(() => {
if (!requestedSessionId || isConversationListLoading) {
return;
}
if (conversationItems.some((item) => item.sessionId === requestedSessionId)) {
return;
}
let isCancelled = false;
const loadRequestedConversation = async () => {
try {
const response = await chatGateway.getConversationDetail(requestedSessionId);
if (isCancelled || response.item.sessionId !== requestedSessionId) {
return;
}
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) {
return [response.item, ...previous];
}
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
});
} catch {
// 유효하지 않은 세션은 이후 기본 빈 상태 흐름이 유지된다.
}
};
void loadRequestedConversation();
return () => {
isCancelled = true;
};
}, [conversationItems, isConversationListLoading, requestedSessionId]);
useEffect(() => {
const handleConversationsUpdated = (event: Event) => {
const detail = readChatConversationsUpdatedEvent(event);
if (!detail) {
return;
}
setConversationItems(detail.items);
};
window.addEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
return () => {
window.removeEventListener(CHAT_CONVERSATIONS_UPDATED_EVENT, handleConversationsUpdated as EventListener);
};
}, []);
return {
conversationItems,
setConversationItems,

View File

@@ -1,5 +1,6 @@
import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { useEffect, useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react';
import { mergeRecoveredChatMessages } from '../../mainChatPanel/chatUtils';
import { sortChatConversationSummaries } from '../../mainChatPanel';
import { chatGateway } from '../data/chatGateway';
import type {
ChatConversationRequest,
@@ -7,39 +8,28 @@ import type {
ChatMessage,
} from '../../mainChatPanel/types';
const INITIAL_CONVERSATION_MESSAGE_LIMIT = 3;
const OLDER_CONVERSATION_MESSAGE_PAGE_SIZE = 20;
const INITIAL_CONVERSATION_REQUEST_PAGE_SIZE = 6;
const OLDER_CONVERSATION_REQUEST_PAGE_SIZE = 6;
const CONVERSATION_DETAIL_RETRY_DELAYS_MS = [0, 250, 800];
function getCachedSessionMessages(cache: Map<string, ChatMessage[]>, sessionId: string) {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return [] as ChatMessage[];
}
return cache.get(normalizedSessionId) ?? [];
}
function getBestAvailableSessionMessages(
cache: Map<string, ChatMessage[]>,
function mergeConversationRequests(
previous: ChatConversationRequest[],
incoming: ChatConversationRequest[],
sessionId: string,
currentSessionId: string,
currentMessages: ChatMessage[],
) {
const cachedMessages = getCachedSessionMessages(cache, sessionId);
const previousSessionItems = previous.filter((item) => item.sessionId === sessionId);
const incomingRequestIds = new Set(incoming.map((item) => item.requestId));
const preservedLocalItems = previousSessionItems.filter((item) => !incomingRequestIds.has(item.requestId));
if (sessionId !== currentSessionId || currentMessages.length === 0) {
return cachedMessages;
}
return mergeRecoveredChatMessages(cachedMessages, currentMessages);
return [...incoming, ...preservedLocalItems].sort((left, right) => left.createdAt.localeCompare(right.createdAt));
}
type UseConversationRoomDataOptions = {
activeSessionId: string;
oldestLoadedMessageId: number | null;
reloadKey: number;
connectionState: 'connecting' | 'connected' | 'disconnected';
shouldBlockConversationWhileLoading: (sessionId: string) => boolean;
captureViewportRestoreSnapshot: () => void;
captureViewportRestoreSnapshot: (options?: { forceStickToBottom?: boolean }) => void;
sessionMessageCacheRef: MutableRefObject<Map<string, ChatMessage[]>>;
messagesRef: MutableRefObject<ChatMessage[]>;
pendingViewportRestoreRef: MutableRefObject<boolean>;
@@ -59,8 +49,9 @@ type UseConversationRoomDataOptions = {
export function useConversationRoomData({
activeSessionId,
oldestLoadedMessageId,
reloadKey,
connectionState,
shouldBlockConversationWhileLoading,
captureViewportRestoreSnapshot,
sessionMessageCacheRef,
messagesRef,
@@ -78,8 +69,11 @@ export function useConversationRoomData({
queueViewportPrependRestore,
viewportRef,
}: UseConversationRoomDataOptions) {
const previousSessionIdRef = useRef('');
useEffect(() => {
if (!activeSessionId.trim()) {
previousSessionIdRef.current = '';
setMessages([]);
setRequestItems([]);
setIsConversationContentLoading(false);
@@ -92,53 +86,95 @@ export function useConversationRoomData({
let isCancelled = false;
const requestedSessionId = activeSessionId;
const waitForRetry = (delayMs: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, delayMs);
});
const loadConversationDetail = async () => {
captureViewportRestoreSnapshot();
const isSessionChanged = previousSessionIdRef.current !== requestedSessionId;
previousSessionIdRef.current = requestedSessionId;
captureViewportRestoreSnapshot({
forceStickToBottom: isSessionChanged,
});
pendingViewportRestoreRef.current = true;
setConversationLoadingLabel('대화 내용을 불러오는 중입니다.');
setIsConversationContentLoading(shouldBlockConversationWhileLoading(requestedSessionId));
const cachedMessages = isSessionChanged ? [] : (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
if (cachedMessages.length > 0) {
setMessages(cachedMessages);
}
setIsConversationContentLoading(true);
setIsDeferringAuxiliaryChatRequests(true);
try {
const response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: INITIAL_CONVERSATION_MESSAGE_LIMIT,
});
let response: Awaited<ReturnType<typeof chatGateway.getConversationDetail>> | null = null;
let lastError: unknown = null;
for (const delayMs of CONVERSATION_DETAIL_RETRY_DELAYS_MS) {
if (delayMs > 0) {
await waitForRetry(delayMs);
}
if (isCancelled) {
return;
}
try {
response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: INITIAL_CONVERSATION_REQUEST_PAGE_SIZE,
});
break;
} catch (error) {
lastError = error;
}
}
if (!response) {
throw lastError ?? new Error('대화 내용을 불러오지 못했습니다.');
}
if (!isCancelled && response.item.sessionId === requestedSessionId) {
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) {
return [response.item, ...previous];
return sortChatConversationSummaries([response.item, ...previous]);
}
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
return sortChatConversationSummaries(
previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item)),
);
});
const baseMessages = getBestAvailableSessionMessages(
sessionMessageCacheRef.current,
requestedSessionId,
activeSessionId,
messagesRef.current,
);
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(nextMessages);
setRequestItems(response.requests);
const baseMessages =
isSessionChanged
? []
: requestedSessionId === activeSessionId
? messagesRef.current
: (sessionMessageCacheRef.current.get(requestedSessionId) ?? []);
const mergedMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
sessionMessageCacheRef.current.set(requestedSessionId, mergedMessages);
setMessages(mergedMessages);
setRequestItems((previous) => {
const preservedOtherSessions = previous.filter((item) => item.sessionId !== requestedSessionId);
return [...preservedOtherSessions, ...mergeConversationRequests(previous, response.requests, requestedSessionId)];
});
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
}
} catch {
if (!isCancelled) {
const cachedMessages = getBestAvailableSessionMessages(
sessionMessageCacheRef.current,
requestedSessionId,
activeSessionId,
messagesRef.current,
setMessages(cachedMessages);
setHasOlderMessages(false);
setOldestLoadedMessageId(cachedMessages[0]?.id ?? null);
setConversationLoadingLabel(
cachedMessages.length > 0
? '저장된 대화 내용을 먼저 보여주고 있습니다. 서버 연결을 다시 확인해 주세요.'
: '대화 내용을 다시 불러오지 못했습니다.',
);
if (cachedMessages.length > 0) {
setMessages(cachedMessages);
}
}
} finally {
if (!isCancelled) {
@@ -158,6 +194,7 @@ export function useConversationRoomData({
captureViewportRestoreSnapshot,
messagesRef,
pendingViewportRestoreRef,
reloadKey,
sessionMessageCacheRef,
setConversationItems,
setConversationLoadingLabel,
@@ -167,105 +204,17 @@ export function useConversationRoomData({
setMessages,
setOldestLoadedMessageId,
setRequestItems,
shouldBlockConversationWhileLoading,
]);
useEffect(() => {
if (connectionState !== 'connected' || !shouldRestoreConversationAfterReconnectRef.current) {
return;
if (connectionState === 'connected') {
shouldRestoreConversationAfterReconnectRef.current = false;
}
let isCancelled = false;
const requestedSessionId = activeSessionId;
const restoreConversationAfterReconnect = async () => {
setIsDeferringAuxiliaryChatRequests(true);
try {
const response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: Math.max(INITIAL_CONVERSATION_MESSAGE_LIMIT, messagesRef.current.length || 0),
});
if (!isCancelled && response.item.sessionId === requestedSessionId) {
const baseMessages = getBestAvailableSessionMessages(
sessionMessageCacheRef.current,
requestedSessionId,
activeSessionId,
messagesRef.current,
);
const nextMessages = mergeRecoveredChatMessages(baseMessages, response.messages);
const hasMessageDiff = nextMessages !== baseMessages;
if (hasMessageDiff) {
captureViewportRestoreSnapshot();
pendingViewportRestoreRef.current = true;
setConversationLoadingLabel('채팅방을 다시 연결하고 내용을 복구하는 중입니다.');
setIsConversationContentLoading(true);
}
setConversationItems((previous) => {
const exists = previous.some((item) => item.sessionId === response.item.sessionId);
if (!exists) {
return [response.item, ...previous];
}
return previous.map((item) => (item.sessionId === response.item.sessionId ? response.item : item));
});
setRequestItems(response.requests);
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
if (hasMessageDiff) {
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(nextMessages);
window.requestAnimationFrame(() => {
if (!isCancelled) {
setIsConversationContentLoading(false);
}
});
}
}
} catch {
if (!isCancelled) {
setIsConversationContentLoading(false);
}
} finally {
if (!isCancelled) {
shouldRestoreConversationAfterReconnectRef.current = false;
if (!pendingViewportRestoreRef.current) {
setIsConversationContentLoading(false);
}
setIsDeferringAuxiliaryChatRequests(false);
}
}
};
void restoreConversationAfterReconnect();
return () => {
isCancelled = true;
};
}, [
activeSessionId,
captureViewportRestoreSnapshot,
connectionState,
messagesRef,
pendingViewportRestoreRef,
sessionMessageCacheRef,
setConversationItems,
setConversationLoadingLabel,
setIsConversationContentLoading,
setIsDeferringAuxiliaryChatRequests,
setHasOlderMessages,
setMessages,
setOldestLoadedMessageId,
setRequestItems,
shouldRestoreConversationAfterReconnectRef,
]);
}, [connectionState, shouldRestoreConversationAfterReconnectRef]);
const loadOlderMessages = async () => {
const requestedSessionId = activeSessionId.trim();
const oldestVisibleMessageId = messagesRef.current[0]?.id ?? null;
const oldestVisibleMessageId = oldestLoadedMessageId;
if (!requestedSessionId || oldestVisibleMessageId == null) {
return;
@@ -275,11 +224,14 @@ export function useConversationRoomData({
try {
const response = await chatGateway.getConversationDetail(requestedSessionId, {
limit: OLDER_CONVERSATION_MESSAGE_PAGE_SIZE,
limit: OLDER_CONVERSATION_REQUEST_PAGE_SIZE,
beforeMessageId: oldestVisibleMessageId,
});
if (response.item.sessionId !== requestedSessionId || response.messages.length === 0) {
if (
response.item.sessionId !== requestedSessionId ||
(response.messages.length === 0 && response.requests.length === 0)
) {
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
return;
@@ -293,7 +245,10 @@ export function useConversationRoomData({
queueViewportPrependRestore(previousScrollHeight, previousScrollTop);
sessionMessageCacheRef.current.set(requestedSessionId, nextMessages);
setMessages(nextMessages);
setRequestItems(response.requests);
setRequestItems((previous) => {
const preservedOtherSessions = previous.filter((item) => item.sessionId !== requestedSessionId);
return [...preservedOtherSessions, ...mergeConversationRequests(previous, response.requests, requestedSessionId)];
});
setHasOlderMessages(response.hasOlderMessages);
setOldestLoadedMessageId(response.oldestLoadedMessageId);
} finally {

View File

@@ -15,7 +15,6 @@ type UseConversationViewControllerOptions = {
previewItems: PreviewItem[];
selectedChatTypeId: string | null;
composerRef: { current: { focus: (options?: { cursor?: 'start' | 'end' | 'all' }) => void } | null };
sessionMessageCacheRef: { current: Map<string, ChatMessage[]> };
setActiveSystemStatus: (value: string | null) => void;
setComposerAttachments: React.Dispatch<React.SetStateAction<ChatComposerAttachment[]>>;
setCopiedMessageId: (value: number | null) => void;
@@ -31,7 +30,6 @@ export function useConversationViewController({
composerRef,
previewItems,
selectedChatTypeId,
sessionMessageCacheRef,
setActiveSystemStatus,
setComposerAttachments,
setCopiedMessageId,
@@ -59,7 +57,7 @@ export function useConversationViewController({
previousSessionIdRef.current = activeSessionId;
setMessages(sessionMessageCacheRef.current.get(activeSessionId)?.slice() ?? []);
setMessages([]);
setDraft('');
setComposerAttachments([]);
setCopiedMessageId(null);
@@ -70,7 +68,6 @@ export function useConversationViewController({
setIsResourceStripOpen(false);
}, [
activeSessionId,
sessionMessageCacheRef,
setActiveSystemStatus,
setComposerAttachments,
setCopiedMessageId,

View File

@@ -132,7 +132,16 @@ export function useConversationViewportController({
setShowScrollToBottom(!isNearBottom);
}, [viewportRef]);
const captureViewportRestoreSnapshot = useCallback(() => {
const captureViewportRestoreSnapshot = useCallback((options?: { forceStickToBottom?: boolean }) => {
if (options?.forceStickToBottom) {
viewportRestoreSnapshotRef.current = {
shouldStickToBottom: true,
offsetFromBottom: 0,
};
shouldStickToBottomRef.current = true;
return;
}
const viewport = viewportRef.current;
if (!viewport) {

View File

@@ -6,6 +6,7 @@ import {
fetchNotificationMessages,
updateNotificationMessageReadState,
type NotificationMessageItem,
type NotificationMessageListStatus,
} from '../../notificationApi';
import { useUnreadCounts } from './useUnreadCounts';
@@ -20,6 +21,7 @@ function mergeMessageItem(items: NotificationMessageItem[], nextItem: Notificati
}
export function useNotificationCenterData(drawerOpen: boolean) {
const [listStatus, setListStatus] = useState<NotificationMessageListStatus>('unread');
const [detailOpen, setDetailOpen] = useState(false);
const [messages, setMessages] = useState<NotificationMessageItem[]>([]);
const [selectedMessage, setSelectedMessage] = useState<NotificationMessageItem | null>(null);
@@ -40,7 +42,7 @@ export function useNotificationCenterData(drawerOpen: boolean) {
setListError(null);
try {
const response = await fetchNotificationMessages({ limit: 30 });
const response = await fetchNotificationMessages({ status: listStatus, limit: 30 });
setMessages(response.items);
} catch (error) {
setListError(error instanceof Error ? error.message : '알림 목록을 불러오지 못했습니다.');
@@ -53,7 +55,10 @@ export function useNotificationCenterData(drawerOpen: boolean) {
setSelectedMessage(nextItem);
setMessages((current) => {
const wasUnread = current.find((item) => item.id === nextItem.id)?.read === false;
const nextItems = mergeMessageItem(current, nextItem);
const nextItems =
listStatus === 'unread' && nextItem.read
? current.filter((item) => item.id !== nextItem.id)
: mergeMessageItem(current, nextItem);
if (wasUnread !== nextItem.read) {
void refreshNotificationUnreadCount();
@@ -149,9 +154,11 @@ export function useNotificationCenterData(drawerOpen: boolean) {
}
void loadMessages();
}, [drawerOpen]);
}, [drawerOpen, listStatus]);
return {
listStatus,
setListStatus,
unreadCount,
detailOpen,
setDetailOpen,