feat: expand live chat and work server tools

This commit is contained in:
2026-04-30 11:40:02 +09:00
parent 42ae640470
commit 2df0ba30cb
112 changed files with 15241 additions and 996 deletions

View File

@@ -64,6 +64,7 @@ export function ConversationRoomPane({
isMobileViewport={false}
isChatTypeSelectionLocked={true}
isComposerAttachmentUploading={false}
isSendWithoutContextEnabled={false}
onViewportScroll={() => {}}
onViewportTouchEnd={() => {}}
onViewportTouchMove={() => {}}
@@ -74,6 +75,7 @@ export function ConversationRoomPane({
onSelectChatType={() => {}}
onSend={() => {}}
onSendImmediate={() => {}}
onToggleSendWithoutContext={() => {}}
onClearDraft={() => {}}
onScrollToBottom={() => {}}
onToggleResourceStrip={() => {}}

View File

@@ -1,4 +1,4 @@
import { useCallback } from 'react';
import { useCallback, useRef } from 'react';
import { chatGateway } from '../data/chatGateway';
import type { ChatComposerAttachment, ChatConversationRequest, ChatMessage } from '../../mainChatPanel/types';
@@ -119,67 +119,88 @@ export function useConversationComposerController({
sendChatRequest,
scrollViewportToBottom,
}: UseConversationComposerControllerOptions) {
const composerUploadQueueRef = useRef(Promise.resolve<ComposerFilePickResult>({ items: [] }));
const activeComposerUploadCountRef = useRef(0);
const handleComposerFilesPicked = useCallback(
async (files: File[]): Promise<ComposerFilePickResult> => {
if (files.length === 0 || isComposerAttachmentUploading) {
if (files.length === 0) {
return { items: [] };
}
setIsComposerAttachmentUploading(true);
const uploadResults = await Promise.allSettled(
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedItems: Array<{ fileName: string; reason: string }> = [];
const uploadBatch = async (): Promise<ComposerFilePickResult> => {
activeComposerUploadCountRef.current += 1;
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
if (activeComposerUploadCountRef.current === 1) {
setIsComposerAttachmentUploading(true);
}
const fileName = files[index]?.name || `파일 ${index + 1}`;
const reason =
result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패';
failedItems.push({ fileName, reason });
});
try {
const uploadResults = await Promise.allSettled(
files.map((file) => chatGateway.uploadComposerFile(activeSessionId, file)),
);
const uploadedItems: ChatComposerAttachment[] = [];
const failedItems: Array<{ fileName: string; reason: string }> = [];
if (uploadedItems.length > 0) {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
}
uploadResults.forEach((result, index) => {
if (result.status === 'fulfilled') {
uploadedItems.push(result.value);
return;
}
if (failedItems.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
),
]);
}
setIsComposerAttachmentUploading(false);
return {
items: uploadResults.map((result, index) => ({
key: buildComposerFilePickKey(files[index] as File),
fileName: files[index]?.name || `파일 ${index + 1}`,
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
reason:
result.status === 'fulfilled'
? undefined
: result.reason instanceof Error && result.reason.message.trim()
const fileName = files[index]?.name || `파일 ${index + 1}`;
const reason =
result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패',
})),
: '업로드 실패';
failedItems.push({ fileName, reason });
});
if (uploadedItems.length > 0) {
setComposerAttachments((previous) => mergeComposerAttachments(previous, uploadedItems));
shouldStickToBottomRef.current = true;
setShowScrollToBottom(false);
}
if (failedItems.length > 0) {
setMessages((previous) => [
...previous.slice(-39),
createLocalMessage(
['파일 업로드에 실패했습니다:', ...failedItems.map((item) => `- ${item.fileName}: ${item.reason}`)].join('\n'),
),
]);
}
return {
items: uploadResults.map((result, index) => ({
key: buildComposerFilePickKey(files[index] as File),
fileName: files[index]?.name || `파일 ${index + 1}`,
status: result.status === 'fulfilled' ? 'uploaded' : 'failed',
reason:
result.status === 'fulfilled'
? undefined
: result.reason instanceof Error && result.reason.message.trim()
? result.reason.message.trim()
: '업로드 실패',
})),
};
} finally {
activeComposerUploadCountRef.current = Math.max(0, activeComposerUploadCountRef.current - 1);
if (activeComposerUploadCountRef.current === 0) {
setIsComposerAttachmentUploading(false);
}
}
};
const queuedUpload = composerUploadQueueRef.current.then(uploadBatch, uploadBatch);
composerUploadQueueRef.current = queuedUpload.catch(() => ({ items: [] }));
return queuedUpload;
},
[
activeSessionId,
composerUploadQueueRef,
createLocalMessage,
isComposerAttachmentUploading,
mergeComposerAttachments,
setComposerAttachments,
setIsComposerAttachmentUploading,

View File

@@ -1,6 +1,7 @@
import { useEffect, useState, type Dispatch, type SetStateAction } from 'react';
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';
type UseConversationListDataOptions = {
@@ -16,6 +17,8 @@ type UseConversationListDataResult = {
setConversationSearch: Dispatch<SetStateAction<string>>;
};
const CONVERSATION_LIST_POLL_INTERVAL_MS = 5000;
function mergeConversationItemsPreservingRequestedSession(
nextItems: ChatConversationSummary[],
previousItems: ChatConversationSummary[],
@@ -49,51 +52,117 @@ export function useConversationListData({
const [conversationItems, setConversationItems] = useState<ChatConversationSummary[]>([]);
const [isConversationListLoading, setIsConversationListLoading] = useState(false);
const [conversationSearch, setConversationSearch] = useState('');
const isMountedRef = useRef(true);
const listRequestIdRef = useRef(0);
const pendingRequestRef = useRef<Promise<void> | null>(null);
const loadConversationItems = async () => {
setIsConversationListLoading(true);
try {
const items = await chatGateway.listConversations();
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
} catch {
setConversationItems((previous) => previous);
} finally {
setIsConversationListLoading(false);
const loadConversationItems = useCallback(async (options?: { silent?: boolean }) => {
if (pendingRequestRef.current) {
return pendingRequestRef.current;
}
};
const requestId = listRequestIdRef.current + 1;
listRequestIdRef.current = requestId;
const isSilent = options?.silent === true;
if (!isSilent) {
setIsConversationListLoading(true);
}
const requestPromise = (async () => {
try {
const items = await chatGateway.listConversations();
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
return;
}
setConversationItems((previous) => {
const nextItems = mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId);
emitChatConversationsUpdated(nextItems);
return nextItems;
});
} catch {
if (!isMountedRef.current || listRequestIdRef.current !== requestId) {
return;
}
setConversationItems((previous) => previous);
} finally {
pendingRequestRef.current = null;
if (!isMountedRef.current || listRequestIdRef.current !== requestId || isSilent) {
return;
}
setIsConversationListLoading(false);
}
})();
pendingRequestRef.current = requestPromise;
return requestPromise;
}, [requestedSessionId]);
useEffect(() => {
let isCancelled = false;
void chatGateway
.listConversations()
.then((items) => {
if (!isCancelled) {
setConversationItems((previous) =>
mergeConversationItemsPreservingRequestedSession(items, previous, requestedSessionId),
);
}
})
.catch(() => {
if (!isCancelled) {
setConversationItems((previous) => previous);
}
})
.finally(() => {
if (!isCancelled) {
setIsConversationListLoading(false);
}
});
isMountedRef.current = true;
setIsConversationListLoading(true);
void loadConversationItems();
return () => {
isCancelled = true;
isMountedRef.current = false;
};
}, []);
}, [loadConversationItems]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
let intervalId: number | null = null;
const startPolling = () => {
if (intervalId != null || document.visibilityState !== 'visible') {
return;
}
intervalId = window.setInterval(() => {
void loadConversationItems({ silent: true });
}, CONVERSATION_LIST_POLL_INTERVAL_MS);
};
const stopPolling = () => {
if (intervalId == null) {
return;
}
window.clearInterval(intervalId);
intervalId = null;
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadConversationItems({ silent: true });
startPolling();
return;
}
stopPolling();
};
const handleFocus = () => {
void loadConversationItems({ silent: true });
startPolling();
};
startPolling();
window.addEventListener('focus', handleFocus);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
stopPolling();
window.removeEventListener('focus', handleFocus);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [loadConversationItems]);
return {
conversationItems,

View File

@@ -42,13 +42,14 @@ export function useConversationViewController({
}: UseConversationViewControllerOptions) {
const previousSessionIdRef = useRef(activeSessionId);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const [activePreviewOverride, setActivePreviewOverride] = useState<PreviewItem | null>(null);
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const [previewText, setPreviewText] = useState('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [previewError, setPreviewError] = useState('');
const [previewContentType, setPreviewContentType] = useState('');
const activePreview = previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
const activePreview = activePreviewOverride ?? previewItems.find((item) => item.id === activePreviewId) ?? previewItems[0] ?? null;
useEffect(() => {
const hasSessionChanged = previousSessionIdRef.current !== activeSessionId;
@@ -64,6 +65,7 @@ export function useConversationViewController({
setComposerAttachments([]);
setCopiedMessageId(null);
setActivePreviewId(null);
setActivePreviewOverride(null);
setIsPreviewModalOpen(false);
setActiveSystemStatus(null);
setIsSystemStatusPending(false);
@@ -80,7 +82,7 @@ export function useConversationViewController({
]);
useEffect(() => {
if (!activePreviewId) {
if (!activePreviewId || activePreviewOverride) {
return;
}
@@ -90,7 +92,7 @@ export function useConversationViewController({
setActivePreviewId(null);
setIsPreviewModalOpen(false);
}, [activePreviewId, previewItems]);
}, [activePreviewId, activePreviewOverride, previewItems]);
useEffect(() => {
if (!isPreviewModalOpen || !activePreview) {
@@ -205,6 +207,7 @@ export function useConversationViewController({
previewError,
previewText,
setActivePreviewId,
setActivePreviewOverride,
setIsPreviewModalOpen,
};
}

View File

@@ -261,14 +261,22 @@ export function useConversationViewportController({
const handleViewportTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport || viewport.scrollTop > 0 || !hasOlderMessages || isLoadingOlderMessages) {
if (!viewport || isLoadingOlderMessages) {
touchStartYRef.current = null;
touchPullActiveRef.current = false;
return;
}
const isAtTop = viewport.scrollTop <= 0;
if (isAtTop && hasOlderMessages) {
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchPullActiveRef.current = true;
return;
}
touchStartYRef.current = event.touches[0]?.clientY ?? null;
touchPullActiveRef.current = true;
touchPullActiveRef.current = false;
}, [hasOlderMessages, isLoadingOlderMessages, viewportRef]);
const handleViewportTouchMove = useCallback((event: TouchEvent<HTMLDivElement>) => {
@@ -279,7 +287,15 @@ export function useConversationViewportController({
const viewport = viewportRef.current;
const currentY = event.touches[0]?.clientY ?? null;
if (!viewport || currentY == null || viewport.scrollTop > 0) {
if (!viewport || currentY == null) {
touchPullActiveRef.current = false;
touchStartYRef.current = null;
setPullToLoadDistance(0);
setIsPullToLoadArmed(false);
return;
}
if (viewport.scrollTop > 0) {
touchPullActiveRef.current = false;
touchStartYRef.current = null;
setPullToLoadDistance(0);
@@ -313,7 +329,13 @@ export function useConversationViewportController({
if (shouldLoadOlder) {
void onLoadOlderMessages();
}
}, [hasOlderMessages, isLoadingOlderMessages, isPullToLoadArmed, onLoadOlderMessages, resetPullToLoad]);
}, [
hasOlderMessages,
isLoadingOlderMessages,
isPullToLoadArmed,
onLoadOlderMessages,
resetPullToLoad,
]);
useEffect(() => {
if (connectionState === 'disconnected') {