feat: expand live chat and work server tools
This commit is contained in:
@@ -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={() => {}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user