From f59522ffc472f3c980d2a6cdd1b52eacee94670a Mon Sep 17 00:00:00 2001 From: how2ice Date: Mon, 25 May 2026 17:26:37 +0900 Subject: [PATCH] feat: update main chat and system chat UI --- src/App.tsx | 14 +- src/app/main/AppShell.tsx | 5 + .../main/AutomationContextManagementPage.tsx | 46 +- src/app/main/AutomationTypeManagementPage.tsx | 46 +- .../main/ChatDefaultContextManagementPage.tsx | 46 +- src/app/main/ChatNotificationBridgeV2.tsx | 224 +- src/app/main/ChatSourceChangesPage.tsx | 51 +- src/app/main/ChatTypeManagementPage.tsx | 134 +- src/app/main/HeaderMessageCenter.css | 45 + src/app/main/HeaderMessageCenter.tsx | 2 +- src/app/main/MainChatPanel.css | 119 + src/app/main/MainChatPanel.tsx | 3930 +++++-- src/app/main/MainContent.tsx | 49 +- src/app/main/MainHeader.tsx | 384 +- src/app/main/MainLayout.css | 1275 ++- src/app/main/MainSidebar.tsx | 4 +- src/app/main/PlayAppOverlay.tsx | 91 + src/app/main/PreviewAppOverlay.tsx | 843 +- src/app/main/PreviewAppWindow.tsx | 23 +- src/app/main/ResourceManagementPage.tsx | 10 +- src/app/main/ScopedChatRoomsWindow.css | 245 + src/app/main/ScopedChatRoomsWindow.tsx | 152 + src/app/main/SharedAppSettingsPage.css | 29 + src/app/main/SharedAppSettingsPage.tsx | 252 + src/app/main/SharedChatManagementPage.css | 246 + src/app/main/SharedChatManagementPage.tsx | 494 + src/app/main/SharedResourceManagementPage.css | 444 + src/app/main/SharedResourceManagementPage.tsx | 1628 +++ src/app/main/SystemChatPanel.css | 1064 ++ src/app/main/SystemChatPanel.hotfix.css | 3 + src/app/main/SystemChatPanel.tsx | 9528 +++++++++++++++++ src/app/main/TokenSettingManagementPage.css | 135 + src/app/main/TokenSettingManagementPage.tsx | 740 ++ src/app/main/appConfig.ts | 45 +- src/app/main/chatActionContextStore.ts | 65 + src/app/main/chatContextSettingsAccess.ts | 58 +- src/app/main/chatTypeAccess.ts | 94 + src/app/main/chatTypeDefaults.ts | 15 +- .../components/ConversationListPane.tsx | 10 +- .../components/ConversationRoomPane.tsx | 39 +- src/app/main/chatV2/data/chatClientEvents.ts | 32 + src/app/main/chatV2/data/chatGateway.ts | 9 +- .../hooks/conversationListMerge.test.mjs | 63 + .../chatV2/hooks/conversationListMerge.ts | 67 +- .../useConversationComposerController.ts | 550 +- .../useConversationRoomActionsController.ts | 87 +- .../chatV2/hooks/useConversationRoomData.ts | 30 +- .../hooks/useConversationViewController.ts | 17 +- .../useConversationViewportController.ts | 90 +- src/app/main/chatV2/hooks/useUnreadCounts.ts | 13 +- src/app/main/clientIdentity.ts | 47 +- src/app/main/codexModelOptions.ts | 60 + src/app/main/isolatedChatRoomScopeStore.ts | 397 + src/app/main/isolatedChatRooms.ts | 155 + src/app/main/layout/MainLayout.tsx | 192 +- src/app/main/layout/buildSearchOptions.ts | 71 + .../mainChatPanel/ChatActivityChecklist.tsx | 318 +- .../mainChatPanel/ChatConversationView.tsx | 6210 +++++++++-- .../mainChatPanel/ChatLinkCardPreview.tsx | 10 +- .../main/mainChatPanel/ChatPreviewBody.tsx | 8 +- src/app/main/mainChatPanel/ChatPromptCard.tsx | 758 +- .../mainChatPanel/ChatRankedLinkPreview.tsx | 4 +- src/app/main/mainChatPanel/chatUtils.js | 6 +- src/app/main/mainChatPanel/chatUtils.ts | 1151 +- .../mainChatPanel/contextConfirmPreference.ts | 83 + .../mainChatPanel/conversationDraftState.ts | 13 + .../main/mainChatPanel/conversationTitle.ts | 45 + .../main/mainChatPanel/conversationUnread.ts | 125 +- .../mainChatPanel/executorActivitySummary.ts | 136 + src/app/main/mainChatPanel/index.ts | 14 +- src/app/main/mainChatPanel/linkNavigation.ts | 192 +- src/app/main/mainChatPanel/messageParts.ts | 257 +- src/app/main/mainChatPanel/previewItems.ts | 6 +- .../main/mainChatPanel/promptPreviewState.ts | 2 +- src/app/main/mainChatPanel/promptState.ts | 7 + .../main/mainChatPanel/requestBadgeLabel.ts | 86 +- .../styles/MainChatPanel.conversation.css | 1033 +- .../styles/MainChatPanel.layout.css | 855 +- .../styles/MainChatPanel.preview-runtime.css | 450 +- .../styles/MainChatPanel.rooms-shared.css | 311 + src/app/main/mainChatPanel/types.ts | 55 + .../main/mainChatPanel/useChatConnection.ts | 36 +- src/app/main/mainView/constants.tsx | 22 +- src/app/main/mainView/navigation.ts | 12 +- src/app/main/mainView/searchOptions.ts | 114 +- src/app/main/modalKeyboard.tsx | 40 + src/app/main/notificationApi.ts | 55 +- src/app/main/notificationIdentity.ts | 72 +- src/app/main/pages/ChatPage.tsx | 6 + src/app/main/pages/ChatSharePage.css | 1794 ++++ src/app/main/pages/ChatSharePage.tsx | 5415 ++++++++++ src/app/main/pages/PlansPage.tsx | 18 + src/app/main/pages/PlayPage.tsx | 4 + src/app/main/previewRuntime.ts | 172 +- src/app/main/routes.tsx | 88 +- src/app/main/sharedResourceTokenAccess.ts | 539 + .../styles/SystemChatPanel.rooms-shared.css | 311 + src/app/main/tokenAccess.ts | 14 +- src/app/main/tokenSettingAccess.ts | 371 + src/app/main/types.ts | 1 + src/app/main/viewportCssVars.ts | 183 +- .../chatActivityExecutor/sampleShared.tsx | 72 + .../samples/AnalysisSample.tsx | 24 + .../samples/ChecklistSample.tsx | 43 + .../samples/DevelopmentSample.tsx | 24 + .../samples/TestSample.tsx | 24 + .../samples/VerificationSample.tsx | 24 + .../chatPromptCard/samples/Sample.tsx | 43 + .../MarkdownPreviewContent.tsx | 9 +- src/components/previewer/CodexDiffBlock.tsx | 122 +- .../previewer/FullscreenPreviewModal.css | 98 +- .../previewer/FullscreenPreviewModal.tsx | 80 +- src/components/previewer/PreviewerUI.css | 12 + src/components/previewer/PreviewerUI.tsx | 2 + .../previewer/ZoomablePreviewSurface.tsx | 14 + src/layer/gps/context/GpsLayerContext.tsx | 9 + .../search/context/SearchLayerContext.tsx | 11 +- src/main.tsx | 5 +- src/sw.js | 29 +- tests/mainChatPanel/messageParts.test.ts | 33 + 120 files changed, 43262 insertions(+), 3325 deletions(-) create mode 100644 src/app/main/PlayAppOverlay.tsx create mode 100644 src/app/main/ScopedChatRoomsWindow.css create mode 100644 src/app/main/ScopedChatRoomsWindow.tsx create mode 100644 src/app/main/SharedAppSettingsPage.css create mode 100644 src/app/main/SharedAppSettingsPage.tsx create mode 100644 src/app/main/SharedChatManagementPage.css create mode 100644 src/app/main/SharedChatManagementPage.tsx create mode 100644 src/app/main/SharedResourceManagementPage.css create mode 100644 src/app/main/SharedResourceManagementPage.tsx create mode 100644 src/app/main/SystemChatPanel.css create mode 100644 src/app/main/SystemChatPanel.hotfix.css create mode 100644 src/app/main/SystemChatPanel.tsx create mode 100644 src/app/main/TokenSettingManagementPage.css create mode 100644 src/app/main/TokenSettingManagementPage.tsx create mode 100644 src/app/main/chatActionContextStore.ts create mode 100644 src/app/main/chatV2/hooks/conversationListMerge.test.mjs create mode 100644 src/app/main/codexModelOptions.ts create mode 100644 src/app/main/isolatedChatRoomScopeStore.ts create mode 100644 src/app/main/isolatedChatRooms.ts create mode 100644 src/app/main/mainChatPanel/contextConfirmPreference.ts create mode 100644 src/app/main/mainChatPanel/conversationDraftState.ts create mode 100644 src/app/main/mainChatPanel/conversationTitle.ts create mode 100644 src/app/main/mainChatPanel/executorActivitySummary.ts create mode 100644 src/app/main/mainChatPanel/promptState.ts create mode 100644 src/app/main/mainChatPanel/styles/MainChatPanel.rooms-shared.css create mode 100644 src/app/main/pages/ChatSharePage.css create mode 100644 src/app/main/pages/ChatSharePage.tsx create mode 100644 src/app/main/sharedResourceTokenAccess.ts create mode 100644 src/app/main/systemChat/styles/SystemChatPanel.rooms-shared.css create mode 100644 src/app/main/tokenSettingAccess.ts create mode 100644 src/components/chatActivityExecutor/sampleShared.tsx create mode 100644 src/components/chatActivityExecutor/samples/AnalysisSample.tsx create mode 100644 src/components/chatActivityExecutor/samples/ChecklistSample.tsx create mode 100644 src/components/chatActivityExecutor/samples/DevelopmentSample.tsx create mode 100644 src/components/chatActivityExecutor/samples/TestSample.tsx create mode 100644 src/components/chatActivityExecutor/samples/VerificationSample.tsx diff --git a/src/App.tsx b/src/App.tsx index 4f94b6f..3cf26fd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,13 +26,17 @@ function retryChunkLoadOnce(errorMessage: string) { return false; } - if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') { + try { + if (sessionStorage.getItem(CHUNK_LOAD_RETRY_SESSION_KEY) === '1') { + return false; + } + + sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1'); + window.location.reload(); + return true; + } catch { return false; } - - sessionStorage.setItem(CHUNK_LOAD_RETRY_SESSION_KEY, '1'); - window.location.reload(); - return true; } function App() { diff --git a/src/app/main/AppShell.tsx b/src/app/main/AppShell.tsx index 80460f7..fb54704 100644 --- a/src/app/main/AppShell.tsx +++ b/src/app/main/AppShell.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { MainLayout } from './layout/MainLayout'; import { ApisPage } from './pages/ApisPage'; import { ChatPage } from './pages/ChatPage'; +import { ChatSharePage } from './pages/ChatSharePage'; import { DocsPage } from './pages/DocsPage'; import { PlansPage } from './pages/PlansPage'; import { PlayPage } from './pages/PlayPage'; @@ -10,6 +11,8 @@ import { buildChatPath, buildDocsPath } from './routes'; export function AppShell() { return ( + } /> + } /> }> } /> } /> @@ -17,6 +20,8 @@ export function AppShell() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/app/main/AutomationContextManagementPage.tsx b/src/app/main/AutomationContextManagementPage.tsx index a58849c..b96e5de 100644 --- a/src/app/main/AutomationContextManagementPage.tsx +++ b/src/app/main/AutomationContextManagementPage.tsx @@ -1,6 +1,6 @@ import { DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, UnorderedListOutlined } from '@ant-design/icons'; -import { Alert, Button, Card, Empty, Form, Input, List, Space, Switch, Typography } from 'antd'; -import { useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Card, Empty, Form, Input, List, Modal, Space, Switch, Typography } from 'antd'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { deleteAutomationContext, @@ -8,6 +8,7 @@ import { upsertAutomationContext, useAutomationContextRegistry, } from './automationContextAccess'; +import { confirmWithKeyboard } from './modalKeyboard'; import { useTokenAccess } from './tokenAccess'; import './AutomationContextManagementPage.css'; @@ -51,6 +52,8 @@ export function AutomationContextManagementPage() { const [isSaving, setIsSaving] = useState(false); const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [form] = Form.useForm(); + const [modalApi, modalContextHolder] = Modal.useModal(); + const lastHydratedFormKeyRef = useRef(''); const selectedAutomationContext = useMemo( () => automationContexts.find((item) => item.id === selectedAutomationContextId) ?? null, @@ -67,12 +70,20 @@ export function AutomationContextManagementPage() { useEffect(() => { if (detailMode !== 'detail') { + lastHydratedFormKeyRef.current = ''; return; } + const nextFormKey = isCreating ? '__create__' : selectedAutomationContext?.id ?? '__empty__'; + + if (lastHydratedFormKeyRef.current === nextFormKey) { + return; + } + + lastHydratedFormKeyRef.current = nextFormKey; form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationContext)); - }, [detailMode, form, isCreating, selectedAutomationContext]); + }, [detailMode, form, isCreating, selectedAutomationContext?.id]); const openCreateForm = () => { setIsCreating(true); @@ -98,7 +109,14 @@ export function AutomationContextManagementPage() { return; } - if (!window.confirm(`"${selectedAutomationContext.title}" Context를 삭제할까요?`)) { + const confirmed = await confirmWithKeyboard(modalApi, { + title: `"${selectedAutomationContext.title}" Context를 삭제할까요?`, + okText: '삭제', + cancelText: '취소', + okButtonProps: { danger: true }, + }); + + if (!confirmed) { return; } @@ -122,19 +140,23 @@ export function AutomationContextManagementPage() { if (!hasAccess) { return ( - - - + <> + {modalContextHolder} + + + + ); } return (
+ {modalContextHolder} {detailMode === 'list' ? ( (); + const [modalApi, modalContextHolder] = Modal.useModal(); + const lastHydratedFormKeyRef = useRef(''); const isPaneMaximized = maximizedPane !== 'none'; const selectedAutomationType = useMemo( @@ -80,12 +83,20 @@ export function AutomationTypeManagementPage() { useEffect(() => { if (detailMode !== 'detail') { + lastHydratedFormKeyRef.current = ''; return; } + const nextFormKey = isCreating ? '__create__' : selectedAutomationType?.id ?? '__empty__'; + + if (lastHydratedFormKeyRef.current === nextFormKey) { + return; + } + + lastHydratedFormKeyRef.current = nextFormKey; form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : selectedAutomationType)); - }, [detailMode, form, isCreating, selectedAutomationType]); + }, [detailMode, form, isCreating, selectedAutomationType?.id]); useEffect(() => { if (detailMode !== 'detail') { @@ -150,7 +161,14 @@ export function AutomationTypeManagementPage() { return; } - if (!window.confirm(`"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`)) { + const confirmed = await confirmWithKeyboard(modalApi, { + title: `"${selectedAutomationType.name}" 자동화 유형을 삭제할까요?`, + okText: '삭제', + cancelText: '취소', + okButtonProps: { danger: true }, + }); + + if (!confirmed) { return; } @@ -209,14 +227,17 @@ export function AutomationTypeManagementPage() { if (!hasAccess) { return ( - - - + <> + {modalContextHolder} + + + + ); } @@ -226,6 +247,7 @@ export function AutomationTypeManagementPage() { isPaneMaximized ? ' chat-type-management-page--pane-maximized' : '' }${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`} > + {modalContextHolder} {detailMode === 'list' ? ( (); + const [modalApi, modalContextHolder] = Modal.useModal(); + const lastHydratedFormKeyRef = useRef(''); const selectedContext = useMemo( () => defaultContexts.find((item) => item.id === selectedContextId) ?? null, @@ -113,12 +116,20 @@ export function ChatDefaultContextManagementPage() { useEffect(() => { if (detailMode !== 'detail') { + lastHydratedFormKeyRef.current = ''; return; } + const nextFormKey = isCreating ? '__create__' : selectedContext?.id ?? '__empty__'; + + if (lastHydratedFormKeyRef.current === nextFormKey) { + return; + } + + lastHydratedFormKeyRef.current = nextFormKey; form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : selectedContext)); - }, [detailMode, form, isCreating, selectedContext]); + }, [detailMode, form, isCreating, selectedContext?.id]); useEffect(() => { if (typeof window === 'undefined') { @@ -227,7 +238,14 @@ export function ChatDefaultContextManagementPage() { return; } - if (!window.confirm(`"${selectedContext.title}" 공통 문맥을 삭제할까요?`)) { + const confirmed = await confirmWithKeyboard(modalApi, { + title: `"${selectedContext.title}" 공통 문맥을 삭제할까요?`, + okText: '삭제', + cancelText: '취소', + okButtonProps: { danger: true }, + }); + + if (!confirmed) { return; } @@ -249,14 +267,17 @@ export function ChatDefaultContextManagementPage() { if (!hasAccess) { return ( - - - + <> + {modalContextHolder} + + + + ); } @@ -268,6 +289,7 @@ export function ChatDefaultContextManagementPage() { isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : '' }`} > + {modalContextHolder} {detailMode === 'list' ? ( ([]); - const lastPolledCodexMessageIdBySessionRef = useRef>({}); - const lastFailedRequestKeyBySessionRef = useRef>({}); - - const createChatNotification = ({ - targetSessionId, - conversationTitle, - title, - body, - previewText, - priority, - metadata, - }: { - targetSessionId: string; - conversationTitle?: string | null; - title: string; - body: string; - previewText?: string; - priority: 'normal' | 'high'; - metadata?: Record; - }) => { - const resolvedConversationTitle = conversationTitle || '현재 채팅방'; - const linkUrl = buildChatNotificationLink(targetSessionId); - const notificationData = { - category: 'chat', - priority, - suppressIfVisible: 'true', - sessionId: targetSessionId, - conversationTitle: resolvedConversationTitle, - targetUrl: linkUrl, - linkUrl, - ...metadata, - }; - const serializedNotificationData = Object.fromEntries( - Object.entries(notificationData).flatMap(([key, value]) => { - if (value == null) { - return []; - } - - return [[key, String(value)]]; - }), - ); - const pushPayload = { - title, - body, - threadId: `chat:${targetSessionId}`, - data: serializedNotificationData, - targetClientIds: (() => { - const clientId = getOrCreateClientId().trim(); - return clientId ? [clientId] : undefined; - })(), - }; - - if (shouldSuppressChatNotificationWhenAppOpen()) { - return Promise.resolve(undefined); - } - - return Promise.allSettled([ - createNotificationMessage({ - title, - body, - category: 'chat', - source: 'codex-live', - priority, - metadata: { - ...notificationData, - previewText, - linkLabel: '채팅 바로 열기', - }, - }), - sendClientNotification(pushPayload), - ]) - .then(async ([storedResult, pushResult]) => { - if (pushResult.status === 'rejected') { - await tryShowLocalChatNotification(pushPayload); - } else if (shouldFallbackToLocalNotification(pushResult.value)) { - await tryShowLocalChatNotification(pushPayload); - } - - if (storedResult.status === 'fulfilled') { - return storedResult.value; - } - - if (pushResult.status === 'fulfilled') { - return pushResult.value; - } - - throw storedResult.reason; - }) - .catch(() => undefined); - }; if (isPreviewRuntime() || !appConfig.chat.receiveRoomNotifications) { return null; } - useEffect(() => { - let cancelled = false; - - const pollNotifications = async () => { - if (cancelled || !shouldPollConversationNotifications()) { - return; - } - - try { - const conversations = await chatGateway.listConversations(); - - if (cancelled) { - return; - } - - const candidates = selectNotificationPollingCandidates(conversations); - - await Promise.all( - candidates.map(async (conversation) => { - const detail = await chatGateway.getConversationDetail(conversation.sessionId, { limit: 40 }); - - if (cancelled) { - return; - } - - const latestCodexMessage = findLatestCodexMessage(detail.messages); - const latestCodexMessageId = latestCodexMessage?.id ?? 0; - const previousCodexMessageId = lastPolledCodexMessageIdBySessionRef.current[conversation.sessionId]; - const questionText = findQuestionText(detail.messages, latestCodexMessage?.clientRequestId); - const latestFailedRequest = findLatestFailedRequest(detail.requests); - const failedRequestKey = latestFailedRequest - ? `${latestFailedRequest.requestId}:${latestFailedRequest.updatedAt}:${latestFailedRequest.status}` - : ''; - const previousFailedRequestKey = lastFailedRequestKeyBySessionRef.current[conversation.sessionId] ?? ''; - - lastPolledCodexMessageIdBySessionRef.current[conversation.sessionId] = latestCodexMessageId; - lastFailedRequestKeyBySessionRef.current[conversation.sessionId] = failedRequestKey; - - if (!shouldNotifyWhileAway()) { - return; - } - - if ( - latestFailedRequest && - failedRequestKey && - previousFailedRequestKey && - previousFailedRequestKey !== failedRequestKey - ) { - const notificationKey = `failed:${conversation.sessionId}:${failedRequestKey}`; - - if (!notifiedFailedJobKeysRef.current.includes(notificationKey)) { - notifiedFailedJobKeysRef.current = [...notifiedFailedJobKeysRef.current, notificationKey].slice(-80); - - const failureDetail = - normalizeNotificationDetailText(latestFailedRequest.statusMessage) || - normalizeNotificationDetailText(latestFailedRequest.responseText) || - '요청이 실패했습니다.'; - - await createChatNotification({ - targetSessionId: conversation.sessionId, - conversationTitle: conversation.title, - title: `${conversation.title || '현재 채팅방'} 실행 실패`, - body: createChatQuestionAnswerNotificationBody({ - questionText: latestFailedRequest.userText, - answerText: failureDetail, - fallback: `${conversation.title || '현재 채팅방'}에서 실행이 실패했습니다.`, - }), - previewText: createChatQuestionOnlyNotificationPreview( - latestFailedRequest.userText, - `${conversation.title || '현재 채팅방'} 실행 실패`, - ), - priority: 'high', - metadata: { - type: 'chat-request-failed', - requestId: latestFailedRequest.requestId, - questionText: latestFailedRequest.userText, - answerText: failureDetail, - }, - }); - } - } - - if ( - !conversation.hasUnreadResponse || - !latestCodexMessage || - latestCodexMessageId <= 0 || - !previousCodexMessageId || - latestCodexMessageId === previousCodexMessageId - ) { - return; - } - - await createChatNotification({ - targetSessionId: conversation.sessionId, - conversationTitle: conversation.title, - title: `${conversation.title || '현재 채팅방'} 새 답변`, - body: createChatQuestionAnswerNotificationBody({ - questionText, - answerText: latestCodexMessage.text, - fallback: `${conversation.title || '현재 채팅방'}에 새 답변이 도착했습니다.`, - }), - previewText: createChatQuestionOnlyNotificationPreview(questionText, `${conversation.title || '현재 채팅방'} 새 답변`), - priority: 'normal', - metadata: { - type: 'chat-response', - requestId: latestCodexMessage.clientRequestId ?? '', - questionText, - answerText: latestCodexMessage.text, - }, - }); - }), - ); - } catch { - // Ignore polling errors and retry on the next interval. - } - }; - - void pollNotifications(); - const timer = window.setInterval(() => { - void pollNotifications(); - }, BACKGROUND_CONVERSATION_POLL_INTERVAL_MS); - - return () => { - cancelled = true; - window.clearInterval(timer); - }; - }, [appConfig.chat.receiveRoomNotifications]); - return null; } diff --git a/src/app/main/ChatSourceChangesPage.tsx b/src/app/main/ChatSourceChangesPage.tsx index 370ee4e..56cf235 100644 --- a/src/app/main/ChatSourceChangesPage.tsx +++ b/src/app/main/ChatSourceChangesPage.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { fetchServerCommands } from '../../features/serverCommand/api'; import type { ServerCommandItem } from '../../features/serverCommand/types'; +import { CodexDiffBlock, parseCodexDiffSections } from '../../components/previewer'; import { fetchChatSourceChanges } from './mainChatPanel'; import { ChatPromptCard } from './mainChatPanel/ChatPromptCard'; import { extractChatMessageParts } from './mainChatPanel/messageParts'; @@ -87,6 +88,17 @@ function hasMeaningfulSourceArtifacts(entry: Pick block.trim()).filter(Boolean); + const diffText = normalizedBlocks.join('\n\n'); + const fileCount = normalizedBlocks.reduce((count, block) => count + parseCodexDiffSections(block).length, 0); + + return { + diffText, + fileCount, + }; +} + function buildFeatureKeyCandidate(value: string) { return value .trim() @@ -247,9 +259,18 @@ export function ChatSourceChangesPage() { setSelectedEntryId(null); try { - const serverCommands = await fetchServerCommands(); - const testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null; - const nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null; + let testServerCommand: ServerCommandItem | null = null; + let nextLatestTestServerBuiltAt: string | null = null; + + try { + const serverCommands = await fetchServerCommands(); + testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null; + nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null; + } catch { + testServerCommand = null; + nextLatestTestServerBuiltAt = null; + } + const sourceChanges = await fetchChatSourceChanges(300); if (cancelled) { @@ -796,17 +817,19 @@ export function ChatSourceChangesPage() { - {selectedEntry.diffBlocks.length ? ( - - {selectedEntry.diffBlocks.map((block, index) => ( -
-                          {block}
-                        
- ))} -
- ) : ( - 추출된 diff 블록이 없습니다. 설명과 파일 목록만 확인할 수 있습니다. - )} + {(() => { + if (!selectedEntry.diffBlocks.length) { + return 추출된 diff 블록이 없습니다. 설명과 파일 목록만 확인할 수 있습니다.; + } + + const combinedDiff = combineCodexDiffBlocks(selectedEntry.diffBlocks); + return ( + + ); + })()}
) : ( diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx index 097a7de..7e40014 100644 --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -25,17 +25,22 @@ import { Tag, Tooltip, Typography, + Modal, + Select, } from 'antd'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { canUseChatType, CHAT_PERMISSION_ROLE_LABELS, + createDefaultChatTypeExecutionPolicy, deleteChatType, resolveCurrentChatPermissionRoles, upsertChatType, useChatTypeRegistry, type ChatPermissionRole, + type ChatTypeExecutionMode, + type ChatTypeExecutionPolicy, type ChatTypeRecord, } from './chatTypeAccess'; import { @@ -43,6 +48,7 @@ import { upsertChatTypeDefaultContextSelection, useChatContextSettingsRegistry, } from './chatContextSettingsAccess'; +import { confirmWithKeyboard } from './modalKeyboard'; import { useTokenAccess } from './tokenAccess'; import './ChatTypeManagementPage.css'; @@ -53,6 +59,7 @@ type ChatTypeFormValue = { name: string; sortOrder: number; description: string; + executionPolicy: ChatTypeExecutionPolicy; permissions: ChatPermissionRole[]; enabled: boolean; }; @@ -61,6 +68,7 @@ const EMPTY_FORM_VALUE: ChatTypeFormValue = { name: '', sortOrder: 1, description: '', + executionPolicy: createDefaultChatTypeExecutionPolicy(), permissions: ['token-user'], enabled: true, }; @@ -75,11 +83,24 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue { name: chatType.name, sortOrder: chatType.sortOrder, description: chatType.description, + executionPolicy: chatType.executionPolicy, permissions: chatType.permissions, enabled: chatType.enabled, }; } +function resolveExecutionPolicyModeLabel(mode: ChatTypeExecutionMode) { + if (mode === 'summary-free-talking') { + return '회의 기록자 + 프리토킹'; + } + + if (mode === 'dispatcher-workers') { + return '중계 지시자 + 실작업자'; + } + + return '직접 구성'; +} + export function ChatTypeManagementPage() { const { hasAccess } = useTokenAccess(); const { chatTypes, setChatTypes, setChatTypesLocal, isLoading, errorMessage, reload } = useChatTypeRegistry(); @@ -101,6 +122,8 @@ export function ChatTypeManagementPage() { const [saveErrorMessage, setSaveErrorMessage] = useState(''); const [selectedDefaultContextIds, setSelectedDefaultContextIds] = useState([]); const [form] = Form.useForm(); + const [modalApi, modalContextHolder] = Modal.useModal(); + const lastHydratedFormKeyRef = useRef(''); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); const isPaneMaximized = maximizedPane !== 'none'; const builtInChatTypes: ChatTypeRecord[] = []; @@ -125,9 +148,17 @@ export function ChatTypeManagementPage() { useEffect(() => { if (detailMode !== 'detail') { + lastHydratedFormKeyRef.current = ''; return; } + const nextFormKey = isCreating ? '__create__' : selectedChatType?.id ?? '__empty__'; + + if (lastHydratedFormKeyRef.current === nextFormKey) { + return; + } + + lastHydratedFormKeyRef.current = nextFormKey; form.resetFields(); form.setFieldsValue(toFormValue(isCreating ? null : selectedChatType)); setSelectedDefaultContextIds( @@ -137,7 +168,7 @@ export function ChatTypeManagementPage() { defaultContexts.some((context) => context.id === contextId && context.enabled), ), ); - }, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType]); + }, [chatTypeDefaults, defaultContexts, detailMode, form, isCreating, selectedChatType?.id]); useEffect(() => { if (detailMode !== 'detail') { @@ -221,7 +252,14 @@ export function ChatTypeManagementPage() { return; } - if (!window.confirm(`"${selectedChatType.name}" 요청 유형을 삭제할까요?`)) { + const confirmed = await confirmWithKeyboard(modalApi, { + title: `"${selectedChatType.name}" 요청 유형을 삭제할까요?`, + okText: '삭제', + cancelText: '취소', + okButtonProps: { danger: true }, + }); + + if (!confirmed) { return; } @@ -351,14 +389,17 @@ export function ChatTypeManagementPage() { if (!hasAccess) { return ( - - - + <> + {modalContextHolder} + + + + ); } @@ -368,6 +409,7 @@ export function ChatTypeManagementPage() { isPaneMaximized ? ' chat-type-management-page--pane-maximized' : '' }${isMobileViewport ? ` chat-type-management-page--mobile-view-${mobileView}` : ''}`} > + {modalContextHolder} {detailMode === 'list' ? ( ( {CHAT_PERMISSION_ROLE_LABELS[permission]} ))} + {resolveExecutionPolicyModeLabel(item.executionPolicy.mode)} {linkedDefaultContexts.map((context) => ( {context.title} @@ -611,6 +654,75 @@ export function ChatTypeManagementPage() {
+
+ + + + + + + + + + + + + + ); + }} + +
{isMobileViewport ? ( span, +.header-message-center__trigger.ant-btn .ant-btn-icon, +.header-message-center__trigger.ant-btn .anticon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + vertical-align: middle; +} + .header-message-center__summary { display: flex; flex-direction: column; @@ -142,6 +175,18 @@ } @media (max-width: 768px) { + .header-message-center__trigger.ant-btn { + width: 31px; + height: 31px; + min-width: 31px; + border-radius: 10px; + } + + .header-message-center__badge.ant-badge .ant-badge-count { + inset-block-start: 5px; + transform: translate(34%, -16%); + } + .header-message-center__item-button { padding: 12px 12px 34px; } diff --git a/src/app/main/HeaderMessageCenter.tsx b/src/app/main/HeaderMessageCenter.tsx index 69fe8b7..344b2a6 100644 --- a/src/app/main/HeaderMessageCenter.tsx +++ b/src/app/main/HeaderMessageCenter.tsx @@ -308,7 +308,7 @@ export function HeaderMessageCenter({ isMobileViewport }: { isMobileViewport: bo return ( <> - + {section.isReorderable ? ( @@ -4640,9 +5786,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ) : null} - {(mobileConversationSectionOpen[section.key] ?? section.defaultOpen) ? ( -
- {section.items.map((item) => renderConversationListItem(item, section.key))} + {visibleMobileItems.length > 0 ? ( +
+ {visibleMobileItems.map((item) => renderConversationListItem(item, section.key))}
) : null} @@ -4719,7 +5869,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }; const replaceChatSessionInUrl = (sessionId: string) => { - if (location.pathname !== buildChatPath('live')) { + if (location.pathname !== chatRoutePath) { return; } @@ -4753,7 +5903,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const replaceChatViewInUrl = useCallback( (view: ChatPanelView) => { - if (location.pathname !== buildChatPath('live')) { + if (location.pathname !== chatRoutePath) { return; } @@ -4778,7 +5928,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ); const clearRequestedRuntimeLogInUrl = useCallback(() => { - if (location.pathname !== buildChatPath('live')) { + if (location.pathname !== chatRoutePath) { return; } @@ -4802,15 +5952,50 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [location.pathname, location.search, navigate]); const openConversationSession = (sessionId: string) => { + if (isMobileViewport && dismissMobileActiveTextInput()) { + applyViewportCssVars(); + scheduleViewportRecoverySync(); + } + isClosingConversationRef.current = false; + setPreserveEmptyConversationSelection(false); + const openedConversation = conversationItemsRef.current.find((item) => item.sessionId === sessionId) ?? null; + if (openedConversation?.hasUnreadResponse) { + rememberConversationUnreadCleared(openedConversation); + } replaceChatSessionInUrl(sessionId); clearRequestedRuntimeLogInUrl(); setActiveView('chat'); + setIsRoomsListDrawerOpen(false); + setConversationItems((previous) => + previous.map((item) => + item.sessionId === sessionId && item.hasUnreadResponse + ? { + ...item, + hasUnreadResponse: false, + } + : item, + ), + ); const now = new Date().toISOString(); const cachedMessages = sessionMessageCacheRef.current.get(sessionId) ?? []; const hasCachedMessages = cachedMessages.length > 0; + const cachedConversationWindow = limitConversationWindowToRecentRequests( + cachedMessages, + requestItemsRef.current, + sessionId, + CHAT_CONVERSATION_DETAIL_PAGE_SIZE, + ); if (sessionId === activeSessionId && !isConversationPaneClosed) { + sessionMessageCacheRef.current.set(sessionId, cachedConversationWindow.messages); + setMessages(cachedConversationWindow.messages); + setRequestItems((previous) => { + const preservedOtherSessions = previous.filter((item) => item.sessionId !== sessionId); + return [...preservedOtherSessions, ...cachedConversationWindow.requests]; + }); + setHasOlderMessages(cachedConversationWindow.requests.length >= CHAT_CONVERSATION_DETAIL_PAGE_SIZE); + setOldestLoadedMessageId(cachedConversationWindow.messages[0]?.id ?? null); if (isMobileViewport) { setIsMobileConversationView(true); } @@ -4876,15 +6061,18 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setIsMobileConversationView(true); } - setMessages(hasCachedMessages ? cachedMessages : []); + if (hasCachedMessages) { + sessionMessageCacheRef.current.set(sessionId, cachedConversationWindow.messages); + } + setMessages(hasCachedMessages ? cachedConversationWindow.messages : []); setRequestItems((previous) => { - const visibleRequestIds = collectVisibleConversationRequestIds(cachedMessages); + const preservedOtherSessions = previous.filter((item) => item.sessionId !== sessionId); - return previous.filter( - (item) => - item.sessionId === sessionId && - (visibleRequestIds.size === 0 ? !hasCachedMessages : visibleRequestIds.has(item.requestId)), - ); + if (!hasCachedMessages) { + return preservedOtherSessions; + } + + return [...preservedOtherSessions, ...cachedConversationWindow.requests]; }); setActivePreviewId(null); setIsPreviewModalOpen(false); @@ -4986,43 +6174,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = sessionMessageCacheRef.current.set(activeSessionId, messages); }, [messages]); - useEffect(() => { - if (activeView !== 'chat') { - return; - } - - if ( - !isActiveChatSessionInForeground({ - sessionId: activeSessionId, - activeSessionId, - activeView, - isConversationPaneClosed, - isMobileViewport, - isMobileConversationView, - }) - ) { - return; - } - - if (isConversationContentLoading || !shouldStickToBottomRef.current) { - return; - } - - if (!messages.some((item) => item.author === 'codex' && !isPreparingChatReplyText(item.text))) { - return; - } - - syncActiveConversationReadState(activeSessionId); - }, [ - activeSessionId, - activeView, - isConversationContentLoading, - isConversationPaneClosed, - isMobileConversationView, - isMobileViewport, - messages, - ]); - const updatePendingMessageStatus = ( requestId: string, status: 'retrying' | 'failed' | null, @@ -5046,8 +6197,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = JSON.stringify({ type: 'message:send', payload: { + sessionId: request.sessionId, text: request.text, + requestOrigin: request.origin ?? 'composer', + parentRequestId: request.parentRequestId?.trim() || null, + promptContextRef: request.promptContextRef ?? null, omitPromptHistory: request.omitPromptHistory === true, + codexModel: request.codexModel, chatTypeId: request.chatTypeId, chatTypeLabel: request.chatTypeLabel, chatTypeDescription: request.chatTypeDescription, @@ -5068,15 +6224,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = handleClearConversation, deleteStoredRequest, handleDeleteConversation, - handleRenameConversation, removeQueuedComposerRequest, retryPendingRequest, } = useConversationRoomActionsController({ activeSessionId, requestedSessionId: requestedSessionId ?? '', + handledRequestedSessionIdRef, + isClosingConversationRef, conversationItems, - activeConversation, - editingConversationTitle, isMobileViewport, pendingRequestsRef, sessionMessageCacheRef, @@ -5095,9 +6250,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setIsResourceStripOpen, setIsConversationPaneClosed, setIsMobileConversationView, - setRenamingConversationSessionId, - setEditingConversationTitle, - setIsEditingConversationTitle, + setPreserveEmptyConversationSelection, updatePendingMessageStatus, sendChatRequest, createLocalMessage, @@ -5111,6 +6264,13 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setPendingClearConversationSessionId(activeConversation.sessionId); }, [activeConversation]); + const openDeleteConversationModal = useCallback(() => { + if (!activeConversation) { + return; + } + + setPendingDeleteSessionId(activeConversation.sessionId); + }, [activeConversation]); const handleMarkAllFilteredConversationsRead = useCallback(async () => { const targetSessionIds = Array.from( new Set( @@ -5126,6 +6286,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } const targetSessionIdSet = new Set(targetSessionIds); + unreadFilteredConversationItems.forEach((item) => { + if (targetSessionIdSet.has(item.sessionId)) { + rememberConversationUnreadCleared(item); + } + }); setIsMarkingAllConversationsRead(true); setConversationItems((previous) => previous.map((item) => @@ -5172,26 +6337,26 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setIsDeletingAllConversations(true); try { - const succeededSessionIds: string[] = []; - let failedCount = 0; - - for (const conversation of targetConversations) { - try { + const deleteResults = await Promise.allSettled( + targetConversations.map(async (conversation) => { if (!conversation.isDraftOnly) { await chatGateway.deleteConversation(conversation.sessionId); } - succeededSessionIds.push(conversation.sessionId); - } catch { - failedCount += 1; - } - } + + return conversation.sessionId; + }), + ); + + const succeededSessionIds = deleteResults.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ); + const failedCount = deleteResults.filter((result) => result.status === 'rejected').length; const succeededSessionIdSet = new Set(succeededSessionIds); if (succeededSessionIdSet.size > 0) { succeededSessionIds.forEach((sessionId) => { sessionMessageCacheRef.current.delete(sessionId); - delete lastMarkedReadResponseIdBySessionRef.current[sessionId]; }); pendingRequestsRef.current = pendingRequestsRef.current.filter( (request) => !succeededSessionIdSet.has(request.sessionId), @@ -5200,6 +6365,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setRequestItems((previous) => previous.filter((item) => !succeededSessionIdSet.has(item.sessionId))); if (succeededSessionIdSet.has(activeSessionId)) { + isClosingConversationRef.current = true; + handledRequestedSessionIdRef.current = ''; + setPreserveEmptyConversationSelection(true); replaceChatSessionInUrl(''); chatConnectionGateway.resetLastReceivedEventId(''); setActiveSessionId(''); @@ -5213,9 +6381,10 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setActiveSystemStatus(null); setIsSystemStatusPending(false); setIsResourceStripOpen(false); - setIsConversationPaneClosed(false); - setIsMobileConversationView(!isMobileViewport); + setIsConversationPaneClosed(true); + setIsMobileConversationView(false); } else if (requestedSessionId && succeededSessionIdSet.has(requestedSessionId)) { + handledRequestedSessionIdRef.current = ''; replaceChatSessionInUrl(activeSessionId); } } @@ -5371,6 +6540,21 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setSelectedChatTypeId(availableChatTypes[0]?.id ?? null); }, [activeConversation?.chatTypeId, activeConversation?.lastChatTypeId, activeSessionId, availableChatTypes, selectedChatTypeId]); + useEffect(() => { + if (activeSessionId) { + const persistedCodexModel = normalizeCodexModel(activeConversation?.codexModel); + + if (selectedCodexModel !== persistedCodexModel) { + setSelectedCodexModel(persistedCodexModel); + } + return; + } + + if (selectedCodexModel !== DEFAULT_CODEX_MODEL) { + setSelectedCodexModel(DEFAULT_CODEX_MODEL); + } + }, [activeConversation?.codexModel, activeSessionId, selectedCodexModel]); + useEffect(() => { if (createConversationChatTypeId && availableChatTypes.some((item) => item.id === createConversationChatTypeId)) { return; @@ -5379,6 +6563,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setCreateConversationChatTypeId(selectedChatType?.id ?? availableChatTypes[0]?.id ?? null); }, [availableChatTypes, createConversationChatTypeId, selectedChatType?.id]); + useEffect(() => { + const normalizedCreateConversationModel = normalizeCodexModel(createConversationCodexModel); + + if (normalizedCreateConversationModel !== createConversationCodexModel) { + setCreateConversationCodexModel(normalizedCreateConversationModel); + } + }, [createConversationCodexModel]); + useEffect(() => { if (!activeSessionId || !selectedChatTypeId || !selectedChatType || isChatTypeSelectionLocked) { return; @@ -5461,6 +6653,51 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setConversationItems, ]); + useEffect(() => { + if (!activeSessionId) { + return; + } + + const nextCodexModel = normalizeCodexModel(selectedCodexModel); + const currentCodexModel = normalizeCodexModel(activeConversation?.codexModel); + + if (currentCodexModel === nextCodexModel) { + return; + } + + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === activeSessionId + ? { + ...entry, + codexModel: nextCodexModel, + } + : entry, + ), + ); + + void chatGateway.updateConversation(activeSessionId, { + codexModel: nextCodexModel, + }).then((item) => { + setConversationItems((previous) => + previous.map((entry) => (entry.sessionId === item.sessionId ? item : entry)), + ); + }).catch((error: unknown) => { + messageApi.error(error instanceof Error ? error.message : 'Codex 모델 저장에 실패했습니다.'); + setConversationItems((previous) => + previous.map((entry) => + entry.sessionId === activeSessionId && activeConversation + ? { + ...entry, + codexModel: activeConversation.codexModel ?? DEFAULT_CODEX_MODEL, + } + : entry, + ), + ); + setSelectedCodexModel(currentCodexModel); + }); + }, [activeConversation, activeSessionId, messageApi, selectedCodexModel, setConversationItems]); + useEffect(() => { const nextView = requestedChatView ?? (initialView === 'errors' ? 'errors' : 'chat'); @@ -5469,8 +6706,16 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } + if (nextView === 'runtime' && !showRuntimeViewShortcut) { + setActiveView('chat'); + if (requestedChatView === 'runtime') { + replaceChatViewInUrl('chat'); + } + return; + } + setActiveView(nextView); - }, [hasAccess, initialView, requestedChatView]); + }, [hasAccess, initialView, replaceChatViewInUrl, requestedChatView, showRuntimeViewShortcut]); useEffect(() => { return () => { @@ -5502,15 +6747,23 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [isMaximized, isMobileViewport, lockOuterScrollOnMobile]); useEffect(() => { - setEditingConversationTitle(activeConversation?.title ?? ''); - }, [activeConversation?.sessionId, activeConversation?.title]); - - useEffect(() => { - if (location.pathname !== buildChatPath('live')) { + if (location.pathname !== chatRoutePath) { handledRequestedSessionIdRef.current = ''; return; } + if (requestedSessionId && !shouldShowConversationForMode(requestedSessionId, mode)) { + handledRequestedSessionIdRef.current = ''; + + if (activeSessionId && shouldShowConversationForMode(activeSessionId, mode)) { + replaceChatSessionInUrl(activeSessionId); + return; + } + + replaceChatSessionInUrl(firstConversationForMode?.sessionId ?? ''); + return; + } + if (requestedSessionId && !requestedChatView) { setActiveView('chat'); } @@ -5558,6 +6811,8 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = location.pathname, requestedChatView, requestedSessionId, + firstConversationForMode, + mode, ]); useEffect(() => { @@ -5572,23 +6827,42 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = nextState[section.key] = section.defaultOpen; } }); - const activeSectionKey = - conversationSections.find((section) => section.items.some((item) => item.sessionId === activeSessionId))?.key ?? null; - - if (activeSectionKey) { - nextState[activeSectionKey] = true; - } - return nextState; }); - }, [activeSessionId, conversationSections, isMobileViewport]); + }, [conversationSections, isMobileViewport]); useEffect(() => { - if (requestedSessionId) { + if (location.pathname !== chatRoutePath) { return; } - if (conversationItems.length === 0) { + if (requestedSessionId || !activeSessionId || isConversationPaneClosed) { + return; + } + + replaceChatSessionInUrl(activeSessionId); + }, [activeSessionId, isConversationPaneClosed, location.pathname, requestedSessionId]); + + useEffect(() => { + if (requestedSessionId) { + setPreserveEmptyConversationSelection(false); + return; + } + + if (isConversationListLoading || pendingConversationCreationRef.current.size > 0) { + return; + } + + if (firstConversationForMode == null) { + if (preserveEmptyConversationSelection) { + setActiveSessionId(''); + setMessages([]); + setRequestItems([]); + setIsConversationPaneClosed(false); + setIsMobileConversationView(false); + return; + } + setActiveSessionId(''); setMessages([]); setRequestItems([]); @@ -5601,17 +6875,46 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return; } - if (location.pathname === '/chat/live') { - replaceChatSessionInUrl(conversationItems[0]!.sessionId); + if (preserveEmptyConversationSelection) { + return; } - }, [activeSessionId, conversationItems, isMobileViewport, requestedSessionId]); + + if (location.pathname === chatRoutePath) { + replaceChatSessionInUrl(firstConversationForMode.sessionId); + } + }, [activeSessionId, firstConversationForMode, isMobileViewport, preserveEmptyConversationSelection, requestedSessionId]); + + useEffect(() => { + if (mode !== 'rooms' || roomsEntryMode !== 'direct') { + return; + } + + if (requestedSessionId || activeSessionId || isConversationListLoading || preserveEmptyConversationSelection) { + return; + } + + if (!matchingRoomConversation) { + return; + } + + openConversationSession(matchingRoomConversation.sessionId); + }, [ + activeSessionId, + isConversationListLoading, + matchingRoomConversation, + mode, + openConversationSession, + preserveEmptyConversationSelection, + requestedSessionId, + roomsEntryMode, + ]); useEffect(() => { if (requestedSessionId) { return; } - if (location.pathname !== '/chat/live') { + if (location.pathname !== chatRoutePath) { return; } @@ -5620,11 +6923,20 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = } if (activeSessionId || conversationItems.length > 0) { - hasAttemptedInitialConversationRef.current = false; + // Keep the initial auto-create guard while an optimistic room is still + // waiting for server persistence. Otherwise a failed create can bounce + // back to the empty state and immediately trigger another auto-create. + if (pendingConversationCreationRef.current.size === 0) { + hasAttemptedInitialConversationRef.current = false; + } return; } - if (hasAttemptedInitialConversationRef.current || isCreatingImportedDraftConversationRef.current) { + if ( + preserveEmptyConversationSelection || + hasAttemptedInitialConversationRef.current || + isCreatingImportedDraftConversationRef.current + ) { return; } @@ -5650,6 +6962,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = handleCreateConversation, isConversationListLoading, location.pathname, + preserveEmptyConversationSelection, requestedSessionId, selectedChatType, ]); @@ -5760,83 +7073,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ); }, [activeSessionId, messages]); - useEffect(() => { - if (!activeConversation) { - return; - } - - if (activeView !== 'chat') { - return; - } - - if ( - !isActiveChatSessionInForeground({ - sessionId: activeConversation.sessionId, - activeSessionId, - activeView, - isConversationPaneClosed, - isMobileViewport, - isMobileConversationView, - }) - ) { - return; - } - - if (isConversationContentLoading || !shouldStickToBottomRef.current) { - return; - } - - const latestResponseMessageId = requestItems.reduce( - (maxId, item) => - item.sessionId === activeConversation.sessionId && item.hasResponse && item.responseMessageId != null - ? Math.max(maxId, item.responseMessageId) - : maxId, - 0, - ); - - if (latestResponseMessageId <= 0) { - return; - } - - if (!messages.some((item) => item.id === latestResponseMessageId && item.author === 'codex')) { - return; - } - - if (lastMarkedReadResponseIdBySessionRef.current[activeConversation.sessionId] === latestResponseMessageId) { - return; - } - - lastMarkedReadResponseIdBySessionRef.current[activeConversation.sessionId] = latestResponseMessageId; - setLastReadResponseMessageIdBySession((previous) => ({ - ...previous, - [activeConversation.sessionId]: latestResponseMessageId, - })); - setConversationItems((previous) => - previous.map((item) => - item.sessionId === activeConversation.sessionId - ? { - ...item, - hasUnreadResponse: false, - } - : item, - ), - ); - - void chatGateway.markConversationRead(activeConversation.sessionId).catch(() => { - delete lastMarkedReadResponseIdBySessionRef.current[activeConversation.sessionId]; - }); - }, [ - activeConversation, - activeSessionId, - activeView, - isConversationContentLoading, - isConversationPaneClosed, - isMobileConversationView, - isMobileViewport, - messages, - requestItems, - ]); - const { executeSendMessage, handleComposerFilesPicked, @@ -5847,6 +7083,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = getDraft: () => draftRef.current, composerAttachments, isComposerAttachmentUploading, + selectedCodexModel: effectiveCodexModel, selectedChatType: effectiveChatType ? { id: effectiveChatType.id, @@ -5883,9 +7120,28 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = mergeComposerAttachments, ensureSessionReady: ensureConversationSessionReady, sendChatRequest, + releaseAutoScrollSuspension, scrollViewportToBottom, }); + const executeSendMessageSafely = useCallback( + async (request: Parameters[0]) => { + try { + return await executeSendMessage(request); + } catch (error) { + const reason = + error instanceof Error && error.message.trim() + ? error.message.trim() + : '요청 전송 중 오류가 발생했습니다.'; + setActiveSystemStatus(null); + setIsSystemStatusPending(false); + setMessages((previous) => [...previous.slice(-39), createLocalMessage(reason)]); + return false; + } + }, + [createLocalMessage, executeSendMessage, setActiveSystemStatus, setIsSystemStatusPending, setMessages], + ); + useEffect(() => { if (!pendingImportedDraftRequest?.autoSend) { return; @@ -5929,9 +7185,11 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = setPendingImportedDraftRequest(null); setDraftValue(''); - executeSendMessage({ + executeSendMessageSafely({ + sessionId: activeSessionId, mode: pendingImportedDraftRequest.sendMode, text: pendingImportedDraftRequest.text, + codexModel: effectiveCodexModel, chatTypeId: effectiveChatType.id, chatTypeLabel: effectiveChatType.name, chatTypeDescription: effectiveChatTypeDescription, @@ -5952,7 +7210,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = effectiveChatTypeDescription, effectiveRoomCustomContextContent, effectiveRoomCustomContextTitle, - executeSendMessage, + executeSendMessageSafely, handleCreateConversation, pendingContextConfirm, pendingImportedDraftRequest, @@ -5963,9 +7221,9 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ]); const handleSendWithoutPreviousContext = useCallback( - (mode: 'queue' | 'direct', draftText?: string) => { + (mode: 'queue' | 'direct', draftText?: string): SendMessageResult => { if (isComposerAttachmentUploading) { - return; + return 'blocked'; } const nextConversationChatType = @@ -5980,7 +7238,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const trimmed = buildOutgoingMessageText(draftText ?? draftRef.current, composerAttachments).trim(); if (!trimmed) { - return; + return 'blocked'; } if (!nextConversationChatType) { @@ -5988,7 +7246,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ...previous.slice(-39), createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없습니다. 관리 페이지에서 컨텍스트 권한을 확인하세요.'), ]); - return; + return 'blocked'; } if (!activeSessionId.trim()) { @@ -5996,12 +7254,14 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ...previous.slice(-39), createLocalMessage('활성 대화방이 없어서 문맥 없이 보내기를 실행할 수 없습니다. 먼저 보낼 대화방을 열어 주세요.'), ]); - return; + return 'blocked'; } - executeSendMessage({ + executeSendMessageSafely({ + sessionId: activeSessionId, mode, text: trimmed, + codexModel: effectiveCodexModel, chatTypeId: nextConversationChatType.id, chatTypeLabel: nextConversationChatType.name, chatTypeDescription: resolveComposedChatTypeDescription(nextConversationChatType, { sessionId: activeSessionId }), @@ -6014,6 +7274,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = omittedContextCount: 0, omitPromptHistory: true, }); + return 'sent'; }, [ activeSessionId, @@ -6027,7 +7288,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = effectiveChatType, effectiveRoomCustomContextContent, effectiveRoomCustomContextTitle, - executeSendMessage, + executeSendMessageSafely, isComposerAttachmentUploading, isSelectedChatTypeAllowed, resolveComposedChatTypeDescription, @@ -6037,31 +7298,63 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = ); const handleComposerSend = useCallback( - (draftText?: string) => { + (draftText?: string): SendMessageResult => { const nextMode = isImmediateSendPinned ? 'direct' : 'queue'; if (isSendWithoutContextEnabled) { setIsSendWithoutContextEnabled(false); - void handleSendWithoutPreviousContext(nextMode, draftText); - return; + return handleSendWithoutPreviousContext(nextMode, draftText); } - sendMessage({ mode: nextMode, draftText }); + return sendMessage({ sessionId: activeSessionId, mode: nextMode, draftText }); }, - [handleSendWithoutPreviousContext, isImmediateSendPinned, isSendWithoutContextEnabled, sendMessage], + [activeSessionId, handleSendWithoutPreviousContext, isImmediateSendPinned, isSendWithoutContextEnabled, sendMessage], ); const handleComposerSendImmediate = useCallback( - (draftText?: string) => { + (draftText?: string): SendMessageResult => { if (isSendWithoutContextEnabled) { setIsSendWithoutContextEnabled(false); - void handleSendWithoutPreviousContext('direct', draftText); + return handleSendWithoutPreviousContext('direct', draftText); + } + + return sendMessage({ sessionId: activeSessionId, mode: 'direct', draftText }); + }, + [activeSessionId, handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage], + ); + + const handlePromoteQueuedRequest = useCallback( + async (requestId: string, text: string) => { + const normalizedRequestId = requestId.trim(); + const normalizedText = text.trim(); + + if (!normalizedRequestId || !normalizedText) { return; } - sendMessage({ mode: 'direct', draftText }); + try { + await removeChatRuntimeJob(normalizedRequestId); + } catch (error) { + messageApi.error(error instanceof Error ? error.message : '대기 요청을 즉시 실행으로 전환하지 못했습니다.'); + return; + } + + setRequestItems((previous) => + previous.map((item) => + item.sessionId === activeSessionId && item.requestId === normalizedRequestId + ? { + ...item, + status: 'removed', + statusMessage: '대기열에서 즉시 실행으로 전환되었습니다.', + terminalAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + : item, + ), + ); + handleComposerSendImmediate(normalizedText); }, - [handleSendWithoutPreviousContext, isSendWithoutContextEnabled, sendMessage], + [activeSessionId, handleComposerSendImmediate, messageApi, setRequestItems], ); const handleToggleImmediateSendPinned = useCallback(() => { @@ -6084,9 +7377,41 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = }, [activeSessionId]); const handlePromptSubmit = useCallback( - async ({ text, mode, parentRequestId }: { text: string; mode: 'queue' | 'direct'; parentRequestId?: string | null }) => { + async ({ + text, + mode, + parentRequestId, + promptIndex, + promptTitle, + promptSignature, + contextRef, + sourceMessageId, + selection, + }: { + text: string; + mode: 'queue' | 'direct'; + parentRequestId?: string | null; + promptIndex: number; + promptTitle: string; + promptSignature: string; + contextRef?: ChatPromptContextRef | null; + sourceMessageId: number; + selection: { + selectedValues: string[]; + freeText: string; + stepSelections?: Array<{ + stepKey: string; + stepTitle: string; + selectedValues: string[]; + freeText: string; + skipped?: boolean; + }>; + summaryText?: string | null; + }; + }) => { const trimmed = text.trim(); const resolvedMode = isImmediateSendPinned ? 'direct' : mode; + const targetSessionId = activeSessionId.trim(); if (!trimmed) { return false; @@ -6100,7 +7425,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return false; } - if (!activeSessionId.trim()) { + if (!targetSessionId) { setMessages((previous) => [ ...previous.slice(-39), createLocalMessage('활성 대화방이 없어서 prompt 선택을 전송하지 못했습니다.'), @@ -6110,7 +7435,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = if ( hasDuplicateActivePromptRequest(requestItemsRef.current, { - sessionId: activeSessionId, + sessionId: targetSessionId, text: trimmed, parentRequestId, }) @@ -6122,11 +7447,57 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = return false; } - executeSendMessage({ + if (parentRequestId?.trim()) { + try { + const persistedSelection = await submitChatPromptSelection(targetSessionId, { + parentRequestId: parentRequestId.trim(), + promptIndex, + promptTitle, + promptSignature, + sourceMessageId, + selectedValues: selection.selectedValues, + freeText: selection.freeText, + stepSelections: selection.stepSelections, + summaryText: selection.summaryText ?? null, + followupText: trimmed, + mode: resolvedMode, + contextRef: contextRef ?? null, + }); + + setRequestItems((previous) => + previous.map((item) => + item.sessionId === targetSessionId && item.requestId === parentRequestId.trim() + ? mergeConversationRequestPreservingContent(item, persistedSelection.item) + : item, + ), + ); + setMessages((previous) => upsertChatMessage(previous, persistedSelection.message)); + return true; + } catch (error) { + messageApi.error(error instanceof Error ? error.message : 'prompt 선택 상태를 저장하지 못했습니다.'); + return false; + } + } + + logConversationTrace('prompt-submit', { + sessionId: targetSessionId, + parentRequestId: parentRequestId?.trim() || null, + mode, + resolvedMode, + requestPreview: createConversationPreviewText(trimmed), + codexModel: effectiveCodexModel, + chatTypeId: effectiveChatType.id, + chatTypeLabel: effectiveChatType.name, + }); + + return executeSendMessageSafely({ + sessionId: targetSessionId, mode: resolvedMode, text: trimmed, origin: 'prompt', parentRequestId: parentRequestId?.trim() || null, + promptContextRef: contextRef ?? null, + codexModel: effectiveCodexModel, chatTypeId: effectiveChatType.id, chatTypeLabel: effectiveChatType.name, chatTypeDescription: effectiveChatTypeDescription, @@ -6138,7 +7509,6 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = includedContextCount: 0, omittedContextCount: 0, }); - return true; }, [ activeSessionId, @@ -6150,7 +7520,166 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = isImmediateSendPinned, effectiveRoomCustomContextContent, effectiveRoomCustomContextTitle, - executeSendMessage, + executeSendMessageSafely, + messageApi, + setMessages, + setRequestItems, + ], + ); + + const handleSubmitChildRequest = useCallback( + async ({ text, parentRequestId }: { text: string; parentRequestId: string }) => { + const trimmed = text.trim(); + const normalizedParentRequestId = parentRequestId.trim(); + const targetSessionId = activeSessionId.trim(); + + if (!trimmed || !normalizedParentRequestId) { + return false; + } + + if (!effectiveChatType) { + setMessages((previous) => [ + ...previous.slice(-39), + createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 하위 즉시 요청을 전송하지 못했습니다.'), + ]); + return false; + } + + if (!targetSessionId) { + setMessages((previous) => [ + ...previous.slice(-39), + createLocalMessage('활성 대화방이 없어서 하위 즉시 요청을 전송하지 못했습니다.'), + ]); + return false; + } + + return executeSendMessageSafely({ + sessionId: targetSessionId, + mode: 'direct', + text: trimmed, + parentRequestId: normalizedParentRequestId, + codexModel: effectiveCodexModel, + chatTypeId: effectiveChatType.id, + chatTypeLabel: effectiveChatType.name, + chatTypeDescription: effectiveChatTypeDescription, + chatTypeBaseDescription: effectiveChatType.description, + defaultContextIds: effectiveDefaultContextIds, + defaultContexts: effectiveDefaultContexts, + customContextTitle: effectiveRoomCustomContextTitle || null, + customContextContent: effectiveRoomCustomContextContent || null, + includedContextCount: 0, + omittedContextCount: 0, + }); + }, + [ + activeSessionId, + createLocalMessage, + effectiveCodexModel, + effectiveDefaultContextIds, + effectiveDefaultContexts, + effectiveChatType, + effectiveChatTypeDescription, + effectiveRoomCustomContextContent, + effectiveRoomCustomContextTitle, + executeSendMessageSafely, + setMessages, + ], + ); + + const handleCompleteManualRequestBadge = useCallback( + async (requestId: string, type: 'prompt' | 'verification') => { + const normalizedSessionId = activeSessionId.trim(); + const normalizedRequestId = requestId.trim(); + + if (!normalizedSessionId || !normalizedRequestId) { + return; + } + + try { + const updatedRequest = await completeChatConversationRequestManualBadge( + normalizedSessionId, + normalizedRequestId, + type, + ); + + setRequestItems((previous) => { + const existingIndex = previous.findIndex( + (item) => item.sessionId === normalizedSessionId && item.requestId === normalizedRequestId, + ); + + if (existingIndex < 0) { + return previous; + } + + const nextItems = [...previous]; + nextItems[existingIndex] = mergeConversationRequestPreservingContent( + nextItems[existingIndex], + updatedRequest, + ); + return nextItems; + }); + } catch (error) { + throw error; + } + }, + [activeSessionId, setRequestItems], + ); + + const handleRetryFailedRequest = useCallback( + (request: ChatConversationRequest) => { + const normalizedText = request.userText.trim(); + + if (!normalizedText) { + messageApi.error('재처리할 요청 본문이 없습니다.'); + return; + } + + if (!effectiveChatType) { + setMessages((previous) => [ + ...previous.slice(-39), + createLocalMessage('현재 사용자에게 허용된 컨텍스트가 없어 실패 요청을 재처리하지 못했습니다.'), + ]); + return; + } + + if (!activeSessionId.trim()) { + setMessages((previous) => [ + ...previous.slice(-39), + createLocalMessage('활성 대화방이 없어 실패 요청을 재처리하지 못했습니다.'), + ]); + return; + } + + executeSendMessageSafely({ + sessionId: activeSessionId, + mode: 'direct', + text: normalizedText, + origin: request.requestOrigin === 'prompt' ? 'prompt' : 'composer', + parentRequestId: request.requestOrigin === 'prompt' ? request.parentRequestId?.trim() || null : null, + codexModel: effectiveCodexModel, + chatTypeId: effectiveChatType.id, + chatTypeLabel: effectiveChatType.name, + chatTypeDescription: effectiveChatTypeDescription, + chatTypeBaseDescription: effectiveChatType.description, + defaultContextIds: effectiveDefaultContextIds, + defaultContexts: effectiveDefaultContexts, + customContextTitle: effectiveRoomCustomContextTitle || null, + customContextContent: effectiveRoomCustomContextContent || null, + includedContextCount: 0, + omittedContextCount: 0, + }); + }, + [ + activeSessionId, + createLocalMessage, + effectiveDefaultContextIds, + effectiveDefaultContexts, + effectiveChatType, + effectiveChatTypeDescription, + effectiveRoomCustomContextContent, + effectiveRoomCustomContextTitle, + executeSendMessageSafely, + messageApi, setMessages, ], ); @@ -6171,6 +7700,17 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = const handleCloseConversationPane = useCallback(() => { setIsMobileActionGroupOpen(false); + + if (mode === 'rooms' && !canReturnToRoomsList) { + if (onRoomsClose) { + onRoomsClose(); + return; + } + + requestScopedChatRoomsWindowAction('close'); + return; + } + isClosingConversationRef.current = true; handledRequestedSessionIdRef.current = ''; replaceChatSessionInUrl(''); @@ -6179,7 +7719,175 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = if (isMobileViewport) { setIsMobileConversationView(false); } - }, [isMobileViewport, replaceChatSessionInUrl]); + }, [canReturnToRoomsList, isMobileViewport, mode, onRoomsClose, replaceChatSessionInUrl]); + + const isRoomsMode = mode === 'rooms'; + const roomSettingsMenuItems = activeConversation + ? [ + ...(canReturnToRoomsList + ? [{ + key: 'list', + icon: , + label: '대화 목록', + }] + : []), + { + key: 'create', + icon: , + label: '새 대화', + disabled: availableChatTypes.length === 0, + }, + { + key: 'refresh', + icon: , + label: '현재 대화 새로고침', + }, + { + key: 'context', + icon: , + label: '채팅방 Context', + }, + { + key: 'clear', + icon: , + danger: true, + label: '대화 데이터 초기화', + }, + { + key: 'delete', + icon: , + danger: true, + label: '채팅방 삭제', + }, + { + key: 'maximize', + icon: isMaximized ? : , + label: isMaximized ? '최대화 해제' : '최대화', + }, + { + key: 'minimize', + icon: , + label: '최소화', + }, + { + key: 'close-window', + icon: , + danger: true, + label: '창 닫기', + }, + { + key: 'divider-danger', + type: 'divider' as const, + }, + ] + : [ + ...(canReturnToRoomsList + ? [{ + key: 'list', + icon: , + label: '대화 목록', + }] + : []), + { + key: 'create', + icon: , + label: '새 대화', + disabled: availableChatTypes.length === 0, + }, + { + key: 'maximize', + icon: isMaximized ? : , + label: isMaximized ? '최대화 해제' : '최대화', + }, + { + key: 'minimize', + icon: , + label: '최소화', + }, + { + key: 'close-window', + icon: , + danger: true, + label: '창 닫기', + }, + ]; + const conversationListPane = ( + + ); if (activeView === 'errors' && !hasAccess) { return ( @@ -6203,131 +7911,280 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile = {messageContextHolder} -
- - - - {hasAccess ? ( - - ) : null} -
-
-
-
- {activeView === 'chat' ? ( - isEditingConversationTitle && activeConversation ? ( - { - setEditingConversationTitle(event.target.value); + isRoomsMode ? null : ( +
+ <> +
+ + + {showRuntimeViewShortcut ? ( + + ) : null} + {hasAccess ? ( + + ) : null} +
+
+
+
+ {activeView === 'chat' ? ( +
+
+ {activeConversation?.title || 'Codex Chat'} + {activeConversation ? ( +
+ {activeConversationRequestBadgeLabel ? ( + + {activeConversationRequestBadgeLabel} + + ) : null} + {isRoomsMode ? ( +
+ + + {connectionState === 'connected' + ? '웹소켓 연결됨' + : connectionState === 'connecting' + ? '웹소켓 연결 중' + : '웹소켓 끊김'} + + + {activeRoomScope?.menuTitle || roomLaunchScope?.menuTitle || '시스템 채팅방'} + +
+ ) : null} +
+ ) : activeView === 'runtime' ? ( + Codex Live 런타임 + ) : ( + 에러 로그 + )}
- ) - ) : activeView === 'runtime' ? ( - Codex Live 런타임 - ) : ( - 에러 로그 - )} -
+
+
+
-
-
- } + ) + } extra={ activeView === 'errors' || activeView === 'runtime' ? null : ( - - {activeConversation ? ( -
) ) : null} - {!activeConversation ? ( - - } + )} >
), }, + { + key: 'codex-participants', + label: 'Codex', + children: ( +
+
+ Codex 참가자 + + 이 방에서 함께 응답할 Codex를 설정합니다. 기본은 순차 흐름이지만, 여러 Codex를 추가한 뒤 `즉시실행`하면 + 병렬로 처리됩니다. `중재·최종정리` 역할은 토론 시작과 종료를 맡고, `프리토킹` 역할은 앞선 Codex 의견을 + 이어받아 자유롭게 토론합니다. + +
+
+ + {editingRoomCodexParticipants.map((participant, index) => ( +
+ + {index === 0 ? ( + + 기본 채팅방 Codex는 이름만 변경할 수 있고, 방 기본 모델과 채팅유형을 따르며 삭제할 수 없습니다. + + ) : null} + { + const nextValue = event.target.value; + setEditingRoomCodexParticipants((previous) => + previous.map((item) => (item.id === participant.id ? { ...item, name: nextValue } : item)), + ); + }} + /> + ({ + value: option.id, + label: `${option.name} · ${option.description || '설명 없음'}`, + })), + ]} + onChange={(value) => { + setEditingRoomCodexParticipants((previous) => + previous.map((item) => + item.id === participant.id + ? { ...item, chatTypeId: value === '__inherit__' ? null : value } + : item, + ), + ); + }} + /> + { + setEditingConversationTitle(event.target.value); + }} + onPressEnter={() => { + void handleSaveConversationTitle(); + }} + /> +
+ + { + if (isShareTargetSubmitting) { + return; + } + + setPendingShareTarget(null); + setShareTokenName(''); + }} + onOk={async () => { + await handleConfirmShareTarget(); + }} + > + + 공유 채팅방은 토큰 설정과 공유 이름을 입력한 뒤 생성됩니다. + {shareTokenSettingsErrorMessage ? : null} + setShareTokenName(event.target.value)} + /> + {selectedShareTokenSettingId ? ( + + {(() => { + const selectedSetting = shareTokenSettingOptions.find((item) => item.id === selectedShareTokenSettingId) ?? null; + + if (!selectedSetting) { + return '선택한 설정 권한으로 공유가 제한됩니다.'; + } + + return [ + selectedSetting.description || '선택한 설정 권한으로 공유가 제한됩니다.', + `7일 ${selectedSetting.maxTokensPer7Days.toLocaleString('ko-KR')}`, + `5시간 ${selectedSetting.maxTokensPer5Hours.toLocaleString('ko-KR')}`, + ].join(' · '); + })()} + + ) : null} + + windowSelections.find((selection) => selection.id === 'page:preview:app') ?? null, [windowSelections], ); + const playAppSelection = useMemo( + () => windowSelections.find((selection) => selection.id.startsWith(PLAY_APP_SELECTION_PREFIX)) ?? null, + [windowSelections], + ); const regularWindowSelections = useMemo( - () => windowSelections.filter((selection) => selection.id !== 'page:preview:app'), + () => + windowSelections.filter( + (selection) => selection.id !== 'page:preview:app' && !selection.id.startsWith(PLAY_APP_SELECTION_PREFIX), + ), [windowSelections], ); const getWindowFrame = (instanceId: string, selectionId: string, index: number): WindowFrame => @@ -231,6 +248,14 @@ export function MainContent({ return ; } + if (selectionId === 'page:plans:token-setting') { + return ; + } + + if (selectionId === 'page:plans:shared-resource') { + return ; + } + const planStatus = getPlanStatusFromWindowSelection(selectionId); if (planStatus) { @@ -248,6 +273,10 @@ export function MainContent({ return ; } + if (selectionId === 'page:chat:rooms') { + return ; + } + if (selectionId === 'page:chat:errors') { return ; } @@ -267,6 +296,11 @@ export function MainContent({ if (selectionId === 'page:chat:manage-defaults') { return ; } + + if (selectionId === 'page:chat:manage-share') { + return ; + } + if (selectionId === 'page:play:layout') { return ; } @@ -303,6 +337,7 @@ export function MainContent({ {!disableWindowLayer && previewAppSelection ? (
{ clearWindowSelection(previewAppSelection.instanceId); @@ -310,6 +345,18 @@ export function MainContent({ />
) : null} + {!disableWindowLayer && playAppSelection ? ( +
+ { + clearWindowSelection(playAppSelection.instanceId); + }} + /> +
+ ) : null} {!disableWindowLayer && regularWindowSelections.length > 0 ? (
diff --git a/src/app/main/MainHeader.tsx b/src/app/main/MainHeader.tsx index 1df6cad..67a118f 100644 --- a/src/app/main/MainHeader.tsx +++ b/src/app/main/MainHeader.tsx @@ -1,24 +1,28 @@ import { ApiOutlined, BellOutlined, + BgColorsOutlined, + BookOutlined, ClockCircleOutlined, CopyOutlined, + ClusterOutlined, DownOutlined, - FileMarkdownOutlined, LoadingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, ProfileOutlined, ReloadOutlined, + RocketOutlined, RightOutlined, + SearchOutlined, SettingOutlined, } from '@ant-design/icons'; import { Alert, Button, Checkbox, - Drawer, Dropdown, + Drawer, Grid, Input, InputNumber, @@ -72,6 +76,7 @@ import { } from './tokenAccess'; import { isPreviewRuntime } from './previewRuntime'; import { chatConnectionGateway, chatGateway } from './chatV2'; +import { emitChatConversationCleared, emitChatConversationsUpdated } from './chatV2/data/chatClientEvents'; import { HeaderMessageCenter } from './HeaderMessageCenter'; import { fetchChatRuntimeJobDetail } from './mainChatPanel'; import { @@ -92,6 +97,7 @@ const APP_SETTINGS_CATEGORIES = [ const APP_SETTINGS_SECTIONS: Array<{ value: + | 'headerAppearance' | 'chatSettings' | 'automationRuntime' | 'planDefaults' @@ -102,6 +108,7 @@ const APP_SETTINGS_SECTIONS: Array<{ label: string; category: (typeof APP_SETTINGS_CATEGORIES)[number]['value']; }> = [ + { value: 'headerAppearance', label: '헤더 표시', category: 'workspace' }, { value: 'chatSettings', label: '채팅 문맥 설정', category: 'workspace' }, { value: 'automationRuntime', label: '자동접수 / 주기', category: 'automation' }, { value: 'planDefaults', label: '자동화 기본값', category: 'automation' }, @@ -165,6 +172,88 @@ const RESERVED_RESTART_WORK_SERVER_DELAY_MS = 5_000; const RESERVED_RESTART_VERIFICATION_INTERVAL_MS = 2_000; const RESERVED_RESTART_VERIFICATION_TIMEOUT_MS = 90_000; const RESERVED_RESTART_VERIFICATION_CLOCK_SKEW_MS = 5_000; +const HEADER_THEME_STORAGE_KEY = 'work-server.header-theme'; +const DESKTOP_HEADER_HEIGHT_STORAGE_KEY = 'work-server.desktop-header-height'; +const MOBILE_HEADER_HEIGHT_STORAGE_KEY = 'work-server.mobile-header-height'; +const DESKTOP_HEADER_HEIGHT_MIN = 48; +const DESKTOP_HEADER_HEIGHT_MAX = 88; +const DESKTOP_HEADER_HEIGHT_DEFAULT = 60; +const MOBILE_HEADER_HEIGHT_MIN = 32; +const MOBILE_HEADER_HEIGHT_MAX = 56; +const MOBILE_HEADER_HEIGHT_DEFAULT = 36; + +type HeaderThemeKey = 'ocean' | 'sunset' | 'forest'; + +const HEADER_THEME_OPTIONS: Array<{ + key: HeaderThemeKey; + label: string; + description: string; + icon: ReactNode; +}> = [ + { key: 'ocean', label: 'Ocean', description: '시원한 블루 중심 테마', icon: }, + { key: 'sunset', label: 'Sunset', description: '주황과 로즈 포인트 테마', icon: }, + { key: 'forest', label: 'Forest', description: '그린과 민트 중심 테마', icon: }, +]; + +function normalizeHeaderTheme(value: string | null | undefined): HeaderThemeKey { + return HEADER_THEME_OPTIONS.some((option) => option.key === value) ? (value as HeaderThemeKey) : 'ocean'; +} + +function getStoredHeaderTheme() { + if (typeof window === 'undefined') { + return 'ocean' satisfies HeaderThemeKey; + } + + try { + return normalizeHeaderTheme(window.localStorage.getItem(HEADER_THEME_STORAGE_KEY)); + } catch { + return 'ocean' satisfies HeaderThemeKey; + } +} + +function normalizeMobileHeaderHeight(value: number | string | null | undefined) { + const parsedValue = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); + + if (!Number.isFinite(parsedValue)) { + return MOBILE_HEADER_HEIGHT_DEFAULT; + } + + return Math.min(MOBILE_HEADER_HEIGHT_MAX, Math.max(MOBILE_HEADER_HEIGHT_MIN, Math.round(parsedValue))); +} + +function normalizeDesktopHeaderHeight(value: number | string | null | undefined) { + const parsedValue = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); + + if (!Number.isFinite(parsedValue)) { + return DESKTOP_HEADER_HEIGHT_DEFAULT; + } + + return Math.min(DESKTOP_HEADER_HEIGHT_MAX, Math.max(DESKTOP_HEADER_HEIGHT_MIN, Math.round(parsedValue))); +} + +function getStoredDesktopHeaderHeight() { + if (typeof window === 'undefined') { + return DESKTOP_HEADER_HEIGHT_DEFAULT; + } + + try { + return normalizeDesktopHeaderHeight(window.localStorage.getItem(DESKTOP_HEADER_HEIGHT_STORAGE_KEY)); + } catch { + return DESKTOP_HEADER_HEIGHT_DEFAULT; + } +} + +function getStoredMobileHeaderHeight() { + if (typeof window === 'undefined') { + return MOBILE_HEADER_HEIGHT_DEFAULT; + } + + try { + return normalizeMobileHeaderHeight(window.localStorage.getItem(MOBILE_HEADER_HEIGHT_STORAGE_KEY)); + } catch { + return MOBILE_HEADER_HEIGHT_DEFAULT; + } +} function areChatSettingsEqual(left: AppConfig['chat'], right: AppConfig['chat']) { return ( @@ -580,7 +669,7 @@ function getGestureShortcutDiffLabels(saved: AppConfig['gestureShortcuts'], draf } if (saved.openWindowSearch !== draft.openWindowSearch) { - changedLabels.push('Window UI 검색 열기'); + changedLabels.push('시스템 채팅 열기'); } return changedLabels; @@ -1511,6 +1600,7 @@ export function MainHeader({ onToggleSidebar, onToggleContentExpanded, onChangeTopMenu, + onOpenSearch, onOpenPlanQuickFilter, }: MainHeaderProps) { void contentExpanded; @@ -1520,6 +1610,9 @@ export function MainHeader({ const [modalApi, modalContextHolder] = Modal.useModal(); const navigate = useNavigate(); const location = useLocation(); + const [headerTheme, setHeaderTheme] = useState(() => getStoredHeaderTheme()); + const [desktopHeaderHeight, setDesktopHeaderHeight] = useState(() => getStoredDesktopHeaderHeight()); + const [mobileHeaderHeight, setMobileHeaderHeight] = useState(() => getStoredMobileHeaderHeight()); const [settingsOpen, setSettingsOpen] = useState(false); const [automationGroupExpanded, setAutomationGroupExpanded] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false); @@ -1575,6 +1668,33 @@ export function MainHeader({ const serverRestartReservationReloadTaskIdRef = useRef(0); const { registeredToken, hasAccess } = useTokenAccess(); const appConfig = useAppConfig(); + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.dataset.appTheme = headerTheme; + } + + if (typeof window !== 'undefined') { + window.localStorage.setItem(HEADER_THEME_STORAGE_KEY, headerTheme); + } + }, [headerTheme]); + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.style.setProperty('--app-desktop-header-height', `${desktopHeaderHeight}px`); + } + + if (typeof window !== 'undefined') { + window.localStorage.setItem(DESKTOP_HEADER_HEIGHT_STORAGE_KEY, String(desktopHeaderHeight)); + } + }, [desktopHeaderHeight]); + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.style.setProperty('--app-mobile-header-height', `${mobileHeaderHeight}px`); + } + + if (typeof window !== 'undefined') { + window.localStorage.setItem(MOBILE_HEADER_HEIGHT_STORAGE_KEY, String(mobileHeaderHeight)); + } + }, [mobileHeaderHeight]); const [appConfigDraft, setAppConfigDraft] = useState(appConfig); const [appConfigFeedback, setAppConfigFeedback] = useState(null); const [appConfigSaving, setAppConfigSaving] = useState(false); @@ -1692,55 +1812,44 @@ export function MainHeader({ serverRestartReservationNowTimestamp, serverRestartReservationReloadPending, ); - const renderTopMenuOptionLabel = (menu: 'docs' | 'plans' | 'play', label: ReactNode, iconLabel: string) => ( + const renderTopMenuOptionLabel = ( + menu: 'docs' | 'plans' | 'play', + label: ReactNode, + icon: ReactNode, + iconLabel: string, + ) => ( { onChangeTopMenu(menu); }} > - {isMobileViewport ? {label} : label} + + {isMobileViewport ? null : {label}} ); const headerTopMenuOptions = hasAccess ? [ { - label: renderTopMenuOptionLabel( - 'docs', - isMobileViewport ? : 'Docs', - 'Docs', - ), + label: renderTopMenuOptionLabel('docs', 'Docs', , 'Docs'), value: 'docs', - icon: isMobileViewport ? undefined : , }, { - label: renderTopMenuOptionLabel( - 'plans', - isMobileViewport ? : '작업', - '작업', - ), + label: renderTopMenuOptionLabel('plans', '작업', , '작업'), value: 'plans', - icon: isMobileViewport ? undefined : , }, { - label: renderTopMenuOptionLabel( - 'play', - isMobileViewport ? : 'Play', - 'Play', - ), + label: renderTopMenuOptionLabel('play', 'Play', , 'Play'), value: 'play', - icon: isMobileViewport ? undefined : , }, ] : [ { - label: renderTopMenuOptionLabel( - 'docs', - isMobileViewport ? : 'Docs', - 'Docs', - ), + label: renderTopMenuOptionLabel('docs', 'Docs', , 'Docs'), value: 'docs', - icon: isMobileViewport ? undefined : , }, ]; const runningRuntimeCount = chatRuntimeSnapshot?.runningCount ?? 0; @@ -1765,6 +1874,7 @@ export function MainHeader({ } const chatConnectionLabel = chatConnectionLabelParts.join(' · '); + const searchShortcutLabel = appConfig.gestureShortcuts.openSearch || DEFAULT_APP_CONFIG.gestureShortcuts.openSearch; const connectionIndicatorClassName = `app-header__connection-indicator app-header__connection-indicator--${chatConnection.connectionState}${ hasPendingRuntimeWork ? ' app-header__connection-indicator--busy' : '' }`; @@ -2742,7 +2852,8 @@ export function MainHeader({ '재기동 요청 완료', `${targetLabel} 재기동 요청이 접수되었습니다. 실제 부팅 완료를 확인할 때까지 기다립니다.`, ); - const verified = await waitForServerRestart(key, baseline, progressTaskId); + const verificationBaseline = result.restartState === 'accepted' ? result.item : baseline; + const verified = await waitForServerRestart(key, verificationBaseline, progressTaskId); if (verified.cancelled || isRestartProgressCancelled(progressTaskId)) { setServerRestartFeedback({ @@ -3030,7 +3141,7 @@ export function MainHeader({ const handleResetNotificationIdentity = () => { modalApi.confirm({ title: '알림 클라이언트 초기화', - content: '현재 클라이언트의 알림 토큰/클라이언트 ID를 초기화합니다. 다시 접속하면 새로 등록됩니다.', + content: '현재 브라우저의 알림 토큰/디바이스 식별자를 초기화합니다. 다시 접속하면 새로 등록됩니다.', okText: '초기화', cancelText: '취소', autoFocusButton: 'ok', @@ -3111,33 +3222,40 @@ export function MainHeader({ return null; } + const handleCopyFeedbackMessage = () => { + void copyTextToClipboard(feedback.message) + .then(() => { + setCopyFeedback({ + tone: 'success', + message: '메시지를 복사했습니다.', + }); + }) + .catch(() => { + setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' }); + }); + }; + return ( } - onClick={() => { - void copyTextToClipboard(feedback.message) - .then(() => { - setCopyFeedback({ - tone: 'success', - message: '메시지를 복사했습니다.', - }); - }) - .catch(() => { - setCopyFeedback({ tone: 'error', message: '메시지 복사에 실패했습니다.' }); - }); - }} + onClick={handleCopyFeedbackMessage} /> - } + ) : null} /> + {screens.xs ? ( + + ) : null} {copyFeedback ? : null} ); @@ -3244,6 +3362,39 @@ export function MainHeader({ setTokenFeedback({ tone: 'info', message: '권한 토큰을 제거했습니다.' }); }; + const handleClearActiveConversation = () => { + const sessionId = activeConversationSnapshot.sessionId.trim(); + const conversationTitle = activeConversationSnapshot.title.trim(); + + if (!sessionId) { + return; + } + + modalApi.confirm({ + title: '채팅방 데이터를 초기화할까요?', + content: conversationTitle + ? `"${conversationTitle}" 채팅방의 이름과 설정은 유지되고, 메시지·요청·활동 로그만 초기화됩니다.` + : '채팅방 이름과 설정은 유지하고, 메시지·요청·활동 로그만 초기화됩니다.', + okText: '초기화', + cancelText: '취소', + autoFocusButton: 'cancel', + modalRender: renderModalWithEnterConfirm, + okButtonProps: { danger: true }, + onOk: async () => { + const clearedItem = await chatGateway.clearConversation(sessionId); + emitChatConversationCleared(clearedItem); + setSettingsOpen(false); + + void chatGateway + .listConversations() + .then((items) => { + emitChatConversationsUpdated(items); + }) + .catch(() => undefined); + }, + }); + }; + const settingsTriggerButton = ( + { + if (typeof nextValue === 'number') { + onChange(nextValue); + } + }} + onStep={(nextValue) => onChange(nextValue)} + /> + +
+
+ ); + const tokenTriggerButton = ( + ); + })} +
+
+ {renderHeaderHeightControls({ + label: '데스크톱 헤더 높이', + description: '기본값은 60px이며, 상단 고정 헤더 높이를 현재 브라우저 기준으로 저장합니다.', + value: desktopHeaderHeight, + min: DESKTOP_HEADER_HEIGHT_MIN, + max: DESKTOP_HEADER_HEIGHT_MAX, + onChange: (nextValue) => setDesktopHeaderHeight(normalizeDesktopHeaderHeight(nextValue)), + })} + {renderHeaderHeightControls({ + label: '모바일 헤더 높이', + description: '기본값은 36px이며, 모바일 폭에서만 적용되는 헤더 높이를 저장합니다.', + value: mobileHeaderHeight, + min: MOBILE_HEADER_HEIGHT_MIN, + max: MOBILE_HEADER_HEIGHT_MAX, + onChange: (nextValue) => setMobileHeaderHeight(normalizeMobileHeaderHeight(nextValue)), + })} + + + + + ); + const settingsMenu = (
{hasAccess ? ( @@ -4352,9 +4628,9 @@ export function MainHeader({ className="app-header__settings-item" disabled={!hasAccess} onClick={() => { - openSettingsModal('appSettings', 'planDefaults'); + openSettingsModal('appSettings', 'headerAppearance'); }} - > + >
+ ); +} diff --git a/src/app/main/PreviewAppOverlay.tsx b/src/app/main/PreviewAppOverlay.tsx index aa70c5d..e40cff0 100644 --- a/src/app/main/PreviewAppOverlay.tsx +++ b/src/app/main/PreviewAppOverlay.tsx @@ -1,7 +1,16 @@ -import { CloseOutlined, DesktopOutlined, MinusOutlined, MobileOutlined } from '@ant-design/icons'; +import { + CloseOutlined, + CodeOutlined, + CopyOutlined, + DesktopOutlined, + MinusOutlined, + MobileOutlined, + ReloadOutlined, +} from '@ant-design/icons'; import { Button } from 'antd'; import { useEffect, + useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, @@ -9,7 +18,13 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import { PreviewAppWindow } from './PreviewAppWindow'; -import type { PreviewTargetDescriptor } from './previewRuntime'; +import { copyTextToClipboard } from '../../utils/clipboard'; +import { + resolvePreviewAppOrigin, + type PreviewRuntimeConsoleBridgeMessage, + type PreviewRuntimeConsoleLevel, + type PreviewTargetDescriptor, +} from './previewRuntime'; type PreviewAppOverlayProps = { pathname: string; @@ -23,16 +38,48 @@ type DragPosition = { y: number; }; +type DetachedConsoleSize = { + width: number; + height: number; +}; + const HEADER_HEIGHT = 44; const MINIMIZED_WIDTH = 168; const MOBILE_SHELL_WIDTH = 430; const MOBILE_SHELL_HEIGHT = 860; const VIEWPORT_PADDING = 12; +const MAX_CONSOLE_ENTRIES = 300; +const DETACHED_CONSOLE_WIDTH = 460; +const DETACHED_CONSOLE_HEIGHT = 340; +const DETACHED_CONSOLE_MIN_WIDTH = 360; +const DETACHED_CONSOLE_MAX_WIDTH = 840; +const DETACHED_CONSOLE_MIN_HEIGHT = 240; +const DETACHED_CONSOLE_MAX_HEIGHT = 620; +const DETACHED_CONSOLE_DRAGGING_CLASS = 'preview-app-overlay-console-dragging'; +const CONSOLE_LEVELS: PreviewRuntimeConsoleLevel[] = ['log', 'info', 'warn', 'error', 'debug']; + +type PreviewConsoleEntry = PreviewRuntimeConsoleBridgeMessage & { + id: string; +}; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } +function formatConsoleEntryCopyText(entry: PreviewConsoleEntry) { + return [ + `${entry.level.toUpperCase()} ${new Date(entry.timestamp).toLocaleTimeString('ko-KR', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })}`, + entry.args.join('\n'), + ] + .filter((value) => value.trim().length > 0) + .join('\n'); +} + function getDefaultMinimizedPosition(): DragPosition { if (typeof window === 'undefined') { return { @@ -77,35 +124,154 @@ function getDefaultMobileShellPosition(): DragPosition { }; } +function getDetachedConsoleMetrics() { + if (typeof window === 'undefined') { + return { + width: DETACHED_CONSOLE_WIDTH, + height: DETACHED_CONSOLE_HEIGHT, + }; + } + + return { + width: Math.min(DETACHED_CONSOLE_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)), + height: Math.min(DETACHED_CONSOLE_HEIGHT, Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2)), + }; +} + +function getDetachedConsoleSizeBounds() { + if (typeof window === 'undefined') { + return { + minWidth: DETACHED_CONSOLE_MIN_WIDTH, + maxWidth: DETACHED_CONSOLE_MAX_WIDTH, + minHeight: DETACHED_CONSOLE_MIN_HEIGHT, + maxHeight: DETACHED_CONSOLE_MAX_HEIGHT, + }; + } + + return { + minWidth: Math.min(DETACHED_CONSOLE_MIN_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)), + maxWidth: Math.max( + Math.min(DETACHED_CONSOLE_MAX_WIDTH, window.innerWidth - VIEWPORT_PADDING * 2), + Math.min(DETACHED_CONSOLE_MIN_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)), + ), + minHeight: Math.min( + DETACHED_CONSOLE_MIN_HEIGHT, + Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2), + ), + maxHeight: Math.max( + Math.min(DETACHED_CONSOLE_MAX_HEIGHT, window.innerHeight - VIEWPORT_PADDING * 2), + Math.min(DETACHED_CONSOLE_MIN_HEIGHT, Math.max(220, window.innerHeight - VIEWPORT_PADDING * 2)), + ), + }; +} + +function normalizeDetachedConsoleSize(size: DetachedConsoleSize): DetachedConsoleSize { + const { minWidth, maxWidth, minHeight, maxHeight } = getDetachedConsoleSizeBounds(); + + return { + width: clamp(size.width, minWidth, maxWidth), + height: clamp(size.height, minHeight, maxHeight), + }; +} + +function getDefaultDetachedConsoleSize(): DetachedConsoleSize { + return normalizeDetachedConsoleSize({ + width: DETACHED_CONSOLE_WIDTH, + height: DETACHED_CONSOLE_HEIGHT, + }); +} + +function getDefaultDetachedConsolePosition(): DragPosition { + if (typeof window === 'undefined') { + return { + x: VIEWPORT_PADDING, + y: VIEWPORT_PADDING, + }; + } + + const { width, height } = getDetachedConsoleMetrics(); + + return { + x: Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING), + y: Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING), + }; +} + export function PreviewAppOverlay({ pathname, search = '', targetDescriptor = null, onClose, }: PreviewAppOverlayProps) { + const iframeRef = useRef(null); const minimizedPositionRef = useRef(getDefaultMinimizedPosition()); const mobileShellPositionRef = useRef(getDefaultMobileShellPosition()); + const detachedConsolePositionRef = useRef(getDefaultDetachedConsolePosition()); + const detachedConsoleSizeRef = useRef(getDefaultDetachedConsoleSize()); const rootRef = useRef(null); + const consoleBodyRef = useRef(null); const dragStateRef = useRef<{ pointerId: number; lastX: number; lastY: number; captureTarget: HTMLDivElement; } | null>(null); + const detachedConsoleDragStateRef = useRef<{ + pointerId: number; + lastX: number; + lastY: number; + captureTarget: HTMLDivElement; + } | null>(null); + const detachedConsoleResizeStateRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + startWidth: number; + startHeight: number; + captureTarget: HTMLDivElement; + } | null>(null); const dragMovedRef = useRef(false); const [minimized, setMinimized] = useState(false); const [deviceMode, setDeviceMode] = useState<'desktop' | 'mobile'>('mobile'); + const [consoleOpen, setConsoleOpen] = useState(false); + const [consoleDetached, setConsoleDetached] = useState(false); + const [consoleEntries, setConsoleEntries] = useState([]); + const [consoleLevelFilter, setConsoleLevelFilter] = useState(CONSOLE_LEVELS); + const [reloadKey, setReloadKey] = useState(0); const [isMobileViewport, setIsMobileViewport] = useState(() => typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false, ); const [position, setPosition] = useState(() => minimizedPositionRef.current); + const [detachedConsolePosition, setDetachedConsolePosition] = useState( + () => detachedConsolePositionRef.current, + ); + const [detachedConsoleSize, setDetachedConsoleSize] = useState( + () => detachedConsoleSizeRef.current, + ); const isDesktopMobileShell = !minimized && deviceMode === 'mobile' && !isMobileViewport; + const canDetachConsole = !isMobileViewport; + const showDetachedConsole = consoleOpen && consoleDetached && canDetachConsole; + const showAttachedConsole = consoleOpen && !showDetachedConsole; + const previewOrigin = useMemo(() => resolvePreviewAppOrigin(), []); + + const clearDetachedConsoleDraggingState = () => { + if (typeof document === 'undefined') { + return; + } + + document.body.classList.remove(DETACHED_CONSOLE_DRAGGING_CLASS); + }; + + useEffect(() => { + setConsoleEntries([]); + }, [pathname, search, targetDescriptor, deviceMode]); useEffect(() => { document.body.classList.add('preview-app-overlay-open'); return () => { document.body.classList.remove('preview-app-overlay-open'); + document.body.classList.remove(DETACHED_CONSOLE_DRAGGING_CLASS); }; }, []); @@ -127,6 +293,12 @@ export function PreviewAppOverlay({ }; }, []); + useEffect(() => { + if (isMobileViewport) { + setConsoleDetached(false); + } + }, [isMobileViewport]); + useEffect(() => { if (!minimized && !isDesktopMobileShell) { return; @@ -288,6 +460,214 @@ export function PreviewAppOverlay({ } }, [isDesktopMobileShell]); + useEffect(() => { + if (!showDetachedConsole) { + detachedConsoleDragStateRef.current = null; + detachedConsoleResizeStateRef.current = null; + clearDetachedConsoleDraggingState(); + return; + } + + const resizeListener = () => { + const { width, height } = normalizeDetachedConsoleSize(detachedConsoleSizeRef.current); + + setDetachedConsolePosition((current) => { + const nextPosition = { + x: clamp( + current.x, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING), + ), + y: clamp( + current.y, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING), + ), + }; + + detachedConsolePositionRef.current = nextPosition; + return nextPosition; + }); + }; + + window.addEventListener('resize', resizeListener); + resizeListener(); + + return () => { + window.removeEventListener('resize', resizeListener); + }; + }, [showDetachedConsole]); + + useEffect(() => { + if (!showDetachedConsole) { + return; + } + + const handlePointerMove = (event: PointerEvent) => { + const dragState = detachedConsoleDragStateRef.current; + const resizeState = detachedConsoleResizeStateRef.current; + + if (dragState && dragState.pointerId === event.pointerId) { + event.preventDefault(); + + const deltaX = event.clientX - dragState.lastX; + const deltaY = event.clientY - dragState.lastY; + + dragState.lastX = event.clientX; + dragState.lastY = event.clientY; + + const { width, height } = normalizeDetachedConsoleSize(detachedConsoleSizeRef.current); + + setDetachedConsolePosition((current) => { + const nextPosition = { + x: clamp( + current.x + deltaX, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING), + ), + y: clamp( + current.y + deltaY, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING), + ), + }; + + detachedConsolePositionRef.current = nextPosition; + return nextPosition; + }); + return; + } + + if (resizeState && resizeState.pointerId === event.pointerId) { + event.preventDefault(); + + updateDetachedConsoleSize({ + width: resizeState.startWidth + (event.clientX - resizeState.startX), + height: resizeState.startHeight + (event.clientY - resizeState.startY), + }); + } + }; + + const finishPointerDrag = (event: PointerEvent) => { + const dragState = detachedConsoleDragStateRef.current; + const resizeState = detachedConsoleResizeStateRef.current; + + if (dragState?.pointerId === event.pointerId) { + if (dragState.captureTarget.hasPointerCapture(event.pointerId)) { + dragState.captureTarget.releasePointerCapture(event.pointerId); + } + + detachedConsoleDragStateRef.current = null; + } + + if (resizeState?.pointerId === event.pointerId) { + if (resizeState.captureTarget.hasPointerCapture(event.pointerId)) { + resizeState.captureTarget.releasePointerCapture(event.pointerId); + } + + detachedConsoleResizeStateRef.current = null; + } + clearDetachedConsoleDraggingState(); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', finishPointerDrag); + window.addEventListener('pointercancel', finishPointerDrag); + + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', finishPointerDrag); + window.removeEventListener('pointercancel', finishPointerDrag); + }; + }, [showDetachedConsole]); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== previewOrigin) { + return; + } + + if (event.source !== iframeRef.current?.contentWindow) { + return; + } + + const payload = event.data as PreviewRuntimeConsoleBridgeMessage | null; + + if (!payload || payload.source !== 'sm-home.preview-runtime.console' || !Array.isArray(payload.args)) { + return; + } + + const nextEntry: PreviewConsoleEntry = { + ...payload, + id: `${payload.timestamp}-${Math.random().toString(36).slice(2, 10)}`, + }; + + setConsoleEntries((current) => { + const nextEntries = [...current, nextEntry]; + + if (nextEntries.length <= MAX_CONSOLE_ENTRIES) { + return nextEntries; + } + + return nextEntries.slice(nextEntries.length - MAX_CONSOLE_ENTRIES); + }); + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [previewOrigin]); + + useEffect(() => { + if (!consoleOpen) { + return; + } + + const nextFrame = window.requestAnimationFrame(() => { + const container = consoleBodyRef.current; + + if (!container) { + return; + } + + container.scrollTop = container.scrollHeight; + }); + + return () => { + window.cancelAnimationFrame(nextFrame); + }; + }, [consoleEntries, consoleOpen]); + + const latestConsoleEntry = consoleEntries.length > 0 ? consoleEntries[consoleEntries.length - 1] : null; + const latestConsoleHref = latestConsoleEntry?.href?.trim() || iframeRef.current?.src || ''; + const consoleLevelCounts = useMemo( + () => + CONSOLE_LEVELS.reduce>( + (counts, level) => ({ + ...counts, + [level]: consoleEntries.filter((entry) => entry.level === level).length, + }), + { + log: 0, + info: 0, + warn: 0, + error: 0, + debug: 0, + }, + ), + [consoleEntries], + ); + const filteredConsoleEntries = useMemo( + () => consoleEntries.filter((entry) => consoleLevelFilter.includes(entry.level)), + [consoleEntries, consoleLevelFilter], + ); + const errorEntryCount = consoleLevelCounts.error; + const warnEntryCount = consoleLevelCounts.warn; + const hasAllConsoleLevelsSelected = consoleLevelFilter.length === CONSOLE_LEVELS.length; + const hasAnyConsoleLevelsSelected = consoleLevelFilter.length > 0; + const handleHeaderPointerDown = (event: ReactPointerEvent) => { if (!minimized && !isDesktopMobileShell) { return; @@ -344,94 +724,417 @@ export function PreviewAppOverlay({ event.stopPropagation(); }; - return createPortal( -
) => { + if (!showDetachedConsole || event.button !== 0) { + return; + } + + detachedConsoleDragStateRef.current = { + pointerId: event.pointerId, + lastX: event.clientX, + lastY: event.clientY, + captureTarget: event.currentTarget, + }; + + document.body.classList.add(DETACHED_CONSOLE_DRAGGING_CLASS); + event.currentTarget.setPointerCapture(event.pointerId); + event.stopPropagation(); + event.preventDefault(); + }; + + const handleDetachedConsolePointerUp = (event: ReactPointerEvent) => { + const dragState = detachedConsoleDragStateRef.current; + + if (dragState?.pointerId === event.pointerId) { + if (dragState.captureTarget.hasPointerCapture(event.pointerId)) { + dragState.captureTarget.releasePointerCapture(event.pointerId); + } + + detachedConsoleDragStateRef.current = null; + } + + clearDetachedConsoleDraggingState(); + event.stopPropagation(); + }; + + const handleDetachedConsoleResizePointerDown = (event: ReactPointerEvent) => { + if (!showDetachedConsole || event.button !== 0) { + return; + } + + detachedConsoleResizeStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startWidth: detachedConsoleSizeRef.current.width, + startHeight: detachedConsoleSizeRef.current.height, + captureTarget: event.currentTarget, + }; + + document.body.classList.add(DETACHED_CONSOLE_DRAGGING_CLASS); + event.currentTarget.setPointerCapture(event.pointerId); + event.stopPropagation(); + event.preventDefault(); + }; + + const handleDetachedConsoleResizePointerUp = (event: ReactPointerEvent) => { + const resizeState = detachedConsoleResizeStateRef.current; + + if (resizeState?.pointerId === event.pointerId) { + if (resizeState.captureTarget.hasPointerCapture(event.pointerId)) { + resizeState.captureTarget.releasePointerCapture(event.pointerId); + } + + detachedConsoleResizeStateRef.current = null; + } + + clearDetachedConsoleDraggingState(); + event.stopPropagation(); + }; + + const updateDetachedConsoleSize = (nextSize: DetachedConsoleSize) => { + const normalized = normalizeDetachedConsoleSize(nextSize); + detachedConsoleSizeRef.current = normalized; + setDetachedConsoleSize(normalized); + setDetachedConsolePosition((current) => { + const nextPosition = { + x: clamp( + current.x, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerWidth - normalized.width - VIEWPORT_PADDING), + ), + y: clamp( + current.y, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerHeight - normalized.height - VIEWPORT_PADDING), + ), + }; + + detachedConsolePositionRef.current = nextPosition; + return nextPosition; + }); + }; + + const toggleConsoleLevel = (level: PreviewRuntimeConsoleLevel) => { + setConsoleLevelFilter((current) => { + if (current.includes(level)) { + if (current.length === 1) { + return current; + } + return current.filter((item) => item !== level); + } + + return CONSOLE_LEVELS.filter((item) => item === level || current.includes(item)); + }); + }; + + const renderConsolePanel = (detached: boolean) => ( +
{ - if (minimized && !dragMovedRef.current) { - setMinimized(false); - } - }} - onPointerDown={handleHeaderPointerDown} - onPointerUp={handleHeaderPointerUp} - onPointerCancel={handleHeaderPointerUp} + className={`preview-app-overlay__console-head${ + detached ? ' preview-app-overlay__console-head--detached' : '' + }`} + onPointerDown={detached ? handleDetachedConsolePointerDown : undefined} + onPointerUp={detached ? handleDetachedConsolePointerUp : undefined} + onPointerCancel={detached ? handleDetachedConsolePointerUp : undefined} > -
- {minimized ? ( -
-
- ) : ( - <> -
+ ); + + return createPortal( + <> +
+
{ + if (minimized && !dragMovedRef.current) { + setMinimized(false); + } + }} + onPointerDown={handleHeaderPointerDown} + onPointerUp={handleHeaderPointerUp} + onPointerCancel={handleHeaderPointerUp} + > +
+ {minimized ? ( +
+
+ ) : ( + <> +
+
+ {!minimized && !isMobileViewport ? ( + + ) : null} + {!minimized ? ( +
+
+
+ + {showAttachedConsole ? renderConsolePanel(false) : null} +
+
+ {showDetachedConsole ? renderConsolePanel(true) : null} + , document.body, ); } diff --git a/src/app/main/PreviewAppWindow.tsx b/src/app/main/PreviewAppWindow.tsx index 8f9d486..7abf785 100644 --- a/src/app/main/PreviewAppWindow.tsx +++ b/src/app/main/PreviewAppWindow.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { forwardRef, useMemo } from 'react'; import { getRegisteredAccessToken } from './tokenAccess'; import { buildPreviewRuntimeUrl, type PreviewTargetDescriptor } from './previewRuntime'; @@ -7,14 +7,19 @@ type PreviewAppWindowProps = { search?: string; targetDescriptor?: PreviewTargetDescriptor; deviceMode?: 'desktop' | 'mobile'; + reloadKey?: number; }; -export function PreviewAppWindow({ - pathname, - search = '', - targetDescriptor = null, - deviceMode = 'desktop', -}: PreviewAppWindowProps) { +export const PreviewAppWindow = forwardRef(function PreviewAppWindow( + { + pathname, + search = '', + targetDescriptor = null, + deviceMode = 'desktop', + reloadKey = 0, + }, + ref, +) { const previewUrl = useMemo( () => buildPreviewRuntimeUrl(pathname, search, getRegisteredAccessToken(), targetDescriptor, deviceMode), [deviceMode, pathname, search, targetDescriptor], @@ -27,6 +32,8 @@ export function PreviewAppWindow({ >