From d38d02287206c96253ea1bca6de41295b0a45a45 Mon Sep 17 00:00:00 2001 From: how2ice Date: Fri, 15 May 2026 10:16:45 +0900 Subject: [PATCH] chore: exclude local resource artifacts from main sync --- .gitignore | 2 + README.md | 11 +- docker-compose.yml | 0 docs/README.md | 0 docs/components/check-combo.md | 0 docs/components/codex-diff-previewer.md | 0 .../evidence-attachment-strip-ui.md | 0 docs/components/input.md | 0 docs/components/popup.md | 0 docs/components/previewer-ui.md | 0 docs/components/process-flow-ui.md | 0 docs/components/search-command.md | 0 docs/components/select.md | 0 docs/components/status-badge.md | 0 docs/components/stepper.md | 0 docs/components/window-ui.md | 0 docs/features/plan-automation.md | 0 docs/features/plan-board-review.md | 0 docs/features/plan-schedule.md | 0 docs/features/plan-usage.md | 0 docs/features/search-layer.md | 0 docs/templates/feature-template.md | 0 docs/templates/worklog-template.md | 0 docs/worklogs/2026-03-30.md | 0 docs/worklogs/2026-03-31.md | 0 docs/worklogs/2026-04-01.md | 0 docs/worklogs/2026-04-02.md | 0 docs/worklogs/2026-04-03.md | 0 docs/worklogs/2026-04-04.md | 0 docs/worklogs/2026-04-05.md | 0 docs/worklogs/2026-04-06.md | 0 docs/worklogs/2026-04-07.md | 0 docs/worklogs/2026-04-08.md | 0 docs/worklogs/2026-04-09.md | 0 docs/worklogs/2026-04-10.md | 0 docs/worklogs/2026-04-11.md | 0 docs/worklogs/2026-05-13.md | 34 + etc/commands/server-command/restart-rel.sh | 0 .../restart-server-command-runner.sh | 0 etc/commands/server-command/restart-test.sh | 11 + .../restart-via-docker-socket.mjs | 0 .../server-command/restart-work-server.sh | 0 etc/db/work-db/README.md | 0 etc/db/work-db/docker-compose.yml | 0 etc/servers/work-server/README.md | 2 +- etc/servers/work-server/package-lock.json | 0 .../scripts/container-supervisor.sh | 0 .../work-server/scripts/write-build-info.mjs | 0 etc/servers/work-server/src/app.ts | 0 etc/servers/work-server/src/config/env.js | 2 +- etc/servers/work-server/src/config/env.ts | 2 +- etc/servers/work-server/src/db/client.ts | 0 etc/servers/work-server/src/json-body.ts | 0 etc/servers/work-server/src/lib/identifier.ts | 0 etc/servers/work-server/src/not-found.test.ts | 0 etc/servers/work-server/src/not-found.ts | 0 .../work-server/src/routes/app-config.ts | 3 +- etc/servers/work-server/src/routes/board.ts | 0 etc/servers/work-server/src/routes/chat.ts | 39 +- .../src/routes/compatibility.test.ts | 0 etc/servers/work-server/src/routes/crud.ts | 0 etc/servers/work-server/src/routes/ddl.ts | 0 .../work-server/src/routes/error-log.ts | 0 etc/servers/work-server/src/routes/health.ts | 0 .../work-server/src/routes/notification.ts | 56 +- etc/servers/work-server/src/routes/plan.ts | 0 etc/servers/work-server/src/routes/schema.ts | 0 .../work-server/src/routes/server-command.ts | 40 +- .../work-server/src/routes/visitor-history.ts | 0 etc/servers/work-server/src/server.ts | 0 .../src/services/app-config-service.js | 70 +- .../src/services/app-config-service.test.ts | 254 ++- .../src/services/app-config-service.ts | 371 +++- .../src/services/chat-message-parts.ts | 34 + .../src/services/chat-room-service.test.ts | 109 +- .../src/services/chat-room-service.ts | 1032 ++++++++++- .../src/services/chat-runtime-service.ts | 0 .../src/services/chat-service.test.ts | 525 +++++- .../work-server/src/services/chat-service.ts | 994 +++++++++-- .../src/services/chat-type-defaults.js | 59 +- .../src/services/chat-type-defaults.ts | 71 +- .../error-log-plan-registration-service.ts | 0 .../src/services/error-log-service.ts | 0 .../src/services/git-service.test.ts | 76 +- .../work-server/src/services/git-service.ts | 51 + .../notification-message-service.test.ts | 31 + .../services/notification-message-service.ts | 204 ++- .../src/services/notification-service.ts | 0 .../src/services/plan-notification-policy.ts | 0 .../src/services/plan-notification-service.ts | 0 .../src/services/plan-policy.test.ts | 0 .../src/services/plan-retry-policy.ts | 0 .../src/services/plan-schedule-service.ts | 0 .../src/services/plan-service.test.ts | 0 .../work-server/src/services/plan-service.ts | 0 .../services/resource-manager-service.test.ts | 111 +- .../src/services/resource-manager-service.ts | 399 +++-- .../services/server-command-service.test.ts | 13 + .../src/services/server-command-service.ts | 2 + .../server-restart-reservation-service.ts | 85 +- .../src/services/visitor-history-service.ts | 0 .../src/services/work-server-build-service.ts | 0 .../worklog-automation-service.test.ts | 0 .../services/worklog-automation-service.ts | 0 .../src/services/worklog-automation-utils.ts | 0 .../work-server/src/types/web-push.d.ts | 0 .../work-server/src/workers/plan-worker.ts | 0 etc/servers/work-server/tsconfig.json | 0 features/layout/README.md | 16 + .../resources/feature-menu-analysis.md | 24 + .../resources/feature-menu-final.md | 73 + .../resources/feature-menu-implementation.md | 58 + features/overview.md | 9 + package.json | 7 +- scripts/capture-auth-utils.mjs | 2 +- scripts/capture-component-screenshot.mjs | 0 scripts/capture-feature-screenshot.mjs | 0 .../capture-fullscreen-toggle-screenshot.mjs | 0 scripts/capture-menu-screenshot.mjs | 0 .../capture-plan-board-mobile-screenshot.mjs | 0 scripts/capture-search-command-screenshot.mjs | 0 scripts/capture-settings-screenshot.mjs | 0 scripts/prepare-app-dist.mjs | 25 + scripts/preview-test-app-watch.mjs | 185 ++ scripts/run-plan-codex-once.mjs | 3 +- scripts/run-server-command-runner.mjs | 210 ++- scripts/serve-app-dist.mjs | 67 +- scripts/server-command-runner-supervisor.sh | 0 scripts/worklog-capture-utils.mjs | 0 src/App.tsx | 0 src/app/main/AppShell.tsx | 0 src/app/main/AutomationTypeManagementPage.tsx | 5 +- .../main/ChatDefaultContextManagementPage.css | 35 + .../main/ChatDefaultContextManagementPage.tsx | 329 +++- src/app/main/ChatNotificationBridgeV2.tsx | 162 +- src/app/main/ChatRuntimeBridgeV2.tsx | 30 +- src/app/main/ChatSourceChangesPage.tsx | 1088 ++++++------ src/app/main/ChatTypeManagementPage.tsx | 204 ++- src/app/main/InitialLoadingOverlay.css | 0 src/app/main/InitialLoadingOverlay.tsx | 0 src/app/main/MainChatPanel.css | 20 +- src/app/main/MainChatPanel.tsx | 1504 +++++++++++++---- src/app/main/MainContent.tsx | 155 +- src/app/main/MainHeader.tsx | 447 +++-- src/app/main/MainLayout.css | 426 ++++- src/app/main/MainSidebar.tsx | 0 src/app/main/MainView.tsx | 0 src/app/main/ManagementPage.shared.css | 165 +- src/app/main/PreviewAppOverlay.tsx | 437 +++++ src/app/main/PreviewAppWindow.tsx | 39 + src/app/main/ReleasePendingMainModal.tsx | 0 src/app/main/ResourceManagementPage.css | 610 ++++++- src/app/main/ResourceManagementPage.tsx | 1444 ++++++++++++++-- src/app/main/appConfig.ts | 7 +- src/app/main/appMaintenance.ts | 3 +- src/app/main/appUpdate.ts | 0 src/app/main/chatContextSettingsAccess.ts | 48 +- src/app/main/chatTypeAccess.ts | 138 +- .../components/ConversationRoomPane.tsx | 3 + src/app/main/chatV2/data/chatGateway.ts | 3 + .../chatV2/hooks/conversationListMerge.ts | 221 +++ .../useConversationComposerController.ts | 131 +- .../chatV2/hooks/useConversationListData.ts | 95 +- .../useConversationRoomActionsController.ts | 32 +- .../chatV2/hooks/useConversationRoomData.ts | 31 +- .../useConversationViewportController.ts | 30 + src/app/main/chatV2/hooks/useUnreadCounts.ts | 36 + src/app/main/clientIdentity.ts | 56 +- src/app/main/errorLogApi.ts | 0 src/app/main/index.ts | 0 src/app/main/layout/MainLayout.tsx | 73 +- src/app/main/layout/MainLayoutContext.ts | 0 src/app/main/layout/buildSearchOptions.ts | 86 +- src/app/main/layout/useMainLayoutData.ts | 0 .../mainChatPanel/ChatActivityChecklist.tsx | 58 +- .../mainChatPanel/ChatConversationView.tsx | 1233 ++++++++++---- .../main/mainChatPanel/ChatPreviewBody.tsx | 35 +- src/app/main/mainChatPanel/ChatPromptCard.tsx | 309 +++- .../mainChatPanel/ChatRuntimeDashboard.tsx | 6 + src/app/main/mainChatPanel/ErrorLogViewer.tsx | 0 src/app/main/mainChatPanel/chatResourceUrl.js | 102 +- src/app/main/mainChatPanel/chatResourceUrl.ts | 136 +- src/app/main/mainChatPanel/chatUtils.js | 22 +- src/app/main/mainChatPanel/chatUtils.ts | 209 ++- .../main/mainChatPanel/composerFilePickKey.ts | 34 + .../main/mainChatPanel/conversationUnread.ts | 33 + src/app/main/mainChatPanel/errorLogUtils.tsx | 0 .../main/mainChatPanel/errorLogUtils.types.ts | 0 src/app/main/mainChatPanel/index.ts | 2 + .../main/mainChatPanel/inlinePreviewUrls.ts | 51 +- src/app/main/mainChatPanel/messageParts.ts | 220 ++- src/app/main/mainChatPanel/previewItems.ts | 58 +- src/app/main/mainChatPanel/previewKind.ts | 111 ++ .../main/mainChatPanel/promptPreviewState.ts | 15 + .../main/mainChatPanel/requestBadgeLabel.ts | 665 ++++++++ .../styles/MainChatPanel.conversation.css | 703 +++++++- .../styles/MainChatPanel.layout.css | 209 ++- .../styles/MainChatPanel.preview-runtime.css | 268 +-- src/app/main/mainChatPanel/types.ts | 61 + .../main/mainChatPanel/useChatConnection.js | 18 + .../main/mainChatPanel/useChatConnection.ts | 134 +- src/app/main/mainChatPanel/useErrorLogs.ts | 0 src/app/main/mainContent/windowLayout.ts | 22 +- src/app/main/mainView/constants.tsx | 0 src/app/main/mainView/index.ts | 0 src/app/main/mainView/navigation.ts | 0 src/app/main/mainView/searchOptions.ts | 65 +- src/app/main/mainView/useMainViewData.ts | 0 src/app/main/mainView/utils.ts | 0 .../main/mobileNavigationGestureBlocker.ts | 92 + src/app/main/modalKeyboard.tsx | 21 +- src/app/main/notificationApi.ts | 96 +- src/app/main/notificationIdentity.ts | 61 +- src/app/main/pages/ApisPage.tsx | 30 +- src/app/main/pages/ChatPage.tsx | 0 src/app/main/pages/DocsPage.tsx | 0 src/app/main/pages/PlansPage.tsx | 0 src/app/main/pages/PlayPage.tsx | 46 +- src/app/main/previewRuntime.ts | 294 ++++ src/app/main/routes.tsx | 59 +- src/app/main/searchRecent.ts | 53 + src/app/main/themeColorSync.ts | 78 + src/app/main/tokenAccess.ts | 73 +- src/app/main/types.ts | 1 + src/app/main/viewportCssVars.ts | 136 +- src/app/manifests/docs.manifest.ts | 0 src/app/manifests/samples.manifest.ts | 0 .../chatPromptCard/samples/LinkCardSample.tsx | 44 + src/components/common/InlineImage.tsx | 0 .../multiProgress/MultiProgressUI.tsx | 0 .../dashboard/multiProgress/index.ts | 0 .../dashboard/multiProgress/plugins/index.ts | 0 .../plugins/multi-progress.plugin.ts | 0 .../multiProgress/samples/BaseSample.tsx | 0 .../multiProgress/samples/Sample.tsx | 0 .../dashboard/multiProgress/types/index.ts | 0 .../multiProgress/types/multi-progress.ts | 0 .../dashboard/progress/ProgressUI.tsx | 0 src/components/dashboard/progress/index.ts | 0 .../dashboard/progress/plugins/index.ts | 0 .../progress/plugins/progress.plugin.ts | 0 .../dashboard/progress/samples/BaseSample.tsx | 0 .../dashboard/progress/samples/Sample.tsx | 0 .../dashboard/progress/types/index.ts | 0 .../dashboard/progress/types/progress.ts | 0 .../dataListTable/DataListTable.css | 0 .../dataListTable/DataListTable.tsx | 0 src/components/dataListTable/index.ts | 0 .../dataListTable/samples/BaseSample.tsx | 0 .../dataStatePanel/DataStatePanel.css | 0 .../dataStatePanel/DataStatePanel.tsx | 0 src/components/dataStatePanel/index.ts | 0 .../dataStatePanel/samples/BaseSample.css | 0 .../dataStatePanel/samples/BaseSample.tsx | 0 src/components/embeddedMap/EmbeddedMapUI.css | 0 src/components/embeddedMap/EmbeddedMapUI.tsx | 0 src/components/embeddedMap/index.ts | 0 .../embeddedMap/samples/BaseSample.tsx | 0 src/components/embeddedMap/samples/Sample.tsx | 0 .../EmptyIllustrationCard.css | 0 .../EmptyIllustrationCard.tsx | 0 src/components/emptyIllustrationCard/index.ts | 0 .../samples/BaseSample.tsx | 0 .../EvidenceAttachmentStrip.css | 0 .../EvidenceAttachmentStrip.tsx | 2 +- .../evidenceAttachmentStrip/index.ts | 0 .../samples/BaseSample.tsx | 0 .../samples/Sample.tsx | 0 .../types/evidence-attachment-strip.ts | 0 .../evidenceAttachmentStrip/types/index.ts | 0 src/components/formField/FormField.css | 0 src/components/formField/FormField.tsx | 0 src/components/formField/index.ts | 0 .../formField/samples/BaseSample.tsx | 0 .../inputs/checkCombo/CheckComboUI.tsx | 0 src/components/inputs/checkCombo/index.ts | 0 .../checkCombo/plugins/check-combo.plugin.ts | 0 .../inputs/checkCombo/plugins/index.ts | 0 .../inputs/checkCombo/samples/BaseSample.tsx | 0 .../inputs/checkCombo/samples/Sample.tsx | 0 .../inputs/checkCombo/types/check-combo.ts | 0 .../inputs/checkCombo/types/index.ts | 0 .../composite/multiInput/MultiInputUI.tsx | 0 .../inputs/composite/multiInput/index.ts | 0 .../composite/multiInput/plugins/index.ts | 0 .../multiInput/plugins/multi-input.plugin.ts | 0 .../multiInput/samples/BaseSample.tsx | 0 .../composite/multiInput/samples/Sample.tsx | 0 .../composite/multiInput/types/index.ts | 0 .../composite/multiInput/types/multi-input.ts | 0 src/components/inputs/popup/PopupUI.tsx | 0 src/components/inputs/popup/index.ts | 0 src/components/inputs/popup/plugins/index.ts | 0 .../inputs/popup/plugins/popup.plugin.ts | 0 .../inputs/popup/samples/BaseSample.tsx | 0 .../inputs/popup/samples/Sample.tsx | 0 src/components/inputs/popup/types/index.ts | 0 src/components/inputs/popup/types/popup.ts | 0 .../inputs/primitives/input/InputUI.tsx | 0 .../inputs/primitives/input/index.ts | 0 .../inputs/primitives/input/plugins/index.ts | 0 .../primitives/input/plugins/input.plugin.ts | 0 .../primitives/input/samples/BaseSample.tsx | 0 .../primitives/input/samples/Sample.tsx | 0 .../input/samples/ValidInputSample.tsx | 0 .../inputs/primitives/input/types/index.ts | 0 .../inputs/primitives/input/types/input.ts | 0 src/components/inputs/select/SelectUI.tsx | 0 src/components/inputs/select/index.ts | 0 src/components/inputs/select/plugins/index.ts | 0 .../inputs/select/plugins/select.plugin.ts | 0 .../inputs/select/samples/BaseSample.tsx | 0 .../inputs/select/samples/Sample.tsx | 0 src/components/inputs/select/types/index.ts | 0 src/components/inputs/select/types/select.ts | 0 .../ButtonEditableInputUI.css | 0 .../ButtonEditableInputUI.tsx | 0 .../specialized/buttonEditableInput/index.ts | 0 .../samples/BaseSample.tsx | 0 .../buttonEditableInput/samples/Sample.tsx | 0 .../specialized/emailInput/EmailInputUI.tsx | 0 .../inputs/specialized/emailInput/index.ts | 0 .../emailInput/plugins/email-input.plugin.ts | 0 .../specialized/emailInput/plugins/index.ts | 0 .../emailInput/samples/BaseSample.tsx | 0 .../specialized/emailInput/samples/Sample.tsx | 0 .../emailInput/types/email-input.ts | 0 .../specialized/emailInput/types/index.ts | 0 .../markdownPreview/MarkdownPreviewCard.tsx | 0 .../MarkdownPreviewContent.tsx | 0 .../markdownPreview/MarkdownPreviewList.tsx | 0 src/components/markdownPreview/index.ts | 0 .../markdownPreview/markdown-document.ts | 0 src/components/markdownPreview/registry.ts | 0 .../samples/MarkdownPreviewCardBaseSample.tsx | 0 .../MarkdownPreviewContentBaseSample.tsx | 0 .../samples/MarkdownPreviewListBaseSample.tsx | 0 .../navigation/SectionMenuLayout.tsx | 0 src/components/navigation/folder-tree-nav.tsx | 0 src/components/navigation/index.ts | 0 .../samples/FolderTreeNavBaseSample.tsx | 0 .../samples/SectionMenuLayoutBaseSample.tsx | 0 src/components/previewer/CodexDiffBlock.tsx | 33 +- .../previewer/CodexDiffPreviewer.css | 0 .../previewer/CodexDiffPreviewer.tsx | 34 +- .../previewer/FullscreenPreviewModal.css | 163 ++ .../previewer/FullscreenPreviewModal.tsx | 57 + src/components/previewer/PreviewerUI.css | 0 src/components/previewer/PreviewerUI.tsx | 0 .../previewer/ZoomablePreviewSurface.tsx | 206 +++ src/components/previewer/index.ts | 2 + src/components/previewer/renderers.tsx | 0 .../previewer/samples/BaseSample.tsx | 0 .../previewer/samples/CodexDiffBaseSample.tsx | 0 .../previewer/samples/CodexDiffSample.tsx | 0 src/components/previewer/samples/Sample.tsx | 0 src/components/previewer/types/index.ts | 0 src/components/previewer/types/previewer.ts | 0 src/components/processFlow/ProcessFlowUI.css | 0 src/components/processFlow/ProcessFlowUI.tsx | 0 src/components/processFlow/index.ts | 0 .../processFlow/samples/BaseSample.tsx | 0 src/components/processFlow/types/index.ts | 0 .../processFlow/types/process-flow.ts | 0 .../QueryFilterBuilderUI.css | 0 .../QueryFilterBuilderUI.tsx | 0 src/components/queryFilterBuilder/index.ts | 0 .../queryFilterBuilder/samples/BaseSample.tsx | 0 .../queryFilterBuilder/types/index.ts | 0 .../types/query-filter-builder.ts | 0 src/components/search/SearchCommandModal.tsx | 48 +- src/components/search/index.ts | 0 src/components/search/samples/BaseSample.tsx | 0 src/components/search/types.ts | 0 src/components/stateKit/StateKit.css | 0 src/components/stateKit/StateKit.tsx | 0 src/components/stateKit/index.ts | 0 .../stateKit/samples/BaseSample.tsx | 0 src/components/status-badge/StatusBadgeUI.tsx | 0 src/components/status-badge/index.ts | 0 src/components/status-badge/plugins/index.ts | 0 .../plugins/status-badge.plugin.ts | 0 .../status-badge/samples/BaseSample.tsx | 0 .../status-badge/samples/Sample.tsx | 0 src/components/status-badge/types/index.ts | 0 .../status-badge/types/status-badge.ts | 0 src/components/stepper/StepperUI.tsx | 0 src/components/stepper/index.ts | 0 src/components/stepper/samples/BaseSample.tsx | 0 src/components/stepper/types.ts | 0 .../timelinePanel/TimelinePanelUI.css | 0 .../timelinePanel/TimelinePanelUI.tsx | 0 src/components/timelinePanel/index.ts | 0 .../timelinePanel/samples/BaseSample.tsx | 0 src/components/window/WindowUI.css | 48 + src/components/window/WindowUI.tsx | 35 +- src/components/window/index.ts | 0 src/components/window/samples/BaseSample.tsx | 0 src/components/window/samples/Sample.tsx | 0 src/components/window/types/index.ts | 0 src/components/window/types/window.ts | 3 + src/data/dashboard-report-presets.ts | 0 src/features/board/api.ts | 0 src/features/board/index.ts | 0 src/features/board/types.ts | 0 .../dashboard/TmsDashboardFeatureSamples.tsx | 0 .../dashboard/WmsDashboardFeatureSamples.tsx | 0 src/features/history/HistoryPage.tsx | 0 src/features/history/api.ts | 0 src/features/history/index.ts | 0 src/features/history/types.ts | 0 src/features/layout/README.md | 0 .../ComponentSamplesLayout.tsx | 0 .../layout/component-sample-gallery/index.ts | 0 .../DashboardFeatureGalleryLayout.tsx | 0 .../layout/dashboard-feature-gallery/index.ts | 0 .../DashboardReportGalleryLayout.tsx | 0 .../layout/dashboard-report-gallery/index.ts | 0 .../DocsMarkdownPreviewLayout.tsx | 0 .../layout/docs-markdown-preview/index.ts | 0 .../FeatureMarkdownPreviewListLayout.tsx | 0 .../layout/feature-markdown-preview/index.ts | 0 .../feature-menu/FeatureMenuLayoutPage.tsx | 70 +- .../layout/feature-menu/featureMenu.chat.ts | 2 +- .../resources/feature-menu-analysis.md | 5 +- .../resources/feature-menu-final.md | 5 +- .../resources/feature-menu-implementation.md | 34 +- .../layout/renderSavedLayoutContent.tsx | 46 + .../WidgetRegistryLayout.tsx | 0 .../layout/widget-registry-gallery/index.ts | 0 .../SampleWidgetsLayout.tsx | 33 +- .../layout/widget-sample-gallery/index.ts | 0 .../FeatureMarkdownPreviewCard.tsx | 0 src/features/markdownPreview/index.ts | 0 src/features/overview.md | 0 src/features/planBoard/PlanBoardPage.tsx | 0 .../planBoard/PlanListDetailLayout.tsx | 0 src/features/planBoard/PlanSchedulePage.tsx | 0 src/features/planBoard/ReleaseReviewPage.tsx | 0 src/features/planBoard/api.ts | 0 src/features/planBoard/charts.tsx | 0 src/features/planBoard/index.ts | 0 src/features/planBoard/noteMasking.ts | 0 src/features/planBoard/planBoard.css | 0 src/features/planBoard/planSchedule.css | 0 src/features/planBoard/quickFilters.ts | 0 src/features/planBoard/types.ts | 0 .../serverCommand/ServerCommandPage.tsx | 41 + src/features/serverCommand/api.ts | 11 + src/features/serverCommand/index.ts | 0 src/features/serverCommand/serverCommand.css | 0 src/features/serverCommand/types.ts | 8 + src/index.ts | 0 src/layer/gesture/context/GestureContext.tsx | 0 src/layer/gesture/hooks/useGestureLayer.ts | 0 src/layer/gesture/index.ts | 0 src/layer/gesture/types/index.ts | 0 src/layer/gps/context/GpsLayerContext.tsx | 0 src/layer/gps/hooks/useGpsLayer.ts | 0 src/layer/gps/index.ts | 0 src/layer/gps/types/index.ts | 0 src/layer/index.ts | 0 .../search/context/SearchLayerContext.tsx | 62 +- src/layer/search/hooks/useSearchLayer.ts | 0 src/layer/search/index.ts | 0 src/layer/search/types/index.ts | 0 src/main.tsx | 74 +- src/samples/registry.ts | 0 .../appStore/context/AppStoreContext.tsx | 0 src/store/appStore/hooks/useAppStore.ts | 0 src/store/appStore/index.ts | 0 src/store/appStore/types/index.ts | 0 src/store/index.ts | 0 src/styles.css | 243 ++- src/sw.js | 54 +- src/types/component-plugin.ts | 0 src/views/play/LayoutPlaygroundView.css | 0 src/views/play/LayoutPlaygroundView.tsx | 44 +- src/views/play/apps/cbt/CbtPlayAppView.tsx | 13 +- src/views/play/apps/test/TestPlayAppView.css | 12 +- src/views/play/layoutStorage.ts | 0 src/vite-env.d.ts | 0 .../api-sample-card/ApiSampleCardWidget.tsx | 0 src/widgets/api-sample-card/index.ts | 0 .../api-sample-card/samples/Sample.tsx | 0 src/widgets/core/WidgetShell.tsx | 0 src/widgets/core/index.ts | 0 src/widgets/core/registry/widget-features.ts | 0 src/widgets/core/types/widget.ts | 0 .../DashboardReportCardWidget.tsx | 0 src/widgets/dashboard-report-card/index.ts | 0 .../samples/TmsDeliveryFlowSample.tsx | 0 .../samples/TmsDeliveryMetricsSample.tsx | 0 .../samples/WmsInboundOutboundSample.tsx | 0 .../samples/WmsInventoryTrendSample.tsx | 0 .../gps-sample-card/GpsSampleWidget.css | 0 .../gps-sample-card/GpsSampleWidget.tsx | 0 src/widgets/gps-sample-card/index.ts | 0 .../gps-sample-card/samples/Sample.tsx | 0 src/widgets/registry.ts | 0 .../text-memo-widget/TextMemoWidget.css | 0 .../text-memo-widget/TextMemoWidget.tsx | 0 src/widgets/text-memo-widget/index.ts | 0 .../text-memo-widget/samples/Sample.tsx | 0 504 files changed, 17074 insertions(+), 3642 deletions(-) mode change 100755 => 100644 README.md mode change 100755 => 100644 docker-compose.yml mode change 100755 => 100644 docs/README.md mode change 100755 => 100644 docs/components/check-combo.md mode change 100755 => 100644 docs/components/codex-diff-previewer.md mode change 100755 => 100644 docs/components/evidence-attachment-strip-ui.md mode change 100755 => 100644 docs/components/input.md mode change 100755 => 100644 docs/components/popup.md mode change 100755 => 100644 docs/components/previewer-ui.md mode change 100755 => 100644 docs/components/process-flow-ui.md mode change 100755 => 100644 docs/components/search-command.md mode change 100755 => 100644 docs/components/select.md mode change 100755 => 100644 docs/components/status-badge.md mode change 100755 => 100644 docs/components/stepper.md mode change 100755 => 100644 docs/components/window-ui.md mode change 100755 => 100644 docs/features/plan-automation.md mode change 100755 => 100644 docs/features/plan-board-review.md mode change 100755 => 100644 docs/features/plan-schedule.md mode change 100755 => 100644 docs/features/plan-usage.md mode change 100755 => 100644 docs/features/search-layer.md mode change 100755 => 100644 docs/templates/feature-template.md mode change 100755 => 100644 docs/templates/worklog-template.md mode change 100755 => 100644 docs/worklogs/2026-03-30.md mode change 100755 => 100644 docs/worklogs/2026-03-31.md mode change 100755 => 100644 docs/worklogs/2026-04-01.md mode change 100755 => 100644 docs/worklogs/2026-04-02.md mode change 100755 => 100644 docs/worklogs/2026-04-03.md mode change 100755 => 100644 docs/worklogs/2026-04-04.md mode change 100755 => 100644 docs/worklogs/2026-04-05.md mode change 100755 => 100644 docs/worklogs/2026-04-06.md mode change 100755 => 100644 docs/worklogs/2026-04-07.md mode change 100755 => 100644 docs/worklogs/2026-04-08.md mode change 100755 => 100644 docs/worklogs/2026-04-09.md mode change 100755 => 100644 docs/worklogs/2026-04-10.md mode change 100755 => 100644 docs/worklogs/2026-04-11.md create mode 100644 docs/worklogs/2026-05-13.md mode change 100755 => 100644 etc/commands/server-command/restart-rel.sh mode change 100755 => 100644 etc/commands/server-command/restart-server-command-runner.sh mode change 100755 => 100644 etc/commands/server-command/restart-test.sh mode change 100755 => 100644 etc/commands/server-command/restart-via-docker-socket.mjs mode change 100755 => 100644 etc/commands/server-command/restart-work-server.sh mode change 100755 => 100644 etc/db/work-db/README.md mode change 100755 => 100644 etc/db/work-db/docker-compose.yml mode change 100755 => 100644 etc/servers/work-server/package-lock.json mode change 100755 => 100644 etc/servers/work-server/scripts/container-supervisor.sh mode change 100755 => 100644 etc/servers/work-server/scripts/write-build-info.mjs mode change 100755 => 100644 etc/servers/work-server/src/app.ts mode change 100755 => 100644 etc/servers/work-server/src/db/client.ts mode change 100755 => 100644 etc/servers/work-server/src/json-body.ts mode change 100755 => 100644 etc/servers/work-server/src/lib/identifier.ts mode change 100755 => 100644 etc/servers/work-server/src/not-found.test.ts mode change 100755 => 100644 etc/servers/work-server/src/not-found.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/app-config.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/board.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/chat.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/compatibility.test.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/crud.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/ddl.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/error-log.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/health.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/notification.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/plan.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/schema.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/server-command.ts mode change 100755 => 100644 etc/servers/work-server/src/routes/visitor-history.ts mode change 100755 => 100644 etc/servers/work-server/src/server.ts mode change 100755 => 100644 etc/servers/work-server/src/services/app-config-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/chat-runtime-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/error-log-plan-registration-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/error-log-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/git-service.test.ts mode change 100755 => 100644 etc/servers/work-server/src/services/git-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/notification-message-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/notification-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/plan-notification-policy.ts mode change 100755 => 100644 etc/servers/work-server/src/services/plan-notification-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/plan-policy.test.ts mode change 100755 => 100644 etc/servers/work-server/src/services/plan-retry-policy.ts mode change 100755 => 100644 etc/servers/work-server/src/services/plan-schedule-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/plan-service.test.ts mode change 100755 => 100644 etc/servers/work-server/src/services/plan-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/server-command-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/visitor-history-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/work-server-build-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/worklog-automation-service.test.ts mode change 100755 => 100644 etc/servers/work-server/src/services/worklog-automation-service.ts mode change 100755 => 100644 etc/servers/work-server/src/services/worklog-automation-utils.ts mode change 100755 => 100644 etc/servers/work-server/src/types/web-push.d.ts mode change 100755 => 100644 etc/servers/work-server/src/workers/plan-worker.ts mode change 100755 => 100644 etc/servers/work-server/tsconfig.json create mode 100644 features/layout/README.md create mode 100644 features/layout/feature-menu/resources/feature-menu-analysis.md create mode 100644 features/layout/feature-menu/resources/feature-menu-final.md create mode 100644 features/layout/feature-menu/resources/feature-menu-implementation.md create mode 100644 features/overview.md mode change 100755 => 100644 package.json mode change 100755 => 100644 scripts/capture-component-screenshot.mjs mode change 100755 => 100644 scripts/capture-feature-screenshot.mjs mode change 100755 => 100644 scripts/capture-fullscreen-toggle-screenshot.mjs mode change 100755 => 100644 scripts/capture-menu-screenshot.mjs mode change 100755 => 100644 scripts/capture-plan-board-mobile-screenshot.mjs mode change 100755 => 100644 scripts/capture-search-command-screenshot.mjs mode change 100755 => 100644 scripts/capture-settings-screenshot.mjs create mode 100644 scripts/prepare-app-dist.mjs create mode 100644 scripts/preview-test-app-watch.mjs mode change 100755 => 100644 scripts/run-plan-codex-once.mjs mode change 100755 => 100644 scripts/serve-app-dist.mjs mode change 100755 => 100644 scripts/server-command-runner-supervisor.sh mode change 100755 => 100644 scripts/worklog-capture-utils.mjs mode change 100755 => 100644 src/App.tsx mode change 100755 => 100644 src/app/main/AppShell.tsx mode change 100755 => 100644 src/app/main/ChatTypeManagementPage.tsx mode change 100755 => 100644 src/app/main/InitialLoadingOverlay.css mode change 100755 => 100644 src/app/main/InitialLoadingOverlay.tsx mode change 100755 => 100644 src/app/main/MainChatPanel.css mode change 100755 => 100644 src/app/main/MainContent.tsx mode change 100755 => 100644 src/app/main/MainHeader.tsx mode change 100755 => 100644 src/app/main/MainLayout.css mode change 100755 => 100644 src/app/main/MainSidebar.tsx mode change 100755 => 100644 src/app/main/MainView.tsx mode change 100755 => 100644 src/app/main/ManagementPage.shared.css create mode 100644 src/app/main/PreviewAppOverlay.tsx create mode 100644 src/app/main/PreviewAppWindow.tsx mode change 100755 => 100644 src/app/main/ReleasePendingMainModal.tsx mode change 100755 => 100644 src/app/main/appConfig.ts mode change 100755 => 100644 src/app/main/appUpdate.ts mode change 100755 => 100644 src/app/main/chatTypeAccess.ts create mode 100644 src/app/main/chatV2/hooks/conversationListMerge.ts mode change 100755 => 100644 src/app/main/clientIdentity.ts mode change 100755 => 100644 src/app/main/errorLogApi.ts mode change 100755 => 100644 src/app/main/index.ts mode change 100755 => 100644 src/app/main/layout/MainLayout.tsx mode change 100755 => 100644 src/app/main/layout/MainLayoutContext.ts mode change 100755 => 100644 src/app/main/layout/buildSearchOptions.ts mode change 100755 => 100644 src/app/main/layout/useMainLayoutData.ts mode change 100755 => 100644 src/app/main/mainChatPanel/ChatConversationView.tsx mode change 100755 => 100644 src/app/main/mainChatPanel/ChatPreviewBody.tsx mode change 100755 => 100644 src/app/main/mainChatPanel/ChatRuntimeDashboard.tsx mode change 100755 => 100644 src/app/main/mainChatPanel/ErrorLogViewer.tsx create mode 100644 src/app/main/mainChatPanel/composerFilePickKey.ts create mode 100644 src/app/main/mainChatPanel/conversationUnread.ts mode change 100755 => 100644 src/app/main/mainChatPanel/errorLogUtils.tsx mode change 100755 => 100644 src/app/main/mainChatPanel/errorLogUtils.types.ts create mode 100644 src/app/main/mainChatPanel/previewKind.ts create mode 100644 src/app/main/mainChatPanel/promptPreviewState.ts create mode 100644 src/app/main/mainChatPanel/requestBadgeLabel.ts mode change 100755 => 100644 src/app/main/mainChatPanel/types.ts mode change 100755 => 100644 src/app/main/mainChatPanel/useChatConnection.ts mode change 100755 => 100644 src/app/main/mainChatPanel/useErrorLogs.ts mode change 100755 => 100644 src/app/main/mainContent/windowLayout.ts mode change 100755 => 100644 src/app/main/mainView/constants.tsx mode change 100755 => 100644 src/app/main/mainView/index.ts mode change 100755 => 100644 src/app/main/mainView/navigation.ts mode change 100755 => 100644 src/app/main/mainView/searchOptions.ts mode change 100755 => 100644 src/app/main/mainView/useMainViewData.ts mode change 100755 => 100644 src/app/main/mainView/utils.ts create mode 100644 src/app/main/mobileNavigationGestureBlocker.ts mode change 100755 => 100644 src/app/main/notificationApi.ts mode change 100755 => 100644 src/app/main/notificationIdentity.ts mode change 100755 => 100644 src/app/main/pages/ApisPage.tsx mode change 100755 => 100644 src/app/main/pages/ChatPage.tsx mode change 100755 => 100644 src/app/main/pages/DocsPage.tsx mode change 100755 => 100644 src/app/main/pages/PlansPage.tsx mode change 100755 => 100644 src/app/main/pages/PlayPage.tsx create mode 100644 src/app/main/previewRuntime.ts mode change 100755 => 100644 src/app/main/routes.tsx create mode 100644 src/app/main/searchRecent.ts create mode 100644 src/app/main/themeColorSync.ts mode change 100755 => 100644 src/app/main/tokenAccess.ts mode change 100755 => 100644 src/app/main/types.ts mode change 100755 => 100644 src/app/manifests/docs.manifest.ts mode change 100755 => 100644 src/app/manifests/samples.manifest.ts create mode 100644 src/components/chatPromptCard/samples/LinkCardSample.tsx mode change 100755 => 100644 src/components/common/InlineImage.tsx mode change 100755 => 100644 src/components/dashboard/multiProgress/MultiProgressUI.tsx mode change 100755 => 100644 src/components/dashboard/multiProgress/index.ts mode change 100755 => 100644 src/components/dashboard/multiProgress/plugins/index.ts mode change 100755 => 100644 src/components/dashboard/multiProgress/plugins/multi-progress.plugin.ts mode change 100755 => 100644 src/components/dashboard/multiProgress/samples/BaseSample.tsx mode change 100755 => 100644 src/components/dashboard/multiProgress/samples/Sample.tsx mode change 100755 => 100644 src/components/dashboard/multiProgress/types/index.ts mode change 100755 => 100644 src/components/dashboard/multiProgress/types/multi-progress.ts mode change 100755 => 100644 src/components/dashboard/progress/ProgressUI.tsx mode change 100755 => 100644 src/components/dashboard/progress/index.ts mode change 100755 => 100644 src/components/dashboard/progress/plugins/index.ts mode change 100755 => 100644 src/components/dashboard/progress/plugins/progress.plugin.ts mode change 100755 => 100644 src/components/dashboard/progress/samples/BaseSample.tsx mode change 100755 => 100644 src/components/dashboard/progress/samples/Sample.tsx mode change 100755 => 100644 src/components/dashboard/progress/types/index.ts mode change 100755 => 100644 src/components/dashboard/progress/types/progress.ts mode change 100755 => 100644 src/components/dataListTable/DataListTable.css mode change 100755 => 100644 src/components/dataListTable/DataListTable.tsx mode change 100755 => 100644 src/components/dataListTable/index.ts mode change 100755 => 100644 src/components/dataListTable/samples/BaseSample.tsx mode change 100755 => 100644 src/components/dataStatePanel/DataStatePanel.css mode change 100755 => 100644 src/components/dataStatePanel/DataStatePanel.tsx mode change 100755 => 100644 src/components/dataStatePanel/index.ts mode change 100755 => 100644 src/components/dataStatePanel/samples/BaseSample.css mode change 100755 => 100644 src/components/dataStatePanel/samples/BaseSample.tsx mode change 100755 => 100644 src/components/embeddedMap/EmbeddedMapUI.css mode change 100755 => 100644 src/components/embeddedMap/EmbeddedMapUI.tsx mode change 100755 => 100644 src/components/embeddedMap/index.ts mode change 100755 => 100644 src/components/embeddedMap/samples/BaseSample.tsx mode change 100755 => 100644 src/components/embeddedMap/samples/Sample.tsx mode change 100755 => 100644 src/components/emptyIllustrationCard/EmptyIllustrationCard.css mode change 100755 => 100644 src/components/emptyIllustrationCard/EmptyIllustrationCard.tsx mode change 100755 => 100644 src/components/emptyIllustrationCard/index.ts mode change 100755 => 100644 src/components/emptyIllustrationCard/samples/BaseSample.tsx mode change 100755 => 100644 src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.css mode change 100755 => 100644 src/components/evidenceAttachmentStrip/EvidenceAttachmentStrip.tsx mode change 100755 => 100644 src/components/evidenceAttachmentStrip/index.ts mode change 100755 => 100644 src/components/evidenceAttachmentStrip/samples/BaseSample.tsx mode change 100755 => 100644 src/components/evidenceAttachmentStrip/samples/Sample.tsx mode change 100755 => 100644 src/components/evidenceAttachmentStrip/types/evidence-attachment-strip.ts mode change 100755 => 100644 src/components/evidenceAttachmentStrip/types/index.ts mode change 100755 => 100644 src/components/formField/FormField.css mode change 100755 => 100644 src/components/formField/FormField.tsx mode change 100755 => 100644 src/components/formField/index.ts mode change 100755 => 100644 src/components/formField/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/checkCombo/CheckComboUI.tsx mode change 100755 => 100644 src/components/inputs/checkCombo/index.ts mode change 100755 => 100644 src/components/inputs/checkCombo/plugins/check-combo.plugin.ts mode change 100755 => 100644 src/components/inputs/checkCombo/plugins/index.ts mode change 100755 => 100644 src/components/inputs/checkCombo/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/checkCombo/samples/Sample.tsx mode change 100755 => 100644 src/components/inputs/checkCombo/types/check-combo.ts mode change 100755 => 100644 src/components/inputs/checkCombo/types/index.ts mode change 100755 => 100644 src/components/inputs/composite/multiInput/MultiInputUI.tsx mode change 100755 => 100644 src/components/inputs/composite/multiInput/index.ts mode change 100755 => 100644 src/components/inputs/composite/multiInput/plugins/index.ts mode change 100755 => 100644 src/components/inputs/composite/multiInput/plugins/multi-input.plugin.ts mode change 100755 => 100644 src/components/inputs/composite/multiInput/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/composite/multiInput/samples/Sample.tsx mode change 100755 => 100644 src/components/inputs/composite/multiInput/types/index.ts mode change 100755 => 100644 src/components/inputs/composite/multiInput/types/multi-input.ts mode change 100755 => 100644 src/components/inputs/popup/PopupUI.tsx mode change 100755 => 100644 src/components/inputs/popup/index.ts mode change 100755 => 100644 src/components/inputs/popup/plugins/index.ts mode change 100755 => 100644 src/components/inputs/popup/plugins/popup.plugin.ts mode change 100755 => 100644 src/components/inputs/popup/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/popup/samples/Sample.tsx mode change 100755 => 100644 src/components/inputs/popup/types/index.ts mode change 100755 => 100644 src/components/inputs/popup/types/popup.ts mode change 100755 => 100644 src/components/inputs/primitives/input/InputUI.tsx mode change 100755 => 100644 src/components/inputs/primitives/input/index.ts mode change 100755 => 100644 src/components/inputs/primitives/input/plugins/index.ts mode change 100755 => 100644 src/components/inputs/primitives/input/plugins/input.plugin.ts mode change 100755 => 100644 src/components/inputs/primitives/input/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/primitives/input/samples/Sample.tsx mode change 100755 => 100644 src/components/inputs/primitives/input/samples/ValidInputSample.tsx mode change 100755 => 100644 src/components/inputs/primitives/input/types/index.ts mode change 100755 => 100644 src/components/inputs/primitives/input/types/input.ts mode change 100755 => 100644 src/components/inputs/select/SelectUI.tsx mode change 100755 => 100644 src/components/inputs/select/index.ts mode change 100755 => 100644 src/components/inputs/select/plugins/index.ts mode change 100755 => 100644 src/components/inputs/select/plugins/select.plugin.ts mode change 100755 => 100644 src/components/inputs/select/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/select/samples/Sample.tsx mode change 100755 => 100644 src/components/inputs/select/types/index.ts mode change 100755 => 100644 src/components/inputs/select/types/select.ts mode change 100755 => 100644 src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.css mode change 100755 => 100644 src/components/inputs/specialized/buttonEditableInput/ButtonEditableInputUI.tsx mode change 100755 => 100644 src/components/inputs/specialized/buttonEditableInput/index.ts mode change 100755 => 100644 src/components/inputs/specialized/buttonEditableInput/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/specialized/buttonEditableInput/samples/Sample.tsx mode change 100755 => 100644 src/components/inputs/specialized/emailInput/EmailInputUI.tsx mode change 100755 => 100644 src/components/inputs/specialized/emailInput/index.ts mode change 100755 => 100644 src/components/inputs/specialized/emailInput/plugins/email-input.plugin.ts mode change 100755 => 100644 src/components/inputs/specialized/emailInput/plugins/index.ts mode change 100755 => 100644 src/components/inputs/specialized/emailInput/samples/BaseSample.tsx mode change 100755 => 100644 src/components/inputs/specialized/emailInput/samples/Sample.tsx mode change 100755 => 100644 src/components/inputs/specialized/emailInput/types/email-input.ts mode change 100755 => 100644 src/components/inputs/specialized/emailInput/types/index.ts mode change 100755 => 100644 src/components/markdownPreview/MarkdownPreviewCard.tsx mode change 100755 => 100644 src/components/markdownPreview/MarkdownPreviewContent.tsx mode change 100755 => 100644 src/components/markdownPreview/MarkdownPreviewList.tsx mode change 100755 => 100644 src/components/markdownPreview/index.ts mode change 100755 => 100644 src/components/markdownPreview/markdown-document.ts mode change 100755 => 100644 src/components/markdownPreview/registry.ts mode change 100755 => 100644 src/components/markdownPreview/samples/MarkdownPreviewCardBaseSample.tsx mode change 100755 => 100644 src/components/markdownPreview/samples/MarkdownPreviewContentBaseSample.tsx mode change 100755 => 100644 src/components/markdownPreview/samples/MarkdownPreviewListBaseSample.tsx mode change 100755 => 100644 src/components/navigation/SectionMenuLayout.tsx mode change 100755 => 100644 src/components/navigation/folder-tree-nav.tsx mode change 100755 => 100644 src/components/navigation/index.ts mode change 100755 => 100644 src/components/navigation/samples/FolderTreeNavBaseSample.tsx mode change 100755 => 100644 src/components/navigation/samples/SectionMenuLayoutBaseSample.tsx mode change 100755 => 100644 src/components/previewer/CodexDiffBlock.tsx mode change 100755 => 100644 src/components/previewer/CodexDiffPreviewer.css mode change 100755 => 100644 src/components/previewer/CodexDiffPreviewer.tsx create mode 100644 src/components/previewer/FullscreenPreviewModal.css create mode 100644 src/components/previewer/FullscreenPreviewModal.tsx mode change 100755 => 100644 src/components/previewer/PreviewerUI.css mode change 100755 => 100644 src/components/previewer/PreviewerUI.tsx create mode 100644 src/components/previewer/ZoomablePreviewSurface.tsx mode change 100755 => 100644 src/components/previewer/index.ts mode change 100755 => 100644 src/components/previewer/renderers.tsx mode change 100755 => 100644 src/components/previewer/samples/BaseSample.tsx mode change 100755 => 100644 src/components/previewer/samples/CodexDiffBaseSample.tsx mode change 100755 => 100644 src/components/previewer/samples/CodexDiffSample.tsx mode change 100755 => 100644 src/components/previewer/samples/Sample.tsx mode change 100755 => 100644 src/components/previewer/types/index.ts mode change 100755 => 100644 src/components/previewer/types/previewer.ts mode change 100755 => 100644 src/components/processFlow/ProcessFlowUI.css mode change 100755 => 100644 src/components/processFlow/ProcessFlowUI.tsx mode change 100755 => 100644 src/components/processFlow/index.ts mode change 100755 => 100644 src/components/processFlow/samples/BaseSample.tsx mode change 100755 => 100644 src/components/processFlow/types/index.ts mode change 100755 => 100644 src/components/processFlow/types/process-flow.ts mode change 100755 => 100644 src/components/queryFilterBuilder/QueryFilterBuilderUI.css mode change 100755 => 100644 src/components/queryFilterBuilder/QueryFilterBuilderUI.tsx mode change 100755 => 100644 src/components/queryFilterBuilder/index.ts mode change 100755 => 100644 src/components/queryFilterBuilder/samples/BaseSample.tsx mode change 100755 => 100644 src/components/queryFilterBuilder/types/index.ts mode change 100755 => 100644 src/components/queryFilterBuilder/types/query-filter-builder.ts mode change 100755 => 100644 src/components/search/SearchCommandModal.tsx mode change 100755 => 100644 src/components/search/index.ts mode change 100755 => 100644 src/components/search/samples/BaseSample.tsx mode change 100755 => 100644 src/components/search/types.ts mode change 100755 => 100644 src/components/stateKit/StateKit.css mode change 100755 => 100644 src/components/stateKit/StateKit.tsx mode change 100755 => 100644 src/components/stateKit/index.ts mode change 100755 => 100644 src/components/stateKit/samples/BaseSample.tsx mode change 100755 => 100644 src/components/status-badge/StatusBadgeUI.tsx mode change 100755 => 100644 src/components/status-badge/index.ts mode change 100755 => 100644 src/components/status-badge/plugins/index.ts mode change 100755 => 100644 src/components/status-badge/plugins/status-badge.plugin.ts mode change 100755 => 100644 src/components/status-badge/samples/BaseSample.tsx mode change 100755 => 100644 src/components/status-badge/samples/Sample.tsx mode change 100755 => 100644 src/components/status-badge/types/index.ts mode change 100755 => 100644 src/components/status-badge/types/status-badge.ts mode change 100755 => 100644 src/components/stepper/StepperUI.tsx mode change 100755 => 100644 src/components/stepper/index.ts mode change 100755 => 100644 src/components/stepper/samples/BaseSample.tsx mode change 100755 => 100644 src/components/stepper/types.ts mode change 100755 => 100644 src/components/timelinePanel/TimelinePanelUI.css mode change 100755 => 100644 src/components/timelinePanel/TimelinePanelUI.tsx mode change 100755 => 100644 src/components/timelinePanel/index.ts mode change 100755 => 100644 src/components/timelinePanel/samples/BaseSample.tsx mode change 100755 => 100644 src/components/window/WindowUI.css mode change 100755 => 100644 src/components/window/WindowUI.tsx mode change 100755 => 100644 src/components/window/index.ts mode change 100755 => 100644 src/components/window/samples/BaseSample.tsx mode change 100755 => 100644 src/components/window/samples/Sample.tsx mode change 100755 => 100644 src/components/window/types/index.ts mode change 100755 => 100644 src/components/window/types/window.ts mode change 100755 => 100644 src/data/dashboard-report-presets.ts mode change 100755 => 100644 src/features/board/api.ts mode change 100755 => 100644 src/features/board/index.ts mode change 100755 => 100644 src/features/board/types.ts mode change 100755 => 100644 src/features/dashboard/TmsDashboardFeatureSamples.tsx mode change 100755 => 100644 src/features/dashboard/WmsDashboardFeatureSamples.tsx mode change 100755 => 100644 src/features/history/HistoryPage.tsx mode change 100755 => 100644 src/features/history/api.ts mode change 100755 => 100644 src/features/history/index.ts mode change 100755 => 100644 src/features/history/types.ts mode change 100755 => 100644 src/features/layout/README.md mode change 100755 => 100644 src/features/layout/component-sample-gallery/ComponentSamplesLayout.tsx mode change 100755 => 100644 src/features/layout/component-sample-gallery/index.ts mode change 100755 => 100644 src/features/layout/dashboard-feature-gallery/DashboardFeatureGalleryLayout.tsx mode change 100755 => 100644 src/features/layout/dashboard-feature-gallery/index.ts mode change 100755 => 100644 src/features/layout/dashboard-report-gallery/DashboardReportGalleryLayout.tsx mode change 100755 => 100644 src/features/layout/dashboard-report-gallery/index.ts mode change 100755 => 100644 src/features/layout/docs-markdown-preview/DocsMarkdownPreviewLayout.tsx mode change 100755 => 100644 src/features/layout/docs-markdown-preview/index.ts mode change 100755 => 100644 src/features/layout/feature-markdown-preview/FeatureMarkdownPreviewListLayout.tsx mode change 100755 => 100644 src/features/layout/feature-markdown-preview/index.ts create mode 100644 src/features/layout/renderSavedLayoutContent.tsx mode change 100755 => 100644 src/features/layout/widget-registry-gallery/WidgetRegistryLayout.tsx mode change 100755 => 100644 src/features/layout/widget-registry-gallery/index.ts mode change 100755 => 100644 src/features/layout/widget-sample-gallery/SampleWidgetsLayout.tsx mode change 100755 => 100644 src/features/layout/widget-sample-gallery/index.ts mode change 100755 => 100644 src/features/markdownPreview/FeatureMarkdownPreviewCard.tsx mode change 100755 => 100644 src/features/markdownPreview/index.ts mode change 100755 => 100644 src/features/overview.md mode change 100755 => 100644 src/features/planBoard/PlanBoardPage.tsx mode change 100755 => 100644 src/features/planBoard/PlanListDetailLayout.tsx mode change 100755 => 100644 src/features/planBoard/PlanSchedulePage.tsx mode change 100755 => 100644 src/features/planBoard/ReleaseReviewPage.tsx mode change 100755 => 100644 src/features/planBoard/api.ts mode change 100755 => 100644 src/features/planBoard/charts.tsx mode change 100755 => 100644 src/features/planBoard/index.ts mode change 100755 => 100644 src/features/planBoard/noteMasking.ts mode change 100755 => 100644 src/features/planBoard/planBoard.css mode change 100755 => 100644 src/features/planBoard/planSchedule.css mode change 100755 => 100644 src/features/planBoard/quickFilters.ts mode change 100755 => 100644 src/features/planBoard/types.ts mode change 100755 => 100644 src/features/serverCommand/ServerCommandPage.tsx mode change 100755 => 100644 src/features/serverCommand/api.ts mode change 100755 => 100644 src/features/serverCommand/index.ts mode change 100755 => 100644 src/features/serverCommand/serverCommand.css mode change 100755 => 100644 src/features/serverCommand/types.ts mode change 100755 => 100644 src/index.ts mode change 100755 => 100644 src/layer/gesture/context/GestureContext.tsx mode change 100755 => 100644 src/layer/gesture/hooks/useGestureLayer.ts mode change 100755 => 100644 src/layer/gesture/index.ts mode change 100755 => 100644 src/layer/gesture/types/index.ts mode change 100755 => 100644 src/layer/gps/context/GpsLayerContext.tsx mode change 100755 => 100644 src/layer/gps/hooks/useGpsLayer.ts mode change 100755 => 100644 src/layer/gps/index.ts mode change 100755 => 100644 src/layer/gps/types/index.ts mode change 100755 => 100644 src/layer/index.ts mode change 100755 => 100644 src/layer/search/context/SearchLayerContext.tsx mode change 100755 => 100644 src/layer/search/hooks/useSearchLayer.ts mode change 100755 => 100644 src/layer/search/index.ts mode change 100755 => 100644 src/layer/search/types/index.ts mode change 100755 => 100644 src/main.tsx mode change 100755 => 100644 src/samples/registry.ts mode change 100755 => 100644 src/store/appStore/context/AppStoreContext.tsx mode change 100755 => 100644 src/store/appStore/hooks/useAppStore.ts mode change 100755 => 100644 src/store/appStore/index.ts mode change 100755 => 100644 src/store/appStore/types/index.ts mode change 100755 => 100644 src/store/index.ts mode change 100755 => 100644 src/styles.css mode change 100755 => 100644 src/sw.js mode change 100755 => 100644 src/types/component-plugin.ts mode change 100755 => 100644 src/views/play/LayoutPlaygroundView.css mode change 100755 => 100644 src/views/play/LayoutPlaygroundView.tsx mode change 100755 => 100644 src/views/play/layoutStorage.ts mode change 100755 => 100644 src/vite-env.d.ts mode change 100755 => 100644 src/widgets/api-sample-card/ApiSampleCardWidget.tsx mode change 100755 => 100644 src/widgets/api-sample-card/index.ts mode change 100755 => 100644 src/widgets/api-sample-card/samples/Sample.tsx mode change 100755 => 100644 src/widgets/core/WidgetShell.tsx mode change 100755 => 100644 src/widgets/core/index.ts mode change 100755 => 100644 src/widgets/core/registry/widget-features.ts mode change 100755 => 100644 src/widgets/core/types/widget.ts mode change 100755 => 100644 src/widgets/dashboard-report-card/DashboardReportCardWidget.tsx mode change 100755 => 100644 src/widgets/dashboard-report-card/index.ts mode change 100755 => 100644 src/widgets/dashboard-report-card/samples/TmsDeliveryFlowSample.tsx mode change 100755 => 100644 src/widgets/dashboard-report-card/samples/TmsDeliveryMetricsSample.tsx mode change 100755 => 100644 src/widgets/dashboard-report-card/samples/WmsInboundOutboundSample.tsx mode change 100755 => 100644 src/widgets/dashboard-report-card/samples/WmsInventoryTrendSample.tsx mode change 100755 => 100644 src/widgets/gps-sample-card/GpsSampleWidget.css mode change 100755 => 100644 src/widgets/gps-sample-card/GpsSampleWidget.tsx mode change 100755 => 100644 src/widgets/gps-sample-card/index.ts mode change 100755 => 100644 src/widgets/gps-sample-card/samples/Sample.tsx mode change 100755 => 100644 src/widgets/registry.ts mode change 100755 => 100644 src/widgets/text-memo-widget/TextMemoWidget.css mode change 100755 => 100644 src/widgets/text-memo-widget/TextMemoWidget.tsx mode change 100755 => 100644 src/widgets/text-memo-widget/index.ts mode change 100755 => 100644 src/widgets/text-memo-widget/samples/Sample.tsx diff --git a/.gitignore b/.gitignore index 9871cb5..e8ea3a8 100755 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ vite.config.d.ts public/.codex_chat .server-command-runner-heartbeat.json docs/assets/worklogs/ +resource/Codex Live/ +resource/To-Do List/ diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 4373d54..9a92a9b --- a/README.md +++ b/README.md @@ -20,14 +20,16 @@ npm run dev ## 확인용 Preview 컨테이너 -실제 반영 화면을 확인할 때는 바인드 마운트 없이 별도 이미지로 빌드하는 `docker-compose.preview.yml`을 사용합니다. +실제 반영 화면을 확인할 때는 별도 preview 컨테이너를 사용합니다. ```bash docker compose -f docker-compose.preview.yml up -d --build ``` -- 기본 접속 주소: `http://127.0.0.1:4173` -- 소스 코드는 이미지 빌드 시점에 복사되므로, 로컬 파일 변경이 컨테이너에 바로 섞이지 않습니다. +- 로컬 preview 컨테이너 접속 주소: `http://127.0.0.1:4173` +- 외부 검증 도메인: `https://preview.sm-home.cloud/` +- preview 컨테이너는 현재 프로젝트 루트를 바인드 마운트하고 `vite build --watch`로 정적 산출물을 자동 재빌드합니다. +- 따라서 `https://preview.sm-home.cloud/`에서는 Vite HMR처럼 즉시 DOM이 바뀌지는 않지만, 소스 저장 후 재빌드가 끝나면 브라우저 새로고침만으로 최신 화면을 확인할 수 있습니다. - API 프록시는 기본적으로 호스트의 `3100` 포트를 `host.docker.internal`로 바라봅니다. - 포트나 API 대상 변경이 필요하면 `PREVIEW_APP_PORT`, `WORK_SERVER_URL` 환경변수를 사용합니다. @@ -35,7 +37,8 @@ docker compose -f docker-compose.preview.yml up -d --build - 테스트용 컨테이너는 현재 `ai-code-app-preview` 하나만 유지합니다. - 이 컨테이너는 채팅 전용 테스트가 아니라 **현재 프로젝트 루트 기준 화면/기능 확인용 테스트 컨테이너**로 사용합니다. -- `https://test.sm-home.cloud/` 운영 기준은 `화면 / -> 5174 앱 테스트 서버`, `/api/` 및 `/ws/chat` -> `127.0.0.1:3100 work-server` 입니다. +- 운영 프록시 확인은 `https://test.sm-home.cloud/` 기준으로 유지합니다. +- 소스 변경 검증과 최종 화면 확인은 `https://preview.sm-home.cloud/` 기준으로 진행합니다. - 위 프록시 기준은 임의로 바꾸지 말고, 변경이 필요하면 반드시 운영 목적을 먼저 문서에 남깁니다. - 임시 테스트 컨테이너를 추가로 띄우지 말고, 필요 시 기존 `ai-code-app-preview`만 재기동합니다. - 재기동은 다른 서비스까지 건드리지 않고 아래 명령으로 해당 컨테이너만 처리합니다. diff --git a/docker-compose.yml b/docker-compose.yml old mode 100755 new mode 100644 diff --git a/docs/README.md b/docs/README.md old mode 100755 new mode 100644 diff --git a/docs/components/check-combo.md b/docs/components/check-combo.md old mode 100755 new mode 100644 diff --git a/docs/components/codex-diff-previewer.md b/docs/components/codex-diff-previewer.md old mode 100755 new mode 100644 diff --git a/docs/components/evidence-attachment-strip-ui.md b/docs/components/evidence-attachment-strip-ui.md old mode 100755 new mode 100644 diff --git a/docs/components/input.md b/docs/components/input.md old mode 100755 new mode 100644 diff --git a/docs/components/popup.md b/docs/components/popup.md old mode 100755 new mode 100644 diff --git a/docs/components/previewer-ui.md b/docs/components/previewer-ui.md old mode 100755 new mode 100644 diff --git a/docs/components/process-flow-ui.md b/docs/components/process-flow-ui.md old mode 100755 new mode 100644 diff --git a/docs/components/search-command.md b/docs/components/search-command.md old mode 100755 new mode 100644 diff --git a/docs/components/select.md b/docs/components/select.md old mode 100755 new mode 100644 diff --git a/docs/components/status-badge.md b/docs/components/status-badge.md old mode 100755 new mode 100644 diff --git a/docs/components/stepper.md b/docs/components/stepper.md old mode 100755 new mode 100644 diff --git a/docs/components/window-ui.md b/docs/components/window-ui.md old mode 100755 new mode 100644 diff --git a/docs/features/plan-automation.md b/docs/features/plan-automation.md old mode 100755 new mode 100644 diff --git a/docs/features/plan-board-review.md b/docs/features/plan-board-review.md old mode 100755 new mode 100644 diff --git a/docs/features/plan-schedule.md b/docs/features/plan-schedule.md old mode 100755 new mode 100644 diff --git a/docs/features/plan-usage.md b/docs/features/plan-usage.md old mode 100755 new mode 100644 diff --git a/docs/features/search-layer.md b/docs/features/search-layer.md old mode 100755 new mode 100644 diff --git a/docs/templates/feature-template.md b/docs/templates/feature-template.md old mode 100755 new mode 100644 diff --git a/docs/templates/worklog-template.md b/docs/templates/worklog-template.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-03-30.md b/docs/worklogs/2026-03-30.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-03-31.md b/docs/worklogs/2026-03-31.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-01.md b/docs/worklogs/2026-04-01.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-02.md b/docs/worklogs/2026-04-02.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-03.md b/docs/worklogs/2026-04-03.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-04.md b/docs/worklogs/2026-04-04.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-05.md b/docs/worklogs/2026-04-05.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-06.md b/docs/worklogs/2026-04-06.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-07.md b/docs/worklogs/2026-04-07.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-08.md b/docs/worklogs/2026-04-08.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-09.md b/docs/worklogs/2026-04-09.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-10.md b/docs/worklogs/2026-04-10.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-04-11.md b/docs/worklogs/2026-04-11.md old mode 100755 new mode 100644 diff --git a/docs/worklogs/2026-05-13.md b/docs/worklogs/2026-05-13.md new file mode 100644 index 0000000..13d5e9a --- /dev/null +++ b/docs/worklogs/2026-05-13.md @@ -0,0 +1,34 @@ +# 2026-05-13 작업일지 + +## 오늘 작업 + +- 화면 캡처 추가 예정 + +## 스크린샷 + +![feature-chat-live](../assets/worklogs/2026-05-13/feature-chat-live.png) + +## 소스 + +### 파일 1: `path/to/file.tsx` + +- 변경 목적과 핵심 수정 내용을 한 줄로 정리 + +```diff +# 이 파일의 핵심 diff +- before ++ after +``` + +### 파일 2: `path/to/another-file.ts` + +- 필요 없으면 이 섹션은 삭제 + +## 실행 커맨드 + +```bash +``` + +## 변경 파일 + +- diff --git a/etc/commands/server-command/restart-rel.sh b/etc/commands/server-command/restart-rel.sh old mode 100755 new mode 100644 diff --git a/etc/commands/server-command/restart-server-command-runner.sh b/etc/commands/server-command/restart-server-command-runner.sh old mode 100755 new mode 100644 diff --git a/etc/commands/server-command/restart-test.sh b/etc/commands/server-command/restart-test.sh old mode 100755 new mode 100644 index db7861e..715cc96 --- a/etc/commands/server-command/restart-test.sh +++ b/etc/commands/server-command/restart-test.sh @@ -7,10 +7,21 @@ SERVER_COMMAND_COMPOSE_FILE="${SERVER_COMMAND_COMPOSE_FILE:-$MAIN_PROJECT_ROOT/d SERVER_COMMAND_SERVICE="${SERVER_COMMAND_SERVICE:-app}" SERVER_COMMAND_CONTAINER_NAME="${SERVER_COMMAND_CONTAINER_NAME:-ai-code-app-app-1}" SERVER_COMMAND_DOCKER_SOCKET="${SERVER_COMMAND_DOCKER_SOCKET:-/var/run/docker.sock}" +SERVER_COMMAND_TEST_GIT_REMOTE="${SERVER_COMMAND_TEST_GIT_REMOTE:-origin}" +SERVER_COMMAND_TEST_GIT_BRANCH="${SERVER_COMMAND_TEST_GIT_BRANCH:-main}" SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) cd "$MAIN_PROJECT_ROOT" +git fetch "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH" + +if git show-ref --verify --quiet "refs/remotes/$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH"; then + git switch "$SERVER_COMMAND_TEST_GIT_BRANCH" 2>/dev/null \ + || git switch -C "$SERVER_COMMAND_TEST_GIT_BRANCH" "$SERVER_COMMAND_TEST_GIT_REMOTE/$SERVER_COMMAND_TEST_GIT_BRANCH" +fi + +git pull --ff-only "$SERVER_COMMAND_TEST_GIT_REMOTE" "$SERVER_COMMAND_TEST_GIT_BRANCH" + if command -v docker >/dev/null 2>&1; then exec docker compose -f "$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVER_COMMAND_SERVICE" fi diff --git a/etc/commands/server-command/restart-via-docker-socket.mjs b/etc/commands/server-command/restart-via-docker-socket.mjs old mode 100755 new mode 100644 diff --git a/etc/commands/server-command/restart-work-server.sh b/etc/commands/server-command/restart-work-server.sh old mode 100755 new mode 100644 diff --git a/etc/db/work-db/README.md b/etc/db/work-db/README.md old mode 100755 new mode 100644 diff --git a/etc/db/work-db/docker-compose.yml b/etc/db/work-db/docker-compose.yml old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/README.md b/etc/servers/work-server/README.md index 18aa931..45edfa0 100644 --- a/etc/servers/work-server/README.md +++ b/etc/servers/work-server/README.md @@ -61,7 +61,7 @@ npm run server-command:runner `Codex Live`와 `Plan` 자동화는 별개입니다. `Codex Live`는 채팅 유형 context를 최우선 기준으로 읽고, 현재 화면 및 최근 대화 문맥은 그 아래 보조 문맥으로 사용합니다. 자동화 유형 context는 기본 문맥으로 섞지 않습니다. -브라우저 기준 접속 확인, 화면 검증, 외부 도메인 테스트는 **`https://test.sm-home.cloud/`를 기본 작업 도메인으로 사용**합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다. +브라우저 기준 운영 접속 확인은 **`https://test.sm-home.cloud/`**, 소스 변경 검증과 최종 화면 테스트는 **`https://preview.sm-home.cloud/`** 기준으로 진행합니다. 별도 요청이 없는 한 `sm-home.cloud`나 `rel.sm-home.cloud`는 기본 검증 대상으로 삼지 않습니다. 채팅에서 파일, 문서, 이미지, 코드 같은 리소스를 제공할 때의 기본 공개 경로는 `public/.codex_chat//resource/...`입니다. Codex가 원본 파일 경로만 답해도 서버가 이 위치로 세션 전용 사본을 만들고, 채팅에는 공개 URL을 다시 적어 줍니다. diff --git a/etc/servers/work-server/package-lock.json b/etc/servers/work-server/package-lock.json old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/scripts/container-supervisor.sh b/etc/servers/work-server/scripts/container-supervisor.sh old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/scripts/write-build-info.mjs b/etc/servers/work-server/scripts/write-build-info.mjs old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/app.ts b/etc/servers/work-server/src/app.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/config/env.js b/etc/servers/work-server/src/config/env.js index af38a39..1b739c9 100644 --- a/etc/servers/work-server/src/config/env.js +++ b/etc/servers/work-server/src/config/env.js @@ -82,7 +82,7 @@ var envSchema = zod_1.z.object({ SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: zod_1.z.string().default('/api/server-commands/{key}/actions/restart'), SERVER_COMMAND_PROJECT_ROOT: zod_1.z.string().default(node_path_1.default.resolve(process.cwd(), '../../..')), SERVER_COMMAND_MAIN_PROJECT_ROOT: zod_1.z.string().default('/workspace/main-project'), - SERVER_COMMAND_TEST_URL: zod_1.z.string().default('https://test.sm-home.cloud/'), + SERVER_COMMAND_TEST_URL: zod_1.z.string().default('https://preview.sm-home.cloud/'), SERVER_COMMAND_REL_URL: zod_1.z.string().default('https://rel.sm-home.cloud/'), SERVER_COMMAND_PROD_URL: zod_1.z.string().default('https://sm-home.cloud/'), SERVER_COMMAND_WORK_SERVER_URL: zod_1.z.string().default('http://127.0.0.1:3100/health'), diff --git a/etc/servers/work-server/src/config/env.ts b/etc/servers/work-server/src/config/env.ts index 4203e02..d170695 100644 --- a/etc/servers/work-server/src/config/env.ts +++ b/etc/servers/work-server/src/config/env.ts @@ -69,7 +69,7 @@ const envSchema = z.object({ SERVER_COMMAND_API_RESTART_PATH_TEMPLATE: z.string().default('/api/server-commands/{key}/actions/restart'), SERVER_COMMAND_PROJECT_ROOT: z.string().default(path.resolve(process.cwd(), '../../..')), SERVER_COMMAND_MAIN_PROJECT_ROOT: z.string().default('/workspace/main-project'), - SERVER_COMMAND_TEST_URL: z.string().default('https://test.sm-home.cloud/'), + SERVER_COMMAND_TEST_URL: z.string().default('https://preview.sm-home.cloud/'), SERVER_COMMAND_REL_URL: z.string().default('https://rel.sm-home.cloud/'), SERVER_COMMAND_PROD_URL: z.string().default('https://sm-home.cloud/'), SERVER_COMMAND_WORK_SERVER_URL: z.string().default('http://127.0.0.1:3100/health'), diff --git a/etc/servers/work-server/src/db/client.ts b/etc/servers/work-server/src/db/client.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/json-body.ts b/etc/servers/work-server/src/json-body.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/lib/identifier.ts b/etc/servers/work-server/src/lib/identifier.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/not-found.test.ts b/etc/servers/work-server/src/not-found.test.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/not-found.ts b/etc/servers/work-server/src/not-found.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/app-config.ts b/etc/servers/work-server/src/routes/app-config.ts old mode 100755 new mode 100644 index 9729289..0257d40 --- a/etc/servers/work-server/src/routes/app-config.ts +++ b/etc/servers/work-server/src/routes/app-config.ts @@ -111,13 +111,12 @@ export async function registerAppConfigRoutes(app: FastifyInstance) { const parsed = z .object({ chatTypes: z.array(z.unknown()).optional(), - customChatTypes: z.array(z.unknown()).optional(), }) .parse(payload ?? {}); const appOrigin = getRequestAppOrigin(request); const appDomain = getRequestAppDomain(request); - const targetChatTypes = parsed.customChatTypes ?? parsed.chatTypes ?? []; + const targetChatTypes = parsed.chatTypes ?? []; const savedChatTypeConfig = await upsertChatTypesConfig(targetChatTypes, appOrigin, appDomain); return { diff --git a/etc/servers/work-server/src/routes/board.ts b/etc/servers/work-server/src/routes/board.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts old mode 100755 new mode 100644 index 7529ba4..cd2ffbc --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -16,6 +16,7 @@ import { deleteChatConversation, ensureChatConversationTables, getChatConversation, + listChatSourceChangeSnapshots, listChatConversationDetailPage, listChatConversations, markChatConversationResponsesRead, @@ -205,6 +206,21 @@ export async function registerChatRoutes(app: FastifyInstance) { }; }); + app.get('/api/chat/source-changes', async (request) => { + const query = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional(), + }).parse(request.query ?? {}); + + const viewerClientId = getClientIdHeader(request); + const clientId = canViewAllConversations(request) ? null : viewerClientId; + const items = await listChatSourceChangeSnapshots(clientId, query.limit ?? 300); + + return { + ok: true, + items, + }; + }); + app.get('/api/chat/runtime', async () => { return { ok: true, @@ -380,6 +396,7 @@ export async function registerChatRoutes(app: FastifyInstance) { const payload = z.object({ sessionId: z.string().trim().min(1).max(120), title: z.string().trim().max(200).optional(), + requestBadgeLabel: z.string().trim().max(120).optional().nullable(), chatTypeId: z.string().trim().max(120).nullable().optional(), lastChatTypeId: z.string().trim().max(120).nullable().optional(), generalSectionName: z.string().trim().max(120).optional().nullable(), @@ -393,6 +410,7 @@ export async function registerChatRoutes(app: FastifyInstance) { sessionId: payload.sessionId, clientId: clientId || null, title: payload.title ?? '새 대화', + requestBadgeLabel: payload.requestBadgeLabel ?? null, chatTypeId: payload.chatTypeId ?? null, lastChatTypeId: payload.lastChatTypeId ?? payload.chatTypeId ?? null, generalSectionName: payload.generalSectionName ?? null, @@ -509,6 +527,7 @@ export async function registerChatRoutes(app: FastifyInstance) { }).parse(request.params ?? {}); const payload = z.object({ title: z.string().trim().min(1).max(200).optional(), + requestBadgeLabel: z.string().trim().max(120).optional().nullable(), chatTypeId: z.string().trim().max(120).optional().nullable(), lastChatTypeId: z.string().trim().max(120).optional().nullable(), generalSectionName: z.string().trim().max(120).optional().nullable(), @@ -528,12 +547,22 @@ export async function registerChatRoutes(app: FastifyInstance) { const item = await updateChatConversationContext(params.sessionId, { title: payload.title ?? current.title, + requestBadgeLabel: + Object.prototype.hasOwnProperty.call(payload, 'requestBadgeLabel') ? payload.requestBadgeLabel ?? null : undefined, clientId: current.clientId, - chatTypeId: payload.chatTypeId ?? current.chatTypeId, - lastChatTypeId: payload.lastChatTypeId ?? current.lastChatTypeId ?? current.chatTypeId, - generalSectionName: payload.generalSectionName ?? current.generalSectionName, - contextLabel: payload.contextLabel ?? current.contextLabel, - contextDescription: payload.contextDescription ?? current.contextDescription, + chatTypeId: Object.prototype.hasOwnProperty.call(payload, 'chatTypeId') ? payload.chatTypeId ?? null : undefined, + lastChatTypeId: + Object.prototype.hasOwnProperty.call(payload, 'lastChatTypeId') ? payload.lastChatTypeId ?? null : undefined, + generalSectionName: + Object.prototype.hasOwnProperty.call(payload, 'generalSectionName') + ? payload.generalSectionName ?? null + : undefined, + contextLabel: + Object.prototype.hasOwnProperty.call(payload, 'contextLabel') ? payload.contextLabel ?? null : undefined, + contextDescription: + Object.prototype.hasOwnProperty.call(payload, 'contextDescription') + ? payload.contextDescription ?? null + : undefined, notifyOffline: payload.notifyOffline ?? current.notifyOffline, }); diff --git a/etc/servers/work-server/src/routes/compatibility.test.ts b/etc/servers/work-server/src/routes/compatibility.test.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/crud.ts b/etc/servers/work-server/src/routes/crud.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/ddl.ts b/etc/servers/work-server/src/routes/ddl.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/error-log.ts b/etc/servers/work-server/src/routes/error-log.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/health.ts b/etc/servers/work-server/src/routes/health.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/notification.ts b/etc/servers/work-server/src/routes/notification.ts old mode 100755 new mode 100644 index 13eaf60..50eb5c5 --- a/etc/servers/work-server/src/routes/notification.ts +++ b/etc/servers/work-server/src/routes/notification.ts @@ -1,21 +1,16 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { - listIosNotificationTokens, listWebPushSubscriptions, getAutomationNotificationPreference, getWebPushConfig, - registerIosNotificationToken, registerAutomationNotificationPreferenceSchema, - registerIosTokenSchema, registerWebPushSubscription, registerWebPushSubscriptionSchema, sendNotifications, sendIosNotificationSchema, setupNotificationTables, upsertAutomationNotificationPreference, - unregisterIosNotificationToken, - unregisterIosTokenSchema, unregisterWebPushSubscription, unregisterWebPushSubscriptionSchema, } from '../services/notification-service.js'; @@ -31,14 +26,10 @@ import { } from '../services/notification-message-service.js'; const automationNotificationPreferenceQuerySchema = z.object({ - targetKind: z.enum(['client', 'ios-token', 'ios-token-client', 'web-endpoint']).optional(), + targetKind: z.enum(['client', 'web-endpoint']).optional(), targetId: z.string().trim().min(1).max(1000).optional(), }); -type AutomationNotificationPreferenceTargetKind = NonNullable< - z.infer['targetKind'] ->; - function getClientIdHeader(request: { headers: Record }) { const rawClientId = request.headers['x-client-id']; const clientId = Array.isArray(rawClientId) ? rawClientId[0] : rawClientId; @@ -48,10 +39,6 @@ function getClientIdHeader(request: { headers: Record setupNotificationTables()); - app.get('/api/notifications/tokens', async () => ({ - items: await listIosNotificationTokens(), - })); - app.get('/api/notifications/subscriptions/web', async () => ({ items: await listWebPushSubscriptions(), })); @@ -60,9 +47,10 @@ export async function registerNotificationRoutes(app: FastifyInstance) { app.get('/api/notifications/messages', async (request) => { const query = notificationMessageListQuerySchema.parse(request.query ?? {}); + const clientId = getClientIdHeader(request); return { ok: true, - ...(await listNotificationMessages(query)), + ...(await listNotificationMessages(query, clientId)), }; }); @@ -130,7 +118,7 @@ export async function registerNotificationRoutes(app: FastifyInstance) { return { ok: true, - automation: await getAutomationNotificationPreferenceWithFallback(targetId, targetKind), + automation: await getAutomationNotificationPreference(targetId, targetKind), }; }); @@ -154,16 +142,6 @@ export async function registerNotificationRoutes(app: FastifyInstance) { } }); - app.put('/api/notifications/tokens/ios', async (request) => { - const payload = registerIosTokenSchema.parse(request.body ?? {}); - return registerIosNotificationToken(payload); - }); - - app.delete('/api/notifications/tokens/ios', async (request) => { - const payload = unregisterIosTokenSchema.parse(request.body ?? {}); - return unregisterIosNotificationToken(payload.token); - }); - app.put('/api/notifications/subscriptions/web', async (request) => { const payload = registerWebPushSubscriptionSchema.parse(request.body ?? {}); return registerWebPushSubscription(payload); @@ -184,29 +162,3 @@ export async function registerNotificationRoutes(app: FastifyInstance) { return sendNotifications(payload); }); } -async function getAutomationNotificationPreferenceWithFallback( - targetId: string, - targetKind: AutomationNotificationPreferenceTargetKind, -) { - const automation = await getAutomationNotificationPreference(targetId, targetKind); - - if (automation || targetKind !== 'ios-token-client') { - return automation; - } - - const [token, clientId] = targetId.split('::client::'); - - if (token?.trim()) { - const tokenAutomation = await getAutomationNotificationPreference(token.trim(), 'ios-token'); - - if (tokenAutomation) { - return tokenAutomation; - } - } - - if (clientId?.trim()) { - return getAutomationNotificationPreference(clientId.trim(), 'client'); - } - - return null; -} diff --git a/etc/servers/work-server/src/routes/plan.ts b/etc/servers/work-server/src/routes/plan.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/schema.ts b/etc/servers/work-server/src/routes/schema.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/routes/server-command.ts b/etc/servers/work-server/src/routes/server-command.ts old mode 100755 new mode 100644 index bc9b4bf..cf237a2 --- a/etc/servers/work-server/src/routes/server-command.ts +++ b/etc/servers/work-server/src/routes/server-command.ts @@ -19,6 +19,36 @@ const restartReservationBodySchema = z.object({ autoExecuteDelaySeconds: z.number().int().min(1).max(300).optional(), }); +function getImmediateRestartBlockInfo( + key: z.infer['key'], + workloadSummary: Awaited>, +) { + const codexPendingCount = workloadSummary.codexRunningCount + workloadSummary.codexQueuedCount; + const automationPendingCount = workloadSummary.automationRunningCount + workloadSummary.automationQueuedCount; + + if (key === 'test') { + const pendingCount = codexPendingCount + automationPendingCount; + + if (pendingCount > 0) { + return { + pendingCount, + message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`, + }; + } + + return null; + } + + if (key === 'work-server' && automationPendingCount > 0) { + return { + pendingCount: automationPendingCount, + message: `진행 중인 자동화 작업 ${automationPendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`, + }; + } + + return null; +} + function getRequestAccessToken(request: FastifyRequest) { const tokenHeader = request.headers['x-access-token']; return Array.isArray(tokenHeader) ? tokenHeader[0]?.trim() ?? '' : String(tokenHeader ?? '').trim(); @@ -75,17 +105,13 @@ export async function registerServerCommandRoutes(app: FastifyInstance) { if (key === 'test' || key === 'work-server') { const workloadSummary = await getRestartReservationWorkloadSummary(); - const pendingCount = - workloadSummary.codexRunningCount - + workloadSummary.codexQueuedCount - + workloadSummary.automationRunningCount - + workloadSummary.automationQueuedCount; + const blockInfo = getImmediateRestartBlockInfo(key, workloadSummary); - if (pendingCount > 0) { + if (blockInfo) { reply.status(409); return { ok: false, - message: `진행 중인 Codex Live/자동화 작업 ${pendingCount}건이 있어 즉시 재기동할 수 없습니다. 재기동 예약을 사용해 주세요.`, + message: blockInfo.message, workloadSummary, }; } diff --git a/etc/servers/work-server/src/routes/visitor-history.ts b/etc/servers/work-server/src/routes/visitor-history.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/server.ts b/etc/servers/work-server/src/server.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/app-config-service.js b/etc/servers/work-server/src/services/app-config-service.js index 165da13..2c1ae92 100644 --- a/etc/servers/work-server/src/services/app-config-service.js +++ b/etc/servers/work-server/src/services/app-config-service.js @@ -50,16 +50,16 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.APP_CONFIG_TABLE = void 0; exports.resolveAppConfigByOrigin = resolveAppConfigByOrigin; exports.getAppConfig = getAppConfig; -exports.mergeDefaultChatTypes = mergeDefaultChatTypes; +exports.sanitizePersistedChatTypes = sanitizePersistedChatTypes; exports.normalizeAppConfigSnapshot = normalizeAppConfigSnapshot; exports.getAppConfigSnapshot = getAppConfigSnapshot; exports.upsertAppConfig = upsertAppConfig; exports.getChatTypesConfig = getChatTypesConfig; exports.upsertChatTypesConfig = upsertChatTypesConfig; var client_js_1 = require("../db/client.js"); -var chat_type_defaults_js_1 = require("./chat-type-defaults.js"); exports.APP_CONFIG_TABLE = 'app_configs'; var CHAT_TYPES_CONFIG_KEY = 'chatTypes'; +var CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings'; var SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs'; var DEFAULT_CHAT_APP_CONFIG = { maxContextMessages: 12, @@ -67,6 +67,7 @@ var DEFAULT_CHAT_APP_CONFIG = { codexLiveMaxExecutionSeconds: 600, codexLiveIdleTimeoutSeconds: 180, receiveRoomNotifications: true, + restartReservationCompletionDelaySeconds: 10, }; function ensureAppConfigTable() { return __awaiter(this, void 0, void 0, function () { @@ -251,6 +252,13 @@ function normalizePermissions(value) { var permissions = Array.from(new Set(value.filter(function (item) { return item === 'guest' || item === 'token-user'; }))); return permissions.length > 0 ? permissions : ['token-user']; } +function normalizePositiveSortOrder(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return Number.NaN; + } + var nextValue = Math.trunc(value); + return nextValue > 0 ? nextValue : Number.NaN; +} function normalizeChatTypeRecord(value) { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; @@ -263,6 +271,7 @@ function normalizeChatTypeRecord(value) { return { id: normalizeText(record.id) || "chat-type-".concat(Date.now().toString(36), "-").concat(Math.random().toString(36).slice(2, 8)), name: name, + sortOrder: normalizePositiveSortOrder(record.sortOrder), description: normalizeText(record.description), permissions: normalizePermissions(record.permissions), enabled: record.enabled !== false, @@ -301,19 +310,29 @@ function sanitizeChatTypes(items) { bySemanticKey.set(semanticKey, item); } } - return Array.from(bySemanticKey.values()).sort(function (left, right) { return left.name.localeCompare(right.name, 'ko-KR'); }); -} -function mergeDefaultChatTypes(items) { - var savedItems = sanitizeChatTypes(items); - var byId = new Map(savedItems.map(function (item) { return [item.id, item]; })); - for (var _i = 0, DEFAULT_CHAT_TYPES_1 = chat_type_defaults_js_1.DEFAULT_CHAT_TYPES; _i < DEFAULT_CHAT_TYPES_1.length; _i++) { - var defaultItem = DEFAULT_CHAT_TYPES_1[_i]; - var savedItem = byId.get(defaultItem.id); - if (!savedItem) { - byId.set(defaultItem.id, defaultItem); + return Array.from(bySemanticKey.values()) + .sort(function (left, right) { + var leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null; + var rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null; + if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) { + return leftSortOrder - rightSortOrder; } - } - return sanitizeChatTypes(Array.from(byId.values())); + if (leftSortOrder !== null && rightSortOrder === null) { + return -1; + } + if (leftSortOrder === null && rightSortOrder !== null) { + return 1; + } + var nameCompare = left.name.localeCompare(right.name, 'ko-KR'); + if (nameCompare !== 0) { + return nameCompare; + } + return compareUpdatedAt(left, right); + }) + .map(function (item, index) { return (__assign(__assign({}, item), { sortOrder: index + 1 })); }); +} +function sanitizePersistedChatTypes(items) { + return sanitizeChatTypes(items); } function isSameChatTypeList(left, right) { if (left.length !== right.length) { @@ -324,6 +343,7 @@ function isSameChatTypeList(left, right) { return (target && item.id === target.id && item.name === target.name && + item.sortOrder === target.sortOrder && item.description === target.description && item.enabled === target.enabled && item.updatedAt === target.updatedAt && @@ -400,8 +420,7 @@ function upsertAppConfig(config, appOrigin, appDomain) { } function getChatTypesConfig(appOrigin) { return __awaiter(this, void 0, void 0, function () { - var config, normalized, chatTypes, savedChatTypes, mergedChatTypes; - var _a; + var config, normalized, chatTypes, savedChatTypes, resolvedChatTypes; return __generator(this, function (_b) { switch (_b.label) { case 0: return [4 /*yield*/, getAppConfig(appOrigin)]; @@ -413,15 +432,10 @@ function getChatTypesConfig(appOrigin) { return [2 /*return*/, null]; } savedChatTypes = Array.isArray(chatTypes) ? sanitizeChatTypes(chatTypes) : []; - mergedChatTypes = mergeDefaultChatTypes(savedChatTypes); - if (!!isSameChatTypeList(savedChatTypes, mergedChatTypes)) return [3 /*break*/, 3]; - return [4 /*yield*/, upsertAppConfig((_a = {}, - _a[CHAT_TYPES_CONFIG_KEY] = mergedChatTypes, - _a), appOrigin)]; - case 2: - _b.sent(); - _b.label = 3; - case 3: return [2 /*return*/, mergedChatTypes]; + resolvedChatTypes = sanitizePersistedChatTypes(savedChatTypes); + return [2 /*return*/, { + chatTypes: resolvedChatTypes, + }]; } }); }); @@ -437,12 +451,14 @@ function upsertChatTypesConfig(chatTypes, appOrigin, appDomain) { return [4 /*yield*/, getAppConfig(appOrigin)]; case 1: current = _a.apply(void 0, [_c.sent()]); - resolvedChatTypes = mergeDefaultChatTypes(chatTypes); + resolvedChatTypes = sanitizePersistedChatTypes(chatTypes); nextConfig = __assign(__assign({}, current), (_b = {}, _b[CHAT_TYPES_CONFIG_KEY] = resolvedChatTypes, _b)); return [4 /*yield*/, upsertAppConfig(nextConfig, appOrigin, appDomain)]; case 2: _c.sent(); - return [2 /*return*/, resolvedChatTypes]; + return [2 /*return*/, { + chatTypes: resolvedChatTypes, + }]; } }); }); diff --git a/etc/servers/work-server/src/services/app-config-service.test.ts b/etc/servers/work-server/src/services/app-config-service.test.ts index 9548d0e..b89e72a 100644 --- a/etc/servers/work-server/src/services/app-config-service.test.ts +++ b/etc/servers/work-server/src/services/app-config-service.test.ts @@ -1,20 +1,21 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - mergeDefaultChatTypes, migrateLegacyChatTypeContexts, - stripBuiltInChatTypes, + sanitizePersistedChatTypes, resolveAppConfigByOrigin, resolveCanonicalChatTypesFromConfig, resolveCanonicalChatContextSettingsFromConfig, stripChatContextSettingsFromScopedAppConfigs, + stripSharedContextDataFromScopedAppConfigs, } from './app-config-service.js'; -test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () => { - const merged = mergeDefaultChatTypes([ +test('sanitizePersistedChatTypes keeps saved chat type edits as-is', () => { + const merged = sanitizePersistedChatTypes([ { id: 'general-request', name: '일반 요청', + sortOrder: 3, description: '사용자가 수정한 일반 요청 문맥', permissions: ['guest', 'token-user'], enabled: true, @@ -27,13 +28,15 @@ test('mergeDefaultChatTypes preserves saved edits for built-in chat types', () = assert.ok(generalRequest); assert.equal(generalRequest.description, '사용자가 수정한 일반 요청 문맥'); assert.deepEqual(generalRequest.permissions, ['guest', 'token-user']); + assert.equal(generalRequest.sortOrder, 1); }); -test('mergeDefaultChatTypes preserves saved edits for layout editor execution', () => { - const merged = mergeDefaultChatTypes([ +test('sanitizePersistedChatTypes keeps saved layout editor execution entries', () => { + const merged = sanitizePersistedChatTypes([ { id: 'layout-editor-execution', name: 'Layout editor 실행', + sortOrder: 2, description: '호출 가능한 API 요청만 처리합니다.', permissions: ['token-user'], enabled: true, @@ -45,13 +48,15 @@ test('mergeDefaultChatTypes preserves saved edits for layout editor execution', assert.ok(layoutEditorExecution); assert.equal(layoutEditorExecution.description, '호출 가능한 API 요청만 처리합니다.'); + assert.equal(layoutEditorExecution.sortOrder, 1); }); -test('mergeDefaultChatTypes preserves saved edits for guided layout editor execution', () => { - const merged = mergeDefaultChatTypes([ +test('sanitizePersistedChatTypes keeps saved guided layout editor entries', () => { + const merged = sanitizePersistedChatTypes([ { id: 'layout-editor-guided-execution', name: 'Layout editor 단계별 실행', + sortOrder: 4, description: '사용자가 정리한 단계별 Layout 실행 문맥', permissions: ['token-user'], enabled: true, @@ -63,24 +68,48 @@ test('mergeDefaultChatTypes preserves saved edits for guided layout editor execu assert.ok(guidedLayoutEditorExecution); assert.equal(guidedLayoutEditorExecution.description, '사용자가 정리한 단계별 Layout 실행 문맥'); + assert.equal(guidedLayoutEditorExecution.sortOrder, 1); }); -test('mergeDefaultChatTypes still appends missing built-in chat types', () => { - const merged = mergeDefaultChatTypes([]); +test('sanitizePersistedChatTypes returns empty list when nothing is saved', () => { + const merged = sanitizePersistedChatTypes([]); - assert.ok(merged.some((item) => item.id === 'general-request')); - assert.ok(merged.some((item) => item.id === 'layout-editor-execution')); - assert.ok(merged.some((item) => item.id === 'api-request-template')); - assert.ok(merged.some((item) => item.id === 'general-inquiry')); - assert.ok(!merged.some((item) => item.id === 'plan-checklist-execution')); - assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution')); + assert.deepEqual(merged, []); }); -test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () => { - const stripped = stripBuiltInChatTypes([ +test('sanitizePersistedChatTypes keeps saved chat type list without backfilling removed entries', () => { + const merged = sanitizePersistedChatTypes([ { id: 'general-request', name: '일반 요청', + sortOrder: 2, + description: '사용자가 수정한 일반 요청 문맥', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + { + id: 'custom-support-flow', + name: '운영 문의 전용', + sortOrder: 1, + description: 'custom', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + ]); + + assert.ok(!merged.some((item) => item.id === 'layout-editor-guided-execution')); + assert.ok(!merged.some((item) => item.id === 'layout-editor-execution')); + assert.ok(merged.some((item) => item.id === 'custom-support-flow')); +}); + +test('sanitizePersistedChatTypes keeps all saved chat types without special filtering', () => { + const stripped = sanitizePersistedChatTypes([ + { + id: 'general-request', + name: '일반 요청', + sortOrder: 2, description: 'builtin', permissions: ['token-user'], enabled: true, @@ -89,6 +118,7 @@ test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () { id: 'plan-checklist-execution', name: 'Plan 체크리스트 실행', + sortOrder: 3, description: 'custom-seeded', permissions: ['token-user'], enabled: true, @@ -97,6 +127,7 @@ test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () { id: 'custom-support-flow', name: '운영 문의 전용', + sortOrder: 1, description: 'custom', permissions: ['token-user'], enabled: true, @@ -104,7 +135,7 @@ test('stripBuiltInChatTypes removes built-in chat type ids from saved list', () }, ]); - assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'plan-checklist-execution']); + assert.deepEqual(stripped.map((item) => item.id), ['custom-support-flow', 'general-request', 'plan-checklist-execution']); }); test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into default context settings', () => { @@ -124,6 +155,7 @@ test('migrateLegacyChatTypeContexts moves legacy plan checklist chat type into d { id: 'plan-checklist-execution', name: 'Plan 체크리스트 실행', + sortOrder: 1, description: 'legacy plan context', permissions: ['token-user'], enabled: true, @@ -178,7 +210,7 @@ test('resolveAppConfigByOrigin falls back to legacy global config when scoped co receiveRoomNotifications: true, }, }, - 'https://test.sm-home.cloud', + 'https://preview.sm-home.cloud', ) as { chat?: { receiveRoomNotifications?: boolean }; }; @@ -208,7 +240,7 @@ test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context ], }, scopedAppConfigs: { - 'https://test.sm-home.cloud': { + 'https://preview.sm-home.cloud': { config: { chatContextSettings: { defaultContexts: [ @@ -225,7 +257,7 @@ test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context }, }, }, - 'https://test.sm-home.cloud', + 'https://preview.sm-home.cloud', ); assert.deepEqual( @@ -234,11 +266,76 @@ test('resolveCanonicalChatContextSettingsFromConfig prefers global chat context ); }); +test('resolveCanonicalChatContextSettingsFromConfig keeps saved default context sort order and renumbers gaps', () => { + const resolved = resolveCanonicalChatContextSettingsFromConfig({ + chatContextSettings: { + defaultContexts: [ + { + id: 'context-b', + title: 'B 문맥', + sortOrder: 3, + content: 'b', + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + { + id: 'context-a', + title: 'A 문맥', + sortOrder: 1, + content: 'a', + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + ], + }, + }); + + assert.deepEqual( + resolved.defaultContexts.map((item) => ({ id: item.id, sortOrder: item.sortOrder })), + [ + { id: 'context-a', sortOrder: 1 }, + { id: 'context-b', sortOrder: 2 }, + ], + ); +}); + +test('resolveCanonicalChatContextSettingsFromConfig appends unsorted default contexts after sorted entries', () => { + const resolved = resolveCanonicalChatContextSettingsFromConfig({ + chatContextSettings: { + defaultContexts: [ + { + id: 'context-b', + title: 'B 문맥', + content: 'b', + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + { + id: 'context-a', + title: 'A 문맥', + sortOrder: 1, + content: 'a', + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + ], + }, + }); + + assert.deepEqual( + resolved.defaultContexts.map((item) => ({ id: item.id, sortOrder: item.sortOrder })), + [ + { id: 'context-a', sortOrder: 1 }, + { id: 'context-b', sortOrder: 2 }, + ], + ); +}); + test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped settings only when global settings are empty', () => { const resolved = resolveCanonicalChatContextSettingsFromConfig( { scopedAppConfigs: { - 'https://test.sm-home.cloud': { + 'https://preview.sm-home.cloud': { config: { chatContextSettings: { defaultContexts: [ @@ -255,7 +352,7 @@ test('resolveCanonicalChatContextSettingsFromConfig falls back to scoped setting }, }, }, - 'https://test.sm-home.cloud', + 'https://preview.sm-home.cloud', ); assert.deepEqual( @@ -278,7 +375,7 @@ test('resolveCanonicalChatTypesFromConfig merges global chat types with stale sc }, ], scopedAppConfigs: { - 'https://test.sm-home.cloud': { + 'https://preview.sm-home.cloud': { config: { chatTypes: [ { @@ -294,7 +391,7 @@ test('resolveCanonicalChatTypesFromConfig merges global chat types with stale sc }, }, }, - 'https://test.sm-home.cloud', + 'https://preview.sm-home.cloud', ); assert.ok(resolved); @@ -305,7 +402,7 @@ test('resolveCanonicalChatTypesFromConfig merges global chat types with stale sc test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat context settings only', () => { const stripped = stripChatContextSettingsFromScopedAppConfigs({ scopedAppConfigs: { - 'https://test.sm-home.cloud': { + 'https://preview.sm-home.cloud': { config: { chatContextSettings: { defaultContexts: [{ id: 'legacy', title: 'legacy', content: 'legacy', enabled: true }], @@ -321,7 +418,7 @@ test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat con assert.equal(stripped.changed, true); assert.deepEqual(stripped.scopedConfigs, { - 'https://test.sm-home.cloud': { + 'https://preview.sm-home.cloud': { config: { chat: { receiveRoomNotifications: false, @@ -331,3 +428,104 @@ test('stripChatContextSettingsFromScopedAppConfigs removes stale scoped chat con }, }); }); + +test('stripSharedContextDataFromScopedAppConfigs removes scoped chat-type/context data and backs up non-shared origins', () => { + const stripped = stripSharedContextDataFromScopedAppConfigs( + { + scopedAppConfigs: { + 'https://preview.sm-home.cloud': { + config: { + chatTypes: [ + { + id: 'general-request', + name: '일반 요청', + description: 'preview-shared', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + ], + chatContextSettings: { + defaultContexts: [ + { + id: 'preview-context', + title: 'preview', + content: 'shared', + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + ], + }, + chat: { + receiveRoomNotifications: false, + }, + }, + updatedAt: '2026-05-08T00:00:00.000Z', + appDomain: 'preview.sm-home.cloud', + }, + 'https://test.sm-home.cloud': { + config: { + chatTypes: [ + { + id: 'chat-type-test-temp', + name: '임시 유형', + description: 'test-only', + permissions: ['token-user'], + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + ], + chatContextSettings: { + defaultContexts: [ + { + id: 'test-context', + title: 'test', + content: 'legacy', + enabled: true, + updatedAt: '2026-05-08T00:00:00.000Z', + }, + ], + }, + automation: { + notifyOnAutomationStart: true, + }, + }, + updatedAt: '2026-05-08T00:00:00.000Z', + appDomain: 'test.sm-home.cloud', + }, + }, + }, + 'https://preview.sm-home.cloud', + ); + + assert.equal(stripped.changed, true); + assert.deepEqual(stripped.scopedConfigs, { + 'https://preview.sm-home.cloud': { + config: { + chat: { + receiveRoomNotifications: false, + }, + }, + updatedAt: '2026-05-08T00:00:00.000Z', + appDomain: 'preview.sm-home.cloud', + }, + 'https://test.sm-home.cloud': { + config: { + automation: { + notifyOnAutomationStart: true, + }, + }, + updatedAt: '2026-05-08T00:00:00.000Z', + appDomain: 'test.sm-home.cloud', + }, + }); + assert.equal( + Array.isArray(stripped.backups['https://test.sm-home.cloud']?.chatTypes), + true, + ); + assert.equal( + stripped.backups['https://test.sm-home.cloud']?.chatContextSettings?.defaultContexts[0]?.id, + 'test-context', + ); + assert.equal(stripped.backups['https://preview.sm-home.cloud'], undefined); +}); diff --git a/etc/servers/work-server/src/services/app-config-service.ts b/etc/servers/work-server/src/services/app-config-service.ts old mode 100755 new mode 100644 index c83a725..b5c32ff --- a/etc/servers/work-server/src/services/app-config-service.ts +++ b/etc/servers/work-server/src/services/app-config-service.ts @@ -1,6 +1,5 @@ import { db } from '../db/client.js'; import { - DEFAULT_CHAT_TYPES, PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT, PLAN_CHECKLIST_DEFAULT_CONTEXT_ID, PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE, @@ -10,6 +9,8 @@ export const APP_CONFIG_TABLE = 'app_configs'; const CHAT_TYPES_CONFIG_KEY = 'chatTypes'; const CHAT_CONTEXT_SETTINGS_CONFIG_KEY = 'chatContextSettings'; const SCOPED_APP_CONFIGS_KEY = 'scopedAppConfigs'; +const SCOPED_CONTEXT_CONFIG_BACKUPS_KEY = 'scopedContextConfigBackups'; +const SHARED_CHAT_CONTEXT_APP_ORIGIN = 'https://preview.sm-home.cloud'; const DEFAULT_CHAT_APP_CONFIG = { maxContextMessages: 12, maxContextChars: 3200, @@ -24,6 +25,7 @@ type ChatPermissionRole = 'guest' | 'token-user'; type ChatTypeRecord = { id: string; name: string; + sortOrder: number; description: string; permissions: ChatPermissionRole[]; enabled: boolean; @@ -33,14 +35,13 @@ type ChatTypeRecord = { const LEGACY_PLAN_CHECKLIST_CHAT_TYPE_ID = 'plan-checklist-execution'; export type ChatTypesConfigSnapshot = { - builtInChatTypes: ChatTypeRecord[]; - customChatTypes: ChatTypeRecord[]; chatTypes: ChatTypeRecord[]; }; type ChatDefaultContextRecord = { id: string; title: string; + sortOrder: number; content: string; enabled: boolean; updatedAt: string; @@ -66,6 +67,15 @@ export type ChatContextSettingsSnapshot = { roomContexts: ChatRoomContextSettings[]; }; +type ScopedContextConfigBackupEntry = { + sourceOrigin: string; + appDomain: string | null; + updatedAt: string; + backedUpAt: string; + chatTypes?: ChatTypeRecord[]; + chatContextSettings?: ChatContextSettingsSnapshot; +}; + async function ensureAppConfigTable() { const hasTable = await db.schema.hasTable(APP_CONFIG_TABLE); @@ -152,6 +162,16 @@ function getScopedAppConfigEntryRecord(value: unknown) { return normalizeConfigRecord(value); } +function getScopedContextConfigBackupsRecord(value: unknown) { + return normalizeConfigRecord( + normalizeConfigRecord(value)[SCOPED_CONTEXT_CONFIG_BACKUPS_KEY], + ) as Record; +} + +function resolveSharedChatContextAppOrigin() { + return SHARED_CHAT_CONTEXT_APP_ORIGIN; +} + function hasChatContextSettingsSnapshot(value: ChatContextSettingsSnapshot) { return ( value.defaultContexts.length > 0 || @@ -192,6 +212,102 @@ export function stripChatContextSettingsFromScopedAppConfigs(value: unknown) { }; } +function buildScopedContextConfigBackupEntry( + origin: string, + entry: Record, + chatTypes: ChatTypeRecord[], + chatContextSettings: ChatContextSettingsSnapshot, +): ScopedContextConfigBackupEntry | null { + const normalizedOrigin = normalizeAppOrigin(origin) || origin; + const hasChatTypes = chatTypes.length > 0; + const hasChatContextSettings = hasChatContextSettingsSnapshot(chatContextSettings); + + if (!normalizedOrigin || (!hasChatTypes && !hasChatContextSettings)) { + return null; + } + + return { + sourceOrigin: normalizedOrigin, + appDomain: normalizeAppDomain(typeof entry.appDomain === 'string' ? entry.appDomain : null) || null, + updatedAt: normalizeText(entry.updatedAt) || new Date().toISOString(), + backedUpAt: new Date().toISOString(), + ...(hasChatTypes ? { chatTypes } : null), + ...(hasChatContextSettings ? { chatContextSettings } : null), + }; +} + +export function stripSharedContextDataFromScopedAppConfigs( + value: unknown, + canonicalAppOrigin = SHARED_CHAT_CONTEXT_APP_ORIGIN, +): { + changed: boolean; + scopedConfigs: Record; + backups: Record; +} { + const scopedConfigs = getScopedAppConfigsRecord(value); + const existingBackups = getScopedContextConfigBackupsRecord(value); + const normalizedCanonicalOrigin = normalizeAppOrigin(canonicalAppOrigin); + let stripped = false; + let backupChanged = false; + + const nextBackups = { ...existingBackups }; + const sanitizedScopedConfigs = Object.fromEntries( + Object.entries(scopedConfigs).map(([origin, entry]) => { + const normalizedEntry = getScopedAppConfigEntryRecord(entry); + const normalizedConfig = normalizeConfigRecord(normalizedEntry.config); + const scopedChatTypes = Array.isArray(normalizedConfig[CHAT_TYPES_CONFIG_KEY]) + ? sanitizeChatTypes(normalizedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]) + : []; + const scopedChatContextSettings = sanitizeChatContextSettings( + normalizedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY], + ); + const isCanonicalOrigin = + normalizedCanonicalOrigin && normalizeAppOrigin(origin) === normalizedCanonicalOrigin; + + if (!isCanonicalOrigin) { + const backupEntry = buildScopedContextConfigBackupEntry( + origin, + normalizedEntry, + scopedChatTypes, + scopedChatContextSettings, + ); + + if (backupEntry) { + nextBackups[origin] = backupEntry; + backupChanged = true; + } + } + + if ( + CHAT_TYPES_CONFIG_KEY in normalizedConfig || + CHAT_CONTEXT_SETTINGS_CONFIG_KEY in normalizedConfig + ) { + stripped = true; + } + + const { + [CHAT_TYPES_CONFIG_KEY]: _removedChatTypes, + [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: _removedChatContextSettings, + ...nextConfig + } = normalizedConfig; + + return [ + origin, + { + ...normalizedEntry, + config: nextConfig, + }, + ]; + }), + ); + + return { + changed: stripped || backupChanged, + scopedConfigs: sanitizedScopedConfigs, + backups: nextBackups, + }; +} + export function resolveCanonicalChatContextSettingsFromConfig(value: unknown, appOrigin?: string | null) { const normalized = normalizeConfigRecord(value); const globalSettings = sanitizeChatContextSettings(normalized[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]); @@ -221,7 +337,7 @@ export function resolveCanonicalChatTypesFromConfig(value: unknown, appOrigin?: return null; } - return mergeDefaultChatTypes([...globalChatTypes, ...scopedChatTypes]); + return sanitizeChatTypes([...globalChatTypes, ...scopedChatTypes]); } function resolveScopedAppConfig(value: unknown, appOrigin?: string | null) { @@ -378,7 +494,8 @@ function normalizeDefaultContextRecord(value: unknown): ChatDefaultContextRecord return { id: normalizeText(record.id) || `chat-default-context-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, - title: title || '기본 유형', + title: title || '공통 문맥', + sortOrder: normalizePositiveSortOrder(record.sortOrder), content, enabled: record.enabled !== false, updatedAt: normalizeText(record.updatedAt) || new Date().toISOString(), @@ -400,7 +517,35 @@ function sanitizeDefaultContexts(items: unknown) { } }); - return Array.from(byId.values()).sort((left, right) => left.title.localeCompare(right.title, 'ko-KR')); + return Array.from(byId.values()) + .sort((left, right) => { + const leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null; + const rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null; + + if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) { + return leftSortOrder - rightSortOrder; + } + + if (leftSortOrder !== null && rightSortOrder === null) { + return -1; + } + + if (leftSortOrder === null && rightSortOrder !== null) { + return 1; + } + + const titleCompare = left.title.localeCompare(right.title, 'ko-KR'); + + if (titleCompare !== 0) { + return titleCompare; + } + + return compareUpdatedAt(left, right); + }) + .map((item, index) => ({ + ...item, + sortOrder: index + 1, + })); } function sanitizeChatTypeDefaultSelections(items: unknown) { @@ -499,6 +644,7 @@ function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null { return { id: normalizeText(record.id) || `chat-type-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, name, + sortOrder: normalizePositiveSortOrder(record.sortOrder), description: normalizeText(record.description), permissions: normalizePermissions(record.permissions), enabled: record.enabled !== false, @@ -506,12 +652,17 @@ function normalizeChatTypeRecord(value: unknown): ChatTypeRecord | null { }; } -function buildChatTypeSemanticKey(record: Pick) { - return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); +function normalizePositiveSortOrder(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return Number.NaN; + } + + const nextValue = Math.trunc(value); + return nextValue > 0 ? nextValue : Number.NaN; } -function isBuiltInChatTypeId(chatTypeId: string) { - return DEFAULT_CHAT_TYPES.some((item) => item.id === chatTypeId); +function buildChatTypeSemanticKey(record: Pick) { + return record.name.replace(/\s+/g, ' ').toLocaleLowerCase('ko-KR'); } function isLegacyMigratedChatTypeId(chatTypeId: string) { @@ -554,25 +705,39 @@ function sanitizeChatTypes(items: unknown[]) { } } - return Array.from(bySemanticKey.values()).sort((left, right) => left.name.localeCompare(right.name, 'ko-KR')); + return Array.from(bySemanticKey.values()) + .sort((left, right) => { + const leftSortOrder = Number.isFinite(left.sortOrder) ? left.sortOrder : null; + const rightSortOrder = Number.isFinite(right.sortOrder) ? right.sortOrder : null; + + if (leftSortOrder !== null && rightSortOrder !== null && leftSortOrder !== rightSortOrder) { + return leftSortOrder - rightSortOrder; + } + + if (leftSortOrder !== null && rightSortOrder === null) { + return -1; + } + + if (leftSortOrder === null && rightSortOrder !== null) { + return 1; + } + + const nameCompare = left.name.localeCompare(right.name, 'ko-KR'); + + if (nameCompare !== 0) { + return nameCompare; + } + + return compareUpdatedAt(left, right); + }) + .map((item, index) => ({ + ...item, + sortOrder: index + 1, + })); } -export function mergeDefaultChatTypes(items: unknown[]) { - const savedItems = sanitizeChatTypes(items); - const byId = new Map(savedItems.map((item) => [item.id, item] as const)); - - for (const defaultItem of DEFAULT_CHAT_TYPES) { - const savedItem = byId.get(defaultItem.id); - if (!savedItem) { - byId.set(defaultItem.id, defaultItem); - } - } - - return sanitizeChatTypes(Array.from(byId.values())); -} - -export function stripBuiltInChatTypes(items: unknown[]) { - return sanitizeChatTypes(items).filter((item) => !isBuiltInChatTypeId(item.id)); +export function sanitizePersistedChatTypes(items: unknown[]) { + return sanitizeChatTypes(items); } function stripLegacyMigratedChatTypes(items: ChatTypeRecord[]) { @@ -634,6 +799,7 @@ function isSameChatTypeList(left: ChatTypeRecord[], right: ChatTypeRecord[]) { item.name === target.name && item.description === target.description && item.enabled === target.enabled && + item.sortOrder === target.sortOrder && item.updatedAt === target.updatedAt && item.permissions.length === target.permissions.length && item.permissions.every((permission, permissionIndex) => permission === target.permissions[permissionIndex]) @@ -735,15 +901,23 @@ export function normalizeAppConfigSnapshot(value: unknown): AppConfigSnapshot { export async function getAppConfigSnapshot(appOrigin?: string | null): Promise { const config = normalizeConfigRecord(await getAppConfig(appOrigin)); const rawConfig = await getRawAppConfigRecord(); - const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin); - const canonicalChatContextSettings = resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin); - const { scopedConfigs } = stripChatContextSettingsFromScopedAppConfigs(rawConfig); + const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin(); + const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin); + const canonicalChatContextSettings = resolveCanonicalChatContextSettingsFromConfig( + rawConfig, + sharedChatContextAppOrigin, + ); + const { scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs( + rawConfig, + sharedChatContextAppOrigin, + ); return normalizeAppConfigSnapshot({ ...config, ...(canonicalChatTypes ? { [CHAT_TYPES_CONFIG_KEY]: canonicalChatTypes } : null), [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: canonicalChatContextSettings, [SCOPED_APP_CONFIGS_KEY]: scopedConfigs, + [SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups, }); } @@ -785,80 +959,123 @@ export async function upsertAppConfig( return resolveAppConfigByOrigin(rows[0]?.config_json ?? mergedConfig, appOrigin); } -export async function getChatTypesConfig(appOrigin?: string | null): Promise { - const rawConfig = await getRawAppConfigRecord(); - const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin); - const builtInChatTypes = sanitizeChatTypes(DEFAULT_CHAT_TYPES); - const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []); - const customChatTypes = stripBuiltInChatTypes(migratedChatTypeList); - const mergedChatTypes = mergeDefaultChatTypes(customChatTypes); - const migratedSettings = migrateLegacyChatTypeContexts( - resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin), - canonicalChatTypes ?? [], - ); +async function replaceAppConfig(config: Record, appOrigin?: string | null) { + await ensureAppConfigTable(); + const nextConfig = normalizeConfigRecord(config); + const existing = await db(APP_CONFIG_TABLE).first(); - const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin)); - const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY]) - ? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]) - : []; - const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]); + if (!existing) { + const rows = await db(APP_CONFIG_TABLE) + .insert({ + config_json: nextConfig, + updated_at: db.fn.now(), + }) + .returning('*'); - if (!isSameChatTypeList(resolvedCustomChatTypes, customChatTypes)) { - await upsertAppConfig({ - [CHAT_TYPES_CONFIG_KEY]: customChatTypes, - }, appOrigin); + return resolveAppConfigByOrigin(rows[0]?.config_json ?? nextConfig, appOrigin); } - if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) { - await upsertChatContextSettingsConfig(migratedSettings); + const rows = await db(APP_CONFIG_TABLE) + .update({ + config_json: nextConfig, + updated_at: db.fn.now(), + }) + .returning('*'); + + return resolveAppConfigByOrigin(rows[0]?.config_json ?? nextConfig, appOrigin); +} + +export async function getChatTypesConfig(appOrigin?: string | null): Promise { + const rawConfig = await getRawAppConfigRecord(); + const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin(); + const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin); + const migratedChatTypeList = stripLegacyMigratedChatTypes(canonicalChatTypes ?? []); + const chatTypes = sanitizePersistedChatTypes(migratedChatTypeList); + const migratedSettings = migrateLegacyChatTypeContexts( + resolveCanonicalChatContextSettingsFromConfig(rawConfig, sharedChatContextAppOrigin), + chatTypes, + ); + const globalChatTypes = Array.isArray(rawConfig[CHAT_TYPES_CONFIG_KEY]) + ? stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])) + : []; + const globalSettings = sanitizeChatContextSettings(rawConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]); + const { changed, scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs( + rawConfig, + sharedChatContextAppOrigin, + ); + + if ( + changed || + !isSameChatTypeList(globalChatTypes, chatTypes) || + JSON.stringify(globalSettings) !== JSON.stringify(migratedSettings) + ) { + await replaceAppConfig({ + ...rawConfig, + [CHAT_TYPES_CONFIG_KEY]: chatTypes, + [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: migratedSettings, + [SCOPED_APP_CONFIGS_KEY]: scopedConfigs, + [SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups, + }); } return { - builtInChatTypes, - customChatTypes, - chatTypes: mergedChatTypes, + chatTypes, }; } export async function upsertChatTypesConfig(chatTypes: unknown[], appOrigin?: string | null, appDomain?: string | null) { - const current = normalizeConfigRecord(await getAppConfig(appOrigin)); - const customChatTypes = stripBuiltInChatTypes(chatTypes); + const current = await getRawAppConfigRecord(); + const nextChatTypes = sanitizePersistedChatTypes(chatTypes); + const { scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs( + current, + resolveSharedChatContextAppOrigin(), + ); const nextConfig = { ...current, - [CHAT_TYPES_CONFIG_KEY]: customChatTypes, + [CHAT_TYPES_CONFIG_KEY]: nextChatTypes, + [SCOPED_APP_CONFIGS_KEY]: scopedConfigs, + [SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups, }; - await upsertAppConfig(nextConfig, appOrigin, appDomain); + void appOrigin; + void appDomain; + await replaceAppConfig(nextConfig); return { - builtInChatTypes: sanitizeChatTypes(DEFAULT_CHAT_TYPES), - customChatTypes, - chatTypes: mergeDefaultChatTypes(customChatTypes), + chatTypes: nextChatTypes, }; } export async function getChatContextSettingsConfig(appOrigin?: string | null) { const rawConfig = await getRawAppConfigRecord(); - const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, appOrigin) ?? []; + const sharedChatContextAppOrigin = resolveSharedChatContextAppOrigin(); + const canonicalChatTypes = resolveCanonicalChatTypesFromConfig(rawConfig, sharedChatContextAppOrigin) ?? []; const migratedSettings = migrateLegacyChatTypeContexts( - resolveCanonicalChatContextSettingsFromConfig(rawConfig, appOrigin), + resolveCanonicalChatContextSettingsFromConfig(rawConfig, sharedChatContextAppOrigin), canonicalChatTypes, ); const migratedChatTypes = stripLegacyMigratedChatTypes(canonicalChatTypes); - const resolvedConfig = normalizeConfigRecord(await getAppConfig(appOrigin)); - const resolvedCustomChatTypes = Array.isArray(resolvedConfig[CHAT_TYPES_CONFIG_KEY]) - ? stripBuiltInChatTypes(resolvedConfig[CHAT_TYPES_CONFIG_KEY] as unknown[]) + const globalChatTypes = Array.isArray(rawConfig[CHAT_TYPES_CONFIG_KEY]) + ? stripLegacyMigratedChatTypes(sanitizePersistedChatTypes(rawConfig[CHAT_TYPES_CONFIG_KEY] as unknown[])) : []; - const nextCustomChatTypes = stripBuiltInChatTypes(migratedChatTypes); - const resolvedSettings = sanitizeChatContextSettings(resolvedConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]); + const nextCustomChatTypes = sanitizePersistedChatTypes(migratedChatTypes); + const globalSettings = sanitizeChatContextSettings(rawConfig[CHAT_CONTEXT_SETTINGS_CONFIG_KEY]); + const { changed, scopedConfigs, backups } = stripSharedContextDataFromScopedAppConfigs( + rawConfig, + sharedChatContextAppOrigin, + ); - if (!isSameChatTypeList(resolvedCustomChatTypes, nextCustomChatTypes)) { - await upsertAppConfig({ + if ( + changed || + !isSameChatTypeList(globalChatTypes, nextCustomChatTypes) || + JSON.stringify(globalSettings) !== JSON.stringify(migratedSettings) + ) { + await replaceAppConfig({ + ...rawConfig, [CHAT_TYPES_CONFIG_KEY]: nextCustomChatTypes, - }, appOrigin); - } - - if (JSON.stringify(resolvedSettings) !== JSON.stringify(migratedSettings)) { - await upsertChatContextSettingsConfig(migratedSettings); + [CHAT_CONTEXT_SETTINGS_CONFIG_KEY]: migratedSettings, + [SCOPED_APP_CONFIGS_KEY]: scopedConfigs, + [SCOPED_CONTEXT_CONFIG_BACKUPS_KEY]: backups, + }); } return migratedSettings; @@ -880,6 +1097,6 @@ export async function upsertChatContextSettingsConfig( void appOrigin; void appDomain; - await upsertAppConfig(nextConfig); + await replaceAppConfig(nextConfig); return nextSettings; } diff --git a/etc/servers/work-server/src/services/chat-message-parts.ts b/etc/servers/work-server/src/services/chat-message-parts.ts index a3b3d76..40a36ac 100644 --- a/etc/servers/work-server/src/services/chat-message-parts.ts +++ b/etc/servers/work-server/src/services/chat-message-parts.ts @@ -77,11 +77,37 @@ const RESOURCE_PATH_PREFIXES = ['/api/chat/resources/', '/public/.codex_chat/', const CHAT_API_RESOURCE_MARKER = '/api/chat/resources/'; const CHAT_DOT_CODEX_MARKER = '/.codex_chat/'; const CHAT_PUBLIC_DOT_CODEX_MARKER = '/public/.codex_chat/'; +const RESOURCE_MANAGER_PREVIEW_MARKER = '/api/resource-manager/preview/'; +const RESOURCE_MANAGER_ROOT_MARKER = 'resource/'; function normalizeText(value: unknown) { return String(value ?? '').trim(); } +function buildResourceManagerPreviewUrl(value: string) { + const normalized = normalizeText(value).replace(/\\/g, '/'); + const matchedResourcePath = normalized.match(/(?:^|\/)(resource\/.+)$/i)?.[1]; + const resourcePath = normalizeText(matchedResourcePath).replace(/^\/+/, ''); + + if (!resourcePath) { + return ''; + } + + const relativePath = resourcePath.slice(RESOURCE_MANAGER_ROOT_MARKER.length).replace(/^\/+/, ''); + + if (!relativePath) { + return ''; + } + + const encodedPath = relativePath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/'); + + return encodedPath ? `${RESOURCE_MANAGER_PREVIEW_MARKER}${encodedPath}` : ''; +} + function normalizeUrl(value: string) { const normalized = normalizeText(value); @@ -113,6 +139,14 @@ function normalizeUrl(value: string) { return `${CHAT_API_RESOURCE_MARKER}${normalized.slice(dotCodexIndex + 1)}`; } + if (normalized === 'resource' || normalized === '/resource' || normalized.startsWith('resource/') || normalized.includes('/resource/')) { + const resourceManagerPreviewUrl = buildResourceManagerPreviewUrl(normalized); + + if (resourceManagerPreviewUrl) { + return resourceManagerPreviewUrl; + } + } + if (/^(?:https?:\/\/|\/)/i.test(normalized)) { return normalized; } diff --git a/etc/servers/work-server/src/services/chat-room-service.test.ts b/etc/servers/work-server/src/services/chat-room-service.test.ts index 501d5bd..9f3f9f4 100644 --- a/etc/servers/work-server/src/services/chat-room-service.test.ts +++ b/etc/servers/work-server/src/services/chat-room-service.test.ts @@ -3,8 +3,11 @@ import assert from 'node:assert/strict'; import { CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH, buildChatConversationRequestPatchFromMessage, + hasMeaningfulChatSourceArtifacts, isVisibleConversationMessage, mergeChatConversationRequestStatus, + normalizeStaleRequestItem, + selectStaleOfflineNotificationClientIds, resolveNextConversationContextValue, resolveNextConversationChatTypeId, shouldClearConversationJobState, @@ -30,9 +33,43 @@ test('resolveNextConversationChatTypeId falls back to the stored chat type when }); test('resolveNextConversationContextValue prefers the requested chat type context', () => { - assert.equal(resolveNextConversationContextValue('old context', 'new context'), 'new context'); - assert.equal(resolveNextConversationContextValue('old context', ' '), 'old context'); - assert.equal(resolveNextConversationContextValue(null, 'new context'), 'new context'); + assert.equal(resolveNextConversationContextValue('old context', 'new context', true), 'new context'); + assert.equal(resolveNextConversationContextValue('old context', ' ', true), null); + assert.equal(resolveNextConversationContextValue(null, 'new context', true), 'new context'); + assert.equal(resolveNextConversationContextValue('old context', undefined, false), 'old context'); +}); + +test('selectStaleOfflineNotificationClientIds keeps current or registered clients and only selects orphaned opt-ins', () => { + assert.deepEqual( + selectStaleOfflineNotificationClientIds( + [ + { + clientId: 'client-current', + notifyOffline: true, + hasActivePushRegistration: false, + }, + { + clientId: 'client-registered', + notifyOffline: true, + hasActivePushRegistration: true, + }, + { + clientId: 'client-stale', + notifyOffline: true, + hasActivePushRegistration: false, + }, + { + clientId: 'client-disabled', + notifyOffline: false, + hasActivePushRegistration: false, + }, + ], + { + keepClientIds: ['client-current'], + }, + ), + ['client-stale'], + ); }); test('buildChatConversationRequestPatchFromMessage ignores system progress messages', () => { @@ -73,6 +110,26 @@ test('isVisibleConversationMessage hides internal system messages and keeps acti ); }); +test('hasMeaningfulChatSourceArtifacts requires real file or diff artifacts', () => { + assert.equal( + hasMeaningfulChatSourceArtifacts({ + changedFiles: [], + currentSourceFiles: [], + diffBlocks: [], + }), + false, + ); + + assert.equal( + hasMeaningfulChatSourceArtifacts({ + changedFiles: ['src/app/main/ChatSourceChangesPage.tsx'], + currentSourceFiles: [], + diffBlocks: [], + }), + true, + ); +}); + test('buildChatConversationRequestPatchFromMessage builds user and codex request patches', () => { assert.deepEqual( buildChatConversationRequestPatchFromMessage({ @@ -256,3 +313,49 @@ test('shouldClearConversationJobState keeps placeholder-only started responses w false, ); }); + +test('normalizeStaleRequestItem keeps queued requests when another request is currently active', () => { + assert.deepEqual( + normalizeStaleRequestItem( + { + sessionId: 'session-1', + requestId: 'chat-req-queued', + requesterClientId: null, + status: 'queued', + statusMessage: '대기열 1건', + userMessageId: 11, + userText: '다음 요청', + responseMessageId: null, + responseText: '', + hasResponse: false, + canDelete: false, + createdAt: '2026-05-11T00:00:00.000Z', + updatedAt: '2026-05-11T00:00:30.000Z', + answeredAt: null, + terminalAt: null, + }, + { + current_request_id: 'chat-req-running', + current_job_status: 'started', + current_status_updated_at: '2026-05-11T00:00:30.000Z', + }, + ), + { + sessionId: 'session-1', + requestId: 'chat-req-queued', + requesterClientId: null, + status: 'queued', + statusMessage: '대기열 1건', + userMessageId: 11, + userText: '다음 요청', + responseMessageId: null, + responseText: '', + hasResponse: false, + canDelete: false, + createdAt: '2026-05-11T00:00:00.000Z', + updatedAt: '2026-05-11T00:00:30.000Z', + answeredAt: null, + terminalAt: null, + }, + ); +}); diff --git a/etc/servers/work-server/src/services/chat-room-service.ts b/etc/servers/work-server/src/services/chat-room-service.ts index d6f9973..24efffc 100644 --- a/etc/servers/work-server/src/services/chat-room-service.ts +++ b/etc/servers/work-server/src/services/chat-room-service.ts @@ -2,20 +2,25 @@ import { z } from 'zod'; import { db } from '../db/client.js'; import { chatRuntimeService } from './chat-runtime-service.js'; import { parseChatMessageParts, stringifyChatMessageParts, type ChatMessagePart } from './chat-message-parts.js'; +import { NOTIFICATION_TOKEN_TABLE, WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js'; +import { cleanupNotificationMessagesForStaleTargetClients } from './notification-message-service.js'; export const CHAT_CONVERSATION_TABLE = 'chat_conversations'; export const CHAT_CONVERSATION_MESSAGE_TABLE = 'chat_conversation_messages'; export const CHAT_CONVERSATION_CLIENT_TABLE = 'chat_conversation_clients'; export const CHAT_CONVERSATION_REQUEST_TABLE = 'chat_conversation_requests'; export const CHAT_CONVERSATION_ACTIVITY_TABLE = 'chat_conversation_request_activities'; +export const CHAT_CONVERSATION_SOURCE_CHANGE_TABLE = 'chat_conversation_source_changes'; const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; export const CHAT_CONTEXT_DESCRIPTION_MAX_LENGTH = 10000; const STALE_CHAT_REQUEST_TIMEOUT_MS = 2 * 60 * 1000; +const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const; const conversationPayloadSchema = z.object({ sessionId: z.string().trim().min(1).max(120), clientId: z.string().trim().max(120).nullable().optional(), title: z.string().trim().max(200).nullable().optional(), + requestBadgeLabel: z.string().trim().max(120).nullable().optional(), chatTypeId: z.string().trim().max(120).nullable().optional(), lastChatTypeId: z.string().trim().max(120).nullable().optional(), generalSectionName: z.string().trim().max(120).nullable().optional(), @@ -38,6 +43,7 @@ export type ChatConversationItem = { sessionId: string; clientId: string | null; title: string; + requestBadgeLabel: string | null; chatTypeId: string | null; lastChatTypeId: string | null; generalSectionName: string | null; @@ -81,6 +87,7 @@ export type ChatConversationRequestStatus = export type ChatConversationRequestItem = { sessionId: string; requestId: string; + requesterClientId: string | null; status: ChatConversationRequestStatus; statusMessage: string | null; userMessageId: number | null; @@ -102,6 +109,31 @@ export type ChatConversationActivityLogItem = { updatedAt: string | null; }; +export type ChatSourceChangeSnapshotItem = { + id: string; + sessionId: string; + clientId: string | null; + conversationTitle: string; + chatTypeId: string | null; + chatTypeLabel: string; + requestId: string; + requestTitle: string; + questionText: string; + answerText: string; + status: ChatConversationRequestStatus; + sourceChangedAt: string; + updatedAt: string; + featureTags: string[]; + changedFiles: string[]; + currentSourceFiles: string[]; + diffBlocks: string[]; + hasSourceChanges: boolean; + reviewStatus: 'reviewed' | 'not-reviewed'; + sourceChangeKind: 'request' | 'verification-group'; + sourceEntryIds: string[]; + conversationDeletedAt: string | null; +}; + export type RecoverableChatConversationRequestItem = { sessionId: string; clientId: string | null; @@ -151,6 +183,12 @@ type ChatConversationClientPreference = { lastReadResponseMessageId: number | null; }; +type ChatConversationOfflineNotificationClientRow = { + clientId: string; + notifyOffline: boolean; + hasActivePushRegistration: boolean; +}; + function normalizeDateTimeValue(value: unknown) { if (value == null) { return null; @@ -170,11 +208,366 @@ function normalizeDateTimeValue(value: unknown) { return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString(); } +function parseStringArray(value: unknown) { + if (typeof value !== 'string') { + return [] as string[]; + } + + try { + const parsed = JSON.parse(value) as unknown; + + if (!Array.isArray(parsed)) { + return [] as string[]; + } + + return parsed + .map((item) => String(item ?? '').trim()) + .filter(Boolean); + } catch { + return [] as string[]; + } +} + +function stringifyStringArray(value: string[]) { + return JSON.stringify( + Array.from( + new Set( + value.map((item) => String(item ?? '').trim()).filter(Boolean), + ), + ), + ); +} + function createPreview(text: string) { const normalized = String(text ?? '').replace(/\s+/g, ' ').trim(); return normalized.length > 140 ? `${normalized.slice(0, 137).trimEnd()}...` : normalized; } +function createCompactText(value: string | null | undefined, limit = 88) { + const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); + + if (!normalized) { + return ''; + } + + return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized; +} + +function normalizeClientIdSet(clientIds: Iterable) { + return new Set( + Array.from(clientIds, (item) => String(item ?? '').trim()).filter(Boolean), + ); +} + +export function selectStaleOfflineNotificationClientIds( + rows: ChatConversationOfflineNotificationClientRow[], + options?: { + keepClientIds?: Iterable; + }, +) { + const keepClientIds = normalizeClientIdSet(options?.keepClientIds ?? []); + + return rows + .filter((row) => { + if (!row.notifyOffline) { + return false; + } + + if (!row.clientId) { + return false; + } + + if (keepClientIds.has(row.clientId)) { + return false; + } + + return row.hasActivePushRegistration !== true; + }) + .map((row) => row.clientId); +} + +const SOURCE_CHANGE_VERIFICATION_PATTERN = /^\s*\[\[source-change-verification:(.+?)\]\]\s*$/iu; +const PROMPT_PART_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/iu; + +type SourceChangeVerificationFeature = { + key: string; + label: string; + entryRefs: Array<{ + sessionId: string; + requestId: string; + }>; +}; + +type SourceChangeVerificationMetadata = { + version: number; + features: SourceChangeVerificationFeature[]; +}; + +function parseJsonRecord(value: string) { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null; + } catch { + return null; + } +} + +function parseSourceChangeVerificationMetadata(text: string) { + const lines = String(text ?? '').split('\n'); + + for (const line of lines) { + const matched = line.match(SOURCE_CHANGE_VERIFICATION_PATTERN); + + if (!matched?.[1]) { + continue; + } + + const record = parseJsonRecord(matched[1]); + if (!record) { + continue; + } + + const features = Array.isArray(record.features) + ? record.features.flatMap((feature) => { + if (!feature || typeof feature !== 'object' || Array.isArray(feature)) { + return []; + } + + const featureRecord = feature as Record; + const key = String(featureRecord.key ?? '').trim(); + const label = String(featureRecord.label ?? '').trim(); + const entryRefs = Array.isArray(featureRecord.entryRefs) + ? featureRecord.entryRefs.flatMap((entryRef) => { + if (!entryRef || typeof entryRef !== 'object' || Array.isArray(entryRef)) { + return []; + } + + const entryRefRecord = entryRef as Record; + const sessionId = String(entryRefRecord.sessionId ?? '').trim(); + const requestId = String(entryRefRecord.requestId ?? '').trim(); + + return sessionId && requestId ? [{ sessionId, requestId }] : []; + }) + : []; + + return key && label && entryRefs.length > 0 ? [{ key, label, entryRefs }] : []; + }) + : []; + + if (features.length === 0) { + continue; + } + + return { + version: Number(record.version ?? 1) || 1, + features, + } satisfies SourceChangeVerificationMetadata; + } + + return null; +} + +function parseSelectedPromptValues(text: string) { + const selectedValues = new Set(); + const lines = String(text ?? '').split('\n'); + + for (const line of lines) { + const matched = line.match(PROMPT_PART_PATTERN); + + if (!matched?.[1]) { + continue; + } + + const record = parseJsonRecord(matched[1]); + if (!record) { + continue; + } + + (Array.isArray(record.selectedValues) ? record.selectedValues : []).forEach((value) => { + const normalized = String(value ?? '').trim(); + + if (normalized) { + selectedValues.add(normalized); + } + }); + } + + return selectedValues; +} + +function sanitizeSourceChangeGroupKey(value: string, fallback: string) { + const normalized = String(value ?? '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 24); + + return normalized || fallback; +} + +function buildVerificationGroupRequestId(requestId: string, featureKey: string, index: number) { + const normalizedRequestId = String(requestId ?? '').trim() || 'verification'; + const safeKey = sanitizeSourceChangeGroupKey(featureKey, `group-${index + 1}`); + const suffix = `::vf:${index + 1}:${safeKey}`; + const baseLimit = Math.max(1, 120 - suffix.length); + return `${normalizedRequestId.slice(0, baseLimit)}${suffix}`; +} + +function createRequestTitle(userText: string, fallback: string) { + const compact = createCompactText(userText, 72); + return compact || fallback; +} + +export function hasMeaningfulChatSourceArtifacts(snapshot: { + changedFiles?: string[] | null; + currentSourceFiles?: string[] | null; + diffBlocks?: string[] | null; +}) { + return [snapshot.changedFiles, snapshot.currentSourceFiles, snapshot.diffBlocks].some((items) => + Array.isArray(items) && items.some((item) => typeof item === 'string' && item.trim().length > 0), + ); +} + +function extractDiffBlocks(text: string) { + return Array.from(text.matchAll(/```diff[^\n]*\n([\s\S]*?)```/g)) + .map((match) => match[1]?.trim() ?? '') + .filter(Boolean); +} + +function normalizeWorkspaceFilePath(value: string) { + const normalized = String(value ?? '') + .trim() + .replace(/\\/g, '/') + .replace(/^file:\/\//, '') + .replace(/[)>.,]+$/, '') + .replace(/:\d+(?::\d+)?$/, ''); + + if (!normalized) { + return ''; + } + + const resourceMarker = '/resource/'; + const resourceIndex = normalized.lastIndexOf(resourceMarker); + + if (resourceIndex >= 0) { + const innerPath = normalized.slice(resourceIndex + resourceMarker.length).replace(/^\/+/, ''); + return innerPath; + } + + const apiResourceMarker = '/api/chat/resources/'; + const apiResourceIndex = normalized.lastIndexOf(apiResourceMarker); + + if (apiResourceIndex >= 0) { + return normalized.slice(apiResourceIndex + apiResourceMarker.length).replace(/^\/+/, ''); + } + + const legacyWorkspaceMarker = '/workspace/main-project/'; + const legacyWorkspaceIndex = normalized.lastIndexOf(legacyWorkspaceMarker); + + if (legacyWorkspaceIndex >= 0) { + return normalized.slice(legacyWorkspaceIndex + legacyWorkspaceMarker.length); + } + + for (const prefix of CURRENT_SOURCE_PREFIXES) { + const marker = `/${prefix}`; + const markerIndex = normalized.lastIndexOf(marker); + + if (markerIndex >= 0) { + return normalized.slice(markerIndex + 1); + } + + if (normalized.startsWith(prefix)) { + return normalized; + } + } + + return normalized.replace(/^\/+/, '').replace(/^\.\//, ''); +} + +function isCurrentSourcePath(path: string) { + return CURRENT_SOURCE_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +function extractChangedFiles(text: string) { + const matches = Array.from( + text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), + ) + .flatMap((match) => [match[1], match[2], match[3]]) + .filter((value): value is string => Boolean(value)); + + return Array.from( + new Set( + matches + .map((item) => normalizeWorkspaceFilePath(item)) + .filter(Boolean), + ), + ).slice(0, 60); +} + +function extractCurrentSourceFiles(text: string) { + const textWithoutChatResourcePaths = text + .replace(/\/api\/chat\/resources\/[^\s)`]+/g, ' ') + .replace(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g, ' '); + + const diffPathMatches = Array.from( + text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), + ) + .flatMap((match) => [match[1], match[2], match[3]]) + .filter((value): value is string => Boolean(value)) + .map((item) => normalizeWorkspaceFilePath(item)) + .filter((path) => path && isCurrentSourcePath(path)); + + const workspacePathMatches = [ + ...(text.match(/\[[^\]]*]\((\/[^)\s]+)\)/g) ?? []).map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), + ...(text.match(/\/(?:[^/\s)]+\/)*(?:src|docs|etc|public|scripts)\/[^\s)`]+/g) ?? []), + ] + .map((item) => normalizeWorkspaceFilePath(item)) + .filter((path) => path && isCurrentSourcePath(path)); + + const directRelativeMatches = (textWithoutChatResourcePaths.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []) + .map((item) => normalizeWorkspaceFilePath(item)) + .filter((path) => path && isCurrentSourcePath(path)); + + return Array.from(new Set([...diffPathMatches, ...workspacePathMatches, ...directRelativeMatches])).slice(0, 60); +} + +function deriveFeatureTags(files: string[]) { + const tags = new Set(); + + files.forEach((file) => { + const segments = file.split('/').filter(Boolean); + + if (segments[0] === 'src' && segments[1] === 'features' && segments[2]) { + tags.add(`feature:${segments[2]}`); + return; + } + + if (segments[0] === 'src' && segments[1] === 'components' && segments[2]) { + tags.add(`component:${segments[2]}`); + return; + } + + if (segments[0] === 'src' && segments[1] === 'widgets' && segments[2]) { + tags.add(`widget:${segments[2]}`); + return; + } + + if (segments[0] === 'docs' && segments[1]) { + tags.add(`docs:${segments[1]}`); + return; + } + + if (segments[0]) { + tags.add(segments[0]); + } + }); + + return Array.from(tags); +} + const PENDING_WORK_ANALYSIS_PATTERNS = [ /분석/u, /검토/u, @@ -423,6 +816,7 @@ function mapConversationRow(row: Record): ChatConversationItem sessionId: String(row.session_id ?? ''), clientId: row.client_id == null ? null : String(row.client_id), title: String(row.title ?? '새 대화'), + requestBadgeLabel: row.request_badge_label == null ? null : String(row.request_badge_label), chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), lastChatTypeId: row.last_chat_type_id == null ? null : String(row.last_chat_type_id), generalSectionName: row.general_section_name == null ? null : String(row.general_section_name), @@ -489,6 +883,7 @@ function mapRequestRow(row: Record): ChatConversationRequestIte return { sessionId: String(row.session_id ?? ''), requestId: String(row.request_id ?? ''), + requesterClientId: row.requester_client_id == null ? null : String(row.requester_client_id), status, statusMessage: row.status_message == null ? null : String(row.status_message), userMessageId: row.user_message_id == null ? null : Number(row.user_message_id), @@ -504,6 +899,89 @@ function mapRequestRow(row: Record): ChatConversationRequestIte }; } +function mapSourceChangeSnapshotRow(row: Record): ChatSourceChangeSnapshotItem { + const sessionId = String(row.session_id ?? '').trim(); + const requestId = String(row.request_id ?? '').trim(); + + return { + id: `${sessionId}:${requestId}`, + sessionId, + clientId: row.client_id == null ? null : String(row.client_id), + conversationTitle: String(row.conversation_title ?? '새 대화'), + chatTypeId: row.chat_type_id == null ? null : String(row.chat_type_id), + chatTypeLabel: row.chat_type_label == null ? '' : String(row.chat_type_label), + requestId, + requestTitle: String(row.request_title ?? requestId), + questionText: String(row.question_text ?? ''), + answerText: String(row.answer_text ?? ''), + status: String(row.status ?? 'completed') as ChatConversationRequestStatus, + sourceChangedAt: normalizeDateTimeValue(row.source_changed_at) ?? normalizeDateTimeValue(row.answered_at) ?? '', + updatedAt: normalizeDateTimeValue(row.updated_at) ?? '', + featureTags: parseStringArray(row.feature_tags_json), + changedFiles: parseStringArray(row.changed_files_json), + currentSourceFiles: parseStringArray(row.current_source_files_json), + diffBlocks: parseStringArray(row.diff_blocks_json), + hasSourceChanges: Boolean(row.has_source_changes), + reviewStatus: String(row.review_status ?? 'not-reviewed') === 'reviewed' ? 'reviewed' : 'not-reviewed', + sourceChangeKind: String(row.source_change_kind ?? 'request') === 'verification-group' ? 'verification-group' : 'request', + sourceEntryIds: parseStringArray(row.source_entry_ids_json), + conversationDeletedAt: normalizeDateTimeValue(row.conversation_deleted_at), + }; +} + +function buildSourceChangeSnapshotPayload(args: { + sessionId: string; + clientId?: string | null; + conversationTitle?: string | null; + chatTypeId?: string | null; + chatTypeLabel?: string | null; + requestId: string; + status: ChatConversationRequestStatus; + questionText: string; + answerText: string; + answeredAt?: string | null; + updatedAt?: string | null; + reviewStatus?: 'reviewed' | 'not-reviewed'; + sourceChangeKind?: 'request' | 'verification-group'; + sourceEntryIds?: string[]; +}) { + const changedFiles = extractChangedFiles(args.answerText); + const diffBlocks = extractDiffBlocks(args.answerText); + const currentSourceFiles = extractCurrentSourceFiles(args.answerText); + const featureTags = deriveFeatureTags(changedFiles); + const hasSourceChanges = hasMeaningfulChatSourceArtifacts({ + changedFiles, + currentSourceFiles, + diffBlocks, + }); + const sourceChangedAt = args.answeredAt?.trim() || args.updatedAt?.trim() || new Date().toISOString(); + + return { + session_id: args.sessionId, + client_id: normalizeClientId(args.clientId), + request_id: args.requestId, + conversation_title: args.conversationTitle?.trim() || '새 대화', + chat_type_id: args.chatTypeId?.trim() || null, + chat_type_label: args.chatTypeLabel?.trim() || null, + request_title: createRequestTitle(args.questionText, args.requestId), + question_text: args.questionText, + answer_text: args.answerText, + status: args.status, + answered_at: args.answeredAt?.trim() || null, + source_changed_at: sourceChangedAt, + feature_tags_json: stringifyStringArray(featureTags), + changed_files_json: stringifyStringArray(changedFiles), + current_source_files_json: stringifyStringArray(currentSourceFiles), + diff_blocks_json: stringifyStringArray(diffBlocks), + has_source_changes: hasSourceChanges, + review_status: args.reviewStatus === 'reviewed' ? 'reviewed' : 'not-reviewed', + source_change_kind: args.sourceChangeKind === 'verification-group' ? 'verification-group' : 'request', + source_entry_ids_json: stringifyStringArray(args.sourceEntryIds ?? []), + conversation_deleted_at: null, + updated_at: db.fn.now(), + }; +} + function normalizeClientId(clientId?: string | null) { return clientId?.trim() || null; } @@ -576,6 +1054,7 @@ function isConversationRequestActive( function hasConversationMetadata( conversation: { title?: unknown; + request_badge_label?: unknown; chat_type_id?: unknown; last_chat_type_id?: unknown; general_section_name?: unknown; @@ -587,6 +1066,7 @@ function hasConversationMetadata( ) { return [ conversation?.title, + conversation?.request_badge_label, conversation?.chat_type_id, conversation?.last_chat_type_id, conversation?.general_section_name, @@ -597,7 +1077,7 @@ function hasConversationMetadata( ].some((value) => String(value ?? '').trim().length > 0); } -function normalizeStaleRequestItem( +export function normalizeStaleRequestItem( item: ChatConversationRequestItem, conversation: { current_request_id?: unknown; @@ -607,20 +1087,6 @@ function normalizeStaleRequestItem( ) { const runtimeActive = isRuntimeRequestActive(item.requestId); - if ( - (item.status === 'queued' || item.status === 'started') && - !item.hasResponse && - !isConversationRequestActive(conversation, item.requestId) - ) { - return { - ...item, - status: 'failed' as const, - statusMessage: item.statusMessage ?? '중단된 오래된 요청', - canDelete: true, - terminalAt: item.terminalAt ?? item.updatedAt, - }; - } - if ( shouldClearConversationJobState({ currentRequestId: String(conversation?.current_request_id ?? ''), @@ -723,6 +1189,27 @@ function getRequestStatusRank(status: ChatConversationRequestStatus | null | und } } +function getDefaultChatConversationRequestStatusMessage(status: ChatConversationRequestStatus) { + switch (status) { + case 'accepted': + return '요청을 접수했습니다.'; + case 'queued': + return '대기열 등록'; + case 'started': + return '요청 처리 중'; + case 'completed': + return '요청 처리 완료'; + case 'failed': + return '요청 처리 실패'; + case 'cancelled': + return '요청 실행 중단'; + case 'removed': + return '요청 기록이 제거되었습니다.'; + default: + return null; + } +} + export function mergeChatConversationRequestStatus( currentStatus: ChatConversationRequestStatus | null | undefined, incomingStatus: ChatConversationRequestStatus | null | undefined, @@ -1081,6 +1568,7 @@ export async function ensureChatConversationTables() { table.string('session_id', 120).primary(); table.string('client_id', 120).nullable().index(); table.string('title', 200).notNullable().defaultTo('새 대화'); + table.string('request_badge_label', 120).nullable(); table.string('chat_type_id', 120).nullable(); table.string('last_chat_type_id', 120).nullable(); table.string('general_section_name', 120).nullable(); @@ -1102,6 +1590,7 @@ export async function ensureChatConversationTables() { const requiredConversationColumns: Array<[string, (table: any) => void]> = [ ['client_id', (table) => table.string('client_id', 120).nullable().index()], ['title', (table) => table.string('title', 200).notNullable().defaultTo('새 대화')], + ['request_badge_label', (table) => table.string('request_badge_label', 120).nullable()], ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], ['last_chat_type_id', (table) => table.string('last_chat_type_id', 120).nullable()], ['general_section_name', (table) => table.string('general_section_name', 120).nullable()], @@ -1208,6 +1697,7 @@ export async function ensureChatConversationTables() { table.increments('id').primary(); table.string('session_id', 120).notNullable().index(); table.string('request_id', 120).notNullable(); + table.string('requester_client_id', 200).nullable(); table.string('status', 40).notNullable().defaultTo('accepted'); table.text('status_message').nullable(); table.bigInteger('user_message_id').nullable(); @@ -1225,6 +1715,7 @@ export async function ensureChatConversationTables() { const requiredRequestColumns: Array<[string, (table: any) => void]> = [ ['session_id', (table) => table.string('session_id', 120).notNullable().index()], ['request_id', (table) => table.string('request_id', 120).notNullable()], + ['requester_client_id', (table) => table.string('requester_client_id', 200).nullable()], ['status', (table) => table.string('status', 40).notNullable().defaultTo('accepted')], ['status_message', (table) => table.text('status_message').nullable()], ['user_message_id', (table) => table.bigInteger('user_message_id').nullable()], @@ -1278,6 +1769,72 @@ export async function ensureChatConversationTables() { }); } } + + const hasSourceChangeTable = await db.schema.hasTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE); + + if (!hasSourceChangeTable) { + await db.schema.createTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => { + table.increments('id').primary(); + table.string('session_id', 120).notNullable().index(); + table.string('client_id', 120).nullable().index(); + table.string('request_id', 120).notNullable(); + table.string('conversation_title', 200).notNullable().defaultTo('새 대화'); + table.string('chat_type_id', 120).nullable(); + table.string('chat_type_label', 200).nullable(); + table.string('request_title', 200).notNullable().defaultTo(''); + table.text('question_text').notNullable().defaultTo(''); + table.text('answer_text').notNullable().defaultTo(''); + table.string('status', 40).notNullable().defaultTo('completed'); + table.timestamp('answered_at', { useTz: true }).nullable(); + table.timestamp('source_changed_at', { useTz: true }).nullable(); + table.text('feature_tags_json').notNullable().defaultTo('[]'); + table.text('changed_files_json').notNullable().defaultTo('[]'); + table.text('current_source_files_json').notNullable().defaultTo('[]'); + table.text('diff_blocks_json').notNullable().defaultTo('[]'); + table.boolean('has_source_changes').notNullable().defaultTo(false); + table.string('review_status', 40).notNullable().defaultTo('not-reviewed'); + table.string('source_change_kind', 40).notNullable().defaultTo('request'); + table.text('source_entry_ids_json').notNullable().defaultTo('[]'); + table.timestamp('conversation_deleted_at', { useTz: true }).nullable(); + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); + table.unique(['session_id', 'request_id']); + }); + } + + const requiredSourceChangeColumns: Array<[string, (table: any) => void]> = [ + ['session_id', (table) => table.string('session_id', 120).notNullable().index()], + ['client_id', (table) => table.string('client_id', 120).nullable().index()], + ['request_id', (table) => table.string('request_id', 120).notNullable()], + ['conversation_title', (table) => table.string('conversation_title', 200).notNullable().defaultTo('새 대화')], + ['chat_type_id', (table) => table.string('chat_type_id', 120).nullable()], + ['chat_type_label', (table) => table.string('chat_type_label', 200).nullable()], + ['request_title', (table) => table.string('request_title', 200).notNullable().defaultTo('')], + ['question_text', (table) => table.text('question_text').notNullable().defaultTo('')], + ['answer_text', (table) => table.text('answer_text').notNullable().defaultTo('')], + ['status', (table) => table.string('status', 40).notNullable().defaultTo('completed')], + ['answered_at', (table) => table.timestamp('answered_at', { useTz: true }).nullable()], + ['source_changed_at', (table) => table.timestamp('source_changed_at', { useTz: true }).nullable()], + ['feature_tags_json', (table) => table.text('feature_tags_json').notNullable().defaultTo('[]')], + ['changed_files_json', (table) => table.text('changed_files_json').notNullable().defaultTo('[]')], + ['current_source_files_json', (table) => table.text('current_source_files_json').notNullable().defaultTo('[]')], + ['diff_blocks_json', (table) => table.text('diff_blocks_json').notNullable().defaultTo('[]')], + ['has_source_changes', (table) => table.boolean('has_source_changes').notNullable().defaultTo(false)], + ['review_status', (table) => table.string('review_status', 40).notNullable().defaultTo('not-reviewed')], + ['source_change_kind', (table) => table.string('source_change_kind', 40).notNullable().defaultTo('request')], + ['source_entry_ids_json', (table) => table.text('source_entry_ids_json').notNullable().defaultTo('[]')], + ['conversation_deleted_at', (table) => table.timestamp('conversation_deleted_at', { useTz: true }).nullable()], + ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], + ]; + + for (const [columnName, createColumn] of requiredSourceChangeColumns) { + const hasColumn = await db.schema.hasColumn(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, columnName); + + if (!hasColumn) { + await db.schema.alterTable(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE, (table) => { + createColumn(table); + }); + } + } } export async function getChatConversation(sessionId: string, clientId?: string | null) { @@ -1390,6 +1947,7 @@ export async function createChatConversation(payload: z.input normalizeStaleRequestItem(mapRequestRow(row), conversation)); } +export async function listChatSourceChangeSnapshots(clientId?: string | null, limit = 200) { + await ensureChatConversationTables(); + await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .where({ has_source_changes: true }) + .andWhere('changed_files_json', '[]') + .andWhere('current_source_files_json', '[]') + .andWhere('diff_blocks_json', '[]') + .update({ + has_source_changes: false, + updated_at: db.fn.now(), + }); + + const normalizedClientId = normalizeClientId(clientId); + const normalizedLimit = Math.max(1, Math.min(500, Math.round(limit))); + + const buildQuery = (targetClientId?: string | null) => + db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .select('*') + .where({ has_source_changes: true }) + .modify((builder) => { + if (targetClientId) { + builder.where({ client_id: targetClientId }); + } + }) + .orderByRaw('COALESCE(source_changed_at, answered_at, updated_at) DESC NULLS LAST') + .orderBy('updated_at', 'desc') + .limit(normalizedLimit); + + let rows = await buildQuery(normalizedClientId); + + if (normalizedClientId && rows.length === 0) { + rows = await buildQuery(null); + } + + return rows.map((row: Parameters[0]) => mapSourceChangeSnapshotRow(row)); +} + +async function applyVerifiedSourceChangeGrouping(args: { + sessionId: string; + clientId?: string | null; + conversationTitle?: string | null; + chatTypeId?: string | null; + chatTypeLabel?: string | null; + requestId: string; + questionText: string; + answerText: string; + status: ChatConversationRequestStatus; + answeredAt?: string | null; + updatedAt?: string | null; +}) { + const metadata = parseSourceChangeVerificationMetadata(args.questionText); + + if (!metadata) { + return; + } + + const selectedFeatureKeys = parseSelectedPromptValues(args.answerText); + const entryRefs = metadata.features.flatMap((feature) => feature.entryRefs); + + if (entryRefs.length === 0) { + return; + } + + const sourceRows = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .select('*') + .where((builder) => { + entryRefs.forEach((entryRef, index) => { + const method = index === 0 ? 'where' : 'orWhere'; + builder[method]((nestedBuilder: any) => { + nestedBuilder.where({ + session_id: entryRef.sessionId, + request_id: entryRef.requestId, + }); + }); + }); + }); + + const sourceRowMap = new Map>(); + sourceRows.forEach((row) => { + const sessionId = String(row.session_id ?? '').trim(); + const requestId = String(row.request_id ?? '').trim(); + + if (sessionId && requestId) { + sourceRowMap.set(`${sessionId}:${requestId}`, row); + } + }); + + const activeSourceIds = Array.from(new Set(entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`))); + + await db.transaction(async (trx) => { + if (activeSourceIds.length > 0) { + await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .where((builder) => { + activeSourceIds.forEach((entryId, index) => { + const dividerIndex = entryId.indexOf(':'); + const sessionId = dividerIndex >= 0 ? entryId.slice(0, dividerIndex) : ''; + const requestId = dividerIndex >= 0 ? entryId.slice(dividerIndex + 1) : ''; + const method = index === 0 ? 'where' : 'orWhere'; + + builder[method]((nestedBuilder: any) => { + nestedBuilder.where({ + session_id: sessionId, + request_id: requestId, + }); + }); + }); + }) + .update({ + has_source_changes: false, + updated_at: db.fn.now(), + }); + } + + await Promise.all(metadata.features.map(async (feature, index) => { + const featureRows = feature.entryRefs + .map((entryRef) => sourceRowMap.get(`${entryRef.sessionId}:${entryRef.requestId}`) ?? null) + .filter((row): row is Record => row != null); + + if (featureRows.length === 0) { + return; + } + + const changedFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.changed_files_json))); + const currentSourceFiles = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.current_source_files_json))); + const diffBlocks = stringifyStringArray(featureRows.flatMap((row) => parseStringArray(row.diff_blocks_json))); + const payload = buildSourceChangeSnapshotPayload({ + sessionId: args.sessionId, + clientId: args.clientId ?? null, + conversationTitle: args.conversationTitle ?? null, + chatTypeId: args.chatTypeId ?? null, + chatTypeLabel: args.chatTypeLabel ?? null, + requestId: buildVerificationGroupRequestId(args.requestId, feature.key, index), + status: args.status, + questionText: args.questionText, + answerText: args.answerText, + answeredAt: args.answeredAt ?? null, + updatedAt: args.updatedAt ?? null, + reviewStatus: selectedFeatureKeys.has(feature.key) ? 'reviewed' : 'not-reviewed', + sourceChangeKind: 'verification-group', + sourceEntryIds: feature.entryRefs.map((entryRef) => `${entryRef.sessionId}:${entryRef.requestId}`), + }); + + await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .insert({ + ...payload, + request_title: `${feature.label} 검증`, + feature_tags_json: stringifyStringArray([feature.label]), + changed_files_json: changedFiles, + current_source_files_json: currentSourceFiles, + diff_blocks_json: diffBlocks, + has_source_changes: hasMeaningfulChatSourceArtifacts({ + changedFiles: parseStringArray(changedFiles), + currentSourceFiles: parseStringArray(currentSourceFiles), + diffBlocks: parseStringArray(diffBlocks), + }), + }) + .onConflict(['session_id', 'request_id']) + .merge(); + })); + }); +} + +export async function syncChatSourceChangeSnapshot(sessionId: string, requestId: string) { + await ensureChatConversationTables(); + + const normalizedSessionId = sessionId.trim(); + const normalizedRequestId = requestId.trim(); + + if (!normalizedSessionId || !normalizedRequestId) { + return null; + } + + const [conversation, request] = await Promise.all([ + db(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).first(), + db(CHAT_CONVERSATION_REQUEST_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .first(), + ]); + + if (!request) { + return null; + } + + const status = String(request.status ?? 'accepted') as ChatConversationRequestStatus; + const questionText = String(request.user_text ?? '').trim(); + const answerText = String(request.response_text ?? '').trim(); + + if (status !== 'completed' || !answerText || isPreparingChatReplyText(answerText)) { + return null; + } + + const payload = buildSourceChangeSnapshotPayload({ + sessionId: normalizedSessionId, + clientId: conversation?.client_id == null ? null : String(conversation.client_id), + conversationTitle: String(conversation?.title ?? '새 대화'), + chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id), + chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label), + requestId: normalizedRequestId, + status, + questionText, + answerText, + answeredAt: normalizeDateTimeValue(request.answered_at), + updatedAt: normalizeDateTimeValue(request.updated_at), + }); + + await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .insert(payload) + .onConflict(['session_id', 'request_id']) + .merge(payload); + + await applyVerifiedSourceChangeGrouping({ + sessionId: normalizedSessionId, + clientId: conversation?.client_id == null ? null : String(conversation.client_id), + conversationTitle: String(conversation?.title ?? '새 대화'), + chatTypeId: conversation?.chat_type_id == null ? null : String(conversation.chat_type_id), + chatTypeLabel: conversation?.context_label == null ? null : String(conversation.context_label), + requestId: normalizedRequestId, + questionText, + answerText, + status, + answeredAt: normalizeDateTimeValue(request.answered_at), + updatedAt: normalizeDateTimeValue(request.updated_at), + }); + + const row = await db(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .first(); + + return row ? mapSourceChangeSnapshotRow(row) : null; +} + export async function getChatConversationRequest(sessionId: string, requestId: string) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); @@ -2090,6 +2909,10 @@ export async function appendChatConversationMessage( if (requestPatch) { await upsertChatConversationRequest(conversation.sessionId, { requestId: requestPatch.requestId, + requesterClientId: + message.author === 'user' + ? normalizeClientId(conversation.clientId) + : undefined, status: requestPatch.status, userMessageId: requestPatch.userMessageId, userText: requestPatch.userText, @@ -2099,7 +2922,12 @@ export async function appendChatConversationMessage( } } -export async function appendChatConversationActivityLine(sessionId: string, requestId: string, line: string) { +export async function appendChatConversationActivityLine( + sessionId: string, + requestId: string, + line: string, + lineNo?: number, +) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = requestId.trim(); const normalizedLine = line.trim(); @@ -2108,14 +2936,20 @@ export async function appendChatConversationActivityLine(sessionId: string, requ return null; } - const existingLineCountRow = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) - .where({ - session_id: normalizedSessionId, - request_id: normalizedRequestId, - }) - .count<{ count?: string | number }>('id as count') - .first(); - const nextLineNo = Number(existingLineCountRow?.count ?? 0) + 1; + const normalizedLineNo = + Number.isInteger(lineNo) && Number(lineNo) > 0 ? Number(lineNo) : undefined; + let nextLineNo = normalizedLineNo ?? null; + + if (nextLineNo == null) { + const existingLineCountRow = await db(CHAT_CONVERSATION_ACTIVITY_TABLE) + .where({ + session_id: normalizedSessionId, + request_id: normalizedRequestId, + }) + .count<{ count?: string | number }>('id as count') + .first(); + nextLineNo = Number(existingLineCountRow?.count ?? 0) + 1; + } await db(CHAT_CONVERSATION_ACTIVITY_TABLE) .insert({ @@ -2126,7 +2960,10 @@ export async function appendChatConversationActivityLine(sessionId: string, requ created_at: db.fn.now(), }) .onConflict(['session_id', 'request_id', 'line_no']) - .ignore(); + .merge({ + text: normalizedLine, + created_at: db.fn.now(), + }); return nextLineNo; } @@ -2310,6 +3147,7 @@ export async function upsertChatConversationRequest( sessionId: string, payload: { requestId: string; + requesterClientId?: string | null; status?: ChatConversationRequestStatus; statusMessage?: string | null; userMessageId?: number | null; @@ -2320,6 +3158,7 @@ export async function upsertChatConversationRequest( ) { const normalizedSessionId = sessionId.trim(); const normalizedRequestId = payload.requestId.trim(); + const normalizedRequesterClientId = payload.requesterClientId?.trim() || null; if (!normalizedSessionId || !normalizedRequestId) { return null; @@ -2329,6 +3168,7 @@ export async function upsertChatConversationRequest( | { session_id: string; request_id: string; + requester_client_id: string | null; status: ChatConversationRequestStatus; status_message: string | null; user_message_id: number | null; @@ -2355,6 +3195,11 @@ export async function upsertChatConversationRequest( (current?.status as ChatConversationRequestStatus | undefined) ?? null, payload.status ?? null, ); + const currentStatus = (current?.status as ChatConversationRequestStatus | undefined) ?? null; + const defaultStatusMessage = + payload.status && payload.status !== currentStatus + ? getDefaultChatConversationRequestStatusMessage(nextStatus) + : null; const terminalStatus = ['completed', 'failed', 'cancelled', 'removed'].includes(nextStatus) ? db.fn.now() : current?.terminal_at ?? null; @@ -2366,8 +3211,9 @@ export async function upsertChatConversationRequest( nextRow = { session_id: normalizedSessionId, request_id: normalizedRequestId, + requester_client_id: normalizedRequesterClientId ?? current?.requester_client_id ?? null, status: nextStatus, - status_message: payload.statusMessage?.trim() || current?.status_message || null, + status_message: payload.statusMessage?.trim() || defaultStatusMessage || current?.status_message || null, user_message_id: payload.userMessageId ?? current?.user_message_id ?? null, user_text: payload.userText ?? current?.user_text ?? '', response_message_id: payload.responseMessageId ?? current?.response_message_id ?? null, @@ -2426,6 +3272,13 @@ export async function upsertChatConversationRequest( await clearConversationJobStateForRequest(normalizedSessionId, normalizedRequestId); } + if ( + nextStatus === 'completed' && + (nextRow.response_message_id != null || String(nextRow.response_text ?? '').trim().length > 0) + ) { + await syncChatSourceChangeSnapshot(normalizedSessionId, normalizedRequestId); + } + return row ? mapRequestRow(row) : null; } @@ -2633,11 +3486,20 @@ export async function clearAllChatConversationJobStates() { } export async function deleteChatConversation(sessionId: string) { + const normalizedSessionId = sessionId.trim(); + return db.transaction(async (trx) => { - await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: sessionId.trim() }).del(); - await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: sessionId.trim() }).del(); - await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: sessionId.trim() }).del(); - const deletedCount = await trx(CHAT_CONVERSATION_TABLE).where({ session_id: sessionId.trim() }).del(); + await trx(CHAT_CONVERSATION_SOURCE_CHANGE_TABLE) + .where({ session_id: normalizedSessionId }) + .update({ + conversation_deleted_at: db.fn.now(), + updated_at: db.fn.now(), + }); + await trx(CHAT_CONVERSATION_CLIENT_TABLE).where({ session_id: normalizedSessionId }).del(); + await trx(CHAT_CONVERSATION_ACTIVITY_TABLE).where({ session_id: normalizedSessionId }).del(); + await trx(CHAT_CONVERSATION_REQUEST_TABLE).where({ session_id: normalizedSessionId }).del(); + await trx(CHAT_CONVERSATION_MESSAGE_TABLE).where({ session_id: normalizedSessionId }).del(); + const deletedCount = await trx(CHAT_CONVERSATION_TABLE).where({ session_id: normalizedSessionId }).del(); return deletedCount > 0; }); } @@ -2683,7 +3545,91 @@ export async function getChatConversationClientPreference(sessionId: string, cli return row ? mapClientPreferenceRow(row) : null; } -export async function listChatConversationOfflineNotificationClientIds(sessionId: string) { +async function listRegisteredNotificationClientIds(clientIds: string[]) { + const normalizedClientIds = [...new Set(clientIds.map((item) => String(item ?? '').trim()).filter(Boolean))]; + + if (!normalizedClientIds.length) { + return new Set(); + } + + const [webPushRows, tokenRows] = await Promise.all([ + db(WEB_PUSH_SUBSCRIPTION_TABLE) + .where({ is_enabled: true }) + .whereIn('device_id', normalizedClientIds) + .select('device_id'), + db(NOTIFICATION_TOKEN_TABLE) + .where({ is_enabled: true }) + .whereIn('device_id', normalizedClientIds) + .select('device_id'), + ]); + + return new Set( + [...webPushRows, ...tokenRows] + .map((row) => String(row.device_id ?? '').trim()) + .filter(Boolean), + ); +} + +export async function cleanupStaleChatConversationOfflineNotificationClients( + sessionId: string, + options?: { + keepClientIds?: Iterable; + }, +) { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedSessionId) { + return [] as string[]; + } + + const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) + .where({ + session_id: normalizedSessionId, + notify_offline: true, + }) + .select('client_id'); + + const optedInClientIds = rows + .map((row) => String(row.client_id ?? '').trim()) + .filter(Boolean); + const registeredClientIds = await listRegisteredNotificationClientIds(optedInClientIds); + const staleClientIds = selectStaleOfflineNotificationClientIds( + optedInClientIds.map((clientId) => ({ + clientId, + notifyOffline: true, + hasActivePushRegistration: registeredClientIds.has(clientId), + })), + options, + ); + + if (!staleClientIds.length) { + return [] as string[]; + } + + await db(CHAT_CONVERSATION_CLIENT_TABLE) + .where({ session_id: normalizedSessionId }) + .whereIn('client_id', staleClientIds) + .update({ + notify_offline: false, + updated_at: db.fn.now(), + }); + + await cleanupNotificationMessagesForStaleTargetClients({ + sessionId: normalizedSessionId, + staleClientIds, + }); + + return staleClientIds; +} + +export async function listChatConversationOfflineNotificationClientIds( + sessionId: string, + options?: { + keepClientIds?: Iterable; + }, +) { + await cleanupStaleChatConversationOfflineNotificationClients(sessionId, options); + const rows = await db(CHAT_CONVERSATION_CLIENT_TABLE) .where({ session_id: sessionId.trim(), @@ -2714,6 +3660,12 @@ export async function upsertChatConversationClientPreference(sessionId: string, updated_at: db.fn.now(), }); + if (notifyOffline) { + await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, { + keepClientIds: [normalizedClientId], + }); + } + return getChatConversationClientPreference(normalizedSessionId, normalizedClientId); } @@ -2749,6 +3701,10 @@ export async function markChatConversationResponsesRead(sessionId: string, clien updated_at: db.fn.now(), }); + await cleanupStaleChatConversationOfflineNotificationClients(normalizedSessionId, { + keepClientIds: [normalizedClientId], + }); + return { sessionId: normalizedSessionId, lastReadResponseMessageId: latestResponseMessageId, diff --git a/etc/servers/work-server/src/services/chat-runtime-service.ts b/etc/servers/work-server/src/services/chat-runtime-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/chat-service.test.ts b/etc/servers/work-server/src/services/chat-service.test.ts index 1256224..e920e69 100644 --- a/etc/servers/work-server/src/services/chat-service.test.ts +++ b/etc/servers/work-server/src/services/chat-service.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { env } from '../config/env.js'; @@ -12,9 +12,13 @@ import { ensureChatSessionReferenceResource, extractDiffCodeBlocks, extractCodexStreamText, + parseStructuredCodexStdoutLine, + filterInactiveOfflineNotificationClientIds, fitActivityLogLines, + isChatClientActivelyViewing, isAutomationRegistrationCountRequest, resolveResponseTimestamp, + resolveChatContextAppOrigin, rewriteCodexOutputWithChatResources, summarizeActivityProgressLine, shouldSendOfflineChatNotification, @@ -31,6 +35,26 @@ test('collectOfflineNotificationClientIds merges session and conversation target ); }); +test('filterInactiveOfflineNotificationClientIds excludes only actively viewing clients', () => { + const activeSession = { + sessionId: 'chat-room', + clientId: 'client-a', + socket: { readyState: 1 }, + lastSeenAt: Date.now(), + context: { + pageVisibilityState: 'visible' as const, + pageFocusState: 'focused' as const, + topMenu: 'chat', + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + }, + }; + + assert.deepEqual( + filterInactiveOfflineNotificationClientIds(['client-a', 'client-b', 'client-c'], [activeSession] as any), + ['client-b', 'client-c'], + ); +}); + test('shouldSendOfflineChatNotification blocks chat push when app setting disables room notifications', () => { assert.equal( shouldSendOfflineChatNotification({ @@ -57,6 +81,83 @@ test('shouldSendOfflineChatNotification blocks chat push when app setting disabl ); }); +test('resolveChatContextAppOrigin returns normalized origin from session page url', () => { + assert.equal( + resolveChatContextAppOrigin({ + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + } as any), + 'https://preview.sm-home.cloud', + ); + assert.equal(resolveChatContextAppOrigin({ pageUrl: 'not-a-url' } as any), null); + assert.equal(resolveChatContextAppOrigin(null), null); +}); + +test('chat active-view suppression only blocks the requester client when that client app is active', () => { + const activeSession = { + sessionId: 'chat-room', + clientId: 'client-a', + socket: { readyState: 1 }, + lastSeenAt: Date.now(), + context: { + pageVisibilityState: 'visible' as const, + pageFocusState: 'focused' as const, + topMenu: 'chat', + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + }, + }; + + assert.equal(isChatClientActivelyViewing('client-a', [activeSession] as any), true); + assert.equal( + isChatClientActivelyViewing('client-a', [ + { + ...activeSession, + context: { ...activeSession.context, pageFocusState: 'blurred' }, + }, + ] as any), + false, + ); + assert.equal( + isChatClientActivelyViewing('client-a', [ + { + ...activeSession, + context: { ...activeSession.context, pageVisibilityState: 'hidden' }, + }, + ] as any), + false, + ); + assert.equal( + isChatClientActivelyViewing('client-a', [ + { + ...activeSession, + context: { + ...activeSession.context, + pageUrl: 'https://external.example.com/chat/live?sessionId=chat-room', + }, + }, + ] as any), + false, + ); + assert.equal( + isChatClientActivelyViewing('client-a', [ + { + ...activeSession, + clientId: 'client-b', + }, + ] as any), + false, + ); + assert.equal( + isChatClientActivelyViewing('client-a', [ + { + ...activeSession, + clientId: 'client-b', + }, + activeSession, + ] as any), + true, + ); +}); + test('isAutomationRegistrationCountRequest detects today automation registration count questions', () => { assert.equal(isAutomationRegistrationCountRequest('오늘 자동화 등록 총 건수'), true); assert.equal(isAutomationRegistrationCountRequest('today automation register count'), true); @@ -81,7 +182,7 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => { pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, - pageUrl: 'https://test.sm-home.cloud/chat/live', + pageUrl: 'https://preview.sm-home.cloud/chat/live', chatTypeLabel: 'API요청', chatTypeDescription: 'API 요청 본문을 정리합니다.', }, @@ -97,7 +198,7 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => { pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, - pageUrl: 'https://test.sm-home.cloud/chat/live', + pageUrl: 'https://preview.sm-home.cloud/chat/live', chatTypeLabel: 'API요청', chatTypeDescription: 'API 요청 본문을 정리합니다.', }, @@ -113,7 +214,7 @@ test('shouldUseTemplateMacroReply is disabled for chat types', () => { pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, - pageUrl: 'https://test.sm-home.cloud/chat/live', + pageUrl: 'https://preview.sm-home.cloud/chat/live', chatTypeLabel: '일반 요청', chatTypeDescription: '일반 요청', }, @@ -130,7 +231,7 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions' pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, - pageUrl: 'https://test.sm-home.cloud/chat/live', + pageUrl: 'https://preview.sm-home.cloud/chat/live', chatTypeLabel: '모바일 검증', chatTypeDescription: '모바일 캡처는 반드시 등록 토큰을 주입한 상태로 진행합니다.', }, @@ -139,9 +240,20 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions' { recentHistoryLines: ['[user] 이전에는 비로그인 화면으로 봤어'], omittedHistoryCount: 2, + sessionReferenceContent: [ + '# 채팅방 참고 리소스', + '', + '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.', + '', + '## 수동 메모', + '- 신규 방이어도 시작 전에 이 문서를 먼저 읽습니다.', + ].join('\n'), }, ); + assert.match(prompt, /## 채팅방 참고 문서/); + assert.match(prompt, /이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다\./); + assert.match(prompt, /신규 방이어도 시작 전에 이 문서를 먼저 읽습니다\./); assert.match(prompt, /## 채팅 유형 context 필수 규칙/); assert.match(prompt, /상위 필수 지시/); assert.match(prompt, /사용자 요청, 최근 대화 문맥, 화면 문맥과 충돌하면 채팅 유형 context를 우선/); @@ -156,9 +268,116 @@ test('buildAgenticCodexPrompt treats chat type context as required instructions' assert.match(prompt, /외부 공개 링크에만 사용하고, `\/api\/chat\/resources\/\.\.\.` 같은 내부 리소스에는 사용하지 마세요/); assert.match(prompt, /이 채팅방의 지속 참고 문서: public\/\.codex_chat\/session-a\/resource\/source\/chat-room-reference\.md/); assert.match(prompt, /작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요\./); + assert.ok(prompt.indexOf('## 채팅방 참고 문서') < prompt.indexOf('## 채팅 유형 context 필수 규칙')); assert.ok(prompt.indexOf('## 채팅 유형 context 필수 규칙') < prompt.indexOf('최근 대화 문맥(보조 참조)')); }); +test('buildAgenticCodexPrompt uses the resolved main project root instead of an env example path', () => { + const prompt = buildAgenticCodexPrompt( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live', + chatTypeLabel: '일반 요청', + chatTypeDescription: '일반 요청 설명', + }, + 'AGENTS 먼저 읽고 확인해줘', + 'session-path', + { + repoPath: '/home/how2ice/project/ai-code-app', + }, + ); + + assert.match(prompt, /저장소 루트\(main_project\): \/home\/how2ice\/project\/ai-code-app/); + assert.doesNotMatch(prompt, /저장소 루트\(main_project\): \/workspace\/main-project/); +}); + +test('buildAgenticCodexPrompt keeps running when session reference content is unavailable', () => { + const prompt = buildAgenticCodexPrompt( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live', + chatTypeLabel: '일반 요청', + chatTypeDescription: '일반 요청 설명', + }, + '바로 확인해줘', + 'session-no-reference', + { + sessionReferenceResourcePath: 'public/.codex_chat/session-no-reference/resource/source/chat-room-reference.md', + sessionReferenceContent: '', + }, + ); + + assert.match(prompt, /## 채팅방 참고 문서/); + assert.match(prompt, /현재 채팅방 참고 문서 본문을 불러오지 못했습니다\./); + assert.match(prompt, /위에 제공된 참고 문서 경로를 우선 확인하고/); +}); + +test('buildAgenticCodexPrompt keeps the chat type label provided by the client context', () => { + const prompt = buildAgenticCodexPrompt( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live', + chatTypeLabel: '일반 요청', + chatTypeDescription: '일반 요청 설명', + }, + '바로 코드 수정 기준으로 다음 단계를 이어서 진행해 주세요.', + 'session-general', + ); + + assert.match(prompt, /- label: 일반 요청/); + assert.doesNotMatch(prompt, /- label: 코드 수정/); +}); + +test('buildAgenticCodexPrompt includes room-selected default contexts as structured sections', () => { + const prompt = buildAgenticCodexPrompt( + { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live', + chatTypeLabel: '일반 요청', + chatTypeDescription: '합성된 설명', + chatTypeBaseDescription: '## 기본 처리\n- 채팅 유형 원문 규칙', + defaultContexts: [ + { + id: 'ctx-a', + title: '권한 관리 공통 문맥', + content: '## 권한 규칙\n- 채팅방에서 선택된 공통 문맥도 항상 반영합니다.', + }, + { + id: 'ctx-b', + title: '방 전용 공통 문맥', + content: '- 신규 방에서도 같은 규칙으로 동작해야 합니다.', + }, + ], + customContextTitle: '운영 메모', + customContextContent: '- preview 기준으로 검증합니다.', + }, + '실제 반영 상태를 확인해줘', + 'session-structured', + ); + + assert.match(prompt, /## 채팅 유형 context 원문/); + assert.match(prompt, /채팅 유형 원문 규칙/); + assert.match(prompt, /## 채팅방에서 선택한 공통 문맥/); + assert.match(prompt, /### 권한 관리 공통 문맥/); + assert.match(prompt, /채팅방에서 선택된 공통 문맥도 항상 반영합니다\./); + assert.match(prompt, /### 방 전용 공통 문맥/); + assert.match(prompt, /신규 방에서도 같은 규칙으로 동작해야 합니다\./); + assert.match(prompt, /## 채팅방 전용 Context · 운영 메모/); + assert.match(prompt, /preview 기준으로 검증합니다\./); +}); + test('ensureChatSessionReferenceResource creates a minimal per-room markdown resource without chat memo accumulation', async () => { const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-')); @@ -171,11 +390,11 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, - pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room', + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', chatTypeLabel: '일반 요청', chatTypeDescription: '일반 요청 설명', }, - input: '첫 요청', + input: '코드 수정 요청', recentHistoryLines: ['[user] 이전 요청'], omittedHistoryCount: 0, }); @@ -186,6 +405,10 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res const firstContent = await readFile(absolutePath, 'utf8'); assert.match(firstContent, /# 채팅방 참고 리소스/); assert.match(firstContent, /## 자동 갱신 문맥/); + assert.match(firstContent, /- 요청 상태: 실행 중/); + assert.match(firstContent, /- 요청 시작 시각: /); + assert.match(firstContent, /- 채팅 유형: 일반 요청/); + assert.doesNotMatch(firstContent, /- 요청 종료 시각: /); assert.doesNotMatch(firstContent, /## 수동 메모/); assert.doesNotMatch(firstContent, /## 최신 사용자 요청/); assert.doesNotMatch(firstContent, /## 최근 대화 요약/); @@ -199,10 +422,13 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: 'message-list', - pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room', + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', chatTypeLabel: '일반 요청', chatTypeDescription: '일반 요청 설명', }, + requestStatus: 'completed', + requestedAt: new Date('2026-05-14T06:00:00.000Z'), + completedAt: new Date('2026-05-14T06:00:05.000Z'), input: '둘째 요청', recentHistoryLines: ['[user] 첫 요청', '[codex] 첫 답변'], omittedHistoryCount: 1, @@ -210,10 +436,170 @@ test('ensureChatSessionReferenceResource creates a minimal per-room markdown res const updatedContent = await readFile(absolutePath, 'utf8'); assert.match(updatedContent, /request-2/); + assert.match(updatedContent, /- 요청 상태: 완료/); + assert.match(updatedContent, /- 요청 종료 시각: /); assert.doesNotMatch(updatedContent, /둘째 요청/); assert.doesNotMatch(updatedContent, /최근 문맥 일부만 포함했습니다/); }); +test('ensureChatSessionReferenceResource rewrites the auto section with terminal timing metadata', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-terminal-')); + + const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md'); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-started', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '기본처리', + chatTypeDescription: '기본처리 설명', + }, + requestStatus: 'started', + requestedAt: new Date('2026-05-14T06:00:00.000Z'), + input: '자동갱신 시작 시점 확인', + recentHistoryLines: [], + omittedHistoryCount: 0, + }); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-finished', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '기본처리', + chatTypeDescription: '기본처리 설명', + }, + requestStatus: 'failed', + requestedAt: new Date('2026-05-14T06:00:00.000Z'), + completedAt: new Date('2026-05-14T06:00:05.000Z'), + input: '자동갱신 종료 시점 확인', + recentHistoryLines: [], + omittedHistoryCount: 0, + }); + + const content = await readFile(absolutePath, 'utf8'); + assert.match(content, /request-finished/); + assert.match(content, /- 요청 상태: 실패/); + assert.match(content, /- 요청 시작 시각: /); + assert.match(content, /- 요청 종료 시각: /); + assert.doesNotMatch(content, /request-started/); +}); + +test('ensureChatSessionReferenceResource updates the same request to completed status', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-same-request-')); + const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md'); + const requestedAt = new Date('2026-05-14T06:00:00.000Z'); + const completedAt = new Date('2026-05-14T06:05:00.000Z'); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-same', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '기본처리', + chatTypeDescription: '기본처리 설명', + }, + requestStatus: 'started', + requestedAt, + input: '같은 요청 시작', + recentHistoryLines: [], + omittedHistoryCount: 0, + }); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-same', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '기본처리', + chatTypeDescription: '기본처리 설명', + }, + requestStatus: 'completed', + requestedAt, + completedAt, + input: '같은 요청 완료', + recentHistoryLines: [], + omittedHistoryCount: 0, + }); + + const content = await readFile(absolutePath, 'utf8'); + assert.match(content, /request-same/); + assert.match(content, /- 요청 상태: 완료/); + assert.match(content, /- 요청 시작 시각: 2026-05-14 15:00:00/); + assert.match(content, /- 요청 종료 시각: 2026-05-14 15:05:00/); +}); + +test('ensureChatSessionReferenceResource summarizes default contexts without copying full shared rules', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-summary-')); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-summary', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '기본처리', + chatTypeDescription: '합성된 설명', + chatTypeBaseDescription: '## 기본 처리\n- Plan 체크리스트 기준', + defaultContexts: [ + { + id: 'ctx-resource', + title: '개발 리소스 관리', + content: '## 리소스 관리 등록 기준\n- 리소스 관리 상세 규칙 본문', + }, + { + id: 'ctx-output', + title: '리소스 출력', + content: '## 리소스 출력\n- resource 경로 우선 규칙', + }, + ], + customContextTitle: '운영 메모', + customContextContent: '- 이 방에서는 preview 기준 검증만 유지합니다.', + }, + input: '공통 문맥 이관 이후 참고 문서 정리', + recentHistoryLines: [], + omittedHistoryCount: 0, + }); + + const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md'); + const content = await readFile(absolutePath, 'utf8'); + + assert.match(content, /## 현재 채팅 유형 context 요약/); + assert.match(content, /### 적용 중인 공통 문맥/); + assert.match(content, /- 개발 리소스 관리/); + assert.match(content, /- 리소스 출력/); + assert.match(content, /### 채팅방 전용 Context · 운영 메모/); + assert.match(content, /preview 기준 검증만 유지합니다\./); + assert.doesNotMatch(content, /## 채팅방에서 선택한 공통 문맥/); + assert.doesNotMatch(content, /리소스 관리 상세 규칙 본문/); + assert.doesNotMatch(content, /resource 경로 우선 규칙/); +}); + test('ensureChatSessionResourceDirectories keeps mixed-user chat session folders writable', async () => { const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-session-dirs-')); const resourceRoot = path.join(tempDir, 'public/.codex_chat/chat-room/resource'); @@ -261,7 +647,7 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho pageTitle: 'Codex Live', topMenu: 'chat', focusedComponentId: null, - pageUrl: 'https://test.sm-home.cloud/chat/live?sessionId=chat-room', + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', chatTypeLabel: '일반 요청', chatTypeDescription: '일반 요청 설명', }, @@ -278,16 +664,64 @@ test('ensureChatSessionReferenceResource rebuilds a corrupted auto section witho assert.doesNotMatch(rebuiltContent, /이전 응답 조각/); }); +test('ensureChatSessionReferenceResource recreates the file when the existing reference is read-only', async () => { + const tempDir = await mkdtemp(path.join(tmpdir(), 'chat-reference-readonly-')); + const absolutePath = path.join(tempDir, 'public/.codex_chat/chat-room/resource/source/chat-room-reference.md'); + await mkdir(path.dirname(absolutePath), { recursive: true, mode: 0o777 }); + await writeFile( + absolutePath, + [ + '# 채팅방 참고 리소스', + '', + '이 문서는 Codex Live가 이 채팅방에서 항상 먼저 확인하는 지속 리소스입니다.', + '', + '', + '## 자동 갱신 문맥', + '- requestId: stale-request', + '- 오래된 자동 갱신 본문', + '', + '', + ].join('\n'), + 'utf8', + ); + await chmod(path.dirname(absolutePath), 0o777); + await chmod(absolutePath, 0o444); + + await ensureChatSessionReferenceResource({ + repoPath: tempDir, + sessionId: 'chat-room', + requestId: 'request-readonly', + context: { + pageId: null, + pageTitle: 'Codex Live', + topMenu: 'chat', + focusedComponentId: null, + pageUrl: 'https://preview.sm-home.cloud/chat/live?sessionId=chat-room', + chatTypeLabel: '기본처리', + chatTypeDescription: '기본처리 설명', + }, + input: '자동 갱신 권한 복구 확인', + recentHistoryLines: [], + omittedHistoryCount: 0, + }); + + const rebuiltContent = await readFile(absolutePath, 'utf8'); + assert.match(rebuiltContent, /request-readonly/); + assert.match(rebuiltContent, /## 자동 갱신 문맥/); + assert.doesNotMatch(rebuiltContent, /stale-request/); + assert.doesNotMatch(rebuiltContent, /오래된 자동 갱신 본문/); +}); + test('extractChatMessageParts strips link-card markers into structured parts', () => { assert.deepEqual( - extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://test.sm-home.cloud/chat/live|열기]]'].join('\n')), + extractChatMessageParts(['결과 본문', '[[link-card:미리보기|https://preview.sm-home.cloud/chat/live|열기]]'].join('\n')), { strippedText: '결과 본문', parts: [ { type: 'link_card', title: '미리보기', - url: 'https://test.sm-home.cloud/chat/live', + url: 'https://preview.sm-home.cloud/chat/live', actionLabel: '열기', }, ], @@ -658,6 +1092,51 @@ test('extractChatMessageParts canonicalizes prompt preview resource urls from pu ); }); +test('extractChatMessageParts canonicalizes prompt preview resource urls from resource manager registered paths', () => { + assert.deepEqual( + extractChatMessageParts( + '[[prompt:{"title":"등록 리소스 선택","options":[{"label":"기능명세","value":"feature-spec","preview":{"type":"resource","url":"resource/Codex Live/리소스 관리/검색 및 아이콘 액션/20260513/docs/feature-spec.md"}}]}]]', + ), + { + strippedText: '', + parts: [ + { + type: 'prompt', + title: '등록 리소스 선택', + description: null, + submitLabel: null, + mode: null, + multiple: false, + responseTemplate: null, + freeTextLabel: null, + freeTextPlaceholder: null, + currentStepKey: null, + readOnly: false, + selectedValues: [], + resolvedBy: null, + resolvedAt: null, + resultText: null, + steps: undefined, + options: [ + { + label: '기능명세', + value: 'feature-spec', + description: null, + preview: { + type: 'resource', + url: '/api/resource-manager/preview/Codex%20Live/%EB%A6%AC%EC%86%8C%EC%8A%A4%20%EA%B4%80%EB%A6%AC/%EA%B2%80%EC%83%89%20%EB%B0%8F%20%EC%95%84%EC%9D%B4%EC%BD%98%20%EC%95%A1%EC%85%98/20260513/docs/feature-spec.md', + content: null, + alt: null, + title: null, + }, + }, + ], + }, + ], + }, + ); +}); + test('extractChatMessageParts promotes standalone markdown links into structured link cards', () => { assert.deepEqual( extractChatMessageParts(['결과 본문', '- [판매글 열기](https://www.daangn.com/kr/buy-sell/mac-studio)'].join('\n')), @@ -759,6 +1238,30 @@ test('extractCodexStreamText ignores command execution item completions', () => ); }); +test('parseStructuredCodexStdoutLine strips nested command execution JSON from raw failure text', () => { + assert.deepEqual( + parseStructuredCodexStdoutLine( + JSON.stringify({ + type: 'item.completed', + item: { + id: 'item_13', + type: 'command_execution', + command: '/bin/bash -lc "sed -n \'1,220p\' /home/how2ice/.codex/config.toml"', + aggregated_output: 'model = "gpt-5.4"', + exit_code: 0, + status: 'completed', + }, + }), + ), + { + activityLog: '# 결과: 완료(0)\n# 출력: model = "gpt-5.4"', + completedText: '', + deltaText: '', + shouldKeepRaw: false, + }, + ); +}); + test('extractCodexStreamText keeps completed agent messages', () => { assert.deepEqual( extractCodexStreamText({ diff --git a/etc/servers/work-server/src/services/chat-service.ts b/etc/servers/work-server/src/services/chat-service.ts index 2a5e9ef..8a1315b 100644 --- a/etc/servers/work-server/src/services/chat-service.ts +++ b/etc/servers/work-server/src/services/chat-service.ts @@ -1,22 +1,21 @@ -import { chmod, cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { chmod, cp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import webpush from 'web-push'; import type { FastifyBaseLogger } from 'fastify'; import type { IncomingMessage } from 'node:http'; import type { Socket } from 'node:net'; import { WebSocketServer, type RawData, type WebSocket } from 'ws'; import { env } from '../config/env.js'; import { db } from '../db/client.js'; -import { getAppConfigSnapshot } from './app-config-service.js'; +import { getAppConfigSnapshot, getChatContextSettingsConfig, getChatTypesConfig } from './app-config-service.js'; import { BOARD_POSTS_TABLE } from './board-service.js'; import { appendChatConversationMessage, appendChatConversationActivityLine, getChatConversationRequest, getChatConversation, + listChatConversationOfflineNotificationClientIds, listRecoverableChatConversationRequests, listChatConversationMessages, - listChatConversationOfflineNotificationClientIds, listChatConversationRequests, upsertChatConversationRequest, updateChatConversationJobState, @@ -24,8 +23,12 @@ import { } from './chat-room-service.js'; import { chatRuntimeService, type ChatRuntimeJobDetail, type ChatRuntimeSnapshot } from './chat-runtime-service.js'; import { hasErrorLogViewAccessToken } from './error-log-service.js'; -import { WEB_PUSH_SUBSCRIPTION_TABLE } from './notification-service.js'; +import { sendNotifications } from './notification-service.js'; import { createNotificationMessage } from './notification-message-service.js'; +import { + subscribeNotificationMessageChanges, + type NotificationMessageChangeEvent, +} from './notification-message-service.js'; import { extractChatMessageParts, type ChatMessagePart } from './chat-message-parts.js'; import { resolveMainProjectRoot } from './main-project-root-service.js'; import { @@ -61,9 +64,19 @@ type ChatContext = { pageUrl: string; isStandaloneMode?: boolean; pageVisibilityState?: 'visible' | 'hidden'; + pageFocusState?: 'focused' | 'blurred'; chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; + chatTypeBaseDescription?: string; + defaultContextIds?: string[]; + defaultContexts?: Array<{ + id?: string; + title?: string; + content?: string; + }>; + customContextTitle?: string | null; + customContextContent?: string | null; }; type ChatInboundMessage = @@ -93,6 +106,15 @@ type ChatInboundMessage = chatTypeId?: string | null; chatTypeLabel?: string; chatTypeDescription?: string; + chatTypeBaseDescription?: string; + defaultContextIds?: string[]; + defaultContexts?: Array<{ + id?: string; + title?: string; + content?: string; + }>; + customContextTitle?: string | null; + customContextContent?: string | null; }; } | { @@ -153,7 +175,22 @@ type ChatOutboundPayload = requestId: string; line: string; lineCount: number; + lineNo?: number; }; + } + | { + type: 'notification:messages-updated'; + payload: + | { + action: 'created' | 'updated'; + itemId: number; + category: string; + read: boolean; + } + | { + action: 'deleted'; + itemId: number; + }; }; type ChatOutboundMessage = ChatOutboundPayload & { @@ -225,61 +262,214 @@ const CHAT_PROMPT_HISTORY_MAX_MESSAGES = 12; const CHAT_PROMPT_HISTORY_MAX_CHARS = 3200; const CHAT_SESSION_EVENT_HISTORY_LIMIT = 400; let chatMessageSequence = 0; -function hasWebPushConfig() { - return Boolean( - env.WEB_PUSH_ENABLED && - env.WEB_PUSH_VAPID_PUBLIC_KEY?.trim() && - env.WEB_PUSH_VAPID_PRIVATE_KEY?.trim() && - env.WEB_PUSH_SUBJECT?.trim(), - ); + +export function isChatClientActivelyViewing(clientId: string | null | undefined, sessions: Iterable) { + return evaluateChatClientActiveViewing(clientId, sessions).isActive; } -function ensureChatWebPushConfigured() { - if (!hasWebPushConfig()) { - return false; +function evaluateChatClientActiveViewing(clientId: string | null | undefined, sessions: Iterable, nowMs = Date.now()) { + const normalizedClientId = clientId?.trim(); + + if (!normalizedClientId) { + return { + isActive: false, + matchedSessions: [], + }; } - webpush.setVapidDetails( - env.WEB_PUSH_SUBJECT!, - env.WEB_PUSH_VAPID_PUBLIC_KEY!.trim(), - env.WEB_PUSH_VAPID_PRIVATE_KEY!.trim(), - ); + const matchedSessions: Array<{ + sessionId: string; + lastSeenAt: number; + presenceAgeMs: number; + socketReadyState: number | null; + pageVisibilityState: 'visible' | 'hidden'; + pageFocusState: 'focused' | 'blurred'; + pageOrigin: string | null; + reason: + | 'socket-not-open' + | 'stale-presence' + | 'backgrounded' + | 'active-no-page-url' + | 'active-supported-origin' + | 'unsupported-origin' + | 'invalid-page-url'; + }> = []; - return true; -} + for (const session of sessions) { + if (session.clientId?.trim() !== normalizedClientId) { + continue; + } -function isChatSessionActivelyViewing(session: ChatSessionState) { - if (!session.socket || session.socket.readyState !== SOCKET_READY_STATE_OPEN) { - return false; - } + const pageVisibilityState = session.context?.pageVisibilityState ?? 'visible'; + const pageFocusState = session.context?.pageFocusState ?? 'focused'; + const pageUrl = session.context?.pageUrl?.trim(); + const presenceAgeMs = Math.max(0, nowMs - session.lastSeenAt); + const socketReadyState = session.socket?.readyState ?? null; - if (session.context?.pageVisibilityState === 'hidden') { - return false; - } + if (!session.socket || session.socket.readyState !== SOCKET_READY_STATE_OPEN) { + matchedSessions.push({ + sessionId: session.sessionId, + lastSeenAt: session.lastSeenAt, + presenceAgeMs, + socketReadyState, + pageVisibilityState, + pageFocusState, + pageOrigin: null, + reason: 'socket-not-open', + }); + continue; + } - if (session.context?.topMenu !== 'chat') { - return false; - } + const hasFreshPresence = presenceAgeMs < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS; - const pageUrl = session.context?.pageUrl?.trim(); + if (!hasFreshPresence) { + matchedSessions.push({ + sessionId: session.sessionId, + lastSeenAt: session.lastSeenAt, + presenceAgeMs, + socketReadyState, + pageVisibilityState, + pageFocusState, + pageOrigin: null, + reason: 'stale-presence', + }); + continue; + } + + if (pageVisibilityState === 'hidden' || pageFocusState === 'blurred') { + matchedSessions.push({ + sessionId: session.sessionId, + lastSeenAt: session.lastSeenAt, + presenceAgeMs, + socketReadyState, + pageVisibilityState, + pageFocusState, + pageOrigin: null, + reason: 'backgrounded', + }); + continue; + } + + if (!pageUrl) { + matchedSessions.push({ + sessionId: session.sessionId, + lastSeenAt: session.lastSeenAt, + presenceAgeMs, + socketReadyState, + pageVisibilityState, + pageFocusState, + pageOrigin: null, + reason: 'active-no-page-url', + }); + return { + isActive: true, + matchedSessions, + }; + } - if (pageUrl) { try { const resolvedUrl = new URL(pageUrl); - if (!resolvedUrl.pathname.startsWith('/chat/live')) { - return false; + if (resolvedUrl.origin === 'https://preview.sm-home.cloud' || resolvedUrl.origin === 'https://test.sm-home.cloud') { + matchedSessions.push({ + sessionId: session.sessionId, + lastSeenAt: session.lastSeenAt, + presenceAgeMs, + socketReadyState, + pageVisibilityState, + pageFocusState, + pageOrigin: resolvedUrl.origin, + reason: 'active-supported-origin', + }); + return { + isActive: true, + matchedSessions, + }; } + + matchedSessions.push({ + sessionId: session.sessionId, + lastSeenAt: session.lastSeenAt, + presenceAgeMs, + socketReadyState, + pageVisibilityState, + pageFocusState, + pageOrigin: resolvedUrl.origin, + reason: 'unsupported-origin', + }); } catch { - return false; + matchedSessions.push({ + sessionId: session.sessionId, + lastSeenAt: session.lastSeenAt, + presenceAgeMs, + socketReadyState, + pageVisibilityState, + pageFocusState, + pageOrigin: null, + reason: 'invalid-page-url', + }); + continue; } } - return Date.now() - session.lastSeenAt < CHAT_OFFLINE_PUSH_SUPPRESSION_WINDOW_MS; + return { + isActive: false, + matchedSessions, + }; +} + +function logChatClientActiveViewingEvaluation( + logger: FastifyBaseLogger, + clientId: string, + evaluation: ReturnType, +) { + if (evaluation.matchedSessions.length === 0) { + logger.info( + { + clientId, + active: false, + reason: 'no-matching-session', + }, + 'chat offline notification presence evaluation', + ); + return; + } + + for (const sessionState of evaluation.matchedSessions) { + logger.info( + { + clientId, + active: evaluation.isActive, + sessionId: sessionState.sessionId, + lastSeenAt: sessionState.lastSeenAt, + presenceAgeMs: sessionState.presenceAgeMs, + socketReadyState: sessionState.socketReadyState, + pageVisibilityState: sessionState.pageVisibilityState, + pageFocusState: sessionState.pageFocusState, + pageOrigin: sessionState.pageOrigin, + reason: sessionState.reason, + }, + 'chat offline notification presence evaluation', + ); + } +} + +export function resolveChatContextAppOrigin(context: ChatContext | null | undefined) { + const pageUrl = context?.pageUrl?.trim(); + + if (!pageUrl) { + return null; + } + + try { + return new URL(pageUrl).origin; + } catch { + return null; + } } function buildChatNotificationTargetUrl(context: ChatContext | null, sessionId: string) { - const fallbackUrl = new URL('https://test.sm-home.cloud/chat/live'); + const fallbackUrl = new URL('https://preview.sm-home.cloud/chat/live'); fallbackUrl.searchParams.set('topMenu', 'chat'); fallbackUrl.searchParams.set('sessionId', sessionId); @@ -380,6 +570,14 @@ export function collectOfflineNotificationClientIds(sessionClientId?: string | n return [...nextClientIds]; } +export function filterInactiveOfflineNotificationClientIds( + clientIds: string[], + sessions: Iterable, + isActive: (clientId: string, currentSessions: Iterable) => boolean = isChatClientActivelyViewing, +) { + return clientIds.filter((clientId) => !isActive(clientId, sessions)); +} + export function shouldSendOfflineChatNotification(options: { receiveRoomNotifications?: boolean | null; conversationNotifyOffline?: boolean | null; @@ -1153,6 +1351,43 @@ export function extractCodexStreamText(parsed: Record) { }; } +export function parseStructuredCodexStdoutLine(line: string) { + const normalizedLine = String(line ?? '').trim(); + + if (!normalizedLine) { + return { + activityLog: '', + completedText: '', + deltaText: '', + shouldKeepRaw: false, + }; + } + + let parsed: Record; + + try { + parsed = JSON.parse(normalizedLine) as Record; + } catch { + return { + activityLog: '', + completedText: '', + deltaText: '', + shouldKeepRaw: true, + }; + } + + const activityLog = extractCodexActivityLog(parsed); + const streamText = extractCodexStreamText(parsed); + const shouldKeepRaw = !activityLog && !streamText.completedText && !streamText.deltaText; + + return { + activityLog, + completedText: streamText.completedText, + deltaText: streamText.deltaText, + shouldKeepRaw, + }; +} + async function streamReplyChunks(text: string, onProgress?: (text: string) => void, chunkSize = 28, delayMs = 24) { const normalized = normalizeCodexReplyOutput(text); @@ -1601,17 +1836,216 @@ function cloneChatContext(context: ChatContext | null): ChatContext | null { return context ? { ...context } : null; } +function resolvePromptChatTypeLabel(context: ChatContext | null) { + return context?.chatTypeLabel?.trim() || ''; +} + +function normalizeChatContextIdList(ids?: string[] | null) { + if (!Array.isArray(ids)) { + return []; + } + + return Array.from( + new Set( + ids + .map((id) => id?.trim() || '') + .filter(Boolean), + ), + ); +} + +function normalizeChatContextEntries( + entries: ChatContext['defaultContexts'], +): Array<{ id: string; title: string; content: string }> { + if (!Array.isArray(entries)) { + return []; + } + + return entries + .map((entry) => ({ + id: entry?.id?.trim() || '', + title: entry?.title?.trim() || '', + content: entry?.content?.trim() || '', + })) + .filter((entry) => entry.content); +} + +function buildStructuredChatContextSections(context: ChatContext | null) { + const sections: string[] = []; + const baseDescription = context?.chatTypeBaseDescription?.trim() || ''; + const defaultContexts = normalizeChatContextEntries(context?.defaultContexts); + const customContextTitle = context?.customContextTitle?.trim() || ''; + const customContextContent = context?.customContextContent?.trim() || ''; + + if (baseDescription) { + sections.push(['## 채팅 유형 context 원문', baseDescription].join('\n')); + } + + if (defaultContexts.length > 0) { + sections.push( + [ + '## 채팅방에서 선택한 공통 문맥', + ...defaultContexts.map((entry) => + [`### ${entry.title || '공통 문맥'}`, entry.content].filter(Boolean).join('\n'), + ), + ].join('\n\n'), + ); + } + + if (customContextTitle || customContextContent) { + sections.push( + [`## 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`, customContextContent] + .filter(Boolean) + .join('\n'), + ); + } + + return sections; +} + +function buildChatSessionReferenceContextSummary(context: ChatContext | null) { + const lines: string[] = []; + const defaultContexts = normalizeChatContextEntries(context?.defaultContexts); + const customContextTitle = context?.customContextTitle?.trim() || ''; + const customContextContent = context?.customContextContent?.trim() || ''; + + lines.push('## 현재 채팅 유형 context 요약'); + lines.push('- 이 문서에는 채팅방 전용 메모와 현재 적용 중인 context 식별 정보만 짧게 유지합니다.'); + lines.push('- 공통 문맥 상세 원문은 공통 문맥 관리 데이터와 실행 prompt 본문을 기준으로 확인합니다.'); + + if (defaultContexts.length > 0) { + lines.push(''); + lines.push('### 적용 중인 공통 문맥'); + lines.push(...defaultContexts.map((entry) => `- ${entry.title || entry.id || '공통 문맥'}`)); + } + + if (customContextTitle || customContextContent) { + lines.push(''); + lines.push(`### 채팅방 전용 Context${customContextTitle ? ` · ${customContextTitle}` : ''}`); + lines.push(customContextContent || '- 전용 메모 본문 없음'); + } + + return lines.join('\n'); +} + +function composeResolvedChatTypeDescription( + baseDescription: string, + defaultContexts: Array<{ id: string; title: string; content: string }>, + customContextTitle?: string | null, + customContextContent?: string | null, +) { + const sections = [baseDescription.trim()].filter(Boolean); + + defaultContexts.forEach((context) => { + const normalizedContent = context.content.trim(); + + if (!normalizedContent) { + return; + } + + sections.push(`## 기본 유형 · ${context.title || '공통 문맥'}\n${normalizedContent}`); + }); + + const normalizedCustomContextTitle = customContextTitle?.trim() || ''; + const normalizedCustomContextContent = customContextContent?.trim() || ''; + + if (normalizedCustomContextTitle || normalizedCustomContextContent) { + sections.push( + [`## 채팅방 전용 Context${normalizedCustomContextTitle ? ` · ${normalizedCustomContextTitle}` : ''}`, normalizedCustomContextContent] + .filter(Boolean) + .join('\n'), + ); + } + + return sections.join('\n\n').trim(); +} + +export async function resolveCodexLiveChatContext(context: ChatContext | null, sessionId?: string | null) { + if (!context) { + return null; + } + + const normalizedChatTypeId = context.chatTypeId?.trim() || null; + + if (!normalizedChatTypeId) { + return cloneChatContext(context); + } + + const [chatTypesConfig, chatContextSettings] = await Promise.all([ + getChatTypesConfig(), + getChatContextSettingsConfig(), + ]); + const resolvedChatType = + chatTypesConfig.chatTypes.find((item) => item.id === normalizedChatTypeId && item.enabled) ?? null; + const roomContext = + chatContextSettings.roomContexts.find((item) => item.sessionId === (sessionId?.trim() || '')) ?? null; + const explicitDefaultContextIds = normalizeChatContextIdList(context.defaultContextIds); + const chatTypeDefaultContextIds = + chatContextSettings.chatTypeDefaults.find((item) => item.chatTypeId === normalizedChatTypeId)?.defaultContextIds ?? []; + const resolvedDefaultContextIds = + explicitDefaultContextIds.length > 0 + ? explicitDefaultContextIds + : roomContext?.defaultContextIds.length + ? roomContext.defaultContextIds + : chatTypeDefaultContextIds; + const resolvedDefaultContexts = resolvedDefaultContextIds + .map((contextId) => + chatContextSettings.defaultContexts.find((item) => item.id === contextId && item.enabled), + ) + .filter((item): item is NonNullable => Boolean(item)) + .map((item) => ({ + id: item.id, + title: item.title, + content: item.content, + })); + const resolvedCustomContextTitle = roomContext?.customContextTitle?.trim() || ''; + const resolvedCustomContextContent = roomContext?.customContextContent?.trim() || ''; + const resolvedBaseDescription = resolvedChatType?.description?.trim() || context.chatTypeBaseDescription?.trim() || ''; + const resolvedDescription = + composeResolvedChatTypeDescription( + resolvedBaseDescription, + resolvedDefaultContexts, + resolvedCustomContextTitle, + resolvedCustomContextContent, + ) || + context.chatTypeDescription?.trim() || + resolvedBaseDescription; + + return { + ...context, + chatTypeId: normalizedChatTypeId, + chatTypeLabel: resolvedChatType?.name ?? context.chatTypeLabel ?? '', + chatTypeDescription: resolvedDescription, + chatTypeBaseDescription: resolvedBaseDescription, + defaultContextIds: resolvedDefaultContextIds, + defaultContexts: resolvedDefaultContexts, + customContextTitle: resolvedCustomContextTitle || null, + customContextContent: resolvedCustomContextContent || null, + } satisfies ChatContext; +} + function buildChatSessionReferenceAutoSection(args: { context: ChatContext | null; sessionId: string; requestId: string; + requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled'; + requestedAt?: Date | null; + completedAt?: Date | null; + input?: string; }) { - const chatTypeLabel = args.context?.chatTypeLabel?.trim() || '일반 요청'; - const chatTypeDescription = args.context?.chatTypeDescription?.trim() || '없음'; + const chatTypeLabel = resolvePromptChatTypeLabel(args.context) || '없음'; const pageTitle = args.context?.pageTitle?.trim() || '없음'; const topMenu = args.context?.topMenu?.trim() || '없음'; const pageUrl = args.context?.pageUrl?.trim() || '없음'; const focusedComponentId = args.context?.focusedComponentId?.trim() || '없음'; + const requestStatusLabel = + args.requestStatus === 'completed' + ? '완료' + : args.requestStatus === 'failed' + ? '실패' + : args.requestStatus === 'cancelled' + ? '취소' + : '실행 중'; return [ CHAT_SESSION_REFERENCE_AUTO_START, @@ -1619,14 +2053,16 @@ function buildChatSessionReferenceAutoSection(args: { `- 마지막 갱신 시각: ${formatTime(new Date())}`, `- sessionId: ${args.sessionId}`, `- requestId: ${args.requestId}`, + `- 요청 상태: ${requestStatusLabel}`, + `- 요청 시작 시각: ${formatTime(args.requestedAt ?? new Date())}`, + ...(args.completedAt ? [`- 요청 종료 시각: ${formatTime(args.completedAt)}`] : []), `- 채팅 유형: ${chatTypeLabel}`, `- 화면 제목: ${pageTitle}`, `- topMenu: ${topMenu}`, `- focusedComponentId: ${focusedComponentId}`, `- pageUrl: ${pageUrl}`, '', - '## 현재 채팅 유형 context', - chatTypeDescription, + buildChatSessionReferenceContextSummary(args.context), CHAT_SESSION_REFERENCE_AUTO_END, ].join('\n'); } @@ -1654,11 +2090,33 @@ function mergeChatSessionReferenceContent(existingContent: string, autoSection: return `${trimmedExisting || defaultHeader}\n\n${autoSection}\n`; } +async function writeChatSessionReferenceContentSafely(absolutePath: string, content: string) { + try { + await writeFile(absolutePath, content, 'utf8'); + return; + } catch (error) { + if ( + !error || + typeof error !== 'object' || + !('code' in error) || + (error.code !== 'EACCES' && error.code !== 'EPERM') + ) { + throw error; + } + } + + await rm(absolutePath, { force: true }); + await writeFile(absolutePath, content, 'utf8'); +} + export async function ensureChatSessionReferenceResource(args: { repoPath: string; sessionId: string; requestId: string; context: ChatContext | null; + requestStatus?: 'started' | 'completed' | 'failed' | 'cancelled'; + requestedAt?: Date | null; + completedAt?: Date | null; input: string; recentHistoryLines: string[]; omittedHistoryCount: number; @@ -1670,6 +2128,10 @@ export async function ensureChatSessionReferenceResource(args: { context: args.context, sessionId: args.sessionId, requestId: args.requestId, + requestStatus: args.requestStatus, + requestedAt: args.requestedAt, + completedAt: args.completedAt, + input: args.input, }); let existingContent = ''; @@ -1683,17 +2145,44 @@ export async function ensureChatSessionReferenceResource(args: { const nextContent = mergeChatSessionReferenceContent(existingContent, autoSection); if (nextContent !== existingContent) { - await writeFile(absolutePath, nextContent, 'utf8'); + await writeChatSessionReferenceContentSafely(absolutePath, nextContent); } return resourceRelativePath; } +async function refreshChatSessionReferenceForRequest(args: { + sessionId: string; + requestId: string; + context: ChatContext | null; + input: string; + requestStatus: 'started' | 'completed' | 'failed' | 'cancelled'; + requestedAt: Date; + completedAt?: Date | null; +}) { + const repoPath = resolveMainProjectRoot(); + await ensureChatSessionReferenceResource({ + repoPath, + sessionId: args.sessionId, + requestId: args.requestId, + context: args.context, + requestStatus: args.requestStatus, + requestedAt: args.requestedAt, + completedAt: args.completedAt ?? null, + input: args.input, + recentHistoryLines: [], + omittedHistoryCount: 0, + }); +} + function buildChatTypeInstructionBlock(context: ChatContext | null) { - const chatTypeLabel = context?.chatTypeLabel?.trim() || ''; + const chatTypeLabel = resolvePromptChatTypeLabel(context); const chatTypeDescription = context?.chatTypeDescription?.trim() || ''; - const hasSpecificChatType = Boolean(chatTypeLabel && chatTypeLabel !== '일반 요청'); - const hasContextDescription = Boolean(chatTypeDescription && chatTypeDescription !== '없음'); + const structuredSections = buildStructuredChatContextSections(context); + const hasSpecificChatType = Boolean(chatTypeLabel); + const hasContextDescription = Boolean( + structuredSections.length > 0 || (chatTypeDescription && chatTypeDescription !== '없음'), + ); if (!hasSpecificChatType && !hasContextDescription) { return [ @@ -1715,7 +2204,29 @@ function buildChatTypeInstructionBlock(context: ChatContext | null) { `- label: ${chatTypeLabel || '없음'}`, '', '### 반드시 지킬 context 원문', - chatTypeDescription || '선택된 채팅 유형 context 원문 없음', + structuredSections.length > 0 + ? structuredSections.join('\n\n') + : (chatTypeDescription || '선택된 채팅 유형 context 원문 없음'), + ]; +} + +function buildChatSessionReferenceInstructionBlock(referenceContent?: string | null) { + const normalizedContent = referenceContent?.trim() || ''; + + if (!normalizedContent) { + return [ + '## 채팅방 참고 문서', + '- 현재 채팅방 참고 문서 본문을 불러오지 못했습니다.', + '- 그래도 위에 제공된 참고 문서 경로를 우선 확인하고, 채팅 유형 context와 AGENTS.md 규칙을 먼저 따르세요.', + ]; + } + + return [ + '## 채팅방 참고 문서', + '- 아래는 이 채팅방에서 시작 전에 먼저 읽어야 하는 참고 문서 원문입니다.', + '- 이 문서의 지시와 메모를 현재 요청 해석에 즉시 반영하세요.', + '', + normalizedContent, ]; } @@ -1724,12 +2235,14 @@ export function buildAgenticCodexPrompt( input: string, sessionId: string, promptContext?: { + repoPath?: string; recentHistoryLines?: string[]; omittedHistoryCount?: number; sessionReferenceResourcePath?: string; + sessionReferenceContent?: string; }, ) { - const repoPath = env.PLAN_MAIN_PROJECT_REPO_PATH || env.PLAN_GIT_REPO_PATH; + const repoPath = promptContext?.repoPath?.trim() || resolveMainProjectRoot(); const chatSessionResourceDir = `public/.codex_chat/${sessionId}/resource`; const chatSessionUploadDir = `${chatSessionResourceDir}/uploads`; const sessionReferenceResourcePath = @@ -1757,6 +2270,8 @@ export function buildAgenticCodexPrompt( '- 작업을 시작하기 전에 위 참고 문서를 항상 먼저 읽으세요.', '- 참고 문서는 필요한 기준만 짧게 유지하고, 최근 대화 요약이나 과한 메모 누적은 만들지 마세요.', '- 사용자가 참고 문서 수정을 요청하면 다른 산출물보다 먼저 위 참고 문서를 직접 수정하세요.', + ...buildChatSessionReferenceInstructionBlock(promptContext?.sessionReferenceContent), + '', ...buildChatTypeInstructionBlock(context), '', '응답 규칙:', @@ -1766,9 +2281,9 @@ export function buildAgenticCodexPrompt( '- 채팅 결과물 중 모바일 캡처나 최종 화면 검증 리소스는 링크 카드 대신 반드시 `[[preview:URL]]` 한 줄 문법으로 제공하세요.', '- `.md`, `.diff`, 소스코드, 로그 같은 문서성 리소스는 최종 화면 검증물이 아니므로 `[[preview:...]]`로 제공하지 마세요. 이런 리소스는 실제 생성이 확인된 경우에만 일반 경로로 언급하세요.', '- 링크를 본문과 분리된 결과 컴포넌트로 추가 제공해야 할 때만 최종 답변에 `[[link-card:제목|URL|버튼라벨]]` 한 줄 문법을 사용하세요. 이 문법은 외부 공개 링크에만 사용하고, `/api/chat/resources/...` 같은 내부 리소스에는 사용하지 마세요. URL은 `https://...` 원문 그대로 쓰고 구분자 `|`를 인코딩하지 마세요. 이 줄은 화면에서 별도 링크 카드로 렌더됩니다.', - '- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 세션 리소스 아래 실제 `.html` 파일을 만든 뒤 기본값으로 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 형태를 사용하세요. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.', - '- 코드 수정을 했다면 최종 답변에 변경 이력을 기본으로 ```diff 코드블록으로 포함하세요.', - '- 코드 수정이 있으면 마지막에 변경한 파일 경로를 짧게 적으세요.', + '- 단계형 선택지를 채팅방 컴포넌트로 보여주려면 `[[prompt:{"title":"질문","description":"설명","submitLabel":"선택 전달","mode":"queue","options":[{"label":"옵션 A","value":"option-a","description":"설명","preview":{"type":"image","url":"https://..."}}],"responseTemplate":"{{selection_label}} 기준으로 다음 단계를 이어서 진행해 주세요."}]]` 한 줄 JSON 문법을 사용하세요. stepper가 필요하면 `steps` 배열을 추가해 `{"key":"scope","title":"범위 선택","options":[...]}` 형태로 단계별 옵션을 나누고, 단계별 `optional`, `multiple`, `freeTextLabel`, `freeTextPlaceholder`, `responseTemplate`도 사용할 수 있습니다. 옵션별 `preview`는 `image`, `markdown`, `html`, `resource`를 지원하고, 아이콘 버튼으로 확대 preview 할 수 있습니다. HTML 문서를 iframe으로 바로 보여줘야 할 때는 현재 앱 URL이나 `/chat/...` 경로를 넣지 말고, 먼저 실제 `.html` 리소스를 준비한 뒤 `preview":{"type":"resource","url":"/api/chat/resources/.../sample.html"}` 또는 `preview":{"type":"resource","url":"resource/Codex Live/.../sample.html"}` 형태를 사용하세요. 리소스 관리에 등록된 `resource/...` 경로는 자동으로 `/api/resource-manager/preview/...`로 해석됩니다. `type:"html"`은 HTML 본문 문자열을 `content`에 직접 넣을 때만 사용합니다. 이미 결정된 결과를 읽기 전용으로 보여줄 때는 `readOnly`, `selectedValues`, `resolvedBy`, `resultText`를 함께 넣을 수 있습니다. 이 줄은 본문에서 숨겨지고 prompt 컴포넌트로 렌더됩니다.', + '- 변경된 소스 파일이 있는 경우에만 최종 답변에 변경 이력을 ```diff 코드블록으로 포함하세요.', + '- 변경된 소스 파일이 있으면 마지막에 해당 파일 경로를 짧게 적으세요.', '- 한국어로 간결하게 답하세요.', '', '참고 화면 정보:', @@ -1933,6 +2448,7 @@ async function runAgenticCodexReply( requestId: string, options?: { omitPromptHistory?: boolean; + requestedAt?: Date | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, @@ -1940,6 +2456,7 @@ async function runAgenticCodexReply( ) { const repoPath = resolveMainProjectRoot(); await validateAgenticCodexRuntime(repoPath, env.PLAN_CODEX_BIN); + const resolvedContext = await resolveCodexLiveChatContext(context, sessionId); const appConfig = await getAppConfigSnapshot(); const recentHistory = await buildRecentChatPromptHistory(sessionId, requestId, { maxMessages: appConfig.chat?.maxContextMessages, @@ -1956,19 +2473,48 @@ async function runAgenticCodexReply( Number.isFinite(appConfig.chat.codexLiveIdleTimeoutSeconds) ? Math.min(3600, Math.max(30, Math.round(appConfig.chat.codexLiveIdleTimeoutSeconds))) : null; + const requestedAt = options?.requestedAt ?? new Date(); + const syncSessionReference = async ( + requestStatus: 'started' | 'completed' | 'failed' | 'cancelled', + completedAt?: Date | null, + ) => + ensureChatSessionReferenceResource({ + repoPath, + sessionId, + requestId, + context: resolvedContext, + requestStatus, + requestedAt, + completedAt: completedAt ?? null, + input, + recentHistoryLines: recentHistory.items, + omittedHistoryCount: recentHistory.omittedCount, + }); const sessionReferenceResourcePath = await ensureChatSessionReferenceResource({ repoPath, sessionId, requestId, - context, + context: resolvedContext, + requestStatus: 'started', + requestedAt, input, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, }); - const prompt = buildAgenticCodexPrompt(context, input, sessionId, { + const sessionReferenceAbsolutePath = path.join(repoPath, sessionReferenceResourcePath); + let sessionReferenceContent = ''; + + try { + sessionReferenceContent = await readFile(sessionReferenceAbsolutePath, 'utf8'); + } catch { + sessionReferenceContent = ''; + } + const prompt = buildAgenticCodexPrompt(resolvedContext, input, sessionId, { + repoPath, recentHistoryLines: recentHistory.items, omittedHistoryCount: recentHistory.omittedCount, sessionReferenceResourcePath, + sessionReferenceContent, }); let streamedOutput = ''; let stdoutTail = ''; @@ -2123,6 +2669,22 @@ async function runAgenticCodexReply( return; } + if (eventType === 'attached') { + const attachedRequestId = String(parsed.requestId ?? '').trim(); + const completed = parsed.completed === true; + const attachSummary = + attachedRequestId && attachedRequestId === requestId + ? completed + ? '기존 command-runner 실행 이력을 다시 연결했습니다.' + : '기존 command-runner 실행에 재부착했습니다.' + : completed + ? '기존 command-runner 응답 이력을 다시 연결했습니다.' + : '기존 command-runner 실행 스트림에 다시 연결했습니다.'; + chatRuntimeService.appendLog(requestId, attachSummary); + onActivity?.(`# ${attachSummary}`); + return; + } + if (eventType === 'activity') { const activityLog = String(parsed.line ?? '').trim(); @@ -2155,6 +2717,30 @@ async function runAgenticCodexReply( const stdoutLine = String(parsed.line ?? '').trim(); if (stdoutLine) { + const structuredStdout = parseStructuredCodexStdoutLine(stdoutLine); + + if (!structuredStdout.shouldKeepRaw) { + if (structuredStdout.activityLog) { + chatRuntimeService.appendLog(requestId, structuredStdout.activityLog); + onActivity?.(structuredStdout.activityLog); + } + + if (structuredStdout.deltaText) { + hasIncrementalDelta = true; + emitProgress(`${streamedOutput}${structuredStdout.deltaText}`); + } + + if (structuredStdout.completedText) { + completedAgentMessage = structuredStdout.completedText.trim(); + + if (completedAgentMessage && hasIncrementalDelta) { + emitProgress(completedAgentMessage); + } + } + + return; + } + stdoutTail = `${stdoutTail}\n${stdoutLine}`.slice(-STREAM_CAPTURE_LIMIT); chatRuntimeService.appendLog(requestId, `[stdout] ${stdoutLine}`); onActivity?.(`[stdout] ${stdoutLine}`); @@ -2217,6 +2803,8 @@ async function runAgenticCodexReply( } }); } catch (error) { + const completedAt = new Date(); + await syncSessionReference(error instanceof Error && error.message === 'CHAT_RUNTIME_CANCELLED' ? 'cancelled' : 'failed', completedAt); const failureResponseText = await finalizeReplyOutput(); if (failureResponseText) { @@ -2226,6 +2814,7 @@ async function runAgenticCodexReply( throw error; } + await syncSessionReference('completed', new Date()); return await finalizeReplyOutput(); } @@ -2313,8 +2902,6 @@ function shouldSkipActivityProgressSummary(text: string) { return ( !text || /^요청을 처리합니다\./.test(text) || - /^응답을 실시간으로 전송 중입니다\./.test(text) || - /^응답 생성이 완료되었습니다\./.test(text) || /^완료(?:\(\d+\))?$/i.test(text) || /^종료\(\d+\)$/i.test(text) ); @@ -2351,6 +2938,73 @@ export function summarizeActivityProgressLine(activityLine: string) { return ''; } +function stripActivityLinePrefix(line: string) { + return line.replace(/^#\s*(상태|진행|이유|경고|오류):\s*/u, '').trim(); +} + +function resolveCompactActivityStageLineNo(summary: string, normalizedLine: string) { + if (!summary) { + return null; + } + + if (/^#\s*오류:/u.test(normalizedLine) || /오류|실패|중단/u.test(summary)) { + return 5; + } + + if (/요청을 처리합니다|대기열|즉시 요청 실행/u.test(summary)) { + return 1; + } + + if (/분석|의도|문맥|생각 중/u.test(summary)) { + return 2; + } + + if (/\bdb\b|데이터베이스|\bapi\b|엔드포인트|응답|소스|코드|파일|흐름|쿼리|집계|resource|리소스|화면/u.test(summary)) { + return 3; + } + + if (/구현|수정|변경|작성|빌드|patch|diff|전송 중/u.test(summary)) { + return 4; + } + + if (/검증|테스트|캡처|preview|완료|결과|정리/u.test(summary)) { + return 5; + } + + return null; +} + +function createCompactActivityLogEntry(activityLine: string) { + const normalizedLine = normalizeProgressSummary(activityLine); + + if (!normalizedLine) { + return null; + } + + const strippedSummary = stripActivityLinePrefix(normalizedLine); + const summarizedProgress = summarizeActivityProgressLine(normalizedLine); + const summary = summarizedProgress || strippedSummary; + const lineNo = resolveCompactActivityStageLineNo(summary, normalizedLine); + + if (!lineNo || !summary) { + return null; + } + + if (/^#\s*오류:/u.test(normalizedLine)) { + return { + line: `# 오류: ${summary}`, + lineNo, + }; + } + + const prefix = lineNo === 1 || lineNo === 5 ? '# 상태:' : '# 진행:'; + + return { + line: `${prefix} ${summary}`, + lineNo, + }; +} + function buildGenericReply(context: ChatContext | null, input: string, snapshot: PlanSnapshot | null) { const normalized = input.toLowerCase(); const pageTitle = context?.pageTitle ?? '현재 화면'; @@ -2562,6 +3216,7 @@ async function buildCodexReply( requestId: string, options?: { omitPromptHistory?: boolean; + requestedAt?: Date | null; }, onProgress?: (text: string) => void, onActivity?: (line: string) => void, @@ -2586,6 +3241,7 @@ export class ChatService { private readonly sessions = new Map(); private readonly cancelledRequestIds = new Set(); private readonly unsubscribeRuntimeBroadcast: () => void; + private readonly unsubscribeNotificationBroadcast: () => void; constructor(private readonly logger: FastifyBaseLogger) { activeChatService = this; @@ -2598,6 +3254,9 @@ export class ChatService { this.unsubscribeRuntimeBroadcast = chatRuntimeService.subscribe((snapshot) => { this.broadcastRuntimeSnapshot(snapshot); }); + this.unsubscribeNotificationBroadcast = subscribeNotificationMessageChanges((event) => { + this.broadcastNotificationMessageChange(event); + }); this.wss.on('connection', (socket: WebSocket, request: IncomingMessage) => { void this.handleConnection(socket, request).catch((error: unknown) => { @@ -2634,6 +3293,7 @@ export class ChatService { activeChatService = null; } this.unsubscribeRuntimeBroadcast(); + this.unsubscribeNotificationBroadcast(); for (const execution of activeChatProcessRegistry.values()) { void Promise.resolve(execution.cancel()).catch(() => { @@ -2873,15 +3533,21 @@ export class ChatService { this.persistConversationMessage(session, normalizedMessage.payload); if (normalizedMessage.payload.author === 'codex' && options?.skipOfflineNotification !== true) { - void this.sendOfflineNotificationIfNeeded(session, normalizedMessage.payload).catch((error: unknown) => { - this.logger.error(error, 'failed to send offline chat notification'); - }); + void this.sendOfflineNotificationBestEffort(session, normalizedMessage.payload); } } return envelope; } + private async sendOfflineNotificationBestEffort(session: ChatSessionState, message: ChatMessage) { + try { + await this.sendOfflineNotificationIfNeeded(session, message); + } catch (error: unknown) { + this.logger.error(error, 'failed to send offline chat notification; request processing will continue'); + } + } + private updateMessageInSession(session: ChatSessionState, message: ChatMessage) { const normalizedMessage = normalizeStructuredChatMessage(message); const envelope = this.createSessionEnvelope(session, { @@ -2913,6 +3579,28 @@ export class ChatService { } } + private broadcastNotificationMessageChange(event: NotificationMessageChangeEvent) { + for (const session of this.sessions.values()) { + const payload = + event.action === 'deleted' + ? { + action: 'deleted' as const, + itemId: event.id, + } + : { + action: event.action, + itemId: event.item.id, + category: event.item.category, + read: event.item.read, + }; + const envelope = this.createSessionEnvelope(session, { + type: 'notification:messages-updated', + payload, + }); + sendSocketEnvelope(this.logger, session.socket, envelope, 'failed to send notification message update envelope'); + } + } + getSessionClientIdMap() { return new Map( [...this.sessions.entries()].map(([sessionId, session]) => [sessionId, session.clientId?.trim() || null]), @@ -3152,9 +3840,10 @@ export class ChatService { return; } + const appOrigin = resolveChatContextAppOrigin(session.context); const [conversation, appConfig] = await Promise.all([ getChatConversation(session.sessionId, session.clientId), - getAppConfigSnapshot(), + getAppConfigSnapshot(appOrigin), ]); if ( @@ -3166,6 +3855,41 @@ export class ChatService { return; } + const requestOwnerClientId = message.clientRequestId?.trim() + ? (await getChatConversationRequest(session.sessionId, message.clientRequestId).catch(() => null)) + ?.requesterClientId?.trim() || null + : null; + + if (!requestOwnerClientId) { + return; + } + + const preferredNotificationClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId, { + keepClientIds: [session.clientId, requestOwnerClientId], + }).catch(() => []); + const notificationCandidateClientIds = collectOfflineNotificationClientIds(session.clientId, [ + requestOwnerClientId, + ...preferredNotificationClientIds, + ]); + const notificationTargetClientIds = notificationCandidateClientIds.filter((clientId) => { + const evaluation = evaluateChatClientActiveViewing(clientId, this.sessions.values()); + logChatClientActiveViewingEvaluation(this.logger, clientId, evaluation); + return !evaluation.isActive; + }); + + if (!notificationTargetClientIds.length) { + this.logger.info( + { + sessionId: session.sessionId, + messageId: message.id, + requestOwnerClientId, + notificationCandidateClientIds, + }, + 'chat offline notification skipped because every target client is actively viewing', + ); + return; + } + const notificationPayload = await this.buildOfflineChatNotificationPayload( session, message, @@ -3176,66 +3900,32 @@ export class ChatService { return; } - await this.createOfflineAppNotificationIfNeeded(notificationPayload); + await this.createOfflineAppNotificationIfNeeded(notificationPayload, notificationTargetClientIds); - if (!ensureChatWebPushConfigured()) { - return; - } - - const preferredClientIds = await listChatConversationOfflineNotificationClientIds(session.sessionId).catch(() => []); - const notificationTargetClientIds = collectOfflineNotificationClientIds(session.clientId, preferredClientIds); - - if (!notificationTargetClientIds.length) { - return; - } - - const rows = await db(WEB_PUSH_SUBSCRIPTION_TABLE) - .where({ - is_enabled: true, - }) - .andWhere((builder) => { - builder.whereIn('device_id', notificationTargetClientIds).orWhereNull('device_id').orWhere('device_id', ''); - }) - .select('endpoint', 'subscription_json'); - - if (!rows.length) { - return; - } - - const uniqueRows = rows.filter((row, index, currentRows) => { - const endpoint = String(row.endpoint ?? '').trim(); - - if (!endpoint) { - return false; - } - - return currentRows.findIndex((candidate) => String(candidate.endpoint ?? '').trim() === endpoint) === index; - }); - - const payloadText = JSON.stringify({ - title: notificationPayload.title, - body: notificationPayload.body, - data: notificationPayload.data, - threadId: notificationPayload.threadId, - }); - await Promise.all( - uniqueRows.map(async (row) => { - try { - await webpush.sendNotification(row.subscription_json as Record, payloadText); - } catch (error: any) { - const statusCode = Number(error?.statusCode ?? 0); - - this.logger.warn( - { - endpoint: String(row.endpoint ?? ''), - statusCode: statusCode || undefined, - detail: normalizeNotificationDetailText(error?.body) ?? normalizeNotificationDetailText(error?.message), - }, - 'chat webpush delivery failed; subscription preserved', - ); - } - }), + const result = await sendNotifications( + { + title: notificationPayload.title, + body: notificationPayload.body, + data: notificationPayload.data, + threadId: notificationPayload.threadId, + targetClientIds: notificationTargetClientIds, + }, + { + disableIos: true, + }, ); + + if (!result.web.ok && !result.web.skipped) { + this.logger.warn( + { + sessionId: session.sessionId, + messageId: message.id, + targetClientIds: notificationTargetClientIds, + webPush: result.web, + }, + 'chat webpush delivery reported failures', + ); + } } private async createOfflineAppNotificationIfNeeded( @@ -3246,6 +3936,7 @@ export class ChatService { metadata: Record; linkUrl: string; }, + targetClientIds: string[], ) { await createNotificationMessage({ title: payload.title, @@ -3255,6 +3946,7 @@ export class ChatService { priority: 'normal', metadata: { ...payload.metadata, + targetClientIds, linkUrl: payload.linkUrl, linkLabel: '채팅 바로 열기', previewText: payload.previewText, @@ -3267,12 +3959,6 @@ export class ChatService { message: ChatMessage, conversationTitle: string, ) { - const isBackgroundedSession = session.context?.pageVisibilityState === 'hidden'; - - if (session.socket && session.socket.readyState === SOCKET_READY_STATE_OPEN && !isBackgroundedSession) { - return null; - } - const answerText = String(message.text ?? '').trim(); if (!answerText || isPreparingChatReply(answerText)) { @@ -3311,6 +3997,7 @@ export class ChatService { targetUrl: linkUrl, notificationKey: `chat:${session.sessionId}:${message.id}`, type: 'chat-reply', + suppressIfVisible: 'true', }; return { @@ -3535,6 +4222,11 @@ export class ChatService { chatTypeId: message.payload.chatTypeId ?? null, chatTypeLabel: message.payload.chatTypeLabel, chatTypeDescription: message.payload.chatTypeDescription, + chatTypeBaseDescription: message.payload.chatTypeBaseDescription, + defaultContextIds: message.payload.defaultContextIds, + defaultContexts: message.payload.defaultContexts, + customContextTitle: message.payload.customContextTitle, + customContextContent: message.payload.customContextContent, }, { omitPromptHistory: message.payload.omitPromptHistory === true, @@ -3640,7 +4332,7 @@ export class ChatService { } if (contextOverride) { - state.context = { + const mergedContext = { ...(state.context ?? { pageId: null, pageTitle: '', @@ -3650,6 +4342,7 @@ export class ChatService { }), ...contextOverride, }; + state.context = (await resolveCodexLiveChatContext(mergedContext, state.sessionId)) ?? mergedContext; void updateChatConversationContext(state.sessionId, { clientId: state.clientId, chatTypeId: state.context.chatTypeId ?? null, @@ -3730,7 +4423,7 @@ export class ChatService { ) { let terminalStatus: 'completed' | 'failed' | 'cancelled' = 'completed'; let hasAnnouncedStreaming = false; - const activityLines: string[] = []; + const compactActivityLineMap = new Map(); session.activeRequestCount += 1; const existingRequest = await getChatConversationRequest(session.sessionId, request.requestId); const hasStoredUserMessage = existingRequest?.userMessageId != null; @@ -3746,22 +4439,38 @@ export class ChatService { } const appendActivityLine = (line: string) => { - const normalized = line.trim(); + const compactEntry = createCompactActivityLogEntry(line); - if (!normalized) { + if (!compactEntry) { return; } - activityLines.push(normalized); - void appendChatConversationActivityLine(session.sessionId, request.requestId, normalized).catch((error: unknown) => { - this.logger.warn(error, 'failed to persist chat activity line'); + const previousLine = compactActivityLineMap.get(compactEntry.lineNo); + + if (previousLine === compactEntry.line) { + return; + } + + compactActivityLineMap.set(compactEntry.lineNo, compactEntry.line); + const activityLines = Array.from(compactActivityLineMap.entries()) + .sort((left, right) => left[0] - right[0]) + .map(([, activityLine]) => activityLine); + + void appendChatConversationActivityLine( + session.sessionId, + request.requestId, + compactEntry.line, + compactEntry.lineNo, + ).catch((error: unknown) => { + this.logger.warn(error, 'failed to persist compact chat activity line'); }); this.sendToSession(session, { type: 'chat:activity', payload: { requestId: request.requestId, - line: normalized, + line: compactEntry.line, lineCount: activityLines.length, + lineNo: compactEntry.lineNo, }, }); const activityMessage = createActivityLogMessage(request.requestId, activityLines); @@ -3880,6 +4589,7 @@ export class ChatService { request.requestId, { omitPromptHistory: request.omitPromptHistory === true, + requestedAt: new Date(request.requestedAtMs), }, (partialReply) => { stopProgressTimer(); @@ -3942,6 +4652,15 @@ export class ChatService { responseMessageId: finalCodexReplyMessage.id, responseText: finalCodexReplyMessage.text, }); + await refreshChatSessionReferenceForRequest({ + sessionId: session.sessionId, + requestId: request.requestId, + context: request.context ?? session.context ?? null, + input: request.text, + requestStatus: 'completed', + requestedAt: new Date(request.requestedAtMs), + completedAt: new Date(), + }); const terminalMessageEnvelope = this.sendToSession(session, { type: 'chat:message', @@ -3962,7 +4681,7 @@ export class ChatService { message: '요청 처리 완료', }); chatRuntimeService.finishJob(request.requestId, 'completed'); - await this.sendOfflineNotificationIfNeeded(session, finalCodexReplyMessage); + await this.sendOfflineNotificationBestEffort(session, finalCodexReplyMessage); } catch (error) { const wasCancelled = this.cancelledRequestIds.has(request.requestId); terminalStatus = wasCancelled ? 'cancelled' : 'failed'; @@ -4030,6 +4749,15 @@ export class ChatService { queueSize: session.queue.length, message: wasCancelled ? '요청 실행 중단' : '요청 처리 실패', }); + await refreshChatSessionReferenceForRequest({ + sessionId: session.sessionId, + requestId: request.requestId, + context: request.context ?? session.context ?? null, + input: request.text, + requestStatus: wasCancelled ? 'cancelled' : 'failed', + requestedAt: new Date(request.requestedAtMs), + completedAt: new Date(), + }); throw error; } finally { stopProgressTimer(); diff --git a/etc/servers/work-server/src/services/chat-type-defaults.js b/etc/servers/work-server/src/services/chat-type-defaults.js index d4c0680..7406ff7 100644 --- a/etc/servers/work-server/src/services/chat-type-defaults.js +++ b/etc/servers/work-server/src/services/chat-type-defaults.js @@ -1,54 +1,9 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.SEEDED_CUSTOM_CHAT_TYPES = exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = exports.DEFAULT_CHAT_TYPES = void 0; -exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE = { - id: 'plan-checklist-execution', - name: 'Plan 체크리스트 실행', - description: '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-05-08T00:00:00.000Z', -}; -exports.SEEDED_CUSTOM_CHAT_TYPES = [exports.PLAN_CHECKLIST_CUSTOM_CHAT_TYPE]; -exports.DEFAULT_CHAT_TYPES = [ - { - id: 'general-request', - name: '일반 요청', - description: '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-05-08T00:00:00.000Z', - }, - { - id: 'md-context-managed', - name: 'MD 기준 관리', - description: '## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-05-08T00:00:00.000Z', - }, - { - id: 'layout-editor-execution', - name: 'Layout editor 실행', - description: '## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 대체하지 않고 `[[preview:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-27T00:00:00.000Z', - }, - { - id: 'api-request-template', - name: 'API요청', - description: '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-16T00:00:00.000Z', - }, - { - id: 'general-inquiry', - name: '일반 문의', - description: '## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat//resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-24T00:00:00.000Z', - }, -]; +exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = exports.UI_IMPROVEMENT_CHAT_TYPE_ID = void 0; +exports.UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement'; +exports.UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선'; +exports.UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = '## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.'; +exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution'; +exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행'; +exports.PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.'; diff --git a/etc/servers/work-server/src/services/chat-type-defaults.ts b/etc/servers/work-server/src/services/chat-type-defaults.ts index 25ee058..278b7b4 100644 --- a/etc/servers/work-server/src/services/chat-type-defaults.ts +++ b/etc/servers/work-server/src/services/chat-type-defaults.ts @@ -1,70 +1,9 @@ -export type DefaultChatTypeRecord = { - id: string; - name: string; - description: string; - permissions: Array<'guest' | 'token-user'>; - enabled: boolean; - updatedAt: string; -}; +export const UI_IMPROVEMENT_CHAT_TYPE_ID = 'ui-improvement'; +export const UI_IMPROVEMENT_CHAT_TYPE_NAME = 'UI개선'; +export const UI_IMPROVEMENT_CHAT_TYPE_DESCRIPTION = + '## 기본 처리\n- 화면 구조, 간격, 정렬, 가독성, 반응형 동작 같은 UI 개선 요청에 우선 사용합니다.\n- 실제 수정 범위와 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 요청에 포함된 화면 문제가 재현되면 원인 확인, 수정, 검증 순서로 진행합니다.\n\n## 구현 기준\n- 기존 디자인 흐름을 해치지 않는 선에서 불필요한 중첩, 깨진 여백, 가려지는 액션, 과한 스타일 중복을 함께 정리합니다.\n- 데스크톱과 모바일 레이아웃을 함께 보고, 특히 모바일에서 마지막 입력이나 주요 액션이 가려지지 않게 유지합니다.\n- 이전 처리에서 불필요해진 CSS, 분기, 임시 보정값은 가능하면 함께 제거합니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경을 우선합니다.\n- 운영 접속 확인은 `https://test.sm-home.cloud/`, 소스 변경 검증은 `https://preview.sm-home.cloud/` 기준으로 진행합니다.\n- 등록 토큰이 필요한 화면은 토큰 주입 상태에서 확인합니다.\n- 최종 화면 검증 결과는 `[[preview:URL]]` 형식으로 제공합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 활동로그 아래 표시되는 Plan 체크리스트는 현재 요청 단계에 맞춰 유지합니다.'; export const PLAN_CHECKLIST_DEFAULT_CONTEXT_ID = 'chat-default-plan-checklist-execution'; export const PLAN_CHECKLIST_DEFAULT_CONTEXT_TITLE = 'Plan 체크리스트 실행'; export const PLAN_CHECKLIST_DEFAULT_CONTEXT_CONTENT = - '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.'; - -export const DEFAULT_CHAT_TYPES: DefaultChatTypeRecord[] = [ - { - id: 'general-request', - name: '일반 요청', - description: - '## 기본 처리\n- 실제 수정 범위와 방식은 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 최근 대화 내용과 현재 요청은 채팅 유형 context를 해치지 않는 범위에서만 보조 문맥으로 참조합니다.\n- 실패 시에는 현재 세션에서 수정한 소스만 범위를 좁혀 되돌립니다.\n\n## 문맥과 리소스\n- 채팅 유형 정보는 대화방 내용보다 먼저 읽고 적용합니다.\n- 산출물과 첨부 리소스는 `public/.codex_chat//resource/` 기준으로 제공합니다.\n- `[[link-card:...]]`는 외부 공개 링크에만 사용하고, 내부 리소스(`/api/chat/resources/...`, `/.codex_chat/...`)에는 사용하지 않습니다.\n- 내부 문서, 로그, 코드, 테이블 파일은 일반 리소스 URL이나 preview 컴포넌트로 바로 열 수 있는 형식으로 제공합니다.\n- prompt 옵션의 preview에 HTML 문서를 붙일 때는 현재 앱 주소나 `/chat/...` 같은 화면 URL을 넣지 말고, 기본값으로 `preview.type="resource"`와 `/api/chat/resources/.../*.html` 형태의 실제 세션 리소스 URL을 사용합니다.\n- `preview.type="html"`은 완성된 HTML 본문 자체를 `content`로 직접 넣을 때만 사용하고, URL만 전달할 때는 세션 HTML 리소스를 만든 뒤 `resource` preview로 연결합니다.\n\n## 검증\n- 사소한 UI 변경이 있더라도 기본값은 모바일 브라우저 환경에서 캡처하고 검증합니다.\n- 모바일 검증은 `ai-code-app-preview` 컨테이너 기준으로 진행합니다.\n- 외부 접속 확인은 `https://test.sm-home.cloud/` 기준으로 진행합니다.\n- 모바일 캡처는 루트 `.env`의 등록 토큰을 `localStorage`에 주입한 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 채팅 결과물 중 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 바꾸지 말고 반드시 `[[preview:URL]]` 형식으로 제공합니다.\n- preview 컨테이너만 필요시 재기동 후 검증합니다.\n- preview 외 다른 서버나 컨테이너는 종료하거나 재기동하지 않습니다.\n- 추가 재기동이나 후속 테스트가 필요하면 직접 처리하지 말고 앱알림으로 필요한 대상과 사유를 안내합니다.\n- 캡처 화면으로 요청사항 반영 여부를 다시 확인하고, 부족하면 추가 작업을 이어서 진행합니다.\n\n## 구현 기준\n- 활동로그 아래 표시되는 Plan 체크리스트는 일반 요청에서도 유지하고, 현재 요청 진행 단계에 맞춰 항목과 상태를 자연스럽게 반영합니다.\n- 소스는 덕지덕지 붙지 않게 정리하고, 분리할 부분은 분리합니다.\n- 이전 처리에서 불필요해진 부분은 제거해 clean code 상태를 유지합니다.\n- 답변 스타일과 기본 문맥은 반드시 채팅 유형 정보만 기준으로 적용하세요.\n- Plan 자동화용 자동화 유형 context가 있더라도 Codex Live에서는 별도 요청이 없는 한 참조하지 마세요.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-05-08T00:00:00.000Z', - }, - { - id: 'md-context-managed', - name: 'MD 기준 관리', - description: - '## 기본 처리\n- 세션 리소스 아래의 Markdown 문서를 먼저 읽고 그 기준으로 작업합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n- 사용자가 문서 수정을 요청하면 관련 Markdown 문서를 먼저 갱신합니다.\n\n## 문서 관리 기준\n- 작업 판단에 필요한 내용은 Markdown으로 관리하되, 채팅 답변에 파일 원문이나 문서 본문을 길게 다시 복사하지 않습니다.\n- 필요한 경우에는 문서 경로와 변경 사실만 짧게 남기고, 상세 내용은 해당 Markdown 리소스를 기준으로 유지합니다.\n- 파일 내용 제거나 문서 정리가 요청되면 세션 리소스 아래의 관련 문서를 우선 정리합니다.\n\n## 응답 기준\n- 코드 수정이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 문서성 리소스는 `[[preview:...]]` 대신 일반 경로로만 제공합니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-05-08T00:00:00.000Z', - }, - { - id: 'chat-maximized-bottom-safe', - name: '채팅 최대화 하단 안전영역', - description: - '## 기본 처리\n- 채팅 화면을 최대화한 상태에서도 최하단 입력영역과 마지막 액션이 가려지지 않도록 우선 확인합니다.\n- 하단 UI를 수정할 때는 메시지 스크롤 여백, 시스템 상태 영역, composer safe-area를 함께 점검합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 검증 기준\n- 기본 검증은 모바일 브라우저 환경에서 최대화 후 최하단까지 스크롤한 상태로 진행합니다.\n- 최하단 입력창, 전송 버튼, 상태영역 bottom 좌표가 viewport 안에 남는지 확인합니다.\n- 최종 검증 이미지는 `[[preview:URL]]`로 제공합니다.\n\n## 구현 기준\n- 모달, 드로어, sticky 액션이 기존 하단 입력영역을 덮지 않게 유지합니다.\n- 이전 처리에서 불필요해진 하단 보정 CSS는 함께 정리합니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-05-08T00:00:00.000Z', - }, - { - id: 'layout-editor-execution', - name: 'Layout editor 실행', - description: - '## 처리 범위\n- Layout editor 실행 유형은 호출 가능한 API 요청만 처리합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.\n- Layout editor에서 추가기능이나 컴포넌트 간 연계동작 수정을 요청하면 기능개선 관련 API 처리와 해당 Layout의 기능설명 API 데이터 갱신을 함께 진행합니다.\n- 연결 요청은 source 컴포넌트, target 컴포넌트, 적용 레이아웃 또는 ID가 모두 식별될 때만 처리합니다.\n- 기능 구현이 진행되면 기능설명 API 데이터 갱신을 필수 후속 단계로 함께 수행합니다. 기능설명 API 데이터 갱신에는 신규 등록과 기존 설명 수정이 모두 포함됩니다.\n- 메모나 요청 첫 줄에 적힌 연결 지시는 Base Input 등 실제 대상 컴포넌트 연결 규칙으로 해석할 수 있지만, 식별 정보가 부족하면 구현하지 않고 API 또는 데이터 기준으로 다시 확인합니다.\n- 메모 첫번째 줄을 Base Input에 연결하는 기능구현, 화면 바인딩 변경, 이벤트 연결 수정, 저장/조회 API 데이터 갱신 요청도 위 식별 조건을 충족하면 이 유형에서 처리할 수 있습니다.\n\n## 금지 사항\n- 서버나 컨테이너 재기동은 이 유형에서 직접 처리하지 않습니다.\n- 전역 상태 변경, 전체 레이아웃 공통 반영, 전체 컴포넌트 타입 반영은 사용자가 명시적으로 요청한 경우에만 처리합니다.\n- 레이아웃 구조, 배치, 스타일 자체만 설명해달라는 요청은 이 유형에서 처리하지 않습니다.\n- 화면 미리보기 감상이나 단순 UI 소개처럼 API 처리와 무관한 레이아웃 설명 요청은 이 유형에서 처리하지 않습니다.\n\n## 검증 기준\n- 변경이 있으면 preview 서버 기준으로 검증 스크린샷을 기본 제공하고, 별도 지시가 없어도 모바일 버전 캡처를 우선 제공합니다.\n- 모바일 캡처는 등록 토큰이 주입된 상태에서 진행하고 비로그인 기본 화면으로 확인하지 않습니다.\n- 검증 결과에 포함하는 스크린샷, 문서, diff 같은 리소스는 preview 컴포넌트에서 바로 열리도록 이미지 URL 또는 `[[preview:URL]]` 형식으로 함께 남깁니다.\n- 모바일 캡처와 최종 화면 검증 결과는 링크 카드로 대체하지 않고 `[[preview:URL]]` 표기를 유지합니다.\n- 링크 카드가 따로 필요하더라도 preview 리소스 표시는 별도로 유지합니다.\n\n## 응답 기준\n- 필요한 경우 API 요청 범위인지 먼저 좁혀서 답합니다.\n- 연결 대상 식별이 안 되면 구현하지 말고 source, target, 적용 레이아웃 또는 ID를 API나 데이터 기준으로 재질문합니다.\n- 컴포넌트 간 연결, 이벤트 흐름, 기능설명 데이터 갱신, 메모 첫 줄과 Base Input 연결처럼 실행 가능한 요청은 범위 안에서 처리합니다.\n- 서버 재기동이나 추가 운영 작업이 필요하면 직접 수행하지 말고 필요한 대상과 사유만 안내합니다.\n- 요청 완료 후 검증에 사용한 스크린샷 리소스는 항상 함께 제공합니다.\n- 단순 설명 요청이 아니라 실제 기능구현이나 연계 수정 요청이면 처리 불가로 제한하지 않습니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-27T00:00:00.000Z', - }, - { - id: 'api-request-template', - name: 'API요청', - description: - '## 처리 범위\n- 호출 가능한 API 요청만 진행합니다.\n- 자동화, 작업요청, 스케줄 등 API 중심 요청에 사용합니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-16T00:00:00.000Z', - }, - { - id: 'general-inquiry', - name: '일반 문의', - description: - '## 기본 원칙\n- 문의 응답과 리소스 제공만 처리합니다.\n- 프로젝트 소스 또는 다른 파일시스템에 대한 파일 생성과 수정은 하지 않습니다.\n\n## 파일과 리소스\n- 파일 생성은 `public/.codex_chat//resource/` 경로에서만 가능합니다.\n- 그 외 경로의 파일과 디렉터리는 읽기만 가능합니다.\n\n## 명령 사용\n- 상태 확인 목적의 조회성 command만 사용할 수 있습니다.\n- 빌드, 실행, 설치, 변경을 발생시키는 다른 command는 사용하지 않습니다.', - permissions: ['token-user'], - enabled: true, - updatedAt: '2026-04-24T00:00:00.000Z', - }, -]; + '## 기본 처리\n- Codex는 작업을 시작하기 전에 처리할 체크리스트 단계를 먼저 정의합니다.\n- 활동 로그와 현재 요청 상태는 채팅 화면의 Plan 체크리스트에 실시간으로 반영된다고 보고 단계 순서를 유지합니다.\n- 가능하면 요청 접수, 요청 분석, 관련 확인, 구현·응답 작성, 검증·결과 정리 순서로 진행합니다.\n- 실제 수정 범위와 검증 방식은 현재 채팅 유형 context와 AGENTS.md 규칙을 최우선으로 따릅니다.\n\n## 실행 계획 기준\n- 요청 내용에 맞춰 Codex가 실제 수행할 작업 항목도 별도로 정의합니다.\n- 예를 들어 신규 화면 개발이면 컴포넌트 개발, 레이아웃 생성, 컴포넌트 배치, API 및 기능 개발, 검증 및 결과 정리 같은 항목으로 나눕니다.\n- 버그 수정, 문서 반영, API 작업도 요청 성격에 따라 다른 실행 계획으로 바꿔 표시합니다.\n- 각 항목은 활동 로그와 요청 상태를 기준으로 대기, 진행중, 완료, 확인필요 상태를 실시간 반영합니다.\n\n## 활동 로그 기준\n- 진행중인 단계는 활동 로그 문장만 봐도 드러나게 남깁니다.\n- 관련 확인 단계에서는 DB, API, 소스, 화면 중 무엇을 확인하는지 짧게 남깁니다.\n- 구현이나 응답 작성이 시작되면 수정, 작성, 검증 여부가 보이도록 이어서 기록합니다.\n\n## 응답 기준\n- 변경된 소스 파일이 있으면 검증 결과와 diff를 함께 남깁니다.\n- 최종 응답은 현재 단계가 모두 정리된 뒤 간결하게 마무리합니다.\n- Plan 자동화용 자동화 유형 context는 Codex Live 기본 문맥으로 섞지 않습니다.'; diff --git a/etc/servers/work-server/src/services/error-log-plan-registration-service.ts b/etc/servers/work-server/src/services/error-log-plan-registration-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/error-log-service.ts b/etc/servers/work-server/src/services/error-log-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/git-service.test.ts b/etc/servers/work-server/src/services/git-service.test.ts old mode 100755 new mode 100644 index 66d40f1..a0de99a --- a/etc/servers/work-server/src/services/git-service.test.ts +++ b/etc/servers/work-server/src/services/git-service.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtemp } from 'node:fs/promises'; +import { mkdtemp, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { execFile } from 'node:child_process'; @@ -9,6 +9,7 @@ import { ensureBranchExists, mergeBranchToRelease, mergeReleaseToMain, + syncMainProjectBranchForReservedRestart, type GitAutomationConfig, } from './git-service.js'; @@ -148,3 +149,76 @@ test('mergeReleaseToMain keeps release to main as a normal merge commit', async assert.equal(mainMessage, 'merge: release -> main'); assert.equal(parentCount.split(' ').length, 3); }); + +test('syncMainProjectBranchForReservedRestart commits local changes and pushes them to origin main', async () => { + const { repoPath } = await createRepo(); + const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE; + process.env.PLAN_LOCAL_MAIN_MODE = 'false'; + + try { + await runGit(repoPath, ['switch', 'main']); + await writeFile(path.join(repoPath, 'note.txt'), 'hello reserved restart\n', 'utf8'); + + const result = await syncMainProjectBranchForReservedRestart( + repoPath, + 'main', + 'chore: sync main before reserved restart', + ); + + const head = await runGit(repoPath, ['rev-parse', 'HEAD']); + const remoteHead = await runGit(repoPath, ['rev-parse', 'origin/main']); + const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']); + const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']); + + assert.equal(result.committed, true); + assert.equal(result.commitMessage, 'chore: sync main before reserved restart'); + assert.equal(result.head, head); + assert.equal(result.syncMode, 'remote'); + assert.equal(remoteHead, head); + assert.equal(mainMessage, 'chore: sync main before reserved restart'); + assert.equal(noteContent, 'hello reserved restart'); + } finally { + if (previousLocalMainMode === undefined) { + delete process.env.PLAN_LOCAL_MAIN_MODE; + } else { + process.env.PLAN_LOCAL_MAIN_MODE = previousLocalMainMode; + } + } +}); + +test('syncMainProjectBranchForReservedRestart keeps reserved restart local when local main mode is enabled', async () => { + const { repoPath } = await createRepo(); + const previousLocalMainMode = process.env.PLAN_LOCAL_MAIN_MODE; + process.env.PLAN_LOCAL_MAIN_MODE = 'true'; + + try { + await runGit(repoPath, ['switch', 'main']); + await writeFile(path.join(repoPath, 'note.txt'), 'hello local reserved restart\n', 'utf8'); + + const remoteHeadBefore = await runGit(repoPath, ['rev-parse', 'origin/main']); + const result = await syncMainProjectBranchForReservedRestart( + repoPath, + 'main', + 'chore: sync main before reserved restart', + ); + const head = await runGit(repoPath, ['rev-parse', 'HEAD']); + const remoteHeadAfter = await runGit(repoPath, ['rev-parse', 'origin/main']); + const mainMessage = await runGit(repoPath, ['log', '-1', '--pretty=%s', 'main']); + const noteContent = await runGit(repoPath, ['show', 'HEAD:note.txt']); + + assert.equal(result.committed, true); + assert.equal(result.commitMessage, 'chore: sync main before reserved restart'); + assert.equal(result.head, head); + assert.equal(result.syncMode, 'local'); + assert.equal(remoteHeadAfter, remoteHeadBefore); + assert.notEqual(remoteHeadAfter, head); + assert.equal(mainMessage, 'chore: sync main before reserved restart'); + assert.equal(noteContent, 'hello local reserved restart'); + } finally { + if (previousLocalMainMode === undefined) { + delete process.env.PLAN_LOCAL_MAIN_MODE; + } else { + process.env.PLAN_LOCAL_MAIN_MODE = previousLocalMainMode; + } + } +}); diff --git a/etc/servers/work-server/src/services/git-service.ts b/etc/servers/work-server/src/services/git-service.ts old mode 100755 new mode 100644 index ac5b097..2517f55 --- a/etc/servers/work-server/src/services/git-service.ts +++ b/etc/servers/work-server/src/services/git-service.ts @@ -151,6 +151,57 @@ export async function hasWorkingTreeChanges(repoPath: string) { return Boolean(stdout); } +async function ensureLocalBranchFromRemote(repoPath: string, branchName: string) { + if (await hasLocalBranch(repoPath, branchName)) { + await runGit(repoPath, ['switch', branchName]); + return; + } + + if (await hasRemoteBranch(repoPath, branchName)) { + await runGit(repoPath, ['switch', '-C', branchName, `origin/${branchName}`]); + return; + } + + await runGit(repoPath, ['switch', '-C', branchName]); +} + +export async function syncMainProjectBranchForReservedRestart( + repoPath: string, + branchName: string, + commitMessage: string, +) { + const env = getEnv(); + const useLocalMainMode = Boolean(env.PLAN_LOCAL_MAIN_MODE); + + if (useLocalMainMode) { + await assertBranchExists(repoPath, branchName); + await runGit(repoPath, ['switch', branchName]); + } else { + await runGit(repoPath, ['fetch', 'origin', branchName]); + await ensureLocalBranchFromRemote(repoPath, branchName); + } + + const hadChanges = await hasWorkingTreeChanges(repoPath); + if (hadChanges) { + await commitAllChanges(repoPath, commitMessage); + } + + if (!useLocalMainMode) { + await runGit(repoPath, ['pull', '--rebase', 'origin', branchName]); + await pushBranch(repoPath, branchName); + } + + const { stdout: head } = await runGit(repoPath, ['rev-parse', 'HEAD']); + + return { + branchName, + commitMessage: hadChanges ? commitMessage : null, + committed: hadChanges, + head, + syncMode: useLocalMainMode ? 'local' as const : 'remote' as const, + }; +} + function isHotfixBranch(branchName: string) { return /^hotfix\//.test(branchName); } diff --git a/etc/servers/work-server/src/services/notification-message-service.test.ts b/etc/servers/work-server/src/services/notification-message-service.test.ts index 3b41e4f..01a4cc9 100644 --- a/etc/servers/work-server/src/services/notification-message-service.test.ts +++ b/etc/servers/work-server/src/services/notification-message-service.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { selectNotificationMessageIdsToDelete } from './notification-message-prune.js'; +import { pruneNotificationMessageTargetClientIds } from './notification-message-service.js'; test('selectNotificationMessageIdsToDelete keeps only the latest notification by createdAt and id', () => { const ids = selectNotificationMessageIdsToDelete([ @@ -22,3 +23,33 @@ test('selectNotificationMessageIdsToDelete respects keepLatestCount', () => { assert.deepEqual(ids, [10]); }); + +test('pruneNotificationMessageTargetClientIds removes stale clients and preserves active targets', () => { + const result = pruneNotificationMessageTargetClientIds( + { + sessionId: 'chat-room', + targetClientIds: ['client-active', 'client-stale-1', 'client-stale-2'], + }, + ['client-stale-1', 'client-stale-2'], + ); + + assert.deepEqual(result.targetClientIds, ['client-active']); + assert.equal(result.hasTargets, true); + assert.equal(result.changed, true); + assert.deepEqual(result.metadata.targetClientIds, ['client-active']); +}); + +test('pruneNotificationMessageTargetClientIds marks rows empty when every target becomes stale', () => { + const result = pruneNotificationMessageTargetClientIds( + { + sessionId: 'chat-room', + targetClientIds: ['client-stale-1'], + }, + ['client-stale-1'], + ); + + assert.deepEqual(result.targetClientIds, []); + assert.equal(result.hasTargets, false); + assert.equal(result.changed, true); + assert.deepEqual(result.metadata.targetClientIds, []); +}); diff --git a/etc/servers/work-server/src/services/notification-message-service.ts b/etc/servers/work-server/src/services/notification-message-service.ts old mode 100755 new mode 100644 index 8b6d377..aea8279 --- a/etc/servers/work-server/src/services/notification-message-service.ts +++ b/etc/servers/work-server/src/services/notification-message-service.ts @@ -40,6 +40,104 @@ export type NotificationMessageItem = { updatedAt: string; }; +export type NotificationMessageChangeEvent = + | { + action: 'created' | 'updated'; + item: NotificationMessageItem; + } + | { + action: 'deleted'; + id: number; + }; + +const notificationMessageChangeListeners = new Set<(event: NotificationMessageChangeEvent) => void>(); + +function emitNotificationMessageChange(event: NotificationMessageChangeEvent) { + for (const listener of notificationMessageChangeListeners) { + try { + listener(event); + } catch { + // noop + } + } +} + +export function subscribeNotificationMessageChanges(listener: (event: NotificationMessageChangeEvent) => void) { + notificationMessageChangeListeners.add(listener); + + return () => { + notificationMessageChangeListeners.delete(listener); + }; +} + +function normalizeTargetClientIdsFromMetadata(metadata: Record) { + const rawTargetClientIds = metadata.targetClientIds; + + if (!Array.isArray(rawTargetClientIds)) { + return []; + } + + return rawTargetClientIds + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0); +} + +export function pruneNotificationMessageTargetClientIds( + metadata: Record, + clientIds: Iterable, +) { + const removeClientIds = new Set( + Array.from(clientIds, (value) => String(value ?? '').trim()).filter(Boolean), + ); + const currentTargetClientIds = normalizeTargetClientIdsFromMetadata(metadata); + + if (!currentTargetClientIds.length || !removeClientIds.size) { + return { + changed: false, + hasTargets: currentTargetClientIds.length > 0, + targetClientIds: currentTargetClientIds, + metadata, + }; + } + + const nextTargetClientIds = currentTargetClientIds.filter((clientId) => !removeClientIds.has(clientId)); + + if (nextTargetClientIds.length === currentTargetClientIds.length) { + return { + changed: false, + hasTargets: true, + targetClientIds: currentTargetClientIds, + metadata, + }; + } + + return { + changed: true, + hasTargets: nextTargetClientIds.length > 0, + targetClientIds: nextTargetClientIds, + metadata: { + ...metadata, + targetClientIds: nextTargetClientIds, + }, + }; +} + +function matchesNotificationMessageTargetClient(metadata: Record, clientId: string) { + const targetClientIds = normalizeTargetClientIdsFromMetadata(metadata); + + if (targetClientIds.length === 0) { + return true; + } + + const normalizedClientId = clientId.trim(); + + if (!normalizedClientId) { + return false; + } + + return targetClientIds.includes(normalizedClientId); +} + function normalizePreviewText(value: string) { const normalized = value .replace(/```[\s\S]*?```/g, ' ') @@ -153,29 +251,28 @@ export async function ensureNotificationMessagesTable() { } } -export async function listNotificationMessages(query: z.infer) { +export async function listNotificationMessages(query: z.infer, clientId = '') { await ensureNotificationMessagesTable(); const parsedQuery = notificationMessageListQuerySchema.parse(query); const builder = db(NOTIFICATION_MESSAGE_TABLE) .select('*') .orderBy('is_read', 'asc') .orderBy('created_at', 'desc') - .orderBy('id', 'desc') - .limit(parsedQuery.limit); + .orderBy('id', 'desc'); if (parsedQuery.status === 'unread') { builder.where({ is_read: false }); } const rows = await builder; - const unreadCountResult = await db(NOTIFICATION_MESSAGE_TABLE) - .where({ is_read: false }) - .count<{ count: string | number }>({ count: '*' }) - .first(); + const filteredItems = rows + .map((row) => mapNotificationMessageRow(row)) + .filter((item) => matchesNotificationMessageTargetClient(item.metadata, clientId)); + const unreadCount = filteredItems.filter((item) => item.read !== true).length; return { - items: rows.map((row) => mapNotificationMessageRow(row)), - unreadCount: Number(unreadCountResult?.count ?? 0), + items: filteredItems.slice(0, parsedQuery.limit), + unreadCount, }; } @@ -213,7 +310,12 @@ export async function createNotificationMessage(payload: z.infer; +}) { + await ensureNotificationMessagesTable(); + + const sessionId = String(args.sessionId ?? '').trim(); + const staleClientIds = Array.from(args.staleClientIds, (value) => String(value ?? '').trim()).filter(Boolean); + + if (!sessionId || staleClientIds.length === 0) { + return [] as NotificationMessageItem[]; + } + + const rows = await db(NOTIFICATION_MESSAGE_TABLE) + .select('*') + .where({ + source: 'codex-live', + category: 'chat', + is_read: false, + }); + + const updatedItems: NotificationMessageItem[] = []; + + for (const row of rows) { + const metadata = + typeof row.metadata_json === 'object' && row.metadata_json ? (row.metadata_json as Record) : {}; + + if (String(metadata.sessionId ?? '').trim() !== sessionId) { + continue; + } + + const pruned = pruneNotificationMessageTargetClientIds(metadata, staleClientIds); + + if (!pruned.changed) { + continue; + } + + await db(NOTIFICATION_MESSAGE_TABLE) + .where({ id: row.id }) + .update({ + metadata_json: pruned.metadata, + is_read: pruned.hasTargets ? false : true, + read_at: pruned.hasTargets ? null : db.fn.now(), + updated_at: db.fn.now(), + }); + + const updatedRow = await db(NOTIFICATION_MESSAGE_TABLE).where({ id: row.id }).first(); + + if (!updatedRow) { + continue; + } + + const item = mapNotificationMessageRow(updatedRow); + updatedItems.push(item); + emitNotificationMessageChange({ + action: 'updated', + item, + }); + } + + return updatedItems; } export async function deleteNotificationMessage(id: number) { await ensureNotificationMessagesTable(); const deletedCount = await db(NOTIFICATION_MESSAGE_TABLE).where({ id }).del(); + + if (deletedCount > 0) { + emitNotificationMessageChange({ + action: 'deleted', + id, + }); + } + return deletedCount > 0; } diff --git a/etc/servers/work-server/src/services/notification-service.ts b/etc/servers/work-server/src/services/notification-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/plan-notification-policy.ts b/etc/servers/work-server/src/services/plan-notification-policy.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/plan-notification-service.ts b/etc/servers/work-server/src/services/plan-notification-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/plan-policy.test.ts b/etc/servers/work-server/src/services/plan-policy.test.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/plan-retry-policy.ts b/etc/servers/work-server/src/services/plan-retry-policy.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/plan-schedule-service.ts b/etc/servers/work-server/src/services/plan-schedule-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/plan-service.test.ts b/etc/servers/work-server/src/services/plan-service.test.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/plan-service.ts b/etc/servers/work-server/src/services/plan-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/resource-manager-service.test.ts b/etc/servers/work-server/src/services/resource-manager-service.test.ts index 34620c4..4c39ace 100644 --- a/etc/servers/work-server/src/services/resource-manager-service.test.ts +++ b/etc/servers/work-server/src/services/resource-manager-service.test.ts @@ -1,6 +1,18 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; -import { resolveStaticContentType } from './resource-manager-service.js'; +import { + copyResourceManagerItem, + createResourceManagerDirectory, + createResourceManagerFile, + deleteResourceManagerItem, + getResourceManagerTree, + listResourceManagerDirectory, + readResourceManagerFile, + resolveStaticContentType, +} from './resource-manager-service.js'; test('resolveStaticContentType returns html content type for resource manager html files', () => { assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8'); @@ -11,3 +23,100 @@ test('resolveStaticContentType keeps markdown and text files unchanged', () => { assert.equal(resolveStaticContentType('/tmp/sample.md'), 'text/markdown; charset=utf-8'); assert.equal(resolveStaticContentType('/tmp/sample.log'), 'text/plain; charset=utf-8'); }); + +test('resolveStaticContentType returns video content types for common video files', () => { + assert.equal(resolveStaticContentType('/tmp/sample.mp4'), 'video/mp4'); + assert.equal(resolveStaticContentType('/tmp/sample.webm'), 'video/webm'); + assert.equal(resolveStaticContentType('/tmp/sample.mov'), 'video/quicktime'); +}); + +async function withTempRepo(callback: (repoRoot: string) => Promise) { + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'resource-manager-test-')); + + try { + await callback(repoRoot); + } finally { + await fs.rm(repoRoot, { recursive: true, force: true }); + } +} + +test('deleteResourceManagerItem removes nested directories recursively', async () => { + await withTempRepo(async (repoRoot) => { + await createResourceManagerDirectory(repoRoot, '', 'docs'); + await createResourceManagerFile(repoRoot, 'docs', 'note.md', '# hello'); + + await deleteResourceManagerItem(repoRoot, 'docs'); + + const directory = await listResourceManagerDirectory(repoRoot, ''); + assert.deepEqual(directory.items, []); + }); +}); + +test('deleteResourceManagerItem keeps the root protected', async () => { + await withTempRepo(async (repoRoot) => { + await assert.rejects( + () => deleteResourceManagerItem(repoRoot, ''), + (error: unknown) => + error instanceof Error && + error.message === '루트 폴더는 삭제할 수 없습니다.' && + 'statusCode' in error && + error.statusCode === 400, + ); + }); +}); + +test('resource manager errors expose friendly missing-item messages', async () => { + await withTempRepo(async (repoRoot) => { + await assert.rejects( + () => readResourceManagerFile(repoRoot, 'missing.md'), + (error: unknown) => + error instanceof Error && + error.message === '항목을 찾을 수 없습니다.' && + 'statusCode' in error && + error.statusCode === 404, + ); + }); +}); + +test('copyResourceManagerItem rejects same-path copy requests with a clear message', async () => { + await withTempRepo(async (repoRoot) => { + await createResourceManagerFile(repoRoot, '', 'dup.md', 'a'); + + await assert.rejects( + () => copyResourceManagerItem(repoRoot, 'dup.md', '', 'dup.md'), + (error: unknown) => + error instanceof Error && + error.message === '같은 위치로는 복사하거나 이동할 수 없습니다.' && + 'statusCode' in error && + error.statusCode === 400, + ); + }); +}); + +test('directory modifiedAt reflects the latest nested descendant change', async () => { + await withTempRepo(async (repoRoot) => { + await createResourceManagerDirectory(repoRoot, '', 'docs'); + await createResourceManagerDirectory(repoRoot, 'docs', 'nested'); + await createResourceManagerFile(repoRoot, 'docs/nested', 'latest.md', 'new'); + + const nestedFilePath = path.join(repoRoot, 'resource', 'docs', 'nested', 'latest.md'); + const nestedDirectoryPath = path.join(repoRoot, 'resource', 'docs', 'nested'); + const docsDirectoryPath = path.join(repoRoot, 'resource', 'docs'); + const latestModifiedAt = new Date('2026-05-13T14:15:16.000Z'); + const staleModifiedAt = new Date('2026-05-10T01:02:03.000Z'); + + await fs.utimes(nestedFilePath, latestModifiedAt, latestModifiedAt); + await fs.utimes(nestedDirectoryPath, staleModifiedAt, staleModifiedAt); + await fs.utimes(docsDirectoryPath, staleModifiedAt, staleModifiedAt); + + const directory = await listResourceManagerDirectory(repoRoot, ''); + const docsEntry = directory.items.find((item) => item.path === 'docs'); + assert.ok(docsEntry); + assert.equal(docsEntry.modifiedAt, latestModifiedAt.toISOString()); + + const tree = await getResourceManagerTree(repoRoot); + const docsNode = tree.tree.children?.find((item) => item.path === 'docs'); + assert.ok(docsNode); + assert.equal(docsNode.modifiedAt, latestModifiedAt.toISOString()); + }); +}); diff --git a/etc/servers/work-server/src/services/resource-manager-service.ts b/etc/servers/work-server/src/services/resource-manager-service.ts index 95ad47e..2ad7a62 100644 --- a/etc/servers/work-server/src/services/resource-manager-service.ts +++ b/etc/servers/work-server/src/services/resource-manager-service.ts @@ -45,6 +45,16 @@ export type ResourceManagerFileDetail = { content: string | null; }; +class ResourceManagerError extends Error { + statusCode: number; + + constructor(message: string, statusCode = 400) { + super(message); + this.name = 'ResourceManagerError'; + this.statusCode = statusCode; + } +} + const RESOURCE_MANAGER_ROOT_DIR = 'resource'; const RESOURCE_MANAGER_ROOT_LABEL = 'resource'; @@ -123,6 +133,16 @@ export function resolveStaticContentType(filePath: string) { return 'image/gif'; case '.webp': return 'image/webp'; + case '.mp4': + return 'video/mp4'; + case '.webm': + return 'video/webm'; + case '.mov': + return 'video/quicktime'; + case '.m4v': + return 'video/x-m4v'; + case '.ogv': + return 'video/ogg'; case '.pdf': return 'application/pdf'; default: @@ -139,7 +159,7 @@ function sanitizeEntryName(name: string) { const trimmed = name.trim().replace(/[\\/:"*?<>|]+/g, '-').replace(/\s+/g, ' '); if (!trimmed || trimmed === '.' || trimmed === '..') { - throw new Error('이름이 올바르지 않습니다.'); + throw new ResourceManagerError('이름이 올바르지 않습니다.'); } return trimmed; @@ -159,7 +179,7 @@ function normalizeRelativeTarget(relativePath: string | null | undefined) { } if (normalized.startsWith('../') || normalized === '..' || normalized.includes('/../')) { - throw new Error('허용되지 않은 경로입니다.'); + throw new ResourceManagerError('허용되지 않은 경로입니다.'); } return normalized.replace(/^\/+/, ''); @@ -193,7 +213,7 @@ function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: st const absolutePath = path.resolve(rootPath, normalizedRelativePath); if (absolutePath !== rootPath && !absolutePath.startsWith(`${rootPath}${path.sep}`)) { - throw new Error('허용되지 않은 경로입니다.'); + throw new ResourceManagerError('허용되지 않은 경로입니다.'); } return { @@ -203,6 +223,48 @@ function resolveResourceManagerTargetPath(repoRootPath: string, relativePath: st }; } +function toResourceManagerError(error: unknown) { + if (error instanceof ResourceManagerError) { + return error; + } + + if (error && typeof error === 'object' && 'code' in error) { + const code = String((error as { code?: string }).code ?? ''); + + switch (code) { + case 'ENOENT': + return new ResourceManagerError('항목을 찾을 수 없습니다.', 404); + case 'EEXIST': + return new ResourceManagerError('같은 이름의 항목이 이미 존재합니다.', 409); + case 'ENOTDIR': + return new ResourceManagerError('디렉터리가 아닙니다.', 400); + case 'EISDIR': + return new ResourceManagerError('파일이 아닙니다.', 400); + case 'ENOTEMPTY': + return new ResourceManagerError('비어 있지 않은 폴더입니다.', 409); + case 'EACCES': + case 'EPERM': + return new ResourceManagerError('항목에 접근할 권한이 없습니다.', 403); + default: + break; + } + } + + if (error instanceof Error) { + return new ResourceManagerError(error.message || '리소스 작업에 실패했습니다.', 500); + } + + return new ResourceManagerError('리소스 작업에 실패했습니다.', 500); +} + +async function withResourceManagerError(callback: () => Promise) { + try { + return await callback(); + } catch (error) { + throw toResourceManagerError(error); + } +} + function buildPreviewUrl(relativePath: string) { const encodedPath = normalizeRelativeTarget(relativePath) .split('/') @@ -213,6 +275,48 @@ function buildPreviewUrl(relativePath: string) { return `/api/resource-manager/preview/${encodedPath}`; } +function resolveLatestModifiedAt(currentModifiedAt: string, childModifiedAts: string[]) { + let latestTime = Date.parse(currentModifiedAt); + let latestModifiedAt = currentModifiedAt; + + for (const childModifiedAt of childModifiedAts) { + const childTime = Date.parse(childModifiedAt); + + if (!Number.isFinite(childTime)) { + continue; + } + + if (!Number.isFinite(latestTime) || childTime > latestTime) { + latestTime = childTime; + latestModifiedAt = childModifiedAt; + } + } + + return latestModifiedAt; +} + +async function resolveDirectoryLatestModifiedAt(absolutePath: string, stats?: Awaited>) { + const currentStats = stats ?? (await fs.stat(absolutePath)); + let latestModifiedAt = currentStats.mtime.toISOString(); + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name.startsWith('.')) { + continue; + } + + const entryAbsolutePath = path.join(absolutePath, entry.name); + const entryStats = await fs.stat(entryAbsolutePath); + const entryModifiedAt = entry.isDirectory() + ? await resolveDirectoryLatestModifiedAt(entryAbsolutePath, entryStats) + : entryStats.mtime.toISOString(); + + latestModifiedAt = resolveLatestModifiedAt(latestModifiedAt, [entryModifiedAt]); + } + + return latestModifiedAt; +} + async function buildTreeNode(absolutePath: string, relativePath: string): Promise { const stats = await fs.stat(absolutePath); const type: ResourceManagerEntryType = stats.isDirectory() ? 'directory' : 'file'; @@ -250,117 +354,138 @@ async function buildTreeNode(absolutePath: string, relativePath: string): Promis ); node.children = children; + node.modifiedAt = resolveLatestModifiedAt( + node.modifiedAt, + children.map((child) => child.modifiedAt), + ); } return node; } export async function ensureResourceManagerRoot(repoRootPath: string) { - const rootPath = resolveResourceManagerRoot(repoRootPath); - await fs.mkdir(rootPath, { recursive: true }); + return withResourceManagerError(async () => { + const rootPath = resolveResourceManagerRoot(repoRootPath); + await fs.mkdir(rootPath, { recursive: true }); + }); } export async function getResourceManagerTree(repoRootPath: string) { - await ensureResourceManagerRoot(repoRootPath); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); - return { - label: RESOURCE_MANAGER_ROOT_LABEL, - rootPath: RESOURCE_MANAGER_ROOT_DIR, - tree: await buildTreeNode(resolveResourceManagerRoot(repoRootPath), ''), - } satisfies ResourceManagerTreeRoot; + return { + label: RESOURCE_MANAGER_ROOT_LABEL, + rootPath: RESOURCE_MANAGER_ROOT_DIR, + tree: await buildTreeNode(resolveResourceManagerRoot(repoRootPath), ''), + } satisfies ResourceManagerTreeRoot; + }); } export async function listResourceManagerDirectory(repoRootPath: string, directoryPath = '') { - await ensureResourceManagerRoot(repoRootPath); - const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, directoryPath); - const stats = await fs.stat(absolutePath); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, directoryPath); + const stats = await fs.stat(absolutePath); - if (!stats.isDirectory()) { - throw new Error('디렉터리가 아닙니다.'); - } + if (!stats.isDirectory()) { + throw new ResourceManagerError('디렉터리가 아닙니다.'); + } - const entries = await fs.readdir(absolutePath, { withFileTypes: true }); - const items: ResourceManagerDirectoryEntry[] = await Promise.all( - entries - .filter((entry) => !entry.name.startsWith('.')) - .sort((left, right) => { - if (left.isDirectory() && !right.isDirectory()) { - return -1; - } + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + const items: ResourceManagerDirectoryEntry[] = await Promise.all( + entries + .filter((entry) => !entry.name.startsWith('.')) + .sort((left, right) => { + if (left.isDirectory() && !right.isDirectory()) { + return -1; + } - if (!left.isDirectory() && right.isDirectory()) { - return 1; - } + if (!left.isDirectory() && right.isDirectory()) { + return 1; + } - return left.name.localeCompare(right.name, 'ko'); - }) - .map(async (entry) => { - const entryRelativePath = path.posix.join(relativePath, entry.name); - const entryAbsolutePath = path.join(absolutePath, entry.name); - const entryStats = await fs.stat(entryAbsolutePath); - const type: ResourceManagerEntryType = entry.isDirectory() ? 'directory' : 'file'; + return left.name.localeCompare(right.name, 'ko'); + }) + .map(async (entry) => { + const entryRelativePath = path.posix.join(relativePath, entry.name); + const entryAbsolutePath = path.join(absolutePath, entry.name); + const entryStats = await fs.stat(entryAbsolutePath); + const type: ResourceManagerEntryType = entry.isDirectory() ? 'directory' : 'file'; - return { - name: entry.name, - path: entryRelativePath, - type, - extension: type === 'file' ? path.extname(entry.name).toLowerCase() || null : null, - size: type === 'file' ? entryStats.size : null, - modifiedAt: entryStats.mtime.toISOString(), - previewUrl: type === 'file' ? buildPreviewUrl(entryRelativePath) : null, - }; - }), - ); + return { + name: entry.name, + path: entryRelativePath, + type, + extension: type === 'file' ? path.extname(entry.name).toLowerCase() || null : null, + size: type === 'file' ? entryStats.size : null, + modifiedAt: + type === 'directory' + ? await resolveDirectoryLatestModifiedAt(entryAbsolutePath, entryStats) + : entryStats.mtime.toISOString(), + previewUrl: type === 'file' ? buildPreviewUrl(entryRelativePath) : null, + }; + }), + ); - return { - path: relativePath, - items, - }; + return { + path: relativePath, + items, + }; + }); } export async function readResourceManagerFile(repoRootPath: string, filePath: string) { - await ensureResourceManagerRoot(repoRootPath); - const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, filePath); - const stats = await fs.stat(absolutePath); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, filePath); + const stats = await fs.stat(absolutePath); - if (!stats.isFile()) { - throw new Error('파일이 아닙니다.'); - } + if (!stats.isFile()) { + throw new ResourceManagerError('파일이 아닙니다.'); + } - const textEditable = isTextEditable(absolutePath); + const textEditable = isTextEditable(absolutePath); - return { - name: path.basename(relativePath), - path: relativePath, - extension: path.extname(absolutePath).toLowerCase() || null, - size: stats.size, - modifiedAt: stats.mtime.toISOString(), - mimeType: resolveStaticContentType(absolutePath), - previewUrl: buildPreviewUrl(relativePath), - isTextEditable: textEditable, - content: textEditable ? await fs.readFile(absolutePath, 'utf8') : null, - } satisfies ResourceManagerFileDetail; + return { + name: path.basename(relativePath), + path: relativePath, + extension: path.extname(absolutePath).toLowerCase() || null, + size: stats.size, + modifiedAt: stats.mtime.toISOString(), + mimeType: resolveStaticContentType(absolutePath), + previewUrl: buildPreviewUrl(relativePath), + isTextEditable: textEditable, + content: textEditable ? await fs.readFile(absolutePath, 'utf8') : null, + } satisfies ResourceManagerFileDetail; + }); } export async function createResourceManagerDirectory(repoRootPath: string, parentPath: string, name: string) { - await ensureResourceManagerRoot(repoRootPath); - const safeName = sanitizeEntryName(name); - const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName)); - await fs.mkdir(absolutePath, { recursive: false }); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const safeName = sanitizeEntryName(name); + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName)); + await fs.mkdir(absolutePath, { recursive: false }); + }); } export async function createResourceManagerFile(repoRootPath: string, parentPath: string, name: string, content = '') { - await ensureResourceManagerRoot(repoRootPath); - const safeName = sanitizeEntryName(name); - const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName)); - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, content, 'utf8'); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const safeName = sanitizeEntryName(name); + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeName)); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, content, 'utf8'); + }); } export async function saveResourceManagerFile(repoRootPath: string, filePath: string, content: string) { - await ensureResourceManagerRoot(repoRootPath); - const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, filePath); - await fs.writeFile(absolutePath, content, 'utf8'); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, filePath); + await fs.writeFile(absolutePath, content, 'utf8'); + }); } export async function uploadResourceManagerFile( @@ -369,25 +494,27 @@ export async function uploadResourceManagerFile( fileName: string, contentBase64: string, ) { - await ensureResourceManagerRoot(repoRootPath); - const safeFileName = sanitizeEntryName(fileName); - const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeFileName)); - const buffer = Buffer.from(contentBase64, 'base64'); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const safeFileName = sanitizeEntryName(fileName); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(parentPath, safeFileName)); + const buffer = Buffer.from(contentBase64, 'base64'); - if (!buffer.byteLength) { - throw new Error('업로드할 파일 내용을 찾지 못했습니다.'); - } + if (!buffer.byteLength) { + throw new ResourceManagerError('업로드할 파일 내용을 찾지 못했습니다.'); + } - await fs.mkdir(path.dirname(absolutePath), { recursive: true }); - await fs.writeFile(absolutePath, buffer); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, buffer); - return { - id: randomUUID(), - name: safeFileName, - path: relativePath, - previewUrl: buildPreviewUrl(relativePath), - size: buffer.byteLength, - }; + return { + id: randomUUID(), + name: safeFileName, + path: relativePath, + previewUrl: buildPreviewUrl(relativePath), + size: buffer.byteLength, + }; + }); } async function resolveCopyMoveTarget( @@ -401,6 +528,10 @@ async function resolveCopyMoveTarget( const resolvedName = sanitizeEntryName(nextName?.trim() || path.basename(sourceTarget.relativePath)); const targetTarget = resolveResourceManagerTargetPath(repoRootPath, path.posix.join(targetDirectoryPath, resolvedName)); + if (sourceTarget.relativePath === targetTarget.relativePath) { + throw new ResourceManagerError('같은 위치로는 복사하거나 이동할 수 없습니다.'); + } + return { sourceAbsolutePath: sourceTarget.absolutePath, sourceRelativePath: sourceTarget.relativePath, @@ -416,14 +547,20 @@ export async function copyResourceManagerItem( targetDirectoryPath: string, nextName?: string | null, ) { - await ensureResourceManagerRoot(repoRootPath); - const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName); - await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true }); - await fs.cp(target.sourceAbsolutePath, target.targetAbsolutePath, { recursive: target.sourceStats.isDirectory(), force: false }); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName); + await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true }); + await fs.cp(target.sourceAbsolutePath, target.targetAbsolutePath, { + recursive: target.sourceStats.isDirectory(), + force: false, + errorOnExist: true, + }); - return { - path: target.targetRelativePath, - }; + return { + path: target.targetRelativePath, + }; + }); } export async function moveResourceManagerItem( @@ -432,42 +569,48 @@ export async function moveResourceManagerItem( targetDirectoryPath: string, nextName?: string | null, ) { - await ensureResourceManagerRoot(repoRootPath); - const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName); - await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true }); - await fs.rename(target.sourceAbsolutePath, target.targetAbsolutePath); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const target = await resolveCopyMoveTarget(repoRootPath, sourcePath, targetDirectoryPath, nextName); + await fs.mkdir(path.dirname(target.targetAbsolutePath), { recursive: true }); + await fs.rename(target.sourceAbsolutePath, target.targetAbsolutePath); - return { - path: target.targetRelativePath, - }; + return { + path: target.targetRelativePath, + }; + }); } export async function deleteResourceManagerItem(repoRootPath: string, targetPath: string) { - await ensureResourceManagerRoot(repoRootPath); - const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath); + return withResourceManagerError(async () => { + await ensureResourceManagerRoot(repoRootPath); + const { absolutePath, relativePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath); - if (!relativePath) { - throw new Error('루트 폴더는 삭제할 수 없습니다.'); - } + if (!relativePath) { + throw new ResourceManagerError('루트 폴더는 삭제할 수 없습니다.'); + } - await fs.rm(absolutePath, { recursive: true, force: false }); + await fs.rm(absolutePath, { recursive: true, force: false }); + }); } export async function openResourceManagerPreviewStream(repoRootPath: string, targetPath: string) { - const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath); + return withResourceManagerError(async () => { + const { absolutePath } = resolveResourceManagerTargetPath(repoRootPath, targetPath); - if (!existsSync(absolutePath)) { - throw new Error('리소스를 찾을 수 없습니다.'); - } + if (!existsSync(absolutePath)) { + throw new ResourceManagerError('리소스를 찾을 수 없습니다.', 404); + } - const stats = await fs.stat(absolutePath); + const stats = await fs.stat(absolutePath); - if (!stats.isFile()) { - throw new Error('파일만 미리보기할 수 있습니다.'); - } + if (!stats.isFile()) { + throw new ResourceManagerError('파일만 미리보기할 수 있습니다.'); + } - return { - stream: createReadStream(absolutePath), - contentType: resolveStaticContentType(absolutePath), - }; + return { + stream: createReadStream(absolutePath), + contentType: resolveStaticContentType(absolutePath), + }; + }); } diff --git a/etc/servers/work-server/src/services/server-command-service.test.ts b/etc/servers/work-server/src/services/server-command-service.test.ts index 32f21a4..d478f8a 100644 --- a/etc/servers/work-server/src/services/server-command-service.test.ts +++ b/etc/servers/work-server/src/services/server-command-service.test.ts @@ -65,6 +65,10 @@ test('test, release and prod restart scripts fall back to Docker socket when doc const socketRestartScript = fs.readFileSync(new URL('restart-via-docker-socket.mjs', commandsRoot), 'utf8'); assert.match(testScript, /command -v docker >/); + assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); + assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); + assert.match(testScript, /SERVER_COMMAND_TEST_GIT_REMOTE="\$\{SERVER_COMMAND_TEST_GIT_REMOTE:-origin\}"/); + assert.match(testScript, /SERVER_COMMAND_TEST_GIT_BRANCH="\$\{SERVER_COMMAND_TEST_GIT_BRANCH:-main\}"/); assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" restart "\$SERVER_COMMAND_SERVICE"/); assert.match(testScript, /docker compose -f "\$SERVER_COMMAND_COMPOSE_FILE" up -d --no-deps "\$SERVER_COMMAND_SERVICE"/); assert.match(testScript, /restart-via-docker-socket\.mjs/); @@ -103,6 +107,15 @@ test('prod restart script pulls the configured remote main branch before restart assert.match(prodScript, /git pull --ff-only "\$SERVER_COMMAND_PROD_GIT_REMOTE" "\$SERVER_COMMAND_PROD_GIT_BRANCH"/); }); +test('test restart script pulls the configured remote main branch before restart', () => { + const commandsRoot = new URL('../../../../commands/server-command/', import.meta.url); + const testScript = fs.readFileSync(new URL('restart-test.sh', commandsRoot), 'utf8'); + + assert.match(testScript, /git fetch "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); + assert.match(testScript, /git switch "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); + assert.match(testScript, /git pull --ff-only "\$SERVER_COMMAND_TEST_GIT_REMOTE" "\$SERVER_COMMAND_TEST_GIT_BRANCH"/); +}); + test('work-server package dev script does not use watch mode and rebuilds before start', async () => { const packageJsonPath = new URL('../../package.json', import.meta.url); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts old mode 100755 new mode 100644 index ee10c26..8767d89 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -596,6 +596,8 @@ function getServerDefinitions(): ServerDefinition[] { SERVER_COMMAND_COMPOSE_FILE: path.join(mainProjectRoot, 'docker-compose.yml'), SERVER_COMMAND_SERVICE: env.SERVER_COMMAND_TEST_SERVICE, SERVER_COMMAND_CONTAINER_NAME: 'ai-code-app-app-1', + SERVER_COMMAND_TEST_GIT_REMOTE: 'origin', + SERVER_COMMAND_TEST_GIT_BRANCH: env.PLAN_MAIN_BRANCH, }, restartStrategy: 'wait', }, diff --git a/etc/servers/work-server/src/services/server-restart-reservation-service.ts b/etc/servers/work-server/src/services/server-restart-reservation-service.ts index 52d76d5..ef7d1e5 100644 --- a/etc/servers/work-server/src/services/server-restart-reservation-service.ts +++ b/etc/servers/work-server/src/services/server-restart-reservation-service.ts @@ -15,6 +15,7 @@ import { type ServerCommandKey, } from './server-command-service.js'; import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js'; +import { syncMainProjectBranchForReservedRestart } from './git-service.js'; const SERVER_RESTART_RESERVATION_TABLE = 'server_restart_reservations'; const SERVER_RESTART_RESERVATION_ROW_ID = 1; @@ -26,9 +27,16 @@ const SERVER_RESTART_RESERVATION_NOTIFICATION_SOURCE = 'server-restart-reservati const RESERVED_RESTART_AUTO_FIX_SESSION_ID = 'server-restart-reservation'; const RESERVED_RESTART_AUTO_FIX_MAX_EXECUTION_SECONDS = 600; const RESERVED_RESTART_AUTO_FIX_IDLE_TIMEOUT_SECONDS = 180; +const RESERVED_RESTART_MAIN_COMMIT_MESSAGE = 'chore: sync main before reserved restart'; type RestartReservationStatus = 'idle' | 'waiting' | 'ready' | 'executing' | 'recovering' | 'completed' | 'cancelled' | 'failed'; type RestartReservationTarget = 'all' | 'test' | 'work-server'; +type RestartReservationExecutionPhase = + | 'idle' + | 'commit-main-worktree' + | 'restart-test' + | 'restart-work-server' + | 'verify-runtime'; type RestartReservationWorkloadSummary = { codexRunningCount: number; @@ -81,6 +89,7 @@ type RestartReservationRow = { app_origin: string | null; auto_execute_at: string | null; auto_execute_delay_seconds: number | null; + execution_phase: RestartReservationExecutionPhase | string | null; updated_at: string | null; auto_fix_json: RestartReservationAutoFix | string | null; }; @@ -104,11 +113,21 @@ export type ServerRestartReservationSnapshot = { appOrigin: string | null; autoExecuteAt: string | null; autoExecuteDelaySeconds: number; + executionPhase: RestartReservationExecutionPhase; updatedAt: string | null; workItems: RestartReservationWorkItem[]; autoFix: RestartReservationAutoFix; }; +function normalizeExecutionPhase(value: unknown): RestartReservationExecutionPhase { + return value === 'commit-main-worktree' + || value === 'restart-test' + || value === 'restart-work-server' + || value === 'verify-runtime' + ? value + : 'idle'; +} + function getDefaultWorkloadSummary(): RestartReservationWorkloadSummary { return { codexRunningCount: 0, @@ -255,6 +274,29 @@ function parseAutoFixState(rawValue: RestartReservationRow['auto_fix_json']): Re }; } +async function syncReservedRestartMainProject(logger: FastifyBaseLogger) { + const mainProjectRoot = resolveMainProjectRoot(); + const result = await syncMainProjectBranchForReservedRestart( + mainProjectRoot, + env.PLAN_MAIN_BRANCH, + RESERVED_RESTART_MAIN_COMMIT_MESSAGE, + ); + + logger.info( + { + branchName: result.branchName, + committed: result.committed, + commitMessage: result.commitMessage, + head: result.head, + syncMode: result.syncMode, + repoPath: mainProjectRoot, + }, + 'Reserved restart synced main project branch', + ); + + return result; +} + function mapReservationRow( row: RestartReservationRow | null | undefined, options?: { @@ -306,6 +348,7 @@ function mapReservationRow( appOrigin: row?.app_origin ?? null, autoExecuteAt: row?.auto_execute_at ?? null, autoExecuteDelaySeconds: Math.max(1, Number(row?.auto_execute_delay_seconds ?? 10)), + executionPhase: normalizeExecutionPhase(row?.execution_phase), updatedAt: row?.updated_at ?? null, workItems: options?.workItems ?? [], autoFix, @@ -335,6 +378,7 @@ async function ensureServerRestartReservationTable() { table.string('app_origin', 255).nullable(); table.timestamp('auto_execute_at', { useTz: true }).nullable(); table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10); + table.string('execution_phase', 40).notNullable().defaultTo('idle'); table.jsonb('auto_fix_json').notNullable().defaultTo('{}'); table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now()); }); @@ -344,6 +388,7 @@ async function ensureServerRestartReservationTable() { ['app_origin', (table) => table.string('app_origin', 255).nullable()], ['auto_execute_at', (table) => table.timestamp('auto_execute_at', { useTz: true }).nullable()], ['auto_execute_delay_seconds', (table) => table.integer('auto_execute_delay_seconds').notNullable().defaultTo(10)], + ['execution_phase', (table) => table.string('execution_phase', 40).notNullable().defaultTo('idle')], ['auto_fix_json', (table) => table.jsonb('auto_fix_json').notNullable().defaultTo('{}')], ]; @@ -366,6 +411,7 @@ async function ensureServerRestartReservationTable() { status: 'idle', workload_summary_json: getDefaultWorkloadSummary(), active_client_count: 0, + execution_phase: 'idle', auto_fix_json: getDefaultAutoFixState(), updated_at: db.fn.now(), }); @@ -857,6 +903,7 @@ async function finalizeReservedRestart(row: RestartReservationRow) { await updateReservationRow({ enabled: true, status: 'executing', + execution_phase: 'verify-runtime', last_checked_at: db.fn.now(), waiting_reason: `${waitingTargets.join(' / ')} 새 런타임과 정상 기동을 확인하는 중입니다.`, last_error: null, @@ -873,6 +920,7 @@ async function finalizeReservedRestart(row: RestartReservationRow) { last_error: null, last_checked_at: db.fn.now(), auto_execute_at: null, + execution_phase: 'idle', }); return mapReservationRow(nextRow); @@ -886,6 +934,7 @@ async function restartReservedTargetWithRecovery( await updateReservationRow({ enabled: true, status: 'executing', + execution_phase: targetKey === 'test' ? 'restart-test' : 'restart-work-server', waiting_reason: startMessage, last_checked_at: db.fn.now(), }); @@ -917,6 +966,7 @@ async function restartReservedTargetWithRecovery( await updateReservationRow({ enabled: true, status: 'executing', + execution_phase: targetKey === 'test' ? 'restart-test' : 'restart-work-server', waiting_reason: `${targetKey.toUpperCase()} 빌드 오류를 수정해 재기동을 다시 시도합니다.`, last_checked_at: db.fn.now(), last_error: null, @@ -937,6 +987,7 @@ async function finalizeSingleServerRestart(targetKey: 'test' | 'work-server') { last_error: null, last_checked_at: db.fn.now(), auto_execute_at: null, + execution_phase: 'idle', }); return mapReservationRow(nextRow); @@ -967,6 +1018,7 @@ export async function requestImmediateRestartRecovery( auto_execute_at: null, auto_execute_delay_seconds: 10, last_checked_at: db.fn.now(), + execution_phase: targetKey === 'test' ? 'restart-test' : 'restart-work-server', auto_fix_json: getDefaultAutoFixState(), }); @@ -992,6 +1044,7 @@ export async function requestImmediateRestartRecovery( waiting_reason: null, last_error: message, last_checked_at: db.fn.now(), + execution_phase: 'idle', }).catch(() => undefined); } finally { immediateRecoveryPromise = null; @@ -1008,7 +1061,8 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes status: 'executing', started_at: row.started_at ?? db.fn.now(), last_checked_at: db.fn.now(), - waiting_reason: null, + execution_phase: 'commit-main-worktree', + waiting_reason: 'main 작업트리 커밋 단계를 확인한 뒤 예약된 재기동을 이어갑니다.', active_client_count: activeClients.length, last_error: null, }); @@ -1043,17 +1097,38 @@ async function executeReservedRestart(logger: FastifyBaseLogger, row: RestartRes 'Executing reserved restart', ); + const syncResult = await syncReservedRestartMainProject(logger); + + await updateReservationRow({ + enabled: true, + status: 'executing', + execution_phase: 'restart-test', + waiting_reason: syncResult.committed + ? 'main 변경을 정리한 뒤 TEST 서버 재기동을 시작합니다.' + : 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.', + last_checked_at: db.fn.now(), + }); + await restartReservedTargetWithRecovery(logger, 'test', '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.'); await waitForDuration(TEST_TO_WORK_SERVER_DELAY_MS); await updateReservationRow({ enabled: true, status: 'executing', + execution_phase: 'restart-work-server', waiting_reason: 'WORK 서버 재기동 후 정상 기동을 확인하는 중입니다.', last_checked_at: db.fn.now(), }); await restartReservedTargetWithRecovery(logger, 'work-server', '예약된 흐름에 따라 WORK 서버 재기동을 시작합니다.'); + + await updateReservationRow({ + enabled: true, + status: 'executing', + execution_phase: 'verify-runtime', + waiting_reason: 'TEST / WORK 서버 새 런타임과 정상 기동을 확인하는 중입니다.', + last_checked_at: db.fn.now(), + }); } export async function getServerRestartReservation() { @@ -1094,6 +1169,7 @@ export async function scheduleServerRestartReservation(options?: { app_origin: options?.appOrigin?.trim() || null, auto_execute_at: null, auto_execute_delay_seconds: autoExecuteDelaySeconds, + execution_phase: 'idle', auto_fix_json: getDefaultAutoFixState(), }); @@ -1110,6 +1186,7 @@ export async function cancelServerRestartReservation() { active_client_count: 0, last_error: null, auto_execute_at: null, + execution_phase: 'idle', auto_fix_json: getDefaultAutoFixState(), }); @@ -1138,9 +1215,10 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger) status: 'executing', started_at: db.fn.now(), last_checked_at: db.fn.now(), - waiting_reason: '예약된 자동 실행 시간이 되어 TEST 서버 재기동을 시작합니다.', + waiting_reason: 'main 작업트리 상태를 확인한 뒤 TEST 서버 재기동을 시작합니다.', last_error: null, auto_execute_at: null, + execution_phase: 'commit-main-worktree', auto_fix_json: getDefaultAutoFixState(), }); @@ -1156,6 +1234,7 @@ export async function confirmServerRestartReservation(logger: FastifyBaseLogger) status: 'failed', last_error: message, waiting_reason: null, + execution_phase: 'idle', }).catch(() => undefined); }); @@ -1239,6 +1318,7 @@ export class ServerRestartReservationWorker { ? row.auto_execute_at : autoExecuteAt, auto_execute_delay_seconds: autoExecuteDelaySeconds, + execution_phase: 'idle', }); if (waitingReason) { @@ -1252,6 +1332,7 @@ export class ServerRestartReservationWorker { status: 'failed', last_error: message, waiting_reason: null, + execution_phase: 'idle', }).catch(() => undefined); } finally { this.running = false; diff --git a/etc/servers/work-server/src/services/visitor-history-service.ts b/etc/servers/work-server/src/services/visitor-history-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/work-server-build-service.ts b/etc/servers/work-server/src/services/work-server-build-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/worklog-automation-service.test.ts b/etc/servers/work-server/src/services/worklog-automation-service.test.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/worklog-automation-service.ts b/etc/servers/work-server/src/services/worklog-automation-service.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/services/worklog-automation-utils.ts b/etc/servers/work-server/src/services/worklog-automation-utils.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/types/web-push.d.ts b/etc/servers/work-server/src/types/web-push.d.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/src/workers/plan-worker.ts b/etc/servers/work-server/src/workers/plan-worker.ts old mode 100755 new mode 100644 diff --git a/etc/servers/work-server/tsconfig.json b/etc/servers/work-server/tsconfig.json old mode 100755 new mode 100644 diff --git a/features/layout/README.md b/features/layout/README.md new file mode 100644 index 0000000..9c60d33 --- /dev/null +++ b/features/layout/README.md @@ -0,0 +1,16 @@ +# Layout Feature + +`src/features/layout`은 현재 프로젝트 전용 레이아웃 기능을 둡니다. + +## 포함 범위 + +- 컴포넌트 샘플 레이아웃 +- 위젯 샘플 레이아웃 +- 문서 미리보기 레이아웃 +- `Layout Editor` + +## 기준 + +- 현재 프로젝트 화면에만 의미가 있으면 여기 둡니다. +- 공통 재사용 가치가 높아지면 `src/components` 또는 `src/widgets`로 승격합니다. +- `Layout Editor`의 기능 명세는 위젯 스펙 문서가 아니라 현재 레이아웃 안에서의 역할 설명으로 취급합니다. diff --git a/features/layout/feature-menu/resources/feature-menu-analysis.md b/features/layout/feature-menu/resources/feature-menu-analysis.md new file mode 100644 index 0000000..882252b --- /dev/null +++ b/features/layout/feature-menu/resources/feature-menu-analysis.md @@ -0,0 +1,24 @@ +# 기능설명 관리 패키지 분석 문서 + +## 요청 목표 +- 기존 `기능설명 관리` 화면에서 모바일 기준 가시성을 높이고, 기능설명 편집 흐름을 단순화한다. +- 최종 산출물을 세션 리소스에만 남기지 않고 `feature-menu` 패키지 내부에서도 바로 추적 가능하게 정리한다. + +## 작업 대상 +- 패키지 루트: `src/features/layout/feature-menu/` +- 관련 저장 레이아웃 ID: `layout-1777643627048` +- 운영 비교 도메인: `https://test.sm-home.cloud/` +- 소스 변경 검증 도메인: `https://preview.sm-home.cloud/` +- 로컬 preview 컨테이너: `http://127.0.0.1:4173` + +## 확인된 기존 문제 +- 상단 description 요약이 모바일 세로 공간을 과도하게 차지했다. +- 하단 액션과 탭 구성이 좁은 화면에서 잘리거나 입력 영역을 압박했다. +- 기능설명 제목은 선택만 가능하고 편집 입력이 없어 수정 흐름이 한 번에 이어지지 않았다. +- 최종 설계/검증 근거가 세션 리소스에 분산돼 패키지 단위 추적성이 약했다. + +## 최종 판단 +- 이 화면은 신규 메뉴가 아니라 기존 `feature-menu` 패키지의 수정 작업으로 유지한다. +- 기능설명 입력과 Codex 설명은 탭으로 분리하되, 제목 입력은 제거하고 본문 textarea 단일 편집 흐름으로 유지한다. +- 최종 설계 문서, 구현 완료 문서, 검증 이미지까지 패키지 내부 `resources/`에서 같이 관리한다. +- 헤더 가림 수정 이후 완료 기준 산출물은 패키지 내부 최종 경로로 이관해 세션 리소스 의존도를 줄인다. diff --git a/features/layout/feature-menu/resources/feature-menu-final.md b/features/layout/feature-menu/resources/feature-menu-final.md new file mode 100644 index 0000000..60b3879 --- /dev/null +++ b/features/layout/feature-menu/resources/feature-menu-final.md @@ -0,0 +1,73 @@ +# 기능설명 관리 패키지 최종 설계문서 + +## 문서 목적 +- 이 문서는 `src/features/layout/feature-menu/` 패키지의 최종 설계 기준 문서다. +- 실제 개발이 진행된 뒤에는 세션 리소스 문서보다 이 문서를 우선 기준으로 사용한다. +- 세션 리소스 문서는 대화 기록용 보조 산출물로만 유지하고, 최종 분석/검증 산출물은 패키지 내부 `resources/`를 기준으로 관리한다. + +## 대상 범위 +- 메뉴명: `기능설명 관리` +- 관련 저장 레이아웃 ID: `layout-1777643627048` +- 패키지 루트: `src/features/layout/feature-menu/` +- 진입 시 현재 메뉴 레이아웃 ID를 우선 선택하고, 같은 이름/ID 레코드에 바로 덮어쓴다. +- 모바일에서는 전체 편집 레이아웃이 부모 높이를 넘지 않도록 루트와 편집 박스의 높이 계산을 `border-box` 기준으로 고정하고, 편집 셸은 남는 높이를 강제로 늘리지 않고 내용 기준 높이로 먼저 맞춘다. + +## 패키지 구조 기준 +- 전용 화면과 로직은 `feature-menu` 패키지 내부에만 둔다. +- 현재 패키지 구성은 `FeatureMenuLayoutPage.tsx`, `FeatureMenuLayoutPage.css`, `featureMenu.types.ts`, `featureMenu.utils.ts`, `featureMenu.chat.ts`로 분리한다. +- 공용 app 계층으로 승격하는 작업은 다른 화면 재사용 근거가 확인될 때만 진행한다. + +## 화면 역할 +- 이 메뉴는 일반 메모 관리가 아니라 선택한 레이아웃의 `tree.interactions`를 선택, 편집, 실행하는 관리 화면이다. +- 선택된 기능설명의 저장 대상은 `selectedLayout.tree.interactions[].description`이다. +- `implementationNotes`는 본문 저장 대상이 아니라 관련 설명 탭에서만 읽는 보조 메타데이터다. + +## 화면 구성 최종본 +1. 상단 필터 영역 +- `레이아웃명` 선택 +- `기능설명 선택` +- `Codex 실행` 아이콘 버튼 + +2. 본문 편집 영역 +- 상단 요약 description 박스는 두지 않는다. +- `추가`, `저장`, `삭제` 액션 아이콘은 두번째 섹션 상단에 고정해 하단 잘림을 피한다. +- 편집영역 툴바에는 문구 없는 `입력 최대화` 아이콘을 함께 둔다. +- 탭 버튼은 본문 상단에 두고, 선택된 탭 내용이 아래로 바로 이어지게 한다. +- 탭 구성: `기능설명 입력`, `Codex 설명` +- `기능설명 입력` 탭은 별도 제목 입력 없이 textarea 하나만 둔다. +- 기능설명 제목은 상단 `기능설명 선택` 드롭다운 항목명으로만 유지한다. +- `기능설명 입력` textarea는 편집 카드 높이를 최대한 채우도록 늘린다. +- `Codex 설명`이 비어 있을 때는 설명 문구를 따로 노출하지 않는다. +- 완료 기준 문서와 검증 산출물은 세션 리소스가 아니라 이 패키지 내부 경로를 우선 기준으로 본다. + +## UI 규칙 +- 기능설명 입력과 관련 설명은 같은 본문에 섞지 않고 탭으로 분리한다. +- 상시 노출 액션 버튼은 문구 없이 아이콘만 사용한다. +- 버튼 의미는 tooltip과 `aria-label`로 보완한다. +- 모바일에서는 설명성 문구보다 입력 영역과 하단 액션/탭 가시성을 우선한다. +- 모바일에서는 첫 섹션 높이를 줄여 두번째 섹션이 더 위에서 시작되도록 배치한다. +- `textarea`는 일반 상태와 최대화 상태 모두에서 마지막 줄이 잘리지 않게 내부 스크롤을 유지한다. +- 하단 입력 마지막 줄이 잘리지 않도록 탭 본문은 내부 스크롤과 하단 여백을 유지한다. +- 모바일에서는 제목 input, 탭 헤더, textarea가 같은 편집 카드 안에서 보이되, 편집 카드 자체는 다시 `auto + 1fr`로 남는 높이를 채운다. +- 모바일 편집 셸과 탭 본문은 `1fr` 채움을 유지하되, 하단 safe-area를 포함한 바깥 padding을 최소한으로 남긴다. +- 모바일에서는 아이폰 12 Pro 실기기 기준으로 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 줄이고, 편집 셸 높이 감산은 `24px`, 입력/설명 패널 감산은 `4px` 수준으로 맞춰 wrapper와 입력 영역이 함께 더 아래까지 늘어나게 한다. +- 모바일 `Codex 설명` 탭도 같은 높이 체계를 유지하고, 하단 padding만 줄여 wrapper 하단의 큰 빈 영역처럼 보이지 않게 한다. +- 전체 페이지 overflow는 숨기고, 넘치는 내용은 페이지 바깥이 아니라 textarea 또는 `Codex 설명` 패널 내부 스크롤에서만 처리한다. + +## Codex Live 실행 규칙 +- 이 메뉴에서 Codex 실행 시 현재 선택된 레이아웃과 기능설명 본문을 프롬프트 입력으로 사용한다. +- 실제 구현 요청을 보낼 때는 이 패키지 최종 설계문서를 함께 갱신 대상으로 간주한다. +- 후속 개발에서 설계가 바뀌면 세션 문서만 수정하지 말고 이 문서를 먼저 갱신한다. + +## 검증 기준 +- 실제 수정본이 있으면 문서 설명보다 화면 결과와 preview 검증을 우선한다. +- 운영 비교 도메인은 `https://test.sm-home.cloud/`다. +- 소스 변경 검증과 최종 확인 도메인은 `https://preview.sm-home.cloud/`다. +- 최종 검증 산출물은 `resources/verification/` 아래에 패키지 기준으로 함께 보관한다. + +## 패키지 내부 산출물 +- 분석 문서: `resources/feature-menu-analysis.md` +- 개발 완료 문서: `resources/feature-menu-implementation.md` +- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-mobile-final.png` +- 최종 preview 검증 이미지: `resources/verification/feature-menu-preview-desktop-final.png` +- `test.sm-home.cloud` 운영 비교 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png` diff --git a/features/layout/feature-menu/resources/feature-menu-implementation.md b/features/layout/feature-menu/resources/feature-menu-implementation.md new file mode 100644 index 0000000..fbf8f3f --- /dev/null +++ b/features/layout/feature-menu/resources/feature-menu-implementation.md @@ -0,0 +1,58 @@ +# 기능설명 관리 패키지 개발 완료 문서 + +## 반영 내용 +- 상단 description 요약 영역을 제거했다. +- 상단 필터에는 `Codex 실행` 아이콘 버튼만 유지했다. +- 상단 필터의 두번째 선택은 `기능설명 선택`으로 유지하고, 제목은 드롭다운 항목명으로만 유지하게 바꿨다. +- 본문은 `기능설명 입력`, `Codex 설명` 탭으로 구성했다. +- 후속 단순화 요청에 따라 `기능설명 입력` 탭의 제목 `input`은 제거하고 textarea 하나만 남겼다. +- `추가`, `저장`, `삭제` 아이콘 액션은 두번째 섹션 상단으로 옮겼다. +- 편집영역 툴바에 문구 없는 `입력 최대화` 아이콘 토글을 추가했다. +- `기능설명 입력` textarea는 편집 영역의 남는 세로 공간을 `100%` 채우도록 다시 조정했다. +- 모바일에서는 편집 필드를 grid 행으로 재구성하고 툴바/탭/입력 패딩을 더 줄여 textarea가 부모 영역을 넘치지 않게 조정했다. +- 이번 수정에서는 편집 셸과 탭 본문 자체를 `minmax(0, 1fr)` 기반 grid로 다시 묶고, `textarea`를 남은 높이만 채우는 방식으로 바꿨다. +- 후속 미세조정으로 모바일 `textarea`는 `calc(100% - 52px)`까지만 차도록 다시 줄여, 하단 테두리가 화면 안에서 분명히 보이도록 맞췄다. +- 추가 미세조정으로 모바일 wrapper 자체가 덜 눌려 보이도록 페이지/편집 셸 패딩과 탭 간격을 한 번 더 줄이고, `textarea`는 `calc(100% - 60px)`까지만 차도록 낮췄다. +- 이번 후속 수정에서는 textarea 자체보다 부모 wrapper가 길게 늘어난 점을 기준으로, 모바일에서 루트/편집 셸/탭/입력 필드의 `1fr` 확장을 풀고 내용 기준 높이로 다시 줄였다. +- 이번 최신 수정에서는 너무 줄어든 모바일 높이를 다시 되돌려, 루트/편집 셸/탭/입력 필드를 `auto + 1fr` 채움 구조로 복구하고 바깥 패딩, 탭 간격, `notes` 하단 padding만 더 줄였다. +- 이번 최신 미세조정에서는 부모 카드가 덜 잘리고 textarea는 조금 더 다시 커지도록, 모바일 편집 셸 높이는 `30px` 안쪽으로만 줄이고 제목행/툴바/탭 간격을 더 압축한 뒤 `textarea`와 `Codex 설명` 패널 높이는 각각 `30px` 안쪽 기준으로 다시 맞췄다. +- 이번 최신 재조정에서는 부모 카드 하단선을 더 확실히 보이게 하려고 모바일 편집 셸 높이 감산을 `42px`로 늘리고, 대신 `textarea`와 `Codex 설명` 패널 높이 감산은 `24px`로만 유지해 입력 높이 손실을 최소화했다. +- 이번 최신 재조정에서는 wrapper 하단선이 실제로 보이도록 모바일 편집 셸 감산을 `56px`로 더 키우고, 대신 필터/툴바/탭/제목행 고정 높이를 더 줄인 뒤 `textarea`와 `Codex 설명` 패널 감산은 `20px`로만 유지했다. +- 이번 최신 후속 조정에서는 아이폰 12 Pro 실기기 캡처 기준으로 하단 safe-area 여유와 편집 셸 감산이 과하다고 보고, 모바일 페이지 하단 padding을 `calc(4px + env(safe-area-inset-bottom))`로 줄이고 편집 셸 감산도 `44px`로 낮췄다. 동시에 입력/설명 패널 감산은 `12px`로 완화해 두번째 카드가 더 아래까지 늘어나도록 다시 키웠다. +- 이번 추가 보정은 원복 요청에 따라 되돌렸고, 모바일 기준은 다시 아이폰 12 Pro 실기기 캡처를 따라 페이지 하단 padding `calc(4px + env(safe-area-inset-bottom))`, 편집 셸 감산 `44px`, 입력/설명 패널 감산 `12px` 조합으로 복구했다. +- 이번 최신 조정에서는 모바일 하단 여백을 더 줄여달라는 요청에 맞춰 페이지 하단 padding을 `calc(2px + env(safe-area-inset-bottom))`로 더 낮추고, 편집 셸 감산을 `24px`, 입력/설명 패널 감산을 `4px`로 완화해 wrapper와 textarea를 함께 다시 키웠다. +- `play-saved` 모바일 레이아웃도 헤더 높이를 `52px` 기준으로 맞춰 상단 가림을 제거했다. +- 진입 직후에는 현재 메뉴 레이아웃 ID를 먼저 선택하도록 바꿔, 이전 다른 레이아웃이 기본값으로 먼저 보이지 않게 맞췄다. +- 모바일 하단 여백처럼 보이던 현상은 페이지 루트가 `height: 100%` 상태에서 `padding`까지 바깥으로 더해지던 문제여서, 루트/편집 셸/탭 영역에 `box-sizing: border-box`를 맞춰 전체 레이아웃 overflow를 막았다. +- 빈 `Codex 설명` 탭에서는 설명성 문구를 제거했다. +- Codex Live 실패 응답 복구 로직은 `src/app/main/mainChatPanel/chatUtils.ts`에서 별도로 보완됐다. +- 최종 완료 기준 문서와 검증 이미지는 `feature-menu` 패키지 내부 `resources/` 최종 경로로 이관했다. + +## 산출물 위치 +- 최종 설계 문서: `resources/feature-menu-final.md` +- 최종 분석 문서: `resources/feature-menu-analysis.md` +- 최종 preview 모바일: `resources/verification/feature-menu-preview-mobile-final.png` +- 최종 preview 데스크톱: `resources/verification/feature-menu-preview-desktop-final.png` +- `test.sm-home.cloud` 운영 비교 이미지: `resources/verification/feature-menu-test-sm-home-mobile.png` + +## 검증 결과 +- `test.sm-home.cloud` 모바일 재현에서는 textarea 하단이 편집 쉘 아래로 약 `91px` 넘치는 기존 상태를 다시 확인했다. +- `2026-05-03` 로컬 preview 컨테이너(`http://127.0.0.1:4173`) 모바일 재검증에서는 `tabs.bottom = 651`, `textarea.bottom = 651`로 맞춰졌고, 마지막 줄까지 내부 스크롤로 확인됐다. +- 최종 반영 결과는 `preview.sm-home.cloud` 기준 모바일/데스크톱 캡처로 보관했다. +- 같은 날짜 후속 개선으로 루트/편집 셸/탭 본문을 다시 `auto + minmax(0, 1fr)` 구조로 복구하고, textarea를 `autoSize` 대신 탭 본문 남는 높이 전체를 채우는 방식으로 조정했다. +- `2026-05-03` 로컬 preview 컨테이너(`http://127.0.0.1:4173`) 최종 재검증에서는 모바일 기준 `bodyScrollHeight = 844`, `root.bottom = 844`, `textarea.bottom = 831`, `textarea.height = 487.17`로 남는 공간을 채우면서도 페이지 바깥 overflow는 발생하지 않았다. +- 같은 날짜 추가 미세조정 뒤 로컬 preview 컨테이너(`http://127.0.0.1:4173`) 모바일 재검증에서는 `bodyScrollHeight = 664`, `root.bottom = 664`, `textarea.bottom = 599`, `textarea.height = 255.17`로 textarea 하단 여유를 더 확보했고, 페이지 바깥 overflow는 없었다. +- 같은 날짜 최신 재검증에서는 `test.sm-home.cloud`가 여전히 기존 번들로 `textarea.bottom = 747.25`인 반면, `preview.sm-home.cloud` 수정본은 `shell.bottom = 660`, `tabs.bottom = 655`, `textarea.bottom = 595`, `textarea.height = 272.17`로 wrapper 외곽과 하단 입력 영역이 더 안쪽에 들어오도록 정리됐다. +- 이번 후속 수정 검증은 동일한 모바일 문제 이미지 기준으로 부모 wrapper 하단의 과한 빈 영역이 사라졌는지 확인하는 것이 목적이다. +- `2026-05-03` 로컬 preview 컨테이너(`http://127.0.0.1:4173`) 모바일 재검증에서는 `shell.bottom = 547.83`, `tabs.bottom = 542.83`, `textarea.bottom = 542.83`, `textarea.height = 220`으로 wrapper 자체가 이전보다 약 `292px` 짧아졌다. +- 같은 날짜 최신 재검증에서는 `preview.sm-home.cloud` 모바일 기준 `shell.bottom = 840`, `tabs.bottom = 836`, `textarea.bottom = 836`, `textarea.height = 521.17`로 다시 하단까지 거의 채우면서도 카드 외곽 하단 선이 캡처 안에서 유지됐다. +- 같은 날짜 최신 재검증에서는 `preview.sm-home.cloud` 모바일 기준 `shell.bottom = 806`, `tabs.bottom = 801`, `textarea.bottom = 771`, `textarea.height = 455.17`로 부모 카드 하단이 다시 화면 안에 들어오면서 textarea 높이도 이전 `v30`보다 `187px` 커졌다. +- 같은 날짜 최신 재검증에서는 `preview.sm-home.cloud` 모바일 기준 `shell.bottom = 794`, `tabs.bottom = 789`, `textarea.bottom = 765`, `textarea.height = 449.17`로 부모 카드 하단선을 `v31`보다 `12px` 더 위로 올리면서도 textarea 높이는 `6px`만 줄였다. +- 같은 날짜 최신 재검증에서는 `preview.sm-home.cloud` 모바일 기준 `shell.bottom = 778`, `tabs.bottom = 774`, `textarea.bottom = 754`, `textarea.height = 447.17`로 wrapper 하단선이 캡처 안에서 분명히 보이면서도 textarea 높이는 직전 대비 `2px`만 줄었다. +- 같은 날짜 원복 기준은 아이폰 12 Pro viewport에서 `test.sm-home.cloud`가 `shell.bottom = 598`, `notes.bottom = 574`인 반면, `preview.sm-home.cloud` 수정본은 `shell.bottom = 616`, `notes.bottom = 600`으로 두번째 카드가 실제로 더 아래까지 내려오던 시점의 값으로 맞춘다. +- 이번 최신 조정 검증은 모바일 wrapper와 textarea를 동시에 더 늘리는 것이 목적이며, `preview.sm-home.cloud` 기준으로 다시 확인한다. +- 같은 날짜 최신 `v36` 검증에서는 아이폰 12 Pro viewport 기준 `preview.sm-home.cloud`가 `shell.bottom = 586`, `tabs.bottom = 582`, `textarea.bottom = 578`, `textarea.height = 323.17`로 확인됐고, 같은 조건 `test.sm-home.cloud`는 `shell.bottom = 564`, `tabs.bottom = 560`, `textarea.bottom = 548`, `textarea.height = 293.17`이었다. +- 같은 날짜 데스크톱 `preview.sm-home.cloud` 재검증에서는 `shell.bottom = 1088`, `tabs.bottom = 1077`, `textarea.bottom = 1077`로 기존 데스크톱 채움 구조는 유지됐다. +- 같은 날짜 데스크톱 `preview.sm-home.cloud` 재검증에서는 `shell.bottom = 1188`, `tabs.bottom = 1177`, `textarea.bottom = 1177`로 데스크톱 채움 구조가 그대로 유지됐다. +- 같은 날짜 후속 수정으로 `기능설명 입력` 탭은 `title input` 없이 textarea 하나만 남았고, `preview.sm-home.cloud` 기준 `titleInputCount = 0`, `textareaCount = 1`로 확인했다. +- 이번 이관 작업은 패키지 내부 문서/리소스 구조 정리이며 동작 로직 추가 변경은 포함하지 않는다. diff --git a/features/overview.md b/features/overview.md new file mode 100644 index 0000000..c2c5e96 --- /dev/null +++ b/features/overview.md @@ -0,0 +1,9 @@ +# Features Overview + +`src/features`는 프로젝트 전용 기능 영역입니다. + +## 구조 기준 + +- 공통 UI로 분리하기 어려운 화면 로직은 `src/features`에 둡니다. +- 재사용 가능한 UI는 `src/components`, 카드형 조합은 `src/widgets`로 분리합니다. +- 레이아웃 전용 기능은 `src/features/layout`에서 관리합니다. diff --git a/package.json b/package.json old mode 100755 new mode 100644 index 5194f48..f4a2d0f --- a/package.json +++ b/package.json @@ -39,14 +39,15 @@ "capture:plan-mobile": "node scripts/capture-plan-board-mobile-screenshot.mjs", "plan:codex:once": "node scripts/run-plan-codex-once.mjs", "server-command:runner": "node scripts/run-server-command-runner.mjs", - "build:app": "tsc -b && vite build --outDir app-dist", - "build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true VITE_DISABLE_PWA=true vite build --outDir /tmp/ai-code-test-app-dist", + "build:app": "node scripts/prepare-app-dist.mjs && tsc -b && VITE_EMPTY_OUT_DIR=false VITE_FILTER_PUBLIC_DIR=true vite build --outDir app-dist", + "build:test-app": "VITE_FILTER_PUBLIC_DIR=true VITE_DISABLE_MODULE_PRELOAD=true vite build --outDir /tmp/ai-code-test-app-dist", "build:lib": "tsc -p tsconfig.lib.json", "build": "npm run build:lib && npm run build:app", "prepublishOnly": "npm run build:lib", "preview": "vite preview", "preview:app": "node scripts/serve-app-dist.mjs", - "preview:test-app": "APP_DIST_DIR=/tmp/ai-code-test-app-dist node scripts/serve-app-dist.mjs" + "preview:test-app": "APP_DIST_DIR=/tmp/ai-code-test-app-dist node scripts/serve-app-dist.mjs", + "preview:test-app:watch": "node scripts/preview-test-app-watch.mjs" }, "dependencies": { "@ant-design/icons": "^6.0.1", diff --git a/scripts/capture-auth-utils.mjs b/scripts/capture-auth-utils.mjs index 392af5a..6322d77 100644 --- a/scripts/capture-auth-utils.mjs +++ b/scripts/capture-auth-utils.mjs @@ -3,7 +3,7 @@ import path from 'node:path'; import process from 'node:process'; const DEFAULT_CAPTURE_STORAGE_KEY = 'work-app.token-access.registered-token'; -const DEFAULT_CAPTURE_BASE_URL = 'https://test.sm-home.cloud/'; +const DEFAULT_CAPTURE_BASE_URL = 'https://preview.sm-home.cloud/'; function stripWrappingQuotes(value) { if (!value) { diff --git a/scripts/capture-component-screenshot.mjs b/scripts/capture-component-screenshot.mjs old mode 100755 new mode 100644 diff --git a/scripts/capture-feature-screenshot.mjs b/scripts/capture-feature-screenshot.mjs old mode 100755 new mode 100644 diff --git a/scripts/capture-fullscreen-toggle-screenshot.mjs b/scripts/capture-fullscreen-toggle-screenshot.mjs old mode 100755 new mode 100644 diff --git a/scripts/capture-menu-screenshot.mjs b/scripts/capture-menu-screenshot.mjs old mode 100755 new mode 100644 diff --git a/scripts/capture-plan-board-mobile-screenshot.mjs b/scripts/capture-plan-board-mobile-screenshot.mjs old mode 100755 new mode 100644 diff --git a/scripts/capture-search-command-screenshot.mjs b/scripts/capture-search-command-screenshot.mjs old mode 100755 new mode 100644 diff --git a/scripts/capture-settings-screenshot.mjs b/scripts/capture-settings-screenshot.mjs old mode 100755 new mode 100644 diff --git a/scripts/prepare-app-dist.mjs b/scripts/prepare-app-dist.mjs new file mode 100644 index 0000000..ec89bda --- /dev/null +++ b/scripts/prepare-app-dist.mjs @@ -0,0 +1,25 @@ +import { existsSync, readdirSync, rmSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; + +const cwd = process.cwd(); +const outDir = resolve(cwd, process.env.APP_DIST_DIR?.trim() || 'app-dist'); +const backupPrefix = 'assets.root-owned-backup-'; + +if (!existsSync(outDir)) { + process.exit(0); +} + +for (const entry of readdirSync(outDir, { withFileTypes: true })) { + if (entry.name.startsWith(backupPrefix)) { + continue; + } + + const targetPath = join(outDir, entry.name); + + try { + rmSync(targetPath, { recursive: true, force: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[prepare-app-dist] skipped ${basename(targetPath)}: ${message}`); + } +} diff --git a/scripts/preview-test-app-watch.mjs b/scripts/preview-test-app-watch.mjs new file mode 100644 index 0000000..4e2bc0e --- /dev/null +++ b/scripts/preview-test-app-watch.mjs @@ -0,0 +1,185 @@ +import { spawn } from 'node:child_process'; +import { resolve } from 'node:path'; + +const rootDir = process.cwd(); +const viteBin = resolve(rootDir, 'node_modules/vite/bin/vite.js'); +const serveScript = resolve(rootDir, 'scripts/serve-app-dist.mjs'); +const appDistDir = '/tmp/ai-code-test-app-dist'; +const buildEnv = { + ...process.env, + VITE_FILTER_PUBLIC_DIR: 'true', + VITE_DISABLE_MODULE_PRELOAD: 'true', +}; +const serveEnv = { + ...process.env, + APP_DIST_DIR: appDistDir, +}; + +function log(message) { + console.log(`[preview:test-app:watch] ${new Date().toISOString()} ${message}`); +} + +function logError(message) { + console.error(`[preview:test-app:watch] ${new Date().toISOString()} ${message}`); +} + +function runNodeScript(args, env, options = {}) { + return spawn(process.execPath, args, { + cwd: rootDir, + env, + stdio: 'inherit', + ...options, + }); +} + +function pipePrefixedLines(stream, writer, prefix, onLine) { + if (!stream) { + return; + } + + let buffer = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk) => { + buffer += chunk; + const lines = buffer.split(/\r?\n/u); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + onLine?.(line); + writer.write(`${prefix}${line}\n`); + } + }); + stream.on('end', () => { + if (!buffer) { + return; + } + + onLine?.(buffer); + writer.write(`${prefix}${buffer}\n`); + buffer = ''; + }); +} + +function attachWatchBuildLogging(child) { + let rebuildStartedAt = null; + let serviceWorkerBuildPending = false; + + const handleLine = (line) => { + const normalized = line.replace(/\u001b\[[0-9;]*m/g, '').trim(); + + if (!normalized) { + return; + } + + if (normalized.startsWith('Building src/sw.js service worker')) { + serviceWorkerBuildPending = true; + return; + } + + if (normalized.includes('building client environment for production')) { + if (serviceWorkerBuildPending) { + serviceWorkerBuildPending = false; + return; + } + + rebuildStartedAt = Date.now(); + log('watch rebuild started'); + return; + } + + const builtMatch = normalized.match(/^built in (\d+)ms\.$/u); + if (!builtMatch) { + return; + } + + const reportedMs = Number.parseInt(builtMatch[1], 10); + const wallMs = rebuildStartedAt === null ? null : Date.now() - rebuildStartedAt; + const durationText = wallMs === null ? `vite=${reportedMs}ms` : `vite=${reportedMs}ms, wall=${wallMs}ms`; + log(`watch rebuild completed (${durationText})`); + rebuildStartedAt = null; + }; + + pipePrefixedLines(child.stdout, process.stdout, '[preview:watch:vite] ', handleLine); + pipePrefixedLines(child.stderr, process.stderr, '[preview:watch:vite] ', handleLine); +} + +function waitForExit(child) { + return new Promise((resolveExit, rejectExit) => { + child.once('error', rejectExit); + child.once('exit', (code, signal) => { + if (code === 0) { + resolveExit(); + return; + } + + rejectExit(new Error(`process exited with code ${code ?? 'null'}${signal ? ` (signal: ${signal})` : ''}`)); + }); + }); +} + +function terminateChild(child, signal = 'SIGTERM') { + if (child.killed) { + return; + } + + try { + child.kill(signal); + } catch { + // Ignore termination races during shutdown. + } +} + +const backgroundChildren = []; +let shuttingDown = false; + +function shutdown(exitCode = 0) { + if (shuttingDown) { + return; + } + + shuttingDown = true; + backgroundChildren.forEach((child) => terminateChild(child)); + setTimeout(() => { + backgroundChildren.forEach((child) => terminateChild(child, 'SIGKILL')); + process.exit(exitCode); + }, 5_000).unref(); +} + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); + +try { + log('initial build started'); + await waitForExit(runNodeScript([viteBin, 'build', '--outDir', appDistDir], buildEnv)); + log('initial build completed'); + + const watchBuild = runNodeScript([viteBin, 'build', '--watch', '--clearScreen', 'false', '--outDir', appDistDir], buildEnv, { + stdio: ['inherit', 'pipe', 'pipe'], + }); + const previewServer = runNodeScript([serveScript], serveEnv); + backgroundChildren.push(watchBuild, previewServer); + attachWatchBuildLogging(watchBuild); + log('preview server and build watcher are running'); + + watchBuild.once('exit', (code, signal) => { + if (shuttingDown) { + return; + } + + logError(`build watcher stopped: code=${code ?? 'null'} signal=${signal ?? 'none'}`); + shutdown(code && code > 0 ? code : 1); + }); + + previewServer.once('exit', (code, signal) => { + if (shuttingDown) { + return; + } + + logError(`preview server stopped: code=${code ?? 'null'} signal=${signal ?? 'none'}`); + shutdown(code && code > 0 ? code : 1); + }); +} catch (error) { + logError('failed to prepare preview build'); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} diff --git a/scripts/run-plan-codex-once.mjs b/scripts/run-plan-codex-once.mjs old mode 100755 new mode 100644 index 791a418..cb16507 --- a/scripts/run-plan-codex-once.mjs +++ b/scripts/run-plan-codex-once.mjs @@ -562,10 +562,11 @@ async function wait(ms) { } async function prepareWritableCodexHome(tempDir) { - const writableCodexHome = process.env.CODEX_HOME?.trim() || path.join(tempDir, '.codex'); + const writableCodexHome = path.join(tempDir, '.codex'); const sourceCodexHome = process.env.PLAN_CODEX_TEMPLATE_HOME?.trim() || process.env.CODEX_HOME_TEMPLATE?.trim() || + process.env.CODEX_HOME?.trim() || path.join(process.env.HOME ?? '/root', '.codex'); await mkdir(writableCodexHome, { recursive: true }); diff --git a/scripts/run-server-command-runner.mjs b/scripts/run-server-command-runner.mjs index 80bd7e0..bbb4100 100644 --- a/scripts/run-server-command-runner.mjs +++ b/scripts/run-server-command-runner.mjs @@ -51,7 +51,100 @@ const CODEX_HOME_RUNTIME_PATHS = [ 'version.json', ]; const CHAT_SESSION_RESOURCE_DIR_MODE = 0o777; +const CODEX_LIVE_EVENT_HISTORY_LIMIT = 400; +const CODEX_LIVE_FINISHED_RETENTION_MS = Math.max( + 60_000, + Number(process.env.SERVER_COMMAND_RUNNER_CODEX_FINISHED_RETENTION_MS?.trim() || `${10 * 60 * 1000}`), +); const activeCodexExecutions = new Map(); +const recentCodexExecutions = new Map(); + +function createCodexExecutionRecord({ requestId, child, tempDir }) { + return { + requestId, + child, + tempDir, + subscribers: new Set(), + history: [], + completed: false, + cleanupTimer: null, + }; +} + +function registerSubscriberClose(record, response) { + response.on('close', () => { + record.subscribers.delete(response); + }); +} + +function attachCodexExecutionSubscriber(record, response) { + response.writeHead(200, { + 'content-type': 'application/x-ndjson; charset=utf-8', + 'cache-control': 'no-store', + }); + record.subscribers.add(response); + registerSubscriberClose(record, response); + + for (const payload of record.history) { + sendJsonLine(response, payload); + } + + if (record.completed) { + response.end(); + record.subscribers.delete(response); + } +} + +function broadcastCodexExecutionEvent(record, payload) { + record.history.push(payload); + if (record.history.length > CODEX_LIVE_EVENT_HISTORY_LIMIT) { + record.history.splice(0, record.history.length - CODEX_LIVE_EVENT_HISTORY_LIMIT); + } + + for (const response of record.subscribers) { + try { + sendJsonLine(response, payload); + } catch { + record.subscribers.delete(response); + } + } +} + +function closeCodexExecutionSubscribers(record) { + for (const response of record.subscribers) { + try { + response.end(); + } catch { + // noop + } + } + + record.subscribers.clear(); +} + +function scheduleCodexExecutionCleanup(record) { + if (record.cleanupTimer) { + clearTimeout(record.cleanupTimer); + } + + record.cleanupTimer = setTimeout(async () => { + recentCodexExecutions.delete(record.requestId); + await rm(record.tempDir, { recursive: true, force: true }).catch(() => undefined); + }, CODEX_LIVE_FINISHED_RETENTION_MS); + record.cleanupTimer.unref?.(); +} + +function finalizeCodexExecution(record) { + if (record.completed) { + return; + } + + record.completed = true; + activeCodexExecutions.delete(record.requestId); + recentCodexExecutions.set(record.requestId, record); + closeCodexExecutionSubscribers(record); + scheduleCodexExecutionCleanup(record); +} async function ensureWorldWritableDirectory(absolutePath) { await mkdir(absolutePath, { recursive: true, mode: CHAT_SESSION_RESOURCE_DIR_MODE }); @@ -559,6 +652,18 @@ async function runCodexLiveExecution(payload, response) { return; } + const existingExecution = activeCodexExecutions.get(requestId) ?? recentCodexExecutions.get(requestId); + + if (existingExecution) { + attachCodexExecutionSubscriber(existingExecution, response); + broadcastCodexExecutionEvent(existingExecution, { + type: 'attached', + requestId, + completed: existingExecution.completed, + }); + return; + } + await validateCodexExecutionRuntime(repoPath, codexBin); await ensureWritableChatSessionDirectories(repoPath, sessionId); @@ -568,16 +673,10 @@ async function runCodexLiveExecution(payload, response) { let stderrTail = ''; let jsonLineBuffer = ''; let completedText = ''; - let responseClosed = false; let idleTimer = null; let executionTimer = null; let terminationRequested = false; - response.writeHead(200, { - 'content-type': 'application/x-ndjson; charset=utf-8', - 'cache-control': 'no-store', - }); - const child = spawn( codexBin, ['exec', '--model', CODEX_LIVE_MODEL, '--json', '--dangerously-bypass-approvals-and-sandbox', '-C', repoPath, '-'], @@ -594,22 +693,20 @@ async function runCodexLiveExecution(payload, response) { }, ); - activeCodexExecutions.set(requestId, { + const executionRecord = createCodexExecutionRecord({ + requestId, child, tempDir, }); - sendJsonLine(response, { + activeCodexExecutions.set(requestId, executionRecord); + attachCodexExecutionSubscriber(executionRecord, response); + broadcastCodexExecutionEvent(executionRecord, { type: 'started', pid: child.pid ?? null, configuredIdleTimeoutSeconds: Math.round(configuredIdleTimeoutMs / 1000), configuredMaxExecutionSeconds: Math.round(configuredMaxExecutionMs / 1000), }); - const cleanup = async () => { - activeCodexExecutions.delete(requestId); - await rm(tempDir, { recursive: true, force: true }).catch(() => undefined); - }; - const clearExecutionTimers = () => { if (idleTimer) { clearTimeout(idleTimer); @@ -630,14 +727,10 @@ async function runCodexLiveExecution(payload, response) { terminationRequested = true; clearExecutionTimers(); - if (!responseClosed) { - sendJsonLine(response, { - type: 'error', - message, - }); - response.end(); - responseClosed = true; - } + broadcastCodexExecutionEvent(executionRecord, { + type: 'error', + message, + }); child.kill('SIGTERM'); setTimeout(() => { @@ -685,7 +778,7 @@ async function runCodexLiveExecution(payload, response) { if (activityLog) { refreshIdleTimer(); - sendJsonLine(response, { + broadcastCodexExecutionEvent(executionRecord, { type: 'activity', line: activityLog, }); @@ -696,7 +789,7 @@ async function runCodexLiveExecution(payload, response) { if (nextCompletedText) { refreshIdleTimer(); completedText = nextCompletedText; - sendJsonLine(response, { + broadcastCodexExecutionEvent(executionRecord, { type: 'completed', text: nextCompletedText, }); @@ -705,7 +798,7 @@ async function runCodexLiveExecution(payload, response) { if (deltaText) { refreshIdleTimer(); - sendJsonLine(response, { + broadcastCodexExecutionEvent(executionRecord, { type: 'delta', text: deltaText, }); @@ -715,11 +808,6 @@ async function runCodexLiveExecution(payload, response) { return false; }; - response.on('close', () => { - responseClosed = true; - clearExecutionTimers(); - }); - child.stdout?.on('data', (chunk) => { refreshIdleTimer(); const text = String(chunk); @@ -740,7 +828,7 @@ async function runCodexLiveExecution(payload, response) { } if (!line.startsWith('{') && !isIgnorableCodexDiagnosticLine(line)) { - sendJsonLine(response, { + broadcastCodexExecutionEvent(executionRecord, { type: 'stdout', line, }); @@ -761,7 +849,7 @@ async function runCodexLiveExecution(payload, response) { return; } - sendJsonLine(response, { + broadcastCodexExecutionEvent(executionRecord, { type: 'stderr', line, }); @@ -770,15 +858,15 @@ async function runCodexLiveExecution(payload, response) { child.on('error', async (error) => { clearExecutionTimers(); - if (!responseClosed) { - sendJsonLine(response, { - type: 'error', - message: error instanceof Error ? error.message : String(error), - }); - response.end(); - } - - await cleanup(); + broadcastCodexExecutionEvent(executionRecord, { + type: 'error', + message: error instanceof Error ? error.message : String(error), + }); + broadcastCodexExecutionEvent(executionRecord, { + type: 'finished', + exitCode: null, + }); + finalizeCodexExecution(executionRecord); }); child.on('close', async (code) => { @@ -788,18 +876,18 @@ async function runCodexLiveExecution(payload, response) { handleCodexJsonLine(trailingLine); } - if (!responseClosed) { - if (code !== 0) { - sendJsonLine(response, { - type: 'error', - message: summarizeCodexOutput(`${stderrTail}\n${completedText}\n${stdoutTail}`), - }); - } - - response.end(); + if (code !== 0) { + broadcastCodexExecutionEvent(executionRecord, { + type: 'error', + message: summarizeCodexOutput(`${stderrTail}\n${completedText}\n${stdoutTail}`), + }); } - await cleanup(); + broadcastCodexExecutionEvent(executionRecord, { + type: 'finished', + exitCode: code ?? null, + }); + finalizeCodexExecution(executionRecord); }); child.stdin?.end(prompt); @@ -958,6 +1046,28 @@ const server = createServer(async (request, response) => { return; } + const attachMatch = requestUrl.pathname.match(/^\/api\/codex-live\/jobs\/([^/]+)\/attach$/); + if (request.method === 'POST' && attachMatch) { + const requestId = decodeURIComponent(attachMatch[1]); + const execution = activeCodexExecutions.get(requestId) ?? recentCodexExecutions.get(requestId); + + if (!execution) { + sendJson(response, 404, { + attached: false, + message: '재부착할 Codex 작업을 찾지 못했습니다.', + }); + return; + } + + attachCodexExecutionSubscriber(execution, response); + broadcastCodexExecutionEvent(execution, { + type: 'attached', + requestId, + completed: execution.completed, + }); + return; + } + const cancelMatch = requestUrl.pathname.match(/^\/api\/codex-live\/jobs\/([^/]+)\/cancel$/); if (request.method === 'POST' && cancelMatch) { const requestId = decodeURIComponent(cancelMatch[1]); diff --git a/scripts/serve-app-dist.mjs b/scripts/serve-app-dist.mjs old mode 100755 new mode 100644 index 9a6da02..ccd4026 --- a/scripts/serve-app-dist.mjs +++ b/scripts/serve-app-dist.mjs @@ -1,13 +1,15 @@ import { createReadStream, existsSync, statSync } from 'node:fs'; import { extname, isAbsolute, join, normalize } from 'node:path'; import { createServer } from 'node:http'; +import { connect as connectNet } from 'node:net'; import { Readable } from 'node:stream'; +import { connect as connectTls } from 'node:tls'; const port = Number(process.env.PORT ?? 5173); const distDirName = process.env.APP_DIST_DIR ?? 'app-dist'; const rootDir = normalize(isAbsolute(distDirName) ? distDirName : join(process.cwd(), distDirName)); const workServerUrl = new URL(process.env.WORK_SERVER_URL ?? 'http://127.0.0.1:3100'); -const proxyPrefixes = ['/api', '/.codex_chat']; +const proxyPrefixes = ['/api', '/.codex_chat', '/ws/chat']; const mimeTypes = { '.css': 'text/css; charset=utf-8', @@ -65,7 +67,8 @@ function resolvePath(urlPath) { } function shouldProxyRequest(urlPath = '/') { - return proxyPrefixes.some((prefix) => urlPath === prefix || urlPath.startsWith(`${prefix}/`)); + const normalizedPath = urlPath.split('?')[0] ?? urlPath; + return proxyPrefixes.some((prefix) => normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`)); } function readRequestBody(request) { @@ -155,6 +158,66 @@ const server = createServer(async (request, response) => { createReadStream(resolvedPath).pipe(response); }); +server.on('upgrade', (request, socket, head) => { + if (!shouldProxyRequest(request.url ?? '/')) { + socket.destroy(); + return; + } + + const upstreamPort = Number( + workServerUrl.port || (workServerUrl.protocol === 'https:' ? '443' : '80'), + ); + const upstreamSocket = + workServerUrl.protocol === 'https:' + ? connectTls(upstreamPort, workServerUrl.hostname, { servername: workServerUrl.hostname }) + : connectNet(upstreamPort, workServerUrl.hostname); + + upstreamSocket.on('connect', () => { + const headerLines = Object.entries(request.headers) + .flatMap(([key, value]) => { + if (value == null || key.toLowerCase() === 'host') { + return []; + } + + return Array.isArray(value) + ? value.map((item) => `${key}: ${item}\r\n`) + : [`${key}: ${value}\r\n`]; + }) + .join(''); + + const requestLine = `${request.method ?? 'GET'} ${request.url ?? '/'} HTTP/${request.httpVersion}\r\n`; + upstreamSocket.write(`${requestLine}host: ${workServerUrl.host}\r\n${headerLines}\r\n`); + + if (head?.length) { + upstreamSocket.write(head); + } + }); + + upstreamSocket.on('data', (chunk) => { + socket.write(chunk); + }); + + upstreamSocket.on('end', () => { + socket.end(); + }); + + upstreamSocket.on('error', () => { + socket.destroy(); + }); + + socket.on('data', (chunk) => { + upstreamSocket.write(chunk); + }); + + socket.on('end', () => { + upstreamSocket.end(); + }); + + socket.on('error', () => { + upstreamSocket.destroy(); + }); +}); + server.listen(port, '0.0.0.0', () => { console.log(`${distDirName} server listening on http://0.0.0.0:${port}`); }); diff --git a/scripts/server-command-runner-supervisor.sh b/scripts/server-command-runner-supervisor.sh old mode 100755 new mode 100644 diff --git a/scripts/worklog-capture-utils.mjs b/scripts/worklog-capture-utils.mjs old mode 100755 new mode 100644 diff --git a/src/App.tsx b/src/App.tsx old mode 100755 new mode 100644 diff --git a/src/app/main/AppShell.tsx b/src/app/main/AppShell.tsx old mode 100755 new mode 100644 diff --git a/src/app/main/AutomationTypeManagementPage.tsx b/src/app/main/AutomationTypeManagementPage.tsx index 186b3ac..20b469e 100644 --- a/src/app/main/AutomationTypeManagementPage.tsx +++ b/src/app/main/AutomationTypeManagementPage.tsx @@ -125,6 +125,7 @@ export function AutomationTypeManagementPage() { setSelectedAutomationTypeId(null); setDetailMode('detail'); setMaximizedPane('none'); + setMobileView('edit'); form.resetFields(); form.setFieldsValue(EMPTY_FORM_VALUE); }; @@ -134,12 +135,14 @@ export function AutomationTypeManagementPage() { setSelectedAutomationTypeId(automationTypeId); setDetailMode('detail'); setMaximizedPane('none'); + setMobileView('edit'); }; const closeDetail = () => { setIsCreating(false); setDetailMode('list'); setMaximizedPane('none'); + setMobileView('edit'); }; const handleDelete = async () => { @@ -221,7 +224,7 @@ export function AutomationTypeManagementPage() {
{detailMode === 'list' ? ( defaultContexts.find((item) => item.id === selectedContextId) ?? null, [defaultContexts, selectedContextId], ); + const requestedContextId = searchParams.get('contextId')?.trim() ?? ''; const shouldRenderServerList = hasLoadedFromServer && !contextSettingsErrorMessage; const isServerDataReadyForEditing = hasLoadedFromServer && !contextSettingsErrorMessage && storeSource === 'server'; @@ -89,6 +95,22 @@ export function ChatDefaultContextManagementPage() { setSelectedContextId(defaultContexts[0]?.id ?? null); }, [defaultContexts, selectedContextId]); + useEffect(() => { + if (!requestedContextId) { + return; + } + + if (!defaultContexts.some((item) => item.id === requestedContextId)) { + return; + } + + setIsCreating(false); + setSelectedContextId(requestedContextId); + setDetailMode('detail'); + setMaximizedPane('none'); + setMobileView('edit'); + }, [defaultContexts, requestedContextId]); + useEffect(() => { if (detailMode !== 'detail') { return; @@ -119,11 +141,68 @@ export function ChatDefaultContextManagementPage() { }; }, []); + const handleReload = async () => { + setIsReloading(true); + setSaveErrorMessage(''); + try { + await reload(); + } catch (error) { + setSaveErrorMessage(error instanceof Error ? error.message : '공통 문맥 재조회에 실패했습니다.'); + } finally { + setIsReloading(false); + } + }; + + const moveDefaultContextInList = async (contextId: string, direction: 'up' | 'down') => { + const currentIndex = defaultContexts.findIndex((item) => item.id === contextId); + + if (currentIndex < 0) { + return; + } + + const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + + if (targetIndex < 0 || targetIndex >= defaultContexts.length) { + return; + } + + const reorderedDefaultContexts = [...defaultContexts]; + const [movedItem] = reorderedDefaultContexts.splice(currentIndex, 1); + + reorderedDefaultContexts.splice(targetIndex, 0, movedItem); + + const nextDefaultContexts = reorderedDefaultContexts.map((item, index) => ({ + ...item, + sortOrder: index + 1, + })); + + setSaveErrorMessage(''); + + try { + const savedStore = await setStore({ + defaultContexts: nextDefaultContexts, + chatTypeDefaults, + roomContexts, + }); + + setSelectedContextId((currentSelectedId) => { + if (currentSelectedId && savedStore.defaultContexts.some((item) => item.id === currentSelectedId)) { + return currentSelectedId; + } + + return contextId; + }); + } catch (error) { + setSaveErrorMessage(error instanceof Error ? error.message : '공통 문맥 순서 변경에 실패했습니다.'); + } + }; + const openCreateForm = () => { setIsCreating(true); setSelectedContextId(null); setDetailMode('detail'); setMaximizedPane('none'); + setMobileView('edit'); form.resetFields(); form.setFieldsValue(EMPTY_FORM_VALUE); }; @@ -133,12 +212,14 @@ export function ChatDefaultContextManagementPage() { setSelectedContextId(contextId); setDetailMode('detail'); setMaximizedPane('none'); + setMobileView('edit'); }; const closeDetail = () => { setIsCreating(false); setDetailMode('list'); setMaximizedPane('none'); + setMobileView('edit'); }; const handleDelete = async () => { @@ -146,7 +227,7 @@ export function ChatDefaultContextManagementPage() { return; } - if (!window.confirm(`"${selectedContext.title}" 기본 유형을 삭제할까요?`)) { + if (!window.confirm(`"${selectedContext.title}" 공통 문맥을 삭제할까요?`)) { return; } @@ -168,12 +249,12 @@ export function ChatDefaultContextManagementPage() { if (!hasAccess) { return ( - + ); @@ -181,18 +262,32 @@ export function ChatDefaultContextManagementPage() { return (
{detailMode === 'list' ? ( } onClick={openCreateForm} disabled={!isServerDataReadyForEditing}> - 신규 기본 유형 - + + + } >
@@ -202,19 +297,14 @@ export function ChatDefaultContextManagementPage() { {contextSettingsErrorMessage} + + +
-
- - )} + + ); + }} /> ) : isLoading && !hasLoadedFromServer ? ( - + ) : ( )} @@ -301,12 +417,22 @@ export function ChatDefaultContextManagementPage() { ) : ( + + /> + /> )} @@ -456,12 +593,25 @@ export function ChatDefaultContextManagementPage() { >
입력 + {isMobileViewport ? ( +
@@ -477,6 +627,19 @@ export function ChatDefaultContextManagementPage() {
미리보기 + {isMobileViewport ? ( +
prev.content !== next.content}> @@ -486,7 +649,7 @@ export function ChatDefaultContextManagementPage() { return content ? ( ) : ( - + ); }} diff --git a/src/app/main/ChatNotificationBridgeV2.tsx b/src/app/main/ChatNotificationBridgeV2.tsx index 7d01986..8bfd3a9 100644 --- a/src/app/main/ChatNotificationBridgeV2.tsx +++ b/src/app/main/ChatNotificationBridgeV2.tsx @@ -1,6 +1,8 @@ // @ts-nocheck import { useEffect, useRef } from 'react'; import { useAppConfig } from './appConfig'; +import { isPreviewRuntime } from './previewRuntime'; +import { getOrCreateClientId } from './clientIdentity'; import { createNotificationMessage, sendClientNotification, @@ -70,6 +72,10 @@ async function tryShowLocalChatNotification(args: { threadId: string; data: Record; }) { + if (shouldSuppressChatNotificationWhenAppOpen()) { + return; + } + await showLocalClientNotification({ title: args.title, body: args.body, @@ -152,6 +158,22 @@ function shouldPollConversationNotifications() { return false; } +function shouldSuppressChatNotificationWhenAppOpen() { + if (typeof window === 'undefined' || typeof document === 'undefined') { + return false; + } + + if (document.visibilityState === 'hidden') { + return false; + } + + if (typeof document.hasFocus === 'function') { + return document.hasFocus(); + } + + return true; +} + function getConversationActivityTime(item: { lastMessageAt?: string | null; updatedAt?: string | null }) { const candidate = item.lastMessageAt || item.updatedAt || ''; const parsed = candidate ? Date.parse(candidate) : Number.NaN; @@ -203,6 +225,7 @@ export function ChatNotificationBridgeV2() { const notificationData = { category: 'chat', priority, + suppressIfVisible: 'true', sessionId: targetSessionId, conversationTitle: resolvedConversationTitle, targetUrl: linkUrl, @@ -223,8 +246,16 @@ export function ChatNotificationBridgeV2() { 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, @@ -260,8 +291,137 @@ export function ChatNotificationBridgeV2() { .catch(() => undefined); }; - if (!appConfig.chat.receiveRoomNotifications) { + 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/ChatRuntimeBridgeV2.tsx b/src/app/main/ChatRuntimeBridgeV2.tsx index fe9d0f6..eef9c02 100644 --- a/src/app/main/ChatRuntimeBridgeV2.tsx +++ b/src/app/main/ChatRuntimeBridgeV2.tsx @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from 'react'; -import { useLocation } from 'react-router-dom'; import { useAppStore } from '../../store'; import { chatConnectionGateway, chatGateway } from './chatV2'; import type { ChatMessage, ChatViewContext } from './mainChatPanel/types'; @@ -15,28 +14,18 @@ function isStandaloneDisplayMode() { ); } +function getLivePageFocusState() { + if (typeof document === 'undefined' || typeof document.hasFocus !== 'function') { + return 'focused' as const; + } + + return document.hasFocus() ? ('focused' as const) : ('blurred' as const); +} + export function ChatRuntimeBridgeV2() { const { currentPage, focusedComponentId } = useAppStore(); - const location = useLocation(); const [, setMessages] = useState([]); - const sessionId = useMemo(() => { - if (typeof window === 'undefined') { - return ''; - } - - if (currentPage.topMenu !== 'chat') { - return ''; - } - - const currentUrl = new URL(window.location.href); - const pathname = currentUrl.pathname.replace(/\/+$/, '') || '/'; - - if (pathname !== '/chat/live') { - return ''; - } - - return currentUrl.searchParams.get('sessionId')?.trim() || ''; - }, [currentPage.topMenu, location.pathname, location.search]); + const sessionId = useMemo(() => '', []); const currentContext: ChatViewContext = useMemo( () => ({ @@ -48,6 +37,7 @@ export function ChatRuntimeBridgeV2() { isStandaloneMode: isStandaloneDisplayMode(), pageVisibilityState: typeof document !== 'undefined' && document.visibilityState === 'hidden' ? 'hidden' : 'visible', + pageFocusState: getLivePageFocusState(), chatTypeId: null, chatTypeLabel: '', chatTypeDescription: '', diff --git a/src/app/main/ChatSourceChangesPage.tsx b/src/app/main/ChatSourceChangesPage.tsx index 6e83b84..370ee4e 100644 --- a/src/app/main/ChatSourceChangesPage.tsx +++ b/src/app/main/ChatSourceChangesPage.tsx @@ -1,36 +1,32 @@ -import { Card, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd'; +import { ArrowLeftOutlined } from '@ant-design/icons'; +import { App, Button, Card, Checkbox, Empty, Input, List, Select, Space, Spin, Tag, Typography } from 'antd'; 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 { chatGateway } from './chatV2'; -import type { ChatMessage, ChatConversationRequest } from './mainChatPanel/types'; +import { fetchChatSourceChanges } from './mainChatPanel'; +import { ChatPromptCard } from './mainChatPanel/ChatPromptCard'; +import { extractChatMessageParts } from './mainChatPanel/messageParts'; +import type { ChatSourceChangeSnapshot } from './mainChatPanel/types'; +import { buildChatPath } from './routes'; +import { stashCodexLiveDraft } from './codexLiveDraftBridge'; const { Paragraph, Text, Title } = Typography; -type ChatSourceChangeEntry = { - id: string; - sessionId: string; - conversationTitle: string; - chatTypeId: string | null; - chatTypeLabel: string; - requestId: string; - requestTitle: string; - questionText: string; - answerText: string; - status: ChatConversationRequest['status']; - sourceChangedAt: string; - updatedAt: string; - featureTags: string[]; - changedFiles: string[]; - currentSourceFiles: string[]; - diffBlocks: string[]; +type DeploymentFilterValue = 'all' | 'pre-deploy' | 'deployed'; +type CurrentSourceStatus = 'applied' | 'not-applied'; +type ReviewFilterValue = 'all' | 'reviewed' | 'not-reviewed'; + +type SourceChangeEntry = ChatSourceChangeSnapshot & { deploymentStatus: DeploymentFilterValue; currentSourceStatus: CurrentSourceStatus; }; -type DeploymentFilterValue = 'all' | 'pre-deploy' | 'deployed'; -type CurrentSourceStatus = 'applied' | 'not-applied'; -type CurrentSourceFilterValue = 'all' | CurrentSourceStatus; +type VerificationFeatureGroup = { + key: string; + label: string; + entries: SourceChangeEntry[]; +}; const DEPLOYMENT_FILTER_OPTIONS: Array<{ label: string; value: DeploymentFilterValue }> = [ { label: '전체', value: 'all' }, @@ -38,14 +34,12 @@ const DEPLOYMENT_FILTER_OPTIONS: Array<{ label: string; value: DeploymentFilterV { label: '배포됨', value: 'deployed' }, ]; -const CURRENT_SOURCE_FILTER_OPTIONS: Array<{ label: string; value: CurrentSourceFilterValue }> = [ +const REVIEW_FILTER_OPTIONS: Array<{ label: string; value: ReviewFilterValue }> = [ { label: '전체', value: 'all' }, - { label: '현재 소스 적용', value: 'applied' }, - { label: '현재 소스 미적용', value: 'not-applied' }, + { label: '검수 완료', value: 'reviewed' }, + { label: '미검수', value: 'not-reviewed' }, ]; -const CURRENT_SOURCE_PREFIXES = ['src/', 'docs/', 'public/', 'scripts/', 'etc/'] as const; - function formatDateTime(value: string | null | undefined) { if (!value) { return '미기록'; @@ -54,198 +48,11 @@ function formatDateTime(value: string | null | undefined) { return new Date(value).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }); } -function createCompactText(value: string | null | undefined, limit = 88) { - const normalized = String(value ?? '').replace(/\s+/g, ' ').trim(); - - if (!normalized) { - return ''; - } - - return normalized.length > limit ? `${normalized.slice(0, limit - 1)}…` : normalized; -} - -function createRequestTitle(userText: string, fallback: string) { - const compact = createCompactText(userText, 72); - return compact || fallback; -} - -function extractDiffBlocks(text: string) { - return Array.from(text.matchAll(/```diff[^\n]*\n([\s\S]*?)```/g)) - .map((match) => match[1]?.trim() ?? '') - .filter(Boolean); -} - -function hasSourceEvidenceText(text: string) { - const normalized = String(text ?? '').trim(); - - if (!normalized) { - return false; - } - - return ( - /```diff[^\n]*\n[\s\S]*?```/m.test(normalized) || - /^(?:diff --git a\/[^\s]+ b\/[^\s]+|\+\+\+ b\/[^\s]+|--- a\/[^\s]+)$/m.test(normalized) || - /\/workspace\/main-project\/[^\s)]+/.test(normalized) || - /\/api\/chat\/resources\/[^\s)`]+/.test(normalized) || - /\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/.test(normalized) || - /\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/.test(normalized) - ); -} - -function normalizeWorkspaceFilePath(value: string) { - const normalized = String(value ?? '') - .trim() - .replace(/\\/g, '/') - .replace(/^file:\/\//, '') - .replace(/[)>.,]+$/, '') - .replace(/:\d+(?::\d+)?$/, ''); - - if (!normalized) { - return ''; - } - - const resourceMarker = '/resource/'; - const resourceIndex = normalized.lastIndexOf(resourceMarker); - - if (resourceIndex >= 0) { - const innerPath = normalized.slice(resourceIndex + resourceMarker.length).replace(/^\/+/, ''); - return innerPath; - } - - const workspaceMarker = '/workspace/main-project/'; - const workspaceIndex = normalized.lastIndexOf(workspaceMarker); - - if (workspaceIndex >= 0) { - return normalized.slice(workspaceIndex + workspaceMarker.length); - } - - const apiResourceMarker = '/api/chat/resources/'; - const apiResourceIndex = normalized.lastIndexOf(apiResourceMarker); - - if (apiResourceIndex >= 0) { - return normalized.slice(apiResourceIndex + apiResourceMarker.length).replace(/^\/+/, ''); - } - - return normalized.replace(/^\/+/, '').replace(/^\.\//, ''); -} - -function isCurrentSourcePath(path: string) { - return CURRENT_SOURCE_PREFIXES.some((prefix) => path.startsWith(prefix)); -} - -function extractCurrentSourceFiles(text: string) { - const textWithoutChatResourcePaths = text - .replace(/\/api\/chat\/resources\/[^\s)`]+/g, ' ') - .replace(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g, ' '); - - const diffPathMatches = Array.from( - text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), - ) - .flatMap((match) => [match[1], match[2], match[3]]) - .filter((value): value is string => Boolean(value)) - .map((item) => normalizeWorkspaceFilePath(item)) - .filter((path) => path && isCurrentSourcePath(path)); - - const workspacePathMatches = [ - ...(text.match(/\[[^\]]*]\((\/workspace\/main-project\/[^)\s]+)\)/g) ?? []) - .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), - ...(text.match(/\/workspace\/main-project\/[^\s)]+/g) ?? []), - ...(text.match(/\[[^\]]*]\((\/api\/chat\/resources\/[^)\s]+)\)/g) ?? []) - .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), - ...(text.match(/\/api\/chat\/resources\/[^\s)`]+/g) ?? []), - ...(text.match(/\[[^\]]*]\((\/?(?:public\/)?\.codex_chat\/[^)\s]+\/resource\/[^)\s]+)\)/g) ?? []) - .map((item) => item.replace(/^[^\(]*\(/, '').replace(/\)$/, '')), - ...(text.match(/\/?(?:public\/)?\.codex_chat\/[^\s)`]+\/resource\/[^\s)`]+/g) ?? []), - ] - .map((item) => normalizeWorkspaceFilePath(item)) - .filter((path) => path && isCurrentSourcePath(path)); - - const directRelativeMatches = (textWithoutChatResourcePaths.match(/\b(?:src|docs|etc|public|scripts)\/[A-Za-z0-9._\-\/]+(?:\.[A-Za-z0-9]+)?\b/g) ?? []) - .map((item) => normalizeWorkspaceFilePath(item)) - .filter((path) => path && isCurrentSourcePath(path)); - - return Array.from(new Set([...diffPathMatches, ...workspacePathMatches, ...directRelativeMatches])).slice(0, 60); -} - -function extractChangedFiles(text: string) { - const matches = Array.from( - text.matchAll(/^(?:diff --git a\/[^\s]+ b\/([^\s]+)|\+\+\+ b\/([^\s]+)|--- a\/([^\s]+))$/gm), - ) - .flatMap((match) => [match[1], match[2], match[3]]) - .filter((value): value is string => Boolean(value)); - - return Array.from( - new Set( - matches - .map((item) => normalizeWorkspaceFilePath(item)) - .filter(Boolean), - ), - ).slice(0, 60); -} - -function deriveFeatureTags(files: string[]) { - const tags = new Set(); - - files.forEach((file) => { - const segments = file.split('/').filter(Boolean); - - if (segments[0] === 'src' && segments[1] === 'features' && segments[2]) { - tags.add(`feature:${segments[2]}`); - return; - } - - if (segments[0] === 'src' && segments[1] === 'components' && segments[2]) { - tags.add(`component:${segments[2]}`); - return; - } - - if (segments[0] === 'src' && segments[1] === 'widgets' && segments[2]) { - tags.add(`widget:${segments[2]}`); - return; - } - - if (segments[0] === 'docs' && segments[1]) { - tags.add(`docs:${segments[1]}`); - return; - } - - if (segments[0]) { - tags.add(segments[0]); - } - }); - - return Array.from(tags); -} - -function isMeaningfulSourceChangeMessage(text: string) { - const normalized = text.trim(); - - if (!normalized) { - return false; - } - - if (normalized === '응답을 준비하고 있습니다...') { - return false; - } - - return normalized.length >= 12 || hasSourceEvidenceText(normalized); -} - function getTimeValue(value: string | null | undefined) { const parsed = new Date(value ?? '').getTime(); return Number.isFinite(parsed) ? parsed : 0; } -function appendUniqueText(target: string[], value: string | null | undefined) { - const normalized = String(value ?? '').trim(); - - if (!normalized || target.includes(normalized)) { - return; - } - - target.push(normalized); -} - function resolveDeploymentStatus( updatedAt: string, latestTestServerBuiltAt: string | null, @@ -262,22 +69,6 @@ function resolveDeploymentStatus( return getTimeValue(updatedAt) > getTimeValue(latestTestServerBuiltAt) ? 'pre-deploy' : 'deployed'; } -function resolveSourceChangedAt( - request: ChatConversationRequest, - messages: ChatMessage[], - nextRequest: ChatConversationRequest | undefined, -) { - const timestamps = collectRequestExplanationMessageTimestamps(request, messages, nextRequest); - - return ( - timestamps[0] ?? - request.answeredAt ?? - request.terminalAt ?? - request.updatedAt ?? - request.createdAt - ); -} - function getDeploymentStatusLabel(value: DeploymentFilterValue) { if (value === 'deployed') { return 'test 배포됨'; @@ -290,256 +81,195 @@ function getDeploymentStatusLabel(value: DeploymentFilterValue) { return '전체'; } -function collectRequestExplanationTexts( - request: ChatConversationRequest, - messages: ChatMessage[], - nextRequest: ChatConversationRequest | undefined, -) { - const texts: string[] = []; - appendUniqueText(texts, request.responseText); - - if (request.responseMessageId != null) { - const matchedMessage = messages.find((message) => message.id === request.responseMessageId); - - if (matchedMessage?.author === 'codex' && isMeaningfulSourceChangeMessage(String(matchedMessage.text ?? ''))) { - appendUniqueText(texts, matchedMessage.text); - } - } - - const directMessageMatches = messages - .filter( - (message) => - message.author === 'codex' && - message.clientRequestId === request.requestId && - isMeaningfulSourceChangeMessage(String(message.text ?? '')), - ); - directMessageMatches.forEach((message) => { - appendUniqueText(texts, message.text); - }); - - const requestCreatedAt = getTimeValue(request.createdAt); - const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt); - const fallbackMessages = messages.filter((message) => { - if (message.author !== 'codex') { - return false; - } - - const messageTimestamp = getTimeValue(message.timestamp); - - if (requestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp < requestCreatedAt) { - return false; - } - - if (nextRequestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp >= nextRequestCreatedAt) { - return false; - } - - return isMeaningfulSourceChangeMessage(String(message.text ?? '')); - }); - - fallbackMessages - .filter((message) => message.clientRequestId === request.requestId || !message.clientRequestId) - .forEach((message) => { - appendUniqueText(texts, message.text); - }); - - appendUniqueText(texts, request.statusMessage); - - return texts; -} - -function collectRequestExplanationMessageTimestamps( - request: ChatConversationRequest, - messages: ChatMessage[], - nextRequest: ChatConversationRequest | undefined, -) { - const timestamps: string[] = []; - - if (request.responseMessageId != null) { - const matchedMessage = messages.find((message) => message.id === request.responseMessageId); - - if (matchedMessage?.author === 'codex' && isMeaningfulSourceChangeMessage(String(matchedMessage.text ?? ''))) { - appendUniqueText(timestamps, matchedMessage.timestamp); - } - } - - messages - .filter( - (message) => - message.author === 'codex' && - message.clientRequestId === request.requestId && - isMeaningfulSourceChangeMessage(String(message.text ?? '')), - ) - .forEach((message) => { - appendUniqueText(timestamps, message.timestamp); - }); - - const requestCreatedAt = getTimeValue(request.createdAt); - const nextRequestCreatedAt = getTimeValue(nextRequest?.createdAt); - messages - .filter((message) => { - if (message.author !== 'codex') { - return false; - } - - const messageTimestamp = getTimeValue(message.timestamp); - - if (requestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp < requestCreatedAt) { - return false; - } - - if (nextRequestCreatedAt > 0 && messageTimestamp > 0 && messageTimestamp >= nextRequestCreatedAt) { - return false; - } - - return isMeaningfulSourceChangeMessage(String(message.text ?? '')); - }) - .filter((message) => message.clientRequestId === request.requestId || !message.clientRequestId) - .forEach((message) => { - appendUniqueText(timestamps, message.timestamp); - }); - - return timestamps.sort((left, right) => getTimeValue(left) - getTimeValue(right)); -} - -function resolveRequestExplanation( - request: ChatConversationRequest, - messages: ChatMessage[], - nextRequest: ChatConversationRequest | undefined, -) { - return collectRequestExplanationTexts(request, messages, nextRequest).join('\n\n').trim(); -} - -function resolveRequestQuestion(request: ChatConversationRequest, messages: ChatMessage[]) { - const requestUserText = String(request.userText ?? '').trim(); - - if (requestUserText) { - return requestUserText; - } - - if (request.userMessageId != null) { - const matchedMessage = messages.find((message) => message.id === request.userMessageId); - - if (matchedMessage?.author === 'user') { - return String(matchedMessage.text ?? '').trim(); - } - } - - const directMatch = messages.find( - (message) => message.author === 'user' && message.clientRequestId === request.requestId, +function hasMeaningfulSourceArtifacts(entry: Pick) { + return [entry.changedFiles, entry.currentSourceFiles, entry.diffBlocks].some((items) => + items.some((item) => item.trim().length > 0), ); - - return String(directMatch?.text ?? '').trim(); } -function buildSourceChangeEntry( - conversationTitle: string, - sessionId: string, - conversation: { - chatTypeId?: string | null; - contextLabel?: string | null; - }, - request: ChatConversationRequest, - messages: ChatMessage[], - nextRequest: ChatConversationRequest | undefined, - testServerCommand: Pick | null, - latestTestServerBuiltAt: string | null, -) { - const questionText = resolveRequestQuestion(request, messages); - const answerText = resolveRequestExplanation(request, messages, nextRequest); - const sourceChangedAt = resolveSourceChangedAt(request, messages, nextRequest); - const diffBlocks = extractDiffBlocks(answerText); - const changedFiles = extractChangedFiles(answerText); - const currentSourceFiles = extractCurrentSourceFiles(answerText); - const featureTags = deriveFeatureTags(changedFiles); - const hasSourceEvidence = diffBlocks.length > 0 || changedFiles.length > 0; +function buildFeatureKeyCandidate(value: string) { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9가-힣]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); +} - if (!hasSourceEvidence) { - return null; +function resolveEntryFeatureGroup(entry: SourceChangeEntry) { + const firstFeatureTag = entry.featureTags.find((tag) => tag.trim().length > 0); + + if (firstFeatureTag) { + return { + key: buildFeatureKeyCandidate(firstFeatureTag) || `feature-${entry.id}`, + label: firstFeatureTag, + }; + } + + const firstPath = [...entry.currentSourceFiles, ...entry.changedFiles].find((file) => file.trim().length > 0) ?? ''; + const firstSegment = firstPath.split('/').filter(Boolean).slice(0, 2).join('/'); + + if (firstSegment) { + return { + key: buildFeatureKeyCandidate(firstSegment) || `feature-${entry.id}`, + label: firstSegment, + }; } return { - id: `${sessionId}:${request.requestId}`, - sessionId, - conversationTitle, - chatTypeId: String(conversation.chatTypeId ?? '').trim() || null, - chatTypeLabel: String(conversation.contextLabel ?? '').trim(), - requestId: request.requestId, - requestTitle: createRequestTitle(questionText, request.requestId), - questionText, - answerText, - status: request.status, - sourceChangedAt, - updatedAt: request.updatedAt, - featureTags, - changedFiles, - currentSourceFiles, - diffBlocks, - deploymentStatus: currentSourceFiles.length > 0 - ? resolveDeploymentStatus(sourceChangedAt, latestTestServerBuiltAt, testServerCommand) - : 'pre-deploy', - currentSourceStatus: currentSourceFiles.length > 0 ? 'applied' : 'not-applied', - } satisfies ChatSourceChangeEntry; + key: buildFeatureKeyCandidate(entry.requestTitle) || `feature-${entry.id}`, + label: entry.requestTitle, + }; +} + +function groupEntriesForVerification(entries: SourceChangeEntry[]) { + const groups = new Map(); + + entries.forEach((entry) => { + const feature = resolveEntryFeatureGroup(entry); + const existing = groups.get(feature.key); + + if (existing) { + existing.entries.push(entry); + return; + } + + groups.set(feature.key, { + key: feature.key, + label: feature.label, + entries: [entry], + }); + }); + + return Array.from(groups.values()).sort((left, right) => left.label.localeCompare(right.label, 'ko-KR')); +} + +function createVerificationDraft(groups: VerificationFeatureGroup[]) { + const promptPayload = { + title: '기능별 검증 결과', + description: '실제 확인이 끝난 기능만 체크했습니다.', + multiple: true, + readOnly: true, + selectedValues: [] as string[], + resultText: '검증 완료 기능만 체크했습니다.', + options: groups.map((group) => ({ + value: group.key, + label: group.label, + description: `${group.entries.length}건 · ${group.entries + .map((entry) => entry.requestTitle.trim()) + .filter(Boolean) + .slice(0, 3) + .join(' / ')}`, + })), + }; + const metadata = { + version: 1, + features: groups.map((group) => ({ + key: group.key, + label: group.label, + entryRefs: group.entries.map((entry) => ({ + sessionId: entry.sessionId, + requestId: entry.requestId, + })), + })), + }; + + return [ + '선택한 변경 이력을 기능별로 묶어 정상 동작 검증을 진행해 주세요.', + '', + '처리 기준', + '- 기능별로 직접 확인 가능한 동작만 검증 완료로 판단합니다.', + '- 오류가 보이면 먼저 수정하고 다시 확인합니다.', + '- 최종 답변에는 아래 형식의 readOnly prompt를 정확히 1개 포함합니다.', + '- `selectedValues`에는 실제 검증 완료 기능만 넣고, 실패하거나 검증하지 못한 기능은 체크하지 않습니다.', + '- prompt의 `options` 배열은 아래 템플릿의 값을 유지하고, description은 실제 검증 결과에 맞게 짧게 보정해도 됩니다.', + '- 최종 검증 스크린샷이 있으면 `[[preview:...]]` 줄을 함께 남깁니다.', + '', + '검증 대상 기능', + ...groups.map((group, index) => + `${index + 1}. ${group.label}: ${group.entries + .map((entry) => `${entry.requestTitle} (${entry.changedFiles.slice(0, 2).join(', ') || '파일 정보 없음'})`) + .join(' / ')}`, + ), + '', + '응답용 prompt 템플릿', + `[[prompt:${JSON.stringify(promptPayload)}]]`, + '', + `[[source-change-verification:${JSON.stringify(metadata)}]]`, + ].join('\n'); } export function ChatSourceChangesPage() { - const [entries, setEntries] = useState([]); + const navigate = useNavigate(); + const { message: messageApi } = App.useApp(); + const [entries, setEntries] = useState([]); const [selectedEntryId, setSelectedEntryId] = useState(null); + const [selectedRequestEntryIds, setSelectedRequestEntryIds] = useState([]); const [searchText, setSearchText] = useState(''); const [deploymentSearchCondition, setDeploymentSearchCondition] = useState('pre-deploy'); - const [currentSourceSearchCondition, setCurrentSourceSearchCondition] = useState('applied'); + const [reviewSearchCondition, setReviewSearchCondition] = useState('not-reviewed'); const [latestTestServerBuiltAt, setLatestTestServerBuiltAt] = useState(null); const [loading, setLoading] = useState(false); + const [loadingProgress, setLoadingProgress] = useState({ completed: 0, total: 0 }); const [errorMessage, setErrorMessage] = useState(null); + const [isMobileViewport, setIsMobileViewport] = useState(false); + const [mobileView, setMobileView] = useState<'list' | 'detail'>('list'); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const mediaQuery = window.matchMedia('(max-width: 960px)'); + const update = () => { + setIsMobileViewport(mediaQuery.matches); + if (!mediaQuery.matches) { + setMobileView('list'); + } + }; + + update(); + mediaQuery.addEventListener('change', update); + + return () => { + mediaQuery.removeEventListener('change', update); + }; + }, []); useEffect(() => { let cancelled = false; const loadChanges = async () => { setLoading(true); + setLoadingProgress({ completed: 0, total: 0 }); setErrorMessage(null); + setEntries([]); + setSelectedEntryId(null); try { const serverCommands = await fetchServerCommands(); const testServerCommand = serverCommands.find((item) => item.key === 'test') ?? null; const nextLatestTestServerBuiltAt = testServerCommand?.runningBuiltAt ?? testServerCommand?.startedAt ?? null; - const conversations = await chatGateway.listConversations(); - const details = await Promise.allSettled( - conversations.map(async (conversation) => ({ - conversation, - detail: await chatGateway.getConversationDetail(conversation.sessionId), - })), - ); + const sourceChanges = await fetchChatSourceChanges(300); if (cancelled) { return; } - const nextEntries = details - .flatMap((result) => { - if (result.status !== 'fulfilled') { - return []; - } - - const { conversation, detail } = result.value; - return detail.requests - .map((request, index, requests) => - buildSourceChangeEntry( - conversation.title || '새 대화', - conversation.sessionId, - detail.item, - request, - detail.messages, - requests[index + 1], - testServerCommand, - nextLatestTestServerBuiltAt, - ), - ) - .filter((item): item is ChatSourceChangeEntry => Boolean(item)); - }) - .sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime()); - setLatestTestServerBuiltAt(nextLatestTestServerBuiltAt); + setLoadingProgress({ completed: sourceChanges.length, total: sourceChanges.length }); + + const nextEntries = sourceChanges + .filter((entry) => entry.hasSourceChanges && hasMeaningfulSourceArtifacts(entry)) + .map((entry) => ({ + ...entry, + deploymentStatus: entry.currentSourceFiles.length > 0 + ? resolveDeploymentStatus(entry.sourceChangedAt, nextLatestTestServerBuiltAt, testServerCommand) + : 'pre-deploy' as const, + currentSourceStatus: entry.currentSourceFiles.length > 0 ? 'applied' as const : 'not-applied' as const, + })) + .sort((left, right) => getTimeValue(right.updatedAt) - getTimeValue(left.updatedAt)); + setEntries(nextEntries); setSelectedEntryId((previous) => { if (previous && nextEntries.some((entry) => entry.id === previous)) { @@ -548,6 +278,9 @@ export function ChatSourceChangesPage() { return nextEntries[0]?.id ?? null; }); + setSelectedRequestEntryIds((previous) => + previous.filter((entryId) => nextEntries.some((entry) => entry.id === entryId && entry.sourceChangeKind === 'request')), + ); } catch (error) { if (!cancelled) { setErrorMessage(error instanceof Error ? error.message : 'Codex Live 변경 이력을 불러오지 못했습니다.'); @@ -574,7 +307,7 @@ export function ChatSourceChangesPage() { return false; } - if (currentSourceSearchCondition !== 'all' && entry.currentSourceStatus !== currentSourceSearchCondition) { + if (reviewSearchCondition !== 'all' && entry.reviewStatus !== reviewSearchCondition) { return false; } @@ -595,53 +328,175 @@ export function ChatSourceChangesPage() { .toLowerCase() .includes(keyword); }); - }, [currentSourceSearchCondition, deploymentSearchCondition, entries, searchText]); + }, [deploymentSearchCondition, entries, reviewSearchCondition, searchText]); const selectedEntry = filteredEntries.find((entry) => entry.id === selectedEntryId) ?? filteredEntries[0] ?? null; + const selectedEntryIndex = selectedEntry ? filteredEntries.findIndex((entry) => entry.id === selectedEntry.id) : -1; + const previousEntry = selectedEntryIndex > 0 ? filteredEntries[selectedEntryIndex - 1] : null; + const nextEntry = selectedEntryIndex >= 0 && selectedEntryIndex < filteredEntries.length - 1 + ? filteredEntries[selectedEntryIndex + 1] + : null; + const showListPane = !isMobileViewport || mobileView === 'list'; + const showDetailPane = !isMobileViewport || mobileView === 'detail'; + const hasPartialEntries = entries.length > 0; + const isInitialLoading = loading && !hasPartialEntries; + const selectedRequestEntries = useMemo( + () => entries.filter((entry) => selectedRequestEntryIds.includes(entry.id) && entry.sourceChangeKind === 'request'), + [entries, selectedRequestEntryIds], + ); + const selectedVerificationGroups = useMemo( + () => groupEntriesForVerification(selectedRequestEntries), + [selectedRequestEntries], + ); + const selectedAnswerRender = useMemo(() => { + if (!selectedEntry) { + return { + visibleText: '', + prompts: [], + }; + } - return ( - - - - - Codex Live 변경 이력 - - - 채팅 요청 중 실제 소스 수정 흔적이 남은 항목만 모아서 현재 소스 반영 여부와 test 도메인 배포 상태를 검색조건으로 확인합니다. - - { - setSearchText(event.target.value); + const extracted = extractChatMessageParts(selectedEntry.answerText || ''); + + return { + visibleText: extracted.strippedText.trim(), + prompts: extracted.parts.filter((part): part is Extract<(typeof extracted.parts)[number], { type: 'prompt' }> => part.type === 'prompt'), + }; + }, [selectedEntry]); + + const openConversationFromEntry = (entry: ChatSourceChangeSnapshot) => { + const searchParams = new URLSearchParams(); + searchParams.set('sessionId', entry.sessionId); + navigate({ + pathname: buildChatPath('live'), + search: `?${searchParams.toString()}`, + }); + }; + + const selectEntryById = (entryId: string) => { + setSelectedEntryId(entryId); + if (isMobileViewport) { + setMobileView('detail'); + } + }; + + const toggleRequestSelection = (entry: SourceChangeEntry) => { + if (entry.sourceChangeKind !== 'request') { + return; + } + + setSelectedRequestEntryIds((previous) => + previous.includes(entry.id) ? previous.filter((entryId) => entryId !== entry.id) : [...previous, entry.id], + ); + }; + + const startVerificationRequest = () => { + if (selectedVerificationGroups.length === 0) { + messageApi.warning('검증 요청할 변경 이력을 먼저 선택해 주세요.'); + return; + } + + const draftText = createVerificationDraft(selectedVerificationGroups); + const stored = stashCodexLiveDraft({ + text: draftText, + source: 'chat-source-changes', + createdAt: new Date().toISOString(), + autoSend: true, + sendMode: 'queue', + }); + + if (!stored) { + messageApi.error('검증 요청 초안을 만들지 못했습니다.'); + return; + } + + navigate({ + pathname: buildChatPath('live'), + }); + }; + + const renderSearchControls = (compact = false) => ( + + {!compact ? ( + + Codex Live 변경 이력 + + ) : null} + { + setSearchText(event.target.value); + }} + /> + +
+ + 검수 관련 조건 + + { - setDeploymentSearchCondition(value); - }} - /> - { + setReviewSearchCondition(value); + }} + /> +
+
+ + 선택된 요청 {selectedRequestEntries.length}건 + + + + + +
+ + {latestTestServerBuiltAt + ? `현재 TEST 서버 빌드 시각 기준: ${formatDateTime(latestTestServerBuiltAt)}` + : 'TEST 서버 빌드 시각을 읽지 못해 현재 항목은 모두 배포 전으로 표시됩니다.'} + + {loading ? ( + + - {latestTestServerBuiltAt - ? `현재 TEST 서버 빌드 시각 기준: ${formatDateTime(latestTestServerBuiltAt)}` - : 'TEST 서버 빌드 시각을 읽지 못해 현재 항목은 모두 배포 전으로 표시됩니다.'} + 변경 이력을 조회 중입니다. + {loadingProgress.total > 0 ? ` (${loadingProgress.completed}/${loadingProgress.total})` : ''} -
-
+ ) : null} +
+ + ); - {loading ? ( + return ( + + {!isMobileViewport ? ( + + {renderSearchControls(false)} + + ) : !isInitialLoading ? ( + +
{renderSearchControls(true)}
+
+ ) : null} + + {isInitialLoading ? (
@@ -662,58 +517,207 @@ export function ChatSourceChangesPage() { /> ) : ( -
- +
+ ( - { - setSelectedEntryId(entry.id); - }} - > - - - - {entry.conversationTitle} - - {entry.status} - - {entry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'} - - - {getDeploymentStatusLabel(entry.deploymentStatus)} - + renderItem={(entry) => { + const isSelectable = entry.sourceChangeKind === 'request'; + const isChecked = selectedRequestEntryIds.includes(entry.id); + + return ( + { + selectEntryById(entry.id); + }} + > + + { + event.stopPropagation(); + }} + onChange={() => { + toggleRequestSelection(entry); + }} + /> + + + + {entry.conversationTitle} + + {entry.status} + + {entry.reviewStatus === 'reviewed' ? '검수 완료' : '미검수'} + + + {entry.sourceChangeKind === 'verification-group' ? '기능별 검증 이력' : '원본 변경 이력'} + + {entry.conversationDeletedAt ? 채팅방 삭제됨 : null} + + {entry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'} + + + {getDeploymentStatusLabel(entry.deploymentStatus)} + + + {entry.requestTitle} + {formatDateTime(entry.updatedAt)} + + {entry.featureTags.map((tag) => ( + {tag} + ))} + + - {entry.requestTitle} - {formatDateTime(entry.updatedAt)} - - {entry.featureTags.map((tag) => ( - {tag} - ))} - - - - )} + + ); + }} /> - + + + + {selectedEntryIndex >= 0 ? `${selectedEntryIndex + 1} / ${filteredEntries.length}` : ''} + + + + + + + ) : null + } + bordered={false} + > {selectedEntry ? ( - - {selectedEntry.requestTitle} - - - {selectedEntry.conversationTitle} · 변경 반영 시각 {formatDateTime(selectedEntry.sourceChangedAt)} - +
+ + + {selectedEntry.requestTitle} + + + {selectedEntry.conversationTitle} · 변경 반영 시각 {formatDateTime(selectedEntry.sourceChangedAt)} + + + {!isMobileViewport ? ( + + + {selectedEntry.sourceChangeKind === 'request' ? ( + + ) : null} + + {selectedEntryIndex >= 0 ? `${selectedEntryIndex + 1} / ${filteredEntries.length}` : ''} + + + + + ) : null} +
+ {isMobileViewport ? ( + + + {selectedEntry.sourceChangeKind === 'request' ? ( + + ) : null} + + ) : null} + {selectedEntry.status} + + {selectedEntry.reviewStatus === 'reviewed' ? '검수 완료' : '미검수'} + + + {selectedEntry.sourceChangeKind === 'verification-group' ? '기능별 검증 이력' : '원본 변경 이력'} + + {selectedEntry.conversationDeletedAt ? 채팅방 삭제됨 : null} {selectedEntry.currentSourceStatus === 'applied' ? '현재 소스 적용' : '현재 소스 미적용'} @@ -725,6 +729,19 @@ export function ChatSourceChangesPage() { ))} + + + {selectedEntry.reviewStatus === 'reviewed' ? '검수 완료' : '미검수'} + {selectedEntry.sourceEntryIds.length > 0 ? ( + + 묶인 원본 변경 이력 {selectedEntry.sourceEntryIds.length}건 + + ) : ( + 아직 기능별 검증 결과로 재정리되지 않은 원본 변경 이력입니다. + )} + + + {selectedEntry.questionText || '기록된 질문이 없습니다.'} @@ -732,9 +749,24 @@ export function ChatSourceChangesPage() { - - {selectedEntry.answerText || '기록된 답변이 없습니다.'} - + + {selectedAnswerRender.visibleText ? ( + + {selectedAnswerRender.visibleText} + + ) : null} + {selectedAnswerRender.prompts.map((prompt, index) => ( + false} + /> + ))} + {!selectedAnswerRender.visibleText && selectedAnswerRender.prompts.length === 0 ? ( + 기록된 답변이 없습니다. + ) : null} + diff --git a/src/app/main/ChatTypeManagementPage.tsx b/src/app/main/ChatTypeManagementPage.tsx old mode 100755 new mode 100644 index 2cb3d79..097a7de --- a/src/app/main/ChatTypeManagementPage.tsx +++ b/src/app/main/ChatTypeManagementPage.tsx @@ -1,4 +1,6 @@ import { + ArrowDownOutlined, + ArrowUpOutlined, ArrowsAltOutlined, DeleteOutlined, EditOutlined, @@ -8,7 +10,22 @@ import { ShrinkOutlined, UnorderedListOutlined, } from '@ant-design/icons'; -import { Alert, Button, Card, Checkbox, Empty, Form, Input, List, Segmented, Space, Switch, Tag, Tooltip, Typography } from 'antd'; +import { + Alert, + Button, + Card, + Checkbox, + Empty, + Form, + Input, + List, + Segmented, + Space, + Switch, + Tag, + Tooltip, + Typography, +} from 'antd'; import { useEffect, useMemo, useState } from 'react'; import { MarkdownPreviewContent } from '../../components/markdownPreview'; import { @@ -34,6 +51,7 @@ const { Text, Title } = Typography; type ChatTypeFormValue = { id?: string; name: string; + sortOrder: number; description: string; permissions: ChatPermissionRole[]; enabled: boolean; @@ -41,6 +59,7 @@ type ChatTypeFormValue = { const EMPTY_FORM_VALUE: ChatTypeFormValue = { name: '', + sortOrder: 1, description: '', permissions: ['token-user'], enabled: true, @@ -54,6 +73,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue { return { id: chatType.id, name: chatType.name, + sortOrder: chatType.sortOrder, description: chatType.description, permissions: chatType.permissions, enabled: chatType.enabled, @@ -62,7 +82,7 @@ function toFormValue(chatType: ChatTypeRecord | null): ChatTypeFormValue { export function ChatTypeManagementPage() { const { hasAccess } = useTokenAccess(); - const { chatTypes, builtInChatTypes, customChatTypes, setChatTypes, isLoading, errorMessage, reload } = useChatTypeRegistry(); + const { chatTypes, setChatTypes, setChatTypesLocal, isLoading, errorMessage, reload } = useChatTypeRegistry(); const { defaultContexts, chatTypeDefaults, @@ -72,7 +92,7 @@ export function ChatTypeManagementPage() { } = useChatContextSettingsRegistry(); const [selectedChatTypeId, setSelectedChatTypeId] = useState(chatTypes[0]?.id ?? null); const [isMobileViewport, setIsMobileViewport] = useState(false); - const [mobileView, setMobileView] = useState<'edit' | 'preview'>('edit'); + const [mobileView, setMobileView] = useState<'default-contexts' | 'edit' | 'preview'>('edit'); const [detailMode, setDetailMode] = useState<'list' | 'detail'>('list'); const [maximizedPane, setMaximizedPane] = useState<'none' | 'edit' | 'preview'>('none'); const [isCreating, setIsCreating] = useState(false); @@ -83,6 +103,12 @@ export function ChatTypeManagementPage() { const [form] = Form.useForm(); const userRoles = useMemo(() => resolveCurrentChatPermissionRoles(hasAccess), [hasAccess]); const isPaneMaximized = maximizedPane !== 'none'; + const builtInChatTypes: ChatTypeRecord[] = []; + const customChatTypes = chatTypes; + const nextNewSortOrder = useMemo( + () => Math.max(0, ...customChatTypes.map((item) => item.sortOrder || 0)) + 1, + [customChatTypes], + ); const selectedChatType = useMemo( () => customChatTypes.find((item) => item.id === selectedChatTypeId) ?? null, @@ -170,7 +196,11 @@ export function ChatTypeManagementPage() { setDetailMode('detail'); setMaximizedPane('none'); form.resetFields(); - form.setFieldsValue(EMPTY_FORM_VALUE); + form.setFieldsValue({ + ...EMPTY_FORM_VALUE, + sortOrder: nextNewSortOrder, + }); + setSelectedDefaultContextIds([]); }; const openDetail = (chatTypeId: string) => { @@ -201,11 +231,14 @@ export function ChatTypeManagementPage() { try { const savedSnapshot = await setChatTypes(nextChatTypes); - setSelectedChatTypeId(savedSnapshot.customChatTypes[0]?.id ?? null); + setSelectedChatTypeId(savedSnapshot.chatTypes[0]?.id ?? null); setIsCreating(false); setDetailMode('list'); form.resetFields(); - form.setFieldsValue(EMPTY_FORM_VALUE); + form.setFieldsValue({ + ...EMPTY_FORM_VALUE, + sortOrder: nextNewSortOrder, + }); } catch (error) { setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 삭제에 실패했습니다.'); } finally { @@ -226,6 +259,50 @@ export function ChatTypeManagementPage() { } }; + const moveChatTypeInList = async (chatTypeId: string, direction: 'up' | 'down') => { + const currentIndex = customChatTypes.findIndex((item) => item.id === chatTypeId); + + if (currentIndex < 0) { + return; + } + + const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + + if (targetIndex < 0 || targetIndex >= customChatTypes.length) { + return; + } + + const reorderedChatTypes = [...customChatTypes]; + const [movedItem] = reorderedChatTypes.splice(currentIndex, 1); + + reorderedChatTypes.splice(targetIndex, 0, movedItem); + + const nextChatTypes = reorderedChatTypes.map((item, index) => ({ + ...item, + sortOrder: index + 1, + })); + + setIsSaving(true); + setSaveErrorMessage(''); + setChatTypesLocal(nextChatTypes); + + try { + const savedSnapshot = await setChatTypes(nextChatTypes); + setSelectedChatTypeId((currentSelectedId) => { + if (currentSelectedId && savedSnapshot.chatTypes.some((item) => item.id === currentSelectedId)) { + return currentSelectedId; + } + + return chatTypeId; + }); + } catch (error) { + setChatTypesLocal(customChatTypes); + setSaveErrorMessage(error instanceof Error ? error.message : '채팅유형 순서 변경에 실패했습니다.'); + } finally { + setIsSaving(false); + } + }; + const detailHeaderActions = ( @@ -289,7 +366,7 @@ export function ChatTypeManagementPage() {
{detailMode === 'list' ? ( defaultContexts.find((context) => context.id === contextId)) .filter((context): context is NonNullable => Boolean(context)); + const itemIndex = customChatTypes.findIndex((chatType) => chatType.id === item.id); + const canMoveUp = itemIndex > 0; + const canMoveDown = itemIndex >= 0 && itemIndex < customChatTypes.length - 1; const itemClassName = item.id === selectedChatTypeId ? 'chat-type-management-page__item chat-type-management-page__item--active' : 'chat-type-management-page__item'; return ( - { - openDetail(item.id); - }} - actions={[ - + + +
); @@ -456,9 +553,9 @@ export function ChatTypeManagementPage() { try { const savedSnapshot = await setChatTypes(nextChatTypes); - const savedChatType = - savedSnapshot.customChatTypes.find((item) => item.id === values.id || item.name === values.name) ?? - savedSnapshot.chatTypes.find((item) => item.id === values.id || item.name === values.name); + const savedChatType = savedSnapshot.chatTypes.find( + (item) => item.id === values.id || item.name === values.name, + ); const nextChatTypeDefaults = savedChatType ? upsertChatTypeDefaultContextSelection(chatTypeDefaults, savedChatType.id, selectedDefaultContextIds) : chatTypeDefaults; @@ -480,6 +577,9 @@ export function ChatTypeManagementPage() { +
-
+ {isMobileViewport ? ( + { + setMobileView(value as 'default-contexts' | 'edit' | 'preview'); + setMaximizedPane('none'); + }} + /> + ) : null} +
- 기본 유형 연결 + 공통유형 연결 여러 개를 선택하면 채팅 요청마다 함께 참조됩니다.
{defaultContexts.filter((context) => context.enabled).length > 0 ? ( @@ -565,26 +686,19 @@ export function ChatTypeManagementPage() { /> )}
-
+
기본 문맥 설명
- {isMobileViewport ? ( - { - setMobileView(value as 'edit' | 'preview'); - setMaximizedPane('none'); - }} - /> - ) : ( + {isMobileViewport ? null : (
) : (
- -
) ) : null} @@ -5709,7 +6466,7 @@ export function MainChatPanel({ initialView = 'live', lockOuterScrollOnMobile =
) : null} -
+
- - - ) : null} {activeSettingsModal === 'token' ? ( diff --git a/src/app/main/MainLayout.css b/src/app/main/MainLayout.css old mode 100755 new mode 100644 index ca05725..0fc2176 --- a/src/app/main/MainLayout.css +++ b/src/app/main/MainLayout.css @@ -21,20 +21,59 @@ linear-gradient(180deg, #f8fbff 0%, #eff5ff 45%, #ffffff 100%); } +.app-shell--preview-runtime { + background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%); +} + .app-header { + --app-header-bg: linear-gradient(135deg, rgba(255, 255, 255, 0.9) 0%, rgba(248, 250, 252, 0.86) 100%); + --app-header-border: rgba(148, 163, 184, 0.16); + --app-header-shadow: rgba(148, 163, 184, 0.08); + --app-header-base-height: 60px; position: sticky; top: 0; z-index: 20; display: flex; align-items: center; justify-content: center; - height: 60px; - padding: 0 18px; - background: rgba(255, 255, 255, 0.82); - border-bottom: 1px solid rgba(148, 163, 184, 0.16); + min-height: calc(var(--app-header-base-height) + env(safe-area-inset-top, 0px)); + padding: env(safe-area-inset-top, 0px) 18px 0; + background: var(--app-header-bg); + border-bottom: 1px solid var(--app-header-border); + box-shadow: inset 0 -1px 0 var(--app-header-shadow); backdrop-filter: blur(18px); } +.app-header.app-header--test { + --app-header-bg: linear-gradient(135deg, rgba(236, 253, 245, 0.94) 0%, rgba(220, 252, 231, 0.9) 100%); + --app-header-border: rgba(74, 222, 128, 0.28); + --app-header-shadow: rgba(34, 197, 94, 0.14); +} + +.app-header.app-header--rel { + --app-header-bg: linear-gradient(135deg, rgba(255, 247, 237, 0.95) 0%, rgba(254, 215, 170, 0.78) 100%); + --app-header-border: rgba(251, 146, 60, 0.28); + --app-header-shadow: rgba(249, 115, 22, 0.12); +} + +.app-header.app-header--preview { + --app-header-bg: linear-gradient(135deg, rgba(239, 246, 255, 0.95) 0%, rgba(219, 234, 254, 0.9) 100%); + --app-header-border: rgba(96, 165, 250, 0.28); + --app-header-shadow: rgba(59, 130, 246, 0.12); +} + +.app-header.app-header--prod { + --app-header-bg: linear-gradient(135deg, rgba(250, 245, 255, 0.95) 0%, rgba(243, 232, 255, 0.88) 100%); + --app-header-border: rgba(168, 85, 247, 0.24); + --app-header-shadow: rgba(147, 51, 234, 0.12); +} + +.app-header.app-header--local { + --app-header-bg: linear-gradient(135deg, rgba(241, 245, 249, 0.96) 0%, rgba(226, 232, 240, 0.92) 100%); + --app-header-border: rgba(100, 116, 139, 0.24); + --app-header-shadow: rgba(71, 85, 105, 0.12); +} + .app-header__row { display: flex; align-items: center; @@ -638,6 +677,13 @@ width: 100%; } +.app-main-panel--widget-preview { + flex: 1 1 auto; + height: 100%; + min-height: 100%; + overflow: hidden; +} + .app-main-layout:has(.app-main-panel--play-saved) { padding: 0; gap: 0; @@ -756,11 +802,20 @@ } .app-main-panel:has(.resource-management-page) { + display: flex; + flex-direction: column; + flex: 1 1 auto; height: 100%; min-height: 100%; overflow: hidden; } +.app-main-panel:has(.resource-management-page) > .resource-management-page { + flex: 1 1 0; + min-height: 0; + height: 100%; +} + .app-main-layout:has(.resource-management-page) { grid-template-columns: minmax(0, 1fr); gap: 12px; @@ -871,25 +926,33 @@ } .app-shell:has(.resource-management-page), - .app-shell:has(.resource-management-page) > .ant-layout, - .app-main-content.ant-layout-content:has(.resource-management-page), - .app-main-panel:has(.resource-management-page), - .app-main-layout:has(.resource-management-page) { + .app-shell:has(.resource-management-page) > .ant-layout { width: 100%; min-width: 100%; max-width: 100%; } .app-shell:has(.resource-management-page), - .app-shell:has(.resource-management-page) > .ant-layout, + .app-shell:has(.resource-management-page) > .ant-layout { + height: var(--app-viewport-height); + min-height: var(--app-viewport-height); + overflow: hidden; + } + .app-main-content.ant-layout-content:has(.resource-management-page), .app-main-panel:has(.resource-management-page), .app-main-layout:has(.resource-management-page) { height: calc(var(--app-viewport-height) - 52px); min-height: calc(var(--app-viewport-height) - 52px); + max-height: calc(var(--app-viewport-height) - 52px); overflow: hidden; } + .app-main-layout:has(.resource-management-page) { + gap: 0; + padding: 0; + } + .app-shell:has(.chat-type-management-page) > .ant-layout, .app-main-content.ant-layout-content:has(.chat-type-management-page), .app-main-panel:has(.chat-type-management-page), @@ -1053,6 +1116,29 @@ padding: 12px 20px 20px; } +.app-main-card--widget-preview.ant-card { + flex: 1 1 auto; + height: 100%; + border-radius: 0; +} + +.app-main-card--widget-preview.ant-card .ant-card-head { + min-height: 34px; + padding: 4px 10px 0; +} + +.app-main-card--widget-preview.ant-card .ant-card-head-title { + padding: 4px 0; + font-size: 13px; +} + +.app-main-card--widget-preview.ant-card .ant-card-body { + flex: 1 1 auto; + min-height: 0; + padding: 0; + overflow: hidden; +} + .app-main-copy.ant-typography { margin-bottom: 20px; } @@ -1064,6 +1150,13 @@ pointer-events: none; } +.app-main-preview-layer { + position: static; + inset: 0; + z-index: 30; + overflow: visible; +} + .app-main-window-layer__stage { position: relative; width: 100%; @@ -1077,6 +1170,10 @@ pointer-events: auto; } +.app-main-window-layer__window--widget-preview { + border-radius: 0; +} + .app-main-window-layer__body { display: flex; flex: 1 1 auto; @@ -1143,6 +1240,309 @@ gap: 8px; } +.preview-app-window { + display: flex; + flex: 1 1 auto; + align-items: stretch; + justify-content: stretch; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; + padding: 0; + background: #ffffff; + overscroll-behavior: contain; +} + +.preview-app-window__viewport { + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.preview-app-window__viewport--desktop { + border-radius: 0; + box-shadow: none; +} + +.preview-app-window__viewport--mobile { + position: relative; + width: 100%; + height: 100%; + border-radius: 0; + border: 0; + background: #ffffff; + box-shadow: none; +} + +.preview-app-window__frame { + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + border: 0; + display: block; + background: #fff; +} + +body.preview-app-overlay-open { + overflow: hidden; +} + +.preview-app-overlay { + position: fixed; + inset: 0; + display: flex; + flex-direction: column; + align-items: stretch; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + linear-gradient(180deg, rgba(244, 247, 251, 0.98) 0%, rgba(231, 238, 248, 0.96) 100%); + z-index: 2000; + overscroll-behavior: none; +} + +.preview-app-overlay--minimized { + inset: auto; + width: 168px; + height: 44px; + border-radius: 999px; + overflow: visible; + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2); + touch-action: none; +} + +.preview-app-overlay--mobile-shell { + inset: auto; + width: min(430px, calc(100vw - 24px)); + height: min(860px, calc(100vh - 24px)); + border-radius: 40px; + overflow: visible; + background: transparent; + touch-action: none; +} + +.preview-app-overlay__header { + display: flex; + align-items: center; + gap: 12px; + flex: 0 0 44px; + min-height: 44px; + padding: 0 max(10px, env(safe-area-inset-right, 0px)) 0 max(12px, env(safe-area-inset-left, 0px)); + background: + linear-gradient(135deg, rgba(231, 242, 255, 0.98) 0%, rgba(255, 244, 230, 0.98) 52%, rgba(255, 236, 214, 0.98) 100%); + color: #172554; + border-bottom: 1px solid rgba(96, 165, 250, 0.2); + box-shadow: + 0 10px 28px rgba(37, 99, 235, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.78); + user-select: none; + width: 100%; +} + +.preview-app-overlay--mobile-shell .preview-app-overlay__header { + border: 1px solid rgba(148, 163, 184, 0.18); + border-bottom: 0; + border-radius: 40px 40px 0 0; + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.14), + inset 0 1px 0 rgba(255, 255, 255, 0.82); + cursor: grab; +} + +.preview-app-overlay--mobile-shell .preview-app-overlay__header:active { + cursor: grabbing; +} + +.preview-app-overlay--minimized .preview-app-overlay__header { + border: 1px solid rgba(96, 165, 250, 0.26); + border-radius: 999px; + background: + linear-gradient(135deg, rgba(222, 239, 255, 0.98) 0%, rgba(255, 248, 238, 0.98) 46%, rgba(255, 233, 211, 0.98) 100%); + box-shadow: + 0 20px 40px rgba(15, 23, 42, 0.16), + inset 0 1px 0 rgba(255, 255, 255, 0.82); + cursor: grab; +} + +.preview-app-overlay--minimized .preview-app-overlay__header:active { + cursor: grabbing; +} + +.preview-app-overlay__title { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: 0; + flex: 1 1 auto; +} + +.preview-app-overlay__title--minimized { + flex: 0 1 auto; +} + +.preview-app-overlay__title-badge { + width: 12px; + height: 12px; + flex: 0 0 12px; + border-radius: 999px; + background: + radial-gradient(circle at 32% 32%, #ffffff 0%, #bfdbfe 24%, #60a5fa 58%, #1d4ed8 100%); + box-shadow: + 0 0 0 4px rgba(191, 219, 254, 0.42), + 0 4px 10px rgba(37, 99, 235, 0.2); +} + +.preview-app-overlay__title-copy { + display: inline-flex; + flex-direction: column; + min-width: 0; + line-height: 1.05; +} + +.preview-app-overlay__title-copy strong { + color: #172554; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.01em; +} + +.preview-app-overlay__title-copy span { + overflow: hidden; + color: #475569; + font-size: 11px; + white-space: nowrap; + text-overflow: ellipsis; +} + +.preview-app-overlay__actions { + display: flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; + margin-left: auto; + position: relative; + z-index: 1; +} + +.preview-app-overlay__actions .ant-btn { + width: 32px; + min-width: 32px; + height: 32px; + color: inherit; + border-radius: 999px; +} + +.preview-app-overlay__actions .ant-btn:hover { + background: rgba(226, 232, 240, 0.68); +} + +.preview-app-overlay__minimized-content { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + padding-left: 8px; + color: #0f172a; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.preview-app-overlay__minimized-dot { + width: 10px; + height: 10px; + flex: 0 0 10px; + border-radius: 999px; + background: + radial-gradient(circle at 35% 35%, #fef3c7 0%, #f59e0b 45%, #ea580c 100%); + box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.16); +} + +.preview-app-overlay__minimized-label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.preview-app-overlay__body { + position: relative; + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; + background: #ffffff; +} + +.preview-app-overlay--mobile-shell .preview-app-overlay__body { + height: calc(100% - 44px); + border: 1px solid rgba(148, 163, 184, 0.18); + border-top: 0; + border-radius: 0 0 40px 40px; + box-shadow: + 0 28px 64px rgba(15, 23, 42, 0.18), + 0 0 0 1px rgba(255, 255, 255, 0.52); +} + +.preview-app-overlay--mobile-shell .preview-app-window { + padding: 0; + background: #ffffff; +} + +.preview-app-overlay--mobile-shell .preview-app-window__viewport--mobile { + width: 100%; + height: 100%; + border-radius: 0 0 40px 40px; + overflow: hidden; +} + +@media (max-width: 900px) { + .preview-app-window__viewport--desktop { + border-radius: 0; + } + + .preview-app-window__viewport--mobile { + border-radius: 0; + } +} + +@media (max-width: 768px) { + .preview-app-overlay--mobile-shell { + inset: 0; + width: 100%; + height: 100%; + border-radius: 0; + overflow: hidden; + background: + linear-gradient(180deg, rgba(244, 247, 251, 0.98) 0%, rgba(231, 238, 248, 0.96) 100%); + } + + .preview-app-overlay--mobile-shell .preview-app-overlay__header, + .preview-app-overlay--mobile-shell .preview-app-overlay__body, + .preview-app-overlay--mobile-shell .preview-app-window__viewport--mobile { + border-radius: 0; + } + + .preview-app-overlay--mobile-shell .preview-app-overlay__header, + .preview-app-overlay--mobile-shell .preview-app-overlay__body { + border: 0; + box-shadow: none; + cursor: default; + } +} + +.preview-app-overlay__body--hidden { + visibility: hidden; + pointer-events: none; +} + .app-main-stack { width: 100%; min-width: 0; @@ -1172,8 +1572,8 @@ @media (max-width: 768px) { .app-header { - padding: 6px 10px; - height: 52px; + --app-header-base-height: 52px; + padding: calc(env(safe-area-inset-top, 0px) + 6px) 10px 6px; } .app-header__row { @@ -1273,6 +1673,10 @@ inset: 8px; } + .app-main-preview-layer { + inset: 0; + } + .app-main-window-layer__stage { min-height: calc(var(--app-viewport-height) - 68px); border-radius: 18px; diff --git a/src/app/main/MainSidebar.tsx b/src/app/main/MainSidebar.tsx old mode 100755 new mode 100644 diff --git a/src/app/main/MainView.tsx b/src/app/main/MainView.tsx old mode 100755 new mode 100644 diff --git a/src/app/main/ManagementPage.shared.css b/src/app/main/ManagementPage.shared.css old mode 100755 new mode 100644 index fcdd6a3..b63ff4f --- a/src/app/main/ManagementPage.shared.css +++ b/src/app/main/ManagementPage.shared.css @@ -117,7 +117,7 @@ } .chat-type-management-page__item { - cursor: pointer; + cursor: default; border: 1px solid #f0f0f0; border-radius: 12px; margin-bottom: 8px; @@ -145,9 +145,17 @@ margin-bottom: 0; } +.chat-type-management-page__item-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + .chat-type-management-page__default-context-field { display: flex; flex-direction: column; + flex: 0 0 auto; gap: 8px; padding: 10px 12px; border-radius: 14px; @@ -168,6 +176,9 @@ .chat-type-management-page__default-context-options { width: 100%; + max-height: min(30dvh, 272px); + overflow: auto; + padding-right: 2px; } .chat-type-management-page__default-context-space { @@ -184,6 +195,19 @@ border: 1px solid #e5e7eb; } +.chat-type-management-page__default-context-option-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.chat-type-management-page__default-context-link.ant-btn { + padding-inline: 0; + height: auto; + white-space: nowrap; +} + .chat-type-management-page__default-context-option-copy { padding-left: 24px; } @@ -467,11 +491,24 @@ .chat-type-management-page__editor-scroll { gap: 3px; - padding: 0 0 6px; + overflow: hidden; + padding: 0 0 calc(6px + env(safe-area-inset-bottom, 0px)); } .chat-type-management-page__mobile-toggle { - display: inline-flex; + display: flex; + width: 100%; + flex: 0 0 auto; + } + + .chat-type-management-page__mobile-toggle.ant-segmented { + width: 100%; + } + + .chat-type-management-page__mobile-toggle .ant-segmented-group { + width: 100%; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); } .chat-type-management-page__editor-toolbar { @@ -486,10 +523,81 @@ align-items: start; } + .chat-type-management-page__meta-grid { + order: 1; + } + .chat-type-management-page__default-context-field { + order: 2; + display: none; + } + + .chat-type-management-page__markdown-editor { + gap: 0; + } + + .chat-type-management-page__markdown-field { + order: 3; + gap: 0; + display: none; + } + + .chat-type-management-page__default-context-field--mobile-active, + .chat-type-management-page__markdown-field--mobile-active { + display: flex; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + } + .chat-type-management-page__default-context-header { flex-direction: column; } + .chat-type-management-page__default-context-options { + flex: 1 1 auto; + max-height: none; + overflow: auto; + padding-right: 2px; + } + + .chat-type-management-page__default-context-space { + display: flex; + flex-direction: column; + } + + .chat-type-management-page__default-context-preview { + flex: 0 0 auto; + } + + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview-body { + flex: 1 1 auto; + min-height: 0; + height: 100%; + } + + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview { + overflow: hidden; + } + .chat-type-management-page__meta-item--enabled .ant-form-item-control-input-content { justify-content: flex-end; } @@ -589,6 +697,52 @@ min-height: clamp(320px, calc(100dvh - 430px), 520px) !important; } + .chat-type-management-page--mobile-view-default-contexts .chat-type-management-page__default-context-field, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item-control, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item-control-input, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane .ant-form-item-control-input-content, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview-body { + height: 100% !important; + min-height: 0 !important; + max-height: none !important; + } + + .chat-type-management-page--mobile-view-default-contexts .chat-type-management-page__default-context-field, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-field, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-editor, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-grid, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-pane, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview { + overflow: hidden; + } + + .chat-type-management-page--mobile-view-default-contexts .chat-type-management-page__default-context-options, + .chat-type-management-page--mobile-view-preview .chat-type-management-page__markdown-preview-body, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea { + overflow: auto !important; + } + + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea, + .chat-type-management-page--mobile-view-edit .chat-type-management-page__markdown-textarea textarea { + height: 100% !important; + } + .chat-type-management-page--pane-maximized { height: calc(100dvh - 52px); max-height: calc(100dvh - 52px); @@ -665,4 +819,9 @@ min-width: 34px; height: 34px; } + + .chat-type-management-page__item-actions .ant-btn { + flex: 1 1 calc(50% - 4px); + min-width: 0; + } } diff --git a/src/app/main/PreviewAppOverlay.tsx b/src/app/main/PreviewAppOverlay.tsx new file mode 100644 index 0000000..aa70c5d --- /dev/null +++ b/src/app/main/PreviewAppOverlay.tsx @@ -0,0 +1,437 @@ +import { CloseOutlined, DesktopOutlined, MinusOutlined, MobileOutlined } from '@ant-design/icons'; +import { Button } from 'antd'; +import { + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { createPortal } from 'react-dom'; +import { PreviewAppWindow } from './PreviewAppWindow'; +import type { PreviewTargetDescriptor } from './previewRuntime'; + +type PreviewAppOverlayProps = { + pathname: string; + search?: string; + targetDescriptor?: PreviewTargetDescriptor; + onClose: () => void; +}; + +type DragPosition = { + x: number; + y: number; +}; + +const HEADER_HEIGHT = 44; +const MINIMIZED_WIDTH = 168; +const MOBILE_SHELL_WIDTH = 430; +const MOBILE_SHELL_HEIGHT = 860; +const VIEWPORT_PADDING = 12; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +function getDefaultMinimizedPosition(): DragPosition { + if (typeof window === 'undefined') { + return { + x: VIEWPORT_PADDING, + y: VIEWPORT_PADDING, + }; + } + + return { + x: Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING), + y: Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING), + }; +} + +function getMobileShellMetrics() { + if (typeof window === 'undefined') { + return { + width: MOBILE_SHELL_WIDTH, + height: MOBILE_SHELL_HEIGHT, + }; + } + + return { + width: Math.min(MOBILE_SHELL_WIDTH, Math.max(320, window.innerWidth - VIEWPORT_PADDING * 2)), + height: Math.min(MOBILE_SHELL_HEIGHT, Math.max(520, window.innerHeight - VIEWPORT_PADDING * 2)), + }; +} + +function getDefaultMobileShellPosition(): DragPosition { + if (typeof window === 'undefined') { + return { + x: VIEWPORT_PADDING, + y: VIEWPORT_PADDING, + }; + } + + const { width, height } = getMobileShellMetrics(); + + return { + x: Math.max(VIEWPORT_PADDING, (window.innerWidth - width) / 2), + y: Math.max(VIEWPORT_PADDING, (window.innerHeight - height) / 2), + }; +} + +export function PreviewAppOverlay({ + pathname, + search = '', + targetDescriptor = null, + onClose, +}: PreviewAppOverlayProps) { + const minimizedPositionRef = useRef(getDefaultMinimizedPosition()); + const mobileShellPositionRef = useRef(getDefaultMobileShellPosition()); + const rootRef = useRef(null); + const dragStateRef = useRef<{ + pointerId: number; + lastX: number; + lastY: number; + captureTarget: HTMLDivElement; + } | null>(null); + const dragMovedRef = useRef(false); + const [minimized, setMinimized] = useState(false); + const [deviceMode, setDeviceMode] = useState<'desktop' | 'mobile'>('mobile'); + const [isMobileViewport, setIsMobileViewport] = useState(() => + typeof window !== 'undefined' ? window.matchMedia('(max-width: 768px)').matches : false, + ); + const [position, setPosition] = useState(() => minimizedPositionRef.current); + const isDesktopMobileShell = !minimized && deviceMode === 'mobile' && !isMobileViewport; + + useEffect(() => { + document.body.classList.add('preview-app-overlay-open'); + + return () => { + document.body.classList.remove('preview-app-overlay-open'); + }; + }, []); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const mediaQuery = window.matchMedia('(max-width: 768px)'); + const handleChange = (event: MediaQueryListEvent) => { + setIsMobileViewport(event.matches); + }; + + setIsMobileViewport(mediaQuery.matches); + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, []); + + useEffect(() => { + if (!minimized && !isDesktopMobileShell) { + return; + } + + const resizeListener = () => { + if (minimized) { + setPosition((current) => { + const nextPosition = { + x: clamp( + current.x, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING), + ), + y: clamp( + current.y, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING), + ), + }; + + minimizedPositionRef.current = nextPosition; + + return nextPosition; + }); + return; + } + + const { width, height } = getMobileShellMetrics(); + + setPosition((current) => ({ + 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), + ), + })); + }; + + window.addEventListener('resize', resizeListener); + resizeListener(); + + return () => { + window.removeEventListener('resize', resizeListener); + }; + }, [isDesktopMobileShell, minimized]); + + useEffect(() => { + if (minimized) { + minimizedPositionRef.current = position; + } + }, [minimized, position]); + + useEffect(() => { + if (isDesktopMobileShell) { + mobileShellPositionRef.current = position; + } + }, [isDesktopMobileShell, position]); + + useEffect(() => { + if (!minimized && !isDesktopMobileShell) { + dragStateRef.current = null; + dragMovedRef.current = false; + return; + } + + const handlePointerMove = (event: PointerEvent) => { + const dragState = dragStateRef.current; + + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - dragState.lastX; + const deltaY = event.clientY - dragState.lastY; + + if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) { + dragMovedRef.current = true; + } + + dragState.lastX = event.clientX; + dragState.lastY = event.clientY; + + const maxX = minimized + ? Math.max(VIEWPORT_PADDING, window.innerWidth - MINIMIZED_WIDTH - VIEWPORT_PADDING) + : Math.max( + VIEWPORT_PADDING, + window.innerWidth - getMobileShellMetrics().width - VIEWPORT_PADDING, + ); + const maxY = minimized + ? Math.max(VIEWPORT_PADDING, window.innerHeight - HEADER_HEIGHT - VIEWPORT_PADDING) + : Math.max( + VIEWPORT_PADDING, + window.innerHeight - getMobileShellMetrics().height - VIEWPORT_PADDING, + ); + + setPosition((current) => { + const nextPosition = { + x: clamp(current.x + deltaX, VIEWPORT_PADDING, maxX), + y: clamp(current.y + deltaY, VIEWPORT_PADDING, maxY), + }; + + if (minimized) { + minimizedPositionRef.current = nextPosition; + } + + return nextPosition; + }); + }; + + const finishPointerDrag = (event: PointerEvent) => { + const dragState = dragStateRef.current; + + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + if (dragState.captureTarget.hasPointerCapture(event.pointerId)) { + dragState.captureTarget.releasePointerCapture(event.pointerId); + } + + dragStateRef.current = null; + }; + + 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); + }; + }, [isDesktopMobileShell, minimized]); + + useEffect(() => { + if (isDesktopMobileShell) { + const { width, height } = getMobileShellMetrics(); + const nextPosition = { + x: clamp( + mobileShellPositionRef.current.x, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerWidth - width - VIEWPORT_PADDING), + ), + y: clamp( + mobileShellPositionRef.current.y, + VIEWPORT_PADDING, + Math.max(VIEWPORT_PADDING, window.innerHeight - height - VIEWPORT_PADDING), + ), + }; + + mobileShellPositionRef.current = nextPosition; + setPosition(nextPosition); + } + }, [isDesktopMobileShell]); + + const handleHeaderPointerDown = (event: ReactPointerEvent) => { + if (!minimized && !isDesktopMobileShell) { + return; + } + + const rootRect = rootRef.current?.getBoundingClientRect(); + + if (!rootRect) { + return; + } + + dragStateRef.current = { + pointerId: event.pointerId, + lastX: event.clientX, + lastY: event.clientY, + captureTarget: event.currentTarget, + }; + dragMovedRef.current = false; + + event.currentTarget.setPointerCapture(event.pointerId); + event.preventDefault(); + }; + + const handleHeaderPointerUp = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + + if (dragState?.pointerId === event.pointerId) { + if (dragState.captureTarget.hasPointerCapture(event.pointerId)) { + dragState.captureTarget.releasePointerCapture(event.pointerId); + } + + dragStateRef.current = null; + } + }; + + const handleMinimizeToggle = () => { + setMinimized((current) => { + if (current) { + if (deviceMode === 'mobile' && !isMobileViewport) { + setPosition(mobileShellPositionRef.current); + } + return false; + } + + if (isDesktopMobileShell) { + mobileShellPositionRef.current = position; + } + setPosition(minimizedPositionRef.current); + return true; + }); + }; + + const handleActionButtonClick = (event: ReactMouseEvent) => { + event.stopPropagation(); + }; + + return createPortal( +
+
{ + if (minimized && !dragMovedRef.current) { + setMinimized(false); + } + }} + onPointerDown={handleHeaderPointerDown} + onPointerUp={handleHeaderPointerUp} + onPointerCancel={handleHeaderPointerUp} + > +
+ {minimized ? ( +
+
+ ) : ( + <> +
+
+ {!minimized && !isMobileViewport ? ( +
+
+
+ +
+
, + document.body, + ); +} diff --git a/src/app/main/PreviewAppWindow.tsx b/src/app/main/PreviewAppWindow.tsx new file mode 100644 index 0000000..8f9d486 --- /dev/null +++ b/src/app/main/PreviewAppWindow.tsx @@ -0,0 +1,39 @@ +import { useMemo } from 'react'; +import { getRegisteredAccessToken } from './tokenAccess'; +import { buildPreviewRuntimeUrl, type PreviewTargetDescriptor } from './previewRuntime'; + +type PreviewAppWindowProps = { + pathname: string; + search?: string; + targetDescriptor?: PreviewTargetDescriptor; + deviceMode?: 'desktop' | 'mobile'; +}; + +export function PreviewAppWindow({ + pathname, + search = '', + targetDescriptor = null, + deviceMode = 'desktop', +}: PreviewAppWindowProps) { + const previewUrl = useMemo( + () => buildPreviewRuntimeUrl(pathname, search, getRegisteredAccessToken(), targetDescriptor, deviceMode), + [deviceMode, pathname, search, targetDescriptor], + ); + + return ( +
event.stopPropagation()} + > +
+