From 27fa47f76c376dd1029e5f02d4b8665d5664c3e4 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:43:21 +0800 Subject: [PATCH 1/7] fix(desktop): bind session commands and lifecycle resources to their sources Problem: asynchronous App actions and cleanup can outlive the session or controller that initiated them, especially across background cancellation and A-to-B-to-A navigation. Root cause: mutable active-view state and independent effect cleanup were used as authority for pending operations and shared subscriptions. Fix: wire committed source-bound command owners, canonical controller cancellation, generation fences, navigation receipts, and subscription leases into App composition. Keep the runtime root and page tree together for this slice. Add a separate three-runner memory screening workflow that verifies one clean build, complete protocols, and unique same-attempt shard evidence. Document the remaining offline heap-retainer and control attribution duty. Verification: 302 frontend discovery suites; App lifecycle and real browser replay; production/test typechecks; production build and measured bundle budgets; repository lint; 21 memory protocol/path negative tests; actionlint; focused independent security review of workflow permissions and aggregation. --- .github/workflows/app-memory.yml | 174 + .github/workflows/ci.yml | 3 + desktop/frontend/.gitignore | 2 + desktop/frontend/bench/app-browser.mjs | 167 + .../frontend/bench/app-memory-aggregate.mjs | 10 + .../frontend/bench/app-memory-evidence.mjs | 138 + .../bench/app-memory-evidence.test.mjs | 119 + desktop/frontend/bench/app-memory-paths.mjs | 17 + .../frontend/bench/app-memory-paths.test.mjs | 14 + desktop/frontend/bench/app-memory-prepare.mjs | 19 + desktop/frontend/bench/app-memory-shards.mjs | 67 + .../frontend/bench/app-memory-shards.test.mjs | 53 + desktop/frontend/bench/app-memory.mjs | 218 + desktop/frontend/bench/app-page-actions.mjs | 9 + desktop/frontend/eslint.config.js | 5 + desktop/frontend/package.json | 5 +- .../frontend/scripts/check-bundle-budget.mjs | 6 +- desktop/frontend/scripts/run-tests.mjs | 19 +- desktop/frontend/src/App.tsx | 6019 ++--------------- .../__tests__/activate-topic-stale.test.tsx | 21 - .../src/__tests__/active-tab-mirror.test.tsx | 41 + .../src/__tests__/app-chrome-tabs.test.ts | 169 +- .../src/__tests__/app-lifecycle-probe.test.ts | 27 + .../src/__tests__/app-lifecycle.test.tsx | 148 + .../automation-navigation-lifecycle.test.tsx | 55 + .../src/__tests__/automation-regions.test.tsx | 41 + .../automation-surface-layout.test.ts | 20 +- .../src/__tests__/bundle-contract.test.ts | 39 +- .../composer-insert-commands.test.tsx | 98 + .../composer-source-operations.test.tsx | 122 + .../controller-profile-lifecycle.test.tsx | 119 + .../__tests__/conversation-projection.test.ts | 73 + .../decision-footer-lifecycle.test.tsx | 83 + .../decision-slots-lifecycle.test.tsx | 30 + .../delivery-continue-commands.test.tsx | 94 + .../desktop-navigation-lifecycle.test.tsx | 146 + .../desktop-preferences-lifecycle.test.tsx | 68 + .../src/__tests__/external-opener.test.tsx | 7 +- .../src/__tests__/goal-action-errors.test.tsx | 28 +- .../goal-activation-tab-routing.test.tsx | 50 +- .../helpers/RemoteNavigationHarness.tsx | 27 + .../history-load-failure-contract.test.ts | 9 +- .../src/__tests__/isolated-worktree.test.ts | 10 +- .../src/__tests__/markdown-history.test.tsx | 2 +- .../markdown-streaming-worker.test.tsx | 15 +- .../src/__tests__/mcp-interaction.test.tsx | 3 +- .../src/__tests__/mock-remote-catalog.test.ts | 30 + .../navigation-surface-lifecycle.test.tsx | 54 + .../navigation-surface-transition.test.ts | 63 +- .../__tests__/onboarding-commands.test.tsx | 30 + .../pending-plan-revision-lifecycle.test.tsx | 79 + .../project-topic-lifecycle.test.tsx | 115 + .../provider-editor-model-picker.test.tsx | 3 + .../remote-composer-commands.test.tsx | 77 + .../remote-composer-presentation.test.tsx | 57 + .../__tests__/remote-connect-wizard.test.tsx | 18 +- .../__tests__/remote-project-tree.test.tsx | 110 +- .../__tests__/remote-session-surface.test.tsx | 7 +- .../src/__tests__/remote-tab-opened.test.tsx | 9 +- .../src/__tests__/rewind-fork-routing.test.ts | 10 +- .../src/__tests__/runtime-job-owner.test.ts | 21 + .../runtime-status-lifecycle.test.tsx | 74 + .../src/__tests__/send-failed.test.ts | 212 +- .../__tests__/session-clear-commands.test.tsx | 106 + .../src/__tests__/session-clear-owner.test.ts | 23 + .../session-control-commands.test.ts | 68 + .../session-prompt-lifecycle.test.tsx | 82 + .../session-submission-lifecycle.test.tsx | 99 + .../__tests__/session-undo-lifecycle.test.tsx | 107 + .../startup-settings-contract.test.ts | 44 +- .../src/__tests__/subscription-scope.test.ts | 32 + .../src/__tests__/terminal-events.test.ts | 19 + .../__tests__/terminal-output-owner.test.ts | 21 + .../terminal-panel-commands.test.tsx | 65 + .../frontend/src/__tests__/theme-pack.test.ts | 11 +- .../__tests__/topic-summary-commands.test.tsx | 81 + .../topicbar-actions-lifecycle.test.tsx | 45 + .../src/__tests__/topicbar-controls.test.ts | 17 +- .../src/__tests__/topicbar-region.test.tsx | 70 + .../turn-verification-commands.test.tsx | 74 + .../__tests__/windows-maximised-sync.test.tsx | 81 + .../src/__tests__/workspace-layout.test.ts | 116 - .../workspace-panel-commands.test.tsx | 69 + .../worktree-merge-commands.test.tsx | 101 + .../src/app-runtime/AppRuntimeEffects.tsx | 71 + .../src/app-runtime/StartupGateLifecycle.tsx | 37 + .../src/app-runtime/WindowChromeLifecycle.tsx | 82 + .../src/app-runtime/activeTabMirror.ts | 25 + .../src/app-runtime/appLifecycleProbe.ts | 83 + .../src/app-runtime/botRuntimeAdapter.ts | 12 + .../src/app-runtime/composerModeOwner.ts | 76 + .../src/app-runtime/controllerProfileOwner.ts | 88 + .../src/app-runtime/conversationProjection.ts | 138 + .../app-runtime/decisionSurfaceProjection.ts | 33 + .../src/app-runtime/desktopBridgeAdapter.ts | 20 + .../src/app-runtime/desktopNavigationOwner.ts | 153 + .../app-runtime/desktopPreferencesAdapter.ts | 69 + .../src/app-runtime/desktopProjectAdapter.ts | 7 + .../app-runtime/desktopSubmissionAdapter.ts | 34 + .../src/app-runtime/historyViewProjection.ts | 15 + .../src/app-runtime/navigationOwner.ts | 34 + .../src/app-runtime/operationOwner.ts | 115 + .../src/app-runtime/pendingRevisionOwner.ts | 68 + .../frontend/src/app-runtime/pollingOwner.ts | 59 + .../src/app-runtime/projectTopicOwner.ts | 44 + .../src/app-runtime/remoteComposerOwner.ts | 63 + .../src/app-runtime/sessionActionOwner.ts | 99 + .../src/app-runtime/sessionPromptExecutor.ts | 41 + .../src/app-runtime/sessionRuntimeOwner.ts | 64 + .../src/app-runtime/sessionSubmissionOwner.ts | 81 + .../frontend/src/app-runtime/sessionTarget.ts | 65 + .../src/app-runtime/sidebarImProjection.ts | 280 + .../src/app-runtime/useAppChromeCommands.ts | 93 + .../src/app-runtime/useAppEffectHosts.ts | 32 + .../useAppNavigationComposition.ts | 221 + .../src/app-runtime/useAppRuntimeAdapter.ts | 94 + .../app-runtime/useAppSessionComposition.ts | 755 +++ .../src/app-runtime/useAppShellStores.ts | 97 + .../app-runtime/useAutomationNavigation.ts | 49 + .../app-runtime/useComposerGoalCommands.ts | 14 + .../app-runtime/useComposerInsertCommands.ts | 140 + .../useComposerProfileProjection.ts | 105 + .../src/app-runtime/useComposerRouter.ts | 181 + .../useDeliveryContinueCommands.ts | 45 + .../src/app-runtime/useDesktopNavigation.ts | 91 + .../src/app-runtime/useDesktopPreferences.ts | 58 + .../src/app-runtime/useExtensionSurface.ts | 64 + .../app-runtime/useFooterHeightLifecycle.ts | 30 + .../src/app-runtime/useHistoryCommands.ts | 85 + .../src/app-runtime/useInvocationMetadata.ts | 26 + .../src/app-runtime/useLocalUiLifecycles.ts | 66 + .../src/app-runtime/useNativeSettingsEvent.ts | 16 + .../app-runtime/useNativeWindowController.ts | 55 + .../src/app-runtime/useOnboardingCommands.ts | 23 + .../src/app-runtime/usePaletteCommands.tsx | 202 + .../app-runtime/useProjectTopicCommands.ts | 62 + .../app-runtime/useRemoteWorkspaceCommands.ts | 83 + .../src/app-runtime/useResourceOperations.ts | 67 + .../app-runtime/useRuntimeEventHandlers.ts | 166 + .../src/app-runtime/useRuntimeStatus.ts | 40 + .../app-runtime/useSessionBannerCommands.ts | 49 + .../app-runtime/useSessionClearCommands.ts | 54 + .../app-runtime/useSessionControlCommands.ts | 80 + .../app-runtime/useSessionExportCommands.ts | 96 + .../useSessionNavigationCommands.ts | 161 + .../src/app-runtime/useSessionOperations.ts | 15 + .../app-runtime/useSessionPromptCommands.ts | 47 + .../src/app-runtime/useSessionUndo.ts | 245 + .../src/app-runtime/useShellGeometry.ts | 390 ++ .../src/app-runtime/useTabBarCommands.ts | 280 + .../app-runtime/useTabProjectionLifecycle.ts | 36 + .../app-runtime/useTerminalPanelCommands.ts | 34 + .../src/app-runtime/useTodoPanelCommands.ts | 125 + .../useTopicNavigationShortcuts.ts | 29 + .../src/app-runtime/useTopicSummary.ts | 52 + .../useTranscriptSurfaceProjection.ts | 114 + .../useTurnVerificationCommands.ts | 45 + .../app-runtime/useWorkspacePanelCommands.ts | 85 + .../app-runtime/useWorktreeMergeCommands.ts | 63 + .../src/app-shell/AppBottomRegions.tsx | 49 + .../frontend/src/app-shell/AppOverlayHost.tsx | 60 + .../frontend/src/app-shell/ChatPaneRegion.tsx | 152 + .../src/app-shell/DecisionFooterRegion.tsx | 110 + .../src/app-shell/DockToggleButton.tsx | 25 + .../src/app-shell/HotkeyRegistrations.tsx | 18 + .../src/app-shell/NoticePreviewPanel.tsx | 79 + .../src/app-shell/SessionStatusBanners.tsx | 93 + .../app-shell/SidebarImConnectionDetail.tsx | 145 + .../frontend/src/app-shell/SidebarRegion.tsx | 128 + .../src/app-shell/TopicbarActionsRegion.tsx | 21 + .../src/app-shell/TopicbarActionsStack.tsx | 126 + .../frontend/src/app-shell/TopicbarRegion.tsx | 79 + .../src/app-shell/WindowsWindowControls.tsx | 22 + .../src/app-shell/WorkspaceDockRegion.tsx | 85 + .../src/app-shell/chromeRegionBuilders.ts | 177 + .../src/app-shell/decisionFooterBuilders.ts | 332 + .../src/app-shell/dockRegionBuilders.ts | 174 + .../frontend/src/app-shell/overlayBuilders.ts | 107 + desktop/frontend/src/components/AppChrome.tsx | 2 +- .../components/ProjectTreeRemoteGroups.tsx | 9 +- .../src/components/RemoteConnectWizard.tsx | 8 +- .../src/components/RemoteSessionSurface.tsx | 16 +- .../frontend/src/components/StartupSplash.tsx | 18 +- .../frontend/src/components/TerminalPanel.tsx | 2 +- .../frontend/src/components/TerminalView.tsx | 2 +- desktop/frontend/src/lib/bridge.ts | 76 +- .../src/lib/controllerModelCommands.ts | 84 + desktop/frontend/src/lib/deliveryContinue.ts | 8 +- desktop/frontend/src/lib/desktopPlatform.ts | 32 + .../src/lib/desktopPreferencesMock.ts | 49 + desktop/frontend/src/lib/goalSubmit.ts | 67 - .../frontend/src/lib/mockRemoteProjects.ts | 22 +- desktop/frontend/src/lib/mockScenarios.ts | 14 + .../src/lib/navigationSurfaceTransition.ts | 40 + .../src/lib/remoteNavigationCommands.ts | 9 + .../frontend/src/lib/remoteSessionActions.ts | 10 - desktop/frontend/src/lib/sessionTitles.ts | 25 + .../frontend/src/lib/startupSplashState.ts | 17 + desktop/frontend/src/lib/subscriptionScope.ts | 39 + desktop/frontend/src/lib/terminalEvents.ts | 34 +- .../frontend/src/lib/todoDismissalStorage.ts | 25 + .../src/lib/useComposerModeActions.ts | 155 +- desktop/frontend/src/lib/useController.ts | 168 +- .../src/lib/useControllerProfileCommands.ts | 55 + .../frontend/src/lib/useNavigationSurface.ts | 53 +- .../src/lib/usePendingPlanRevisions.ts | 18 + .../src/lib/useRemoteComposerIntegration.ts | 73 +- .../frontend/src/lib/useRemoteTabOpened.ts | 24 +- .../frontend/src/lib/useSessionSubmission.ts | 44 + desktop/frontend/src/store/layout.ts | 30 +- desktop/frontend/src/store/overlays.ts | 14 +- desktop/frontend/src/store/windowChrome.ts | 49 + docs/APP_SESSION_OWNERSHIP.md | 46 + docs/APP_SESSION_OWNERSHIP.zh-CN.md | 35 + 214 files changed, 14593 insertions(+), 6586 deletions(-) create mode 100644 .github/workflows/app-memory.yml create mode 100644 desktop/frontend/bench/app-browser.mjs create mode 100644 desktop/frontend/bench/app-memory-aggregate.mjs create mode 100644 desktop/frontend/bench/app-memory-evidence.mjs create mode 100644 desktop/frontend/bench/app-memory-evidence.test.mjs create mode 100644 desktop/frontend/bench/app-memory-paths.mjs create mode 100644 desktop/frontend/bench/app-memory-paths.test.mjs create mode 100644 desktop/frontend/bench/app-memory-prepare.mjs create mode 100644 desktop/frontend/bench/app-memory-shards.mjs create mode 100644 desktop/frontend/bench/app-memory-shards.test.mjs create mode 100644 desktop/frontend/bench/app-memory.mjs create mode 100644 desktop/frontend/bench/app-page-actions.mjs create mode 100644 desktop/frontend/src/__tests__/active-tab-mirror.test.tsx create mode 100644 desktop/frontend/src/__tests__/app-lifecycle-probe.test.ts create mode 100644 desktop/frontend/src/__tests__/app-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/automation-navigation-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/automation-regions.test.tsx create mode 100644 desktop/frontend/src/__tests__/composer-insert-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/composer-source-operations.test.tsx create mode 100644 desktop/frontend/src/__tests__/controller-profile-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/conversation-projection.test.ts create mode 100644 desktop/frontend/src/__tests__/decision-footer-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/decision-slots-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/delivery-continue-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/desktop-navigation-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/desktop-preferences-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/helpers/RemoteNavigationHarness.tsx create mode 100644 desktop/frontend/src/__tests__/mock-remote-catalog.test.ts create mode 100644 desktop/frontend/src/__tests__/navigation-surface-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/onboarding-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/pending-plan-revision-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/project-topic-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/remote-composer-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/remote-composer-presentation.test.tsx create mode 100644 desktop/frontend/src/__tests__/runtime-job-owner.test.ts create mode 100644 desktop/frontend/src/__tests__/runtime-status-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/session-clear-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/session-clear-owner.test.ts create mode 100644 desktop/frontend/src/__tests__/session-control-commands.test.ts create mode 100644 desktop/frontend/src/__tests__/session-prompt-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/session-submission-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/session-undo-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/subscription-scope.test.ts create mode 100644 desktop/frontend/src/__tests__/terminal-output-owner.test.ts create mode 100644 desktop/frontend/src/__tests__/terminal-panel-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/topic-summary-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/topicbar-actions-lifecycle.test.tsx create mode 100644 desktop/frontend/src/__tests__/topicbar-region.test.tsx create mode 100644 desktop/frontend/src/__tests__/turn-verification-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/windows-maximised-sync.test.tsx create mode 100644 desktop/frontend/src/__tests__/workspace-panel-commands.test.tsx create mode 100644 desktop/frontend/src/__tests__/worktree-merge-commands.test.tsx create mode 100644 desktop/frontend/src/app-runtime/AppRuntimeEffects.tsx create mode 100644 desktop/frontend/src/app-runtime/StartupGateLifecycle.tsx create mode 100644 desktop/frontend/src/app-runtime/WindowChromeLifecycle.tsx create mode 100644 desktop/frontend/src/app-runtime/activeTabMirror.ts create mode 100644 desktop/frontend/src/app-runtime/appLifecycleProbe.ts create mode 100644 desktop/frontend/src/app-runtime/botRuntimeAdapter.ts create mode 100644 desktop/frontend/src/app-runtime/composerModeOwner.ts create mode 100644 desktop/frontend/src/app-runtime/controllerProfileOwner.ts create mode 100644 desktop/frontend/src/app-runtime/conversationProjection.ts create mode 100644 desktop/frontend/src/app-runtime/decisionSurfaceProjection.ts create mode 100644 desktop/frontend/src/app-runtime/desktopBridgeAdapter.ts create mode 100644 desktop/frontend/src/app-runtime/desktopNavigationOwner.ts create mode 100644 desktop/frontend/src/app-runtime/desktopPreferencesAdapter.ts create mode 100644 desktop/frontend/src/app-runtime/desktopProjectAdapter.ts create mode 100644 desktop/frontend/src/app-runtime/desktopSubmissionAdapter.ts create mode 100644 desktop/frontend/src/app-runtime/historyViewProjection.ts create mode 100644 desktop/frontend/src/app-runtime/navigationOwner.ts create mode 100644 desktop/frontend/src/app-runtime/operationOwner.ts create mode 100644 desktop/frontend/src/app-runtime/pendingRevisionOwner.ts create mode 100644 desktop/frontend/src/app-runtime/pollingOwner.ts create mode 100644 desktop/frontend/src/app-runtime/projectTopicOwner.ts create mode 100644 desktop/frontend/src/app-runtime/remoteComposerOwner.ts create mode 100644 desktop/frontend/src/app-runtime/sessionActionOwner.ts create mode 100644 desktop/frontend/src/app-runtime/sessionPromptExecutor.ts create mode 100644 desktop/frontend/src/app-runtime/sessionRuntimeOwner.ts create mode 100644 desktop/frontend/src/app-runtime/sessionSubmissionOwner.ts create mode 100644 desktop/frontend/src/app-runtime/sessionTarget.ts create mode 100644 desktop/frontend/src/app-runtime/sidebarImProjection.ts create mode 100644 desktop/frontend/src/app-runtime/useAppChromeCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useAppEffectHosts.ts create mode 100644 desktop/frontend/src/app-runtime/useAppNavigationComposition.ts create mode 100644 desktop/frontend/src/app-runtime/useAppRuntimeAdapter.ts create mode 100644 desktop/frontend/src/app-runtime/useAppSessionComposition.ts create mode 100644 desktop/frontend/src/app-runtime/useAppShellStores.ts create mode 100644 desktop/frontend/src/app-runtime/useAutomationNavigation.ts create mode 100644 desktop/frontend/src/app-runtime/useComposerGoalCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useComposerInsertCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useComposerProfileProjection.ts create mode 100644 desktop/frontend/src/app-runtime/useComposerRouter.ts create mode 100644 desktop/frontend/src/app-runtime/useDeliveryContinueCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useDesktopNavigation.ts create mode 100644 desktop/frontend/src/app-runtime/useDesktopPreferences.ts create mode 100644 desktop/frontend/src/app-runtime/useExtensionSurface.ts create mode 100644 desktop/frontend/src/app-runtime/useFooterHeightLifecycle.ts create mode 100644 desktop/frontend/src/app-runtime/useHistoryCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useInvocationMetadata.ts create mode 100644 desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts create mode 100644 desktop/frontend/src/app-runtime/useNativeSettingsEvent.ts create mode 100644 desktop/frontend/src/app-runtime/useNativeWindowController.ts create mode 100644 desktop/frontend/src/app-runtime/useOnboardingCommands.ts create mode 100644 desktop/frontend/src/app-runtime/usePaletteCommands.tsx create mode 100644 desktop/frontend/src/app-runtime/useProjectTopicCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useRemoteWorkspaceCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useResourceOperations.ts create mode 100644 desktop/frontend/src/app-runtime/useRuntimeEventHandlers.ts create mode 100644 desktop/frontend/src/app-runtime/useRuntimeStatus.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionBannerCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionClearCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionControlCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionExportCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionNavigationCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionOperations.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionPromptCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useSessionUndo.ts create mode 100644 desktop/frontend/src/app-runtime/useShellGeometry.ts create mode 100644 desktop/frontend/src/app-runtime/useTabBarCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useTabProjectionLifecycle.ts create mode 100644 desktop/frontend/src/app-runtime/useTerminalPanelCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useTodoPanelCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useTopicNavigationShortcuts.ts create mode 100644 desktop/frontend/src/app-runtime/useTopicSummary.ts create mode 100644 desktop/frontend/src/app-runtime/useTranscriptSurfaceProjection.ts create mode 100644 desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useWorkspacePanelCommands.ts create mode 100644 desktop/frontend/src/app-runtime/useWorktreeMergeCommands.ts create mode 100644 desktop/frontend/src/app-shell/AppBottomRegions.tsx create mode 100644 desktop/frontend/src/app-shell/AppOverlayHost.tsx create mode 100644 desktop/frontend/src/app-shell/ChatPaneRegion.tsx create mode 100644 desktop/frontend/src/app-shell/DecisionFooterRegion.tsx create mode 100644 desktop/frontend/src/app-shell/DockToggleButton.tsx create mode 100644 desktop/frontend/src/app-shell/HotkeyRegistrations.tsx create mode 100644 desktop/frontend/src/app-shell/NoticePreviewPanel.tsx create mode 100644 desktop/frontend/src/app-shell/SessionStatusBanners.tsx create mode 100644 desktop/frontend/src/app-shell/SidebarImConnectionDetail.tsx create mode 100644 desktop/frontend/src/app-shell/SidebarRegion.tsx create mode 100644 desktop/frontend/src/app-shell/TopicbarActionsRegion.tsx create mode 100644 desktop/frontend/src/app-shell/TopicbarActionsStack.tsx create mode 100644 desktop/frontend/src/app-shell/TopicbarRegion.tsx create mode 100644 desktop/frontend/src/app-shell/WindowsWindowControls.tsx create mode 100644 desktop/frontend/src/app-shell/WorkspaceDockRegion.tsx create mode 100644 desktop/frontend/src/app-shell/chromeRegionBuilders.ts create mode 100644 desktop/frontend/src/app-shell/decisionFooterBuilders.ts create mode 100644 desktop/frontend/src/app-shell/dockRegionBuilders.ts create mode 100644 desktop/frontend/src/app-shell/overlayBuilders.ts create mode 100644 desktop/frontend/src/lib/controllerModelCommands.ts create mode 100644 desktop/frontend/src/lib/desktopPlatform.ts create mode 100644 desktop/frontend/src/lib/desktopPreferencesMock.ts delete mode 100644 desktop/frontend/src/lib/goalSubmit.ts create mode 100644 desktop/frontend/src/lib/mockScenarios.ts create mode 100644 desktop/frontend/src/lib/remoteNavigationCommands.ts delete mode 100644 desktop/frontend/src/lib/remoteSessionActions.ts create mode 100644 desktop/frontend/src/lib/sessionTitles.ts create mode 100644 desktop/frontend/src/lib/startupSplashState.ts create mode 100644 desktop/frontend/src/lib/subscriptionScope.ts create mode 100644 desktop/frontend/src/lib/todoDismissalStorage.ts create mode 100644 desktop/frontend/src/lib/useControllerProfileCommands.ts create mode 100644 desktop/frontend/src/lib/usePendingPlanRevisions.ts create mode 100644 desktop/frontend/src/lib/useSessionSubmission.ts create mode 100644 desktop/frontend/src/store/windowChrome.ts create mode 100644 docs/APP_SESSION_OWNERSHIP.md create mode 100644 docs/APP_SESSION_OWNERSHIP.zh-CN.md diff --git a/.github/workflows/app-memory.yml b/.github/workflows/app-memory.yml new file mode 100644 index 0000000000..99804f2032 --- /dev/null +++ b/.github/workflows/app-memory.yml @@ -0,0 +1,174 @@ +name: App memory screening + +on: + pull_request: + branches: [main-v2] + push: + branches: [main-v2] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: app-memory-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + EXPECTED_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + +jobs: + changes: + runs-on: ubuntu-22.04 + outputs: + run: ${{ steps.paths.outputs.run }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.EXPECTED_SOURCE_SHA }} + fetch-depth: 0 + - uses: actions/setup-node@v7 + with: + node-version: "24" + - id: paths + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: | + if [ "$GITHUB_EVENT_NAME" != pull_request ]; then + echo 'run=true' >> "$GITHUB_OUTPUT" + else + git diff --name-only -z "$BASE_SHA...$EXPECTED_SOURCE_SHA" > "$RUNNER_TEMP/memory-paths" + node desktop/frontend/bench/app-memory-paths.mjs "$RUNNER_TEMP/memory-paths" >> "$GITHUB_OUTPUT" + fi + + prepare: + needs: changes + if: needs.changes.outputs.run == 'true' + runs-on: ubuntu-22.04 + timeout-minutes: 20 + defaults: + run: + working-directory: desktop/frontend + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.EXPECTED_SOURCE_SHA }} + - uses: pnpm/action-setup@v6.0.9 + with: + version: 10 + run_install: false + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: node --test bench/app-memory-shards.test.mjs bench/app-memory-paths.test.mjs + - run: pnpm build + - run: node bench/app-memory-prepare.mjs "$RUNNER_TEMP/app-memory-build" + - uses: actions/upload-artifact@v7 + with: + name: app-memory-build-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/app-memory-build + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + + shard: + needs: prepare + runs-on: ubuntu-22.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] + defaults: + run: + working-directory: desktop/frontend + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ env.EXPECTED_SOURCE_SHA }} + - uses: pnpm/action-setup@v6.0.9 + with: + version: 10 + run_install: false + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: pnpm + cache-dependency-path: desktop/frontend/pnpm-lock.yaml + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - uses: actions/download-artifact@v8 + with: + name: app-memory-build-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/app-memory-build + - run: cp -a "$RUNNER_TEMP/app-memory-build/dist/." dist/ + - name: Run complete independent memory process + env: + REASONIX_APP_MEMORY_SHARD: ${{ matrix.shard }} + REASONIX_APP_MEMORY_PREPARED: ${{ runner.temp }}/app-memory-build/identity.json + REASONIX_APP_MEMORY_ARTIFACTS: ${{ runner.temp }}/app-memory-results + PLAYWRIGHT_BROWSERS_PATH: /home/runner/.cache/ms-playwright + run: node bench/app-memory.mjs + - uses: actions/upload-artifact@v7 + if: always() + with: + name: app-memory-shard-${{ matrix.shard }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/app-memory-results + if-no-files-found: error + retention-days: 7 + + app-memory: + if: always() + needs: [changes, prepare, shard] + runs-on: ubuntu-22.04 + steps: + - name: Verify all prerequisite jobs + env: + CHANGES_RESULT: ${{ needs.changes.result }} + SHOULD_RUN: ${{ needs.changes.outputs.run }} + PREPARE_RESULT: ${{ needs.prepare.result }} + SHARD_RESULT: ${{ needs.shard.result }} + run: | + test "$CHANGES_RESULT" = success + if [ "$SHOULD_RUN" = true ]; then + test "$PREPARE_RESULT" = success + test "$SHARD_RESULT" = success + else + test "$SHOULD_RUN" = false + test "$PREPARE_RESULT" = skipped + test "$SHARD_RESULT" = skipped + fi + - uses: actions/checkout@v7 + if: needs.changes.outputs.run == 'true' + with: + ref: ${{ env.EXPECTED_SOURCE_SHA }} + - uses: actions/setup-node@v7 + if: needs.changes.outputs.run == 'true' + with: + node-version: "24" + - uses: actions/download-artifact@v8 + if: needs.changes.outputs.run == 'true' + with: + pattern: app-memory-shard-*-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/app-memory-reports + - uses: actions/download-artifact@v8 + if: needs.changes.outputs.run == 'true' + with: + name: app-memory-build-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/app-memory-build + - name: Verify same-build complete protocol + if: needs.changes.outputs.run == 'true' + run: | + node desktop/frontend/bench/app-memory-aggregate.mjs \ + "$RUNNER_TEMP/app-memory-reports" "$RUNNER_TEMP/app-memory-build/identity.json" \ + "$RUNNER_TEMP/app-memory-report.json" "$EXPECTED_SOURCE_SHA" + - uses: actions/upload-artifact@v7 + if: success() && needs.changes.outputs.run == 'true' + with: + name: app-memory-aggregate-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/app-memory-report.json + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 653166cdfa..0ec8ac7c10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -395,6 +395,9 @@ jobs: pnpm --dir frontend test:motion-browser pnpm --dir frontend test:composer pnpm --dir frontend test:todo-visibility + pnpm --dir frontend test:app-lifecycle + PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend test:app-browser + pnpm --dir frontend test:all pnpm --dir frontend test:transcript xvfb-run -a env REASONIX_TRANSCRIPT_NATIVE_THUMB=1 pnpm --dir frontend test:transcript-browser PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend test:transcript-reader-browser diff --git a/desktop/frontend/.gitignore b/desktop/frontend/.gitignore index c9d2c66a11..c91bc5a2b2 100644 --- a/desktop/frontend/.gitignore +++ b/desktop/frontend/.gitignore @@ -20,3 +20,5 @@ package-lock.json # Playwright browsers for the real-DOM bench (kept inside the repo; see bench/). .pw-browsers bench/results.json +bench/app-memory-results.json +bench/app-memory-artifacts/ diff --git a/desktop/frontend/bench/app-browser.mjs b/desktop/frontend/bench/app-browser.mjs new file mode 100644 index 0000000000..7c4a8a6f59 --- /dev/null +++ b/desktop/frontend/bench/app-browser.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { startPreviewServer } from "./vite-preview-server.mjs"; +import { chooseAppLayout } from "./app-page-actions.mjs"; + +const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" + ? path.join(frontendDir, ".pw-browsers") + : process.env.PLAYWRIGHT_BROWSERS_PATH; +// Playwright reads PLAYWRIGHT_BROWSERS_PATH at module evaluation; import it +// only after the path normalization above. +const { chromium } = await import("playwright"); +const port = Number(process.env.REASONIX_APP_BROWSER_PORT ?? 4657); +const preview = await startPreviewServer(frontendDir, port); +const browser = await chromium.launch({ headless: true }); + +function assert(condition, message) { + if (!condition) throw new Error(message); + process.stdout.write(` PASS ${message}\n`); +} + +async function settle(page, frames = 5) { + await page.evaluate((count) => new Promise((resolve) => { + const tick = () => --count <= 0 ? resolve() : requestAnimationFrame(tick); + requestAnimationFrame(tick); + }), frames); +} + +async function chooseLayout(page, label, className) { + await chooseAppLayout(page, label, className); + await settle(page); +} + +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const pageErrors = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + await page.goto(`http://127.0.0.1:${port}/?mock=bench&bench=1&app-lifecycle-probe=1`, { waitUntil: "domcontentloaded" }); + await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor(); + await page.locator(".project-tree").waitFor(); + await page.evaluate(() => { + window.__appBrowserIdentity = { + composer: document.querySelector("textarea.composer__input:not([aria-hidden=true])"), + projectTree: document.querySelector(".project-tree"), + sidebar: document.querySelector(".sidebar"), + }; + }); + const composer = page.locator("textarea.composer__input:not([aria-hidden=true])"); + await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click(); + await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('ASYNC LAYOUT EXPANSION COMPLETE')); + await composer.fill("layout-owned draft"); + // Raw Markdown fallbacks become parsed DOM asynchronously. History preservation + // means stable block identity/content revision, not identical transient textContent. + const transcriptIdentity = () => [...document.querySelectorAll('[data-transcript-block-key]')].map(node => ({ + key: node.getAttribute('data-transcript-block-key'), revision: node.getAttribute('data-transcript-content-revision'), + })); + const transcriptBeforeModel = await page.evaluate(transcriptIdentity); + assert(transcriptBeforeModel.length > 0, 'model replay starts with hydrated transcript blocks'); + await page.locator('.modelsw__trigger:not(.effortsw__trigger)').click(); + const nextModel = page.locator('.modelsw__item[role="option"]:not([aria-selected="true"])').first(); + const nextModelName = await nextModel.locator('.modelsw__model').textContent(); + await nextModel.click(); + await page.waitForFunction(name => document.querySelector('.modelsw__label')?.textContent?.includes(name), nextModelName); + await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false + && window.__reasonixAppLifecycle?.snapshot().activeOperations === 0); + const transcriptAfterModel = await page.evaluate(transcriptIdentity); + const draftAfterModel = await composer.inputValue(); + assert(draftAfterModel === 'layout-owned draft' && JSON.stringify(transcriptAfterModel) === JSON.stringify(transcriptBeforeModel), + 'real model selection preserves source transcript, Composer draft and writable readiness'); + await page.getByRole('tab', { name: 'Files', exact: true }).click(); + await page.locator('[data-workspace-path="README.md"]').click(); + await page.waitForFunction(() => document.querySelector('.workspace-preview__body')?.textContent?.includes('Browser-dev workspace preview.')); + await page.evaluate(() => { + Object.assign(window.__appBrowserIdentity, { + workspace: document.querySelector('.workspace-panel'), + workspaceTree: document.querySelector('.workspace-tree'), + preview: document.querySelector('.workspace-preview__body'), + }); + }); + + assert(await page.locator(".app.app--workbench").count() === 1, "workbench layout renders from the authoritative startup snapshot"); + await chooseLayout(page, "Creation", "app--creation"); + assert(await composer.inputValue() === "layout-owned draft", "creation layout preserves the Composer draft and mount"); + await chooseLayout(page, "Workbench", "app--workbench"); + assert(await composer.inputValue() === "layout-owned draft", "workbench layout preserves the Composer draft and mount"); + + const identities = await page.evaluate(() => ({ + composer: window.__appBrowserIdentity.composer === document.querySelector("textarea.composer__input:not([aria-hidden=true])"), + projectTree: window.__appBrowserIdentity.projectTree === document.querySelector(".project-tree"), + sidebar: window.__appBrowserIdentity.sidebar === document.querySelector(".sidebar"), + workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'), + workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'), + preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'), + })); + assert(Object.values(identities).every(Boolean), "layout variants and management-page visits retain Sidebar, Composer, actual WorkspacePanel/tree and file preview identity"); + + const terminalToggle = page.getByRole("button", { name: "Terminal", exact: true }).first(); + await terminalToggle.click(); + await page.locator('.terminal-drawer[aria-hidden="false"]').waitFor(); + assert(await page.locator('.terminal-drawer-resizer[tabindex="0"]').count() === 1, "open terminal drawer exposes one keyboard resizer"); + assert(await page.locator(".footer.footer--compact").count() === 1, "open terminal compacts the shared footer without remounting Composer"); + assert(await composer.inputValue() === "layout-owned draft", "terminal drawer lifecycle preserves the Composer draft"); + await terminalToggle.click(); + await page.locator('.terminal-drawer[aria-hidden="true"][inert]').waitFor(); + assert(await page.locator('.terminal-drawer-resizer[tabindex="-1"]').count() === 1, "closed warm terminal is inert and leaves keyboard navigation"); + + await page.locator('.project-tree__topic-main:has-text("bench:geometry")').click(); + await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete.")); + await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click(); + await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE")); + const afterSwitch = await page.evaluate(() => ({ + projectTree: window.__appBrowserIdentity.projectTree === document.querySelector(".project-tree"), + workspace: window.__appBrowserIdentity.workspace === document.querySelector('.workspace-panel'), + workspaceTree: window.__appBrowserIdentity.workspaceTree === document.querySelector('.workspace-tree'), + preview: window.__appBrowserIdentity.preview === document.querySelector('.workspace-preview__body'), + selectedFile: document.querySelector('.workspace-tree__row--active')?.getAttribute('data-workspace-path'), + subscriptions: window.__reasonixAppLifecycle?.snapshot().activeSubscriptions, + operations: window.__reasonixAppLifecycle?.snapshot().activeOperations, + })); + assert(afterSwitch.projectTree, "same-project session switching preserves the Sidebar project tree (not WorkspacePanel)"); + assert(afterSwitch.workspace && afterSwitch.workspaceTree && afterSwitch.preview && afterSwitch.selectedFile === 'README.md', + 'same-project session switching preserves actual WorkspacePanel, tree, preview DOM and selected file'); + assert(afterSwitch.subscriptions === 6, `the six AppRuntimeEffects subscriptions remain singular (${afterSwitch.subscriptions})`); + assert(afterSwitch.operations === 0, "instrumented operation owners report zero active operations (not yet all App operations)"); + + await page.locator('.project-tree__folder-main:has(svg.lucide-cloud)').click(); + await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click(); + await page.locator('.remote-surface--ready').waitFor(); + await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false); + assert((await page.locator('.topicbar').textContent()).includes('Remote demo session'), "remote project selection adopts its source workspace and authoritative hydrated surface"); + await page.locator('.sidebar__quick-action').click(); + await page.waitForFunction(() => document.querySelector('.topicbar')?.textContent?.includes('New session')); + await page.locator('.remote-surface--ready').waitFor(); + await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false); + assert(await page.locator('.remote-surface').count() === 1, "global New Session stays on the remote workspace instead of opening a local blank"); + assert(await page.evaluate(() => window.__appBrowserIdentity.composer === document.querySelector('textarea.composer__input:not([aria-hidden=true])')), "local/remote navigation and remote New Session preserve the Composer DOM identity"); + await page.locator('.project-tree__topic-main:has-text("bench:geometry")').click(); + await page.waitForFunction(() => document.querySelector('.transcript')?.textContent?.includes('Geometry contract fixture complete.')); + assert(await page.locator('.remote-surface').count() === 0, "subsequent local navigation owns the surface; remote events do not reclaim it"); + const sentText = 'App source-bound submission fixture'; + await composer.fill(sentText); + await composer.press('Enter'); + await page.locator('[data-row-kind="user"]').filter({ hasText: sentText }).waitFor(); + await page.locator('.composer__btn--stop').click(); + await page.locator('.composer__btn--stop').waitFor({ state: 'hidden' }); + await page.waitForFunction(() => document.querySelector('textarea.composer__input:not([aria-hidden=true])')?.disabled === false); + assert(await page.evaluate(() => window.__appBrowserIdentity.composer === document.querySelector('textarea.composer__input:not([aria-hidden=true])')), + 'ordinary source-bound send and native Stop preserve Composer identity and restore writable readiness'); + assert(pageErrors.length === 0, `three-layout replay emits no page errors (${pageErrors.length})`); + + const classicPage = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); + const classicErrors = []; + classicPage.on("pageerror", (error) => classicErrors.push(error.message)); + await classicPage.goto(`http://127.0.0.1:${port}/?mock=bench&bench=1&layout=classic`, { waitUntil: "domcontentloaded" }); + await classicPage.locator(".app").waitFor(); + process.stdout.write(` INFO classic fixture class: ${await classicPage.locator(".app").getAttribute("class")}\n`); + await classicPage.locator(".app.app--classic textarea.composer__input:not([aria-hidden=true])").waitFor(); + await classicPage.locator(".app.app--classic .project-tree").waitFor(); + assert(classicErrors.length === 0, "classic compatibility snapshot renders the shared Composer and project tree without errors"); + await classicPage.close(); + process.stdout.write("app browser lifecycle gate passed\n"); +} finally { + await browser.close(); + await preview.close(); +} diff --git a/desktop/frontend/bench/app-memory-aggregate.mjs b/desktop/frontend/bench/app-memory-aggregate.mjs new file mode 100644 index 0000000000..b6affbec72 --- /dev/null +++ b/desktop/frontend/bench/app-memory-aggregate.mjs @@ -0,0 +1,10 @@ +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { aggregateShards } from "./app-memory-shards.mjs"; +const [directory, manifestFile, output, sourceSHA] = process.argv.slice(2); +if (!directory || !manifestFile || !output || !sourceSHA) throw new Error("aggregate requires reports directory, manifest, output and source SHA"); +const files = readdirSync(directory, { recursive: true }).filter(file => path.basename(file) === "report.json"); +const reports = files.map(file => JSON.parse(readFileSync(path.join(directory, file), "utf8"))); +const result = aggregateShards(reports, JSON.parse(readFileSync(manifestFile, "utf8")), sourceSHA); +writeFileSync(output, JSON.stringify(result, null, 2)); +console.log("App memory: three independent 896-round-trip shards passed; heap attribution remains pending."); diff --git a/desktop/frontend/bench/app-memory-evidence.mjs b/desktop/frontend/bench/app-memory-evidence.mjs new file mode 100644 index 0000000000..458de636ff --- /dev/null +++ b/desktop/frontend/bench/app-memory-evidence.mjs @@ -0,0 +1,138 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +export function buildIdentity(frontendDir) { + const hash = createHash("sha256"); + function visit(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const file = path.join(directory, entry.name); + if (entry.isDirectory()) visit(file); + else hash.update(path.relative(frontendDir, file)).update(readFileSync(file)); + } + } + visit(path.join(frontendDir, "dist")); + const git = (...args) => execFileSync("git", args, { cwd: frontendDir, encoding: "utf8" }).trim(); + const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z", "--", "."], { cwd: frontendDir, encoding: "utf8" }).split("\0").filter(Boolean).sort(); + const untrackedHash = createHash("sha256"); + for (const file of untracked) untrackedHash.update(file).update("\0").update(readFileSync(path.join(frontendDir, file))).update("\0"); + return { + sourceSHA: git("rev-parse", "HEAD"), + trackedDiffSHA256: createHash("sha256").update(git("diff", "HEAD", "--", ".")).digest("hex"), + untrackedSourceSHA256: untrackedHash.digest("hex"), + sourceStatus: git("status", "--porcelain", "--", "."), + buildSHA256: hash.digest("hex"), + node: process.version, platform: process.platform, arch: process.arch, + }; +} + +// IDs, not count deltas: an increasing population can hide behind simultaneous GC. +export function retainedCohorts(samples) { + const firstSeen = new Map(); + return samples.map((sample, index) => { + const ids = sample.lifecycle.liveRenderTokenIds; + for (const id of ids) if (!firstSeen.has(id)) firstSeen.set(id, index); + return { + phase: sample.phase, roundTrips: sample.roundTrips, + survivorsFromBaseline: ids.filter(id => firstSeen.get(id) === 0), + // Two later observations after completed round trips; not a whole-App count budget. + retainedPostBaseline: ids.filter(id => firstSeen.get(id) > 0 && firstSeen.get(id) < index - 1), + }; + }); +} + +export function evidenceIntegrity(samples) { + return samples.length > 0 && samples.every(({ lifecycle }) => ( + Array.isArray(lifecycle.liveRenderTokenIds) + && new Set(lifecycle.liveRenderTokenIds).size === lifecycle.liveRenderTokenIds.length + && lifecycle.liveRenderTokenIds.length === lifecycle.liveRenderTokens + && lifecycle.overflow === false && lifecycle.invariantViolations === 0 + && lifecycle.activeOperations >= 0 && lifecycle.activeSubscriptions >= 0 + )); +} + +// Counter stability is a screening result, not heap-retainer attribution. +// Weak refs observe only instrumented tokens. They cannot explain survivors +// outside that cohort, compiled-code growth, or the mainline control delta, +// so heap-retainer and control evidence stays an offline attribution duty +// that the automated gate can never discharge by itself. +export const OFFLINE_ATTRIBUTION_REASON = "heap-retainer-and-control-evidence-required"; + +// A counter excursion that fully returns to the warmed baseline is a recorded +// observation, not a leak signal: the objects were provably freed. Real soak +// data shows such blips at phase transitions (e.g. 614 listeners settling +// back to 512). Only a displaced final tail is persistent drift. +export const TRANSIENT_EXCURSION_REASON = "transient-counter-excursion"; + +// Reasons the automated gate must block on. Observations (the offline +// attribution duty, fully-recovered excursions) are recorded on every report +// but are not screening failures. +export function screeningBlockers(reasons) { + return reasons.filter((reason) => reason !== OFFLINE_ATTRIBUTION_REASON && reason !== TRANSIENT_EXCURSION_REASON); +} + +// The gate blocks on persistent drift: the final checkpoint displaced from +// the warmed baseline, or the tail still moving. Intermediate excursions are +// kept as observations so they still get an offline explanation. When the +// bench ends with an explicit "settled" resting-state sample, that sample is +// the authoritative tail and every earlier checkpoint is intermediate. +function counterDriftReason(values, phases) { + const baseline = values[0]; + const final = values.at(-1); + const settledTail = phases.at(-1) === "settled"; + if (final !== baseline) return "persistent"; + if (!settledTail) { + const tail = values.slice(1).slice(-3); + if (tail.some((value) => value !== final)) return "persistent"; + } + return values.some((value) => value !== baseline) ? "transient" : null; +} + +export function attributeRetention(samples, cohorts = retainedCohorts(samples)) { + if (!evidenceIntegrity(samples) || samples.length < 2) return { status: "needs-attribution", reasons: ["invalid-evidence"] }; + const retained = cohorts.some((cohort) => cohort.retainedPostBaseline.length > 0); + const nativeCountersValid = samples.every(({ dom }) => + [dom?.nodes, dom?.jsEventListeners].every(value => Number.isSafeInteger(value) && value >= 0)); + const released = samples.every((sample) => sample.lifecycle.activeOperations === 0); + const phases = samples.map((sample) => sample.phase); + const reasons = []; + if (retained) reasons.push("persistent-render-cohort"); + if (!nativeCountersValid) reasons.push("invalid-native-counters"); + if (nativeCountersValid) { + const nodeDrift = counterDriftReason(samples.map((sample) => sample.dom.nodes), phases); + const listenerDrift = counterDriftReason(samples.map((sample) => sample.dom.jsEventListeners), phases); + if (nodeDrift === "persistent" || listenerDrift === "persistent") reasons.push("post-gc-dom-or-listener-drift"); + else if (nodeDrift === "transient" || listenerDrift === "transient") reasons.push(TRANSIENT_EXCURSION_REASON); + } + const subscriptionDrift = counterDriftReason(samples.map((sample) => sample.lifecycle.activeSubscriptions), phases); + if (subscriptionDrift === "persistent") reasons.push("subscription-population-drift"); + else if (subscriptionDrift === "transient") reasons.push(TRANSIENT_EXCURSION_REASON); + if (!released) reasons.push("active-operations"); + reasons.push(OFFLINE_ATTRIBUTION_REASON); + return { status: "needs-attribution", reasons: [...new Set(reasons)] }; +} + +// CDP's DOM counter includes attached and detached nodes. Only the heap's +// detachedness field can label an object as detached; unknown stays unknown. +export function summarizeHeap(snapshot) { + const { node_fields: fields, node_types: types } = snapshot.snapshot.meta; + const width = fields.length; + const at = Object.fromEntries(fields.map((field, index) => [field, index])); + const categories = {}; + const detached = {}; + for (let offset = 0; offset < snapshot.nodes.length; offset += width) { + const type = types[at.type][snapshot.nodes[offset + at.type]]; + const category = categories[type] ??= { count: 0, selfBytes: 0 }; + category.count++; + category.selfBytes += snapshot.nodes[offset + at.self_size]; + if (at.detachedness !== undefined && snapshot.nodes[offset + at.detachedness] === 2) { + const name = snapshot.strings[snapshot.nodes[offset + at.name]]; + const entry = detached[name] ??= { count: 0, selfBytes: 0, ids: [] }; + entry.count++; + entry.selfBytes += snapshot.nodes[offset + at.self_size]; + entry.ids.push(snapshot.nodes[offset + at.id]); + } + } + return { categories, detached, detachednessAvailable: at.detachedness !== undefined }; +} diff --git a/desktop/frontend/bench/app-memory-evidence.test.mjs b/desktop/frontend/bench/app-memory-evidence.test.mjs new file mode 100644 index 0000000000..e1f6d2f9ac --- /dev/null +++ b/desktop/frontend/bench/app-memory-evidence.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { attributeRetention, evidenceIntegrity, retainedCohorts, screeningBlockers, summarizeHeap, TRANSIENT_EXCURSION_REASON } from "./app-memory-evidence.mjs"; + +const sample = (ids, roundTrips) => ({ phase: "full", roundTrips, lifecycle: { + liveRenderTokenIds: ids, liveRenderTokens: ids.length, + activeOperations: 0, activeSubscriptions: 6, invariantViolations: 0, overflow: false, +} }); +test("deliberately retained cohorts remain detectable even when totals are constant", () => { + const samples = [sample([1, 2], 0), sample([3, 4], 32), sample([3, 5], 64), sample([3, 6], 96)]; + assert.equal(evidenceIntegrity(samples), true); + assert.deepEqual(retainedCohorts(samples).at(-1).retainedPostBaseline, [3]); +}); +test("probe overflow, duplicate IDs and cleanup underflow invalidate evidence", () => { + for (const mutation of [{ overflow: true }, { invariantViolations: 1 }, { activeSubscriptions: -1 }, { liveRenderTokenIds: [1, 1] }]) { + const value = sample([1, 2], 0); + Object.assign(value.lifecycle, mutation); + assert.equal(evidenceIntegrity([value]), false); + } +}); +test("stable owner counters alone cannot establish whole-App retention attribution", () => { + const samples = [ + { ...sample([1, 2], 0), dom: { nodes: 6000, jsEventListeners: 500 } }, + { ...sample([1, 3], 32), dom: { nodes: 6024, jsEventListeners: 512 } }, + { ...sample([1, 4], 64), dom: { nodes: 6024, jsEventListeners: 512 } }, + { ...sample([1, 5], 96), dom: { nodes: 6024, jsEventListeners: 512 } }, + ]; + assert.equal(attributeRetention(samples).status, "needs-attribution"); + assert.ok(attributeRetention(samples).reasons.includes("heap-retainer-and-control-evidence-required")); +}); +test("a stable tail cannot hide growth earlier in the post-GC sequence", () => { + const samples = [6000, 6024, 6100, 6100, 6100].map((nodes, index) => ({ + ...sample([1, index + 2], index * 32), dom: { nodes, jsEventListeners: 500 }, + })); + assert.ok(attributeRetention(samples).reasons.includes("post-gc-dom-or-listener-drift")); +}); +test("missing or non-finite native counters are invalid evidence", () => { + for (const dom of [undefined, {}, { nodes: NaN, jsEventListeners: 500 }, { nodes: 6000, jsEventListeners: -1 }]) { + const samples = Array.from({ length: 4 }, (_, index) => ({ ...sample([1, index + 2], index * 32), dom })); + assert.ok(attributeRetention(samples).reasons.includes("invalid-native-counters")); + } +}); +test("subscriptions retained after a round trip require attribution", () => { + const samples = Array.from({ length: 4 }, (_, index) => ({ + ...sample([1, index + 2], index * 32), dom: { nodes: 6000, jsEventListeners: 500 }, + })); + samples[1].lifecycle.activeSubscriptions++; + assert.ok(attributeRetention(samples).reasons.includes("subscription-population-drift")); +}); +test("persistent post-baseline cohorts remain a qualification blocker", () => { + const samples = [ + { ...sample([1, 2], 0), dom: { nodes: 6000, jsEventListeners: 500 } }, + { ...sample([1, 3], 32), dom: { nodes: 6024, jsEventListeners: 512 } }, + { ...sample([1, 3], 64), dom: { nodes: 6024, jsEventListeners: 512 } }, + { ...sample([1, 3], 96), dom: { nodes: 6024, jsEventListeners: 512 } }, + ]; + assert.equal(attributeRetention(samples).status, "needs-attribution"); +}); +test("the automated gate blocks on screening failures, not the offline attribution duty", () => { + const clean = Array.from({ length: 4 }, (_, index) => ({ + ...sample([1, index + 2], index * 32), dom: { nodes: 6024, jsEventListeners: 512 }, + })); + assert.deepEqual(screeningBlockers(attributeRetention(clean).reasons), []); + const drift = [6000, 6024, 6100, 6100].map((nodes, index) => ({ + ...sample([1, index + 2], index * 32), dom: { nodes, jsEventListeners: 500 }, + })); + assert.deepEqual(screeningBlockers(attributeRetention(drift).reasons), ["post-gc-dom-or-listener-drift"]); + assert.deepEqual(screeningBlockers(["missing-attribution"]), ["missing-attribution"]); +}); +test("a mid-sequence excursion that fully returns to baseline is an observation, not a blocker", () => { + // The real Linux/Chromium soak shows 614 listeners at phase transitions + // settling back to the 512 baseline; freed counters are not retention. + const samples = Array.from({ length: 21 }, (_, index) => ({ + ...sample([1, index + 2], index * 32), + dom: { nodes: 6049, jsEventListeners: index === 6 || index === 13 ? 614 : 512 }, + })); + const result = attributeRetention(samples); + assert.ok(result.reasons.includes(TRANSIENT_EXCURSION_REASON)); + assert.deepEqual(screeningBlockers(result.reasons), []); +}); +test("a displaced final tail remains a persistent blocker", () => { + const samples = Array.from({ length: 21 }, (_, index) => ({ + ...sample([1, index + 2], index * 32), + dom: { nodes: 6049, jsEventListeners: index === 20 ? 614 : 512 }, + })); + const result = attributeRetention(samples); + assert.ok(result.reasons.includes("post-gc-dom-or-listener-drift")); + assert.deepEqual(screeningBlockers(result.reasons), ["post-gc-dom-or-listener-drift"]); +}); +test("an explicit settled tail sample is the authoritative resting state", () => { + // Round 5 CI data: the blip can land on the final round checkpoint; the + // quiescent sample after it proves recovery, so this must not block. + const blipBeforeSettled = Array.from({ length: 21 }, (_, index) => ({ + ...sample([1, index + 2], index * 32), + dom: { nodes: 6049, jsEventListeners: index === 20 ? 614 : 512 }, + })); + blipBeforeSettled.push({ ...sample([1, 23], 512), phase: "settled", dom: { nodes: 6049, jsEventListeners: 512 } }); + const recovered = attributeRetention(blipBeforeSettled); + assert.ok(recovered.reasons.includes(TRANSIENT_EXCURSION_REASON)); + assert.deepEqual(screeningBlockers(recovered.reasons), []); + // A settled tail that stays displaced is still a persistent blocker. + const stuck = blipBeforeSettled.map((sample_) => ({ ...sample_ })); + stuck[21] = { ...stuck[21], dom: { nodes: 6049, jsEventListeners: 614 } }; + assert.deepEqual(screeningBlockers(attributeRetention(stuck).reasons), ["post-gc-dom-or-listener-drift"]); +}); +test("native objects are not automatically detached DOM", () => { + const heap = { snapshot: { meta: { + node_fields: ["type", "name", "id", "self_size", "detachedness"], + node_types: [["native", "code"], [], [], [], []], + } }, strings: ["HTMLDivElement", "compiled function"], + nodes: [0, 0, 1, 64, 1, 0, 0, 2, 64, 2, 1, 1, 3, 128, 0] }; + const result = summarizeHeap(heap); + assert.equal(result.categories.native.count, 2); + assert.equal(result.categories.code.selfBytes, 128); + assert.deepEqual(result.detached.HTMLDivElement.ids, [2]); + heap.snapshot.meta.node_fields[4] = "unknown"; + assert.equal(summarizeHeap(heap).detachednessAvailable, false); + assert.deepEqual(summarizeHeap(heap).detached, {}); +}); diff --git a/desktop/frontend/bench/app-memory-paths.mjs b/desktop/frontend/bench/app-memory-paths.mjs new file mode 100644 index 0000000000..3958e0fb60 --- /dev/null +++ b/desktop/frontend/bench/app-memory-paths.mjs @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +// The soak runs the production frontend against browser mocks. These source +// trees cannot enter that build; native/backend behavior has separate gates. +const knownIndependent = /^(?:internal\/|cmd\/|sdk\/|site\/|release-notes\/|docs\/[^\n]*\.md$|desktop\/(?:[^/]+\.go$|cmd\/|internal\/))/; +export function memoryAffected(files) { + return files.some(file => { + if (file.startsWith("desktop/frontend/") || file === ".github/workflows/app-memory.yml") return true; + if (knownIndependent.test(file) || /^[^/]+\.md$/.test(file)) return false; + return true; // Unknown dependency or workflow changes fail closed. + }); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const files = readFileSync(process.argv[2], "utf8").split("\0").filter(Boolean); + console.log(`run=${memoryAffected(files)}`); +} diff --git a/desktop/frontend/bench/app-memory-paths.test.mjs b/desktop/frontend/bench/app-memory-paths.test.mjs new file mode 100644 index 0000000000..9608f2360a --- /dev/null +++ b/desktop/frontend/bench/app-memory-paths.test.mjs @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { memoryAffected } from "./app-memory-paths.mjs"; +test("all frontend consumers, configuration and tests trigger the soak", () => { + for (const path of ["desktop/frontend/src/App.tsx", "desktop/frontend/src/lib/types.ts", "desktop/frontend/pnpm-lock.yaml", "desktop/frontend/bench/fixture.json", ".github/workflows/app-memory.yml"]) + assert.equal(memoryAffected([path]), true, path); +}); +test("known independent backend and documentation changes skip only this mock frontend soak", () => { + assert.equal(memoryAffected(["internal/control/turn.go", "desktop/app.go", "sdk/types.ts", "docs/guide.md", "README.md"]), false); +}); +test("unknown paths fail closed and cannot be hidden by a documentation change", () => { + for (const path of [".npmrc", "shared/new-loader.js", ".github/workflows/ci.yml", "docs/build.js", "desktop/build/config.json"]) + assert.equal(memoryAffected(["README.md", path]), true, path); +}); diff --git a/desktop/frontend/bench/app-memory-prepare.mjs b/desktop/frontend/bench/app-memory-prepare.mjs new file mode 100644 index 0000000000..e214064646 --- /dev/null +++ b/desktop/frontend/bench/app-memory-prepare.mjs @@ -0,0 +1,19 @@ +import { cpSync, mkdirSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildIdentity } from "./app-memory-evidence.mjs"; +import { MEMORY_PROTOCOL, verifyIdentity } from "./app-memory-shards.mjs"; +const frontend = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const target = process.argv[2]; +const executionId = `${process.env.GITHUB_RUN_ID}:${process.env.GITHUB_RUN_ATTEMPT}`; +if (!target || !process.env.GITHUB_RUN_ID || !process.env.GITHUB_RUN_ATTEMPT) throw new Error("prepare requires an output directory and workflow identity"); +// Vite removes this tracked placeholder; restore it before hashing the clean build. +const prefix = execFileSync("git", ["rev-parse", "--show-prefix"], { cwd: frontend, encoding: "utf8" }).trim(); +writeFileSync(path.join(frontend, "dist/.gitkeep"), execFileSync("git", ["show", `HEAD:${prefix}dist/.gitkeep`], { cwd: frontend })); +const identity = buildIdentity(frontend); +verifyIdentity(identity, identity); +if (identity.sourceSHA !== process.env.EXPECTED_SOURCE_SHA) throw new Error("prepared build is not the requested commit"); +mkdirSync(target, { recursive: true }); +cpSync(path.join(frontend, "dist"), path.join(target, "dist"), { recursive: true }); +writeFileSync(path.join(target, "identity.json"), JSON.stringify({ identity, executionId, protocol: MEMORY_PROTOCOL }, null, 2)); diff --git a/desktop/frontend/bench/app-memory-shards.mjs b/desktop/frontend/bench/app-memory-shards.mjs new file mode 100644 index 0000000000..70c3428366 --- /dev/null +++ b/desktop/frontend/bench/app-memory-shards.mjs @@ -0,0 +1,67 @@ +import { attributeRetention, evidenceIntegrity, retainedCohorts, screeningBlockers } from "./app-memory-evidence.mjs"; + +export const MEMORY_PROTOCOL = Object.freeze({ version: 1, shards: 3, cycles: 128, mixedCycles: 512, viewport: { width: 1440, height: 1000 } }); +export const MEMORY_FIXTURES = Object.freeze({ + full: { label: "bench:small-6t", marker: "ASYNC LAYOUT EXPANSION COMPLETE" }, + geometry: { label: "bench:geometry", marker: "Geometry contract fixture complete." }, + windowed: { label: "bench:windowed-1000t", marker: "Windowed turn 1000" }, +}); +const identityFields = ["sourceSHA", "trackedDiffSHA256", "untrackedSourceSHA256", "buildSHA256", "node", "platform", "arch"]; + +export function verifyIdentity(actual, expected) { + if (!actual || !expected || actual.sourceStatus !== "" || expected.sourceStatus !== "") throw new Error("memory evidence requires a clean source checkout"); + for (const field of identityFields) { + if (typeof actual[field] !== "string" || !actual[field] || actual[field] !== expected[field]) throw new Error(`memory identity mismatch: ${field}`); + } +} + +export function protocolSamples(samples) { + const expected = [["baseline", 0]]; + for (const phase of ["full", "windowed", "safety", "mixed"]) { + const count = phase === "mixed" ? MEMORY_PROTOCOL.mixedCycles : MEMORY_PROTOCOL.cycles; + for (let round = 32; round <= count; round += 32) expected.push([phase, round]); + } + expected.push(["settled", MEMORY_PROTOCOL.mixedCycles]); + return Array.isArray(samples) && samples.length === expected.length + && samples.every((sample, index) => sample.phase === expected[index][0] && sample.roundTrips === expected[index][1]); +} + +export function completeShard(report) { + const run = report.processes?.[0]; + return report.cycles === MEMORY_PROTOCOL.cycles && report.mixedCycles === MEMORY_PROTOCOL.mixedCycles + && report.processes?.length === 1 && run.process === report.shard?.id + && protocolSamples(run.samples) + && Array.isArray(run.snapshots) && run.snapshots.length === 5 + && ["baseline", "full", "windowed", "safety", "mixed"].every((phase, index) => + run.snapshots[index].file === `${run.process}-${phase}.heapsnapshot` && run.snapshots[index].summary); +} + +export function aggregateShards(reports, manifest, sourceSHA) { + if (JSON.stringify(manifest.protocol) !== JSON.stringify(MEMORY_PROTOCOL) + || manifest.identity?.sourceSHA !== sourceSHA || !manifest.executionId) throw new Error("invalid memory build manifest"); + if (!Array.isArray(reports) || reports.length !== 3) throw new Error("three complete independent memory shards are required"); + const seen = new Set(); + const processes = []; + let browser; + for (const report of reports) { + const id = report.shard?.id; + if (![1, 2, 3].includes(id) || seen.has(id)) throw new Error("duplicate or invalid memory shard"); + seen.add(id); + if (report.shard.executionId !== manifest.executionId || report.shard.total !== 3) throw new Error("memory shard belongs to another workflow attempt"); + verifyIdentity(report.identity, manifest.identity); + if (JSON.stringify(report.fixtures) !== JSON.stringify(MEMORY_FIXTURES)) throw new Error("memory fixture configuration differs"); + if (report.failure || report.verdict !== "SHARD_PASS" || !report.shardComplete || !completeShard(report)) throw new Error(`incomplete memory shard ${id}`); + const run = report.processes[0]; + if (!run.browser || (browser && browser !== run.browser)) throw new Error("memory browser versions differ"); + browser = run.browser; + const cohorts = retainedCohorts(run.samples); + const attribution = attributeRetention(run.samples, cohorts); + if (!evidenceIntegrity(run.samples) || !run.samples.every(sample => sample.lifecycle.activeOperations === 0) + || !["evidenceIntegrity", "instrumentedOperationsReleased", "noPageErrors"].every(key => run.checks?.[key] === true) + || !Array.isArray(run.metrics?.pageErrors) || run.metrics.pageErrors.length !== 0 + || screeningBlockers(attribution.reasons).length !== 0) throw new Error(`memory screening failed in shard ${id}`); + processes.push({ ...run, cohorts, attribution }); + } + return { identity: manifest.identity, executionId: manifest.executionId, protocol: MEMORY_PROTOCOL, + protocolComplete: true, verdict: "PASS", attribution: "pending", processes: processes.sort((a, b) => a.process - b.process) }; +} diff --git a/desktop/frontend/bench/app-memory-shards.test.mjs b/desktop/frontend/bench/app-memory-shards.test.mjs new file mode 100644 index 0000000000..f8a5709663 --- /dev/null +++ b/desktop/frontend/bench/app-memory-shards.test.mjs @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { aggregateShards, completeShard, MEMORY_PROTOCOL, MEMORY_FIXTURES } from "./app-memory-shards.mjs"; +const identity = { sourceSHA: "a".repeat(40), trackedDiffSHA256: "clean-diff", untrackedSourceSHA256: "clean-untracked", buildSHA256: "shared-build", node: "v24", platform: "linux", arch: "x64", sourceStatus: "" }; +const manifest = { identity, protocol: MEMORY_PROTOCOL, executionId: "123:1" }; +function report(id) { + const sample = (phase, roundTrips) => ({ phase, roundTrips, + lifecycle: { liveRenderTokenIds: [1], liveRenderTokens: 1, activeOperations: 0, activeSubscriptions: 2, overflow: false, invariantViolations: 0 }, + dom: { nodes: 10, jsEventListeners: 2 }, heap: { usedSize: 100 } }); + const samples = [sample("baseline", 0)]; + for (const phase of ["full", "windowed", "safety", "mixed"]) { + for (let count = 32; count <= (phase === "mixed" ? 512 : 128); count += 32) samples.push(sample(phase, count)); + } + samples.push(sample("settled", 512)); + return { identity: structuredClone(identity), fixtures: structuredClone(MEMORY_FIXTURES), shard: { id, total: 3, executionId: "123:1" }, cycles: 128, mixedCycles: 512, + shardComplete: true, protocolComplete: false, verdict: "SHARD_PASS", processes: [{ process: id, browser: "chromium-fixed", samples, + snapshots: ["baseline", "full", "windowed", "safety", "mixed"].map(phase => ({ file: `${id}-${phase}.heapsnapshot`, summary: {} })), + checks: { evidenceIntegrity: true, instrumentedOperationsReleased: true, noPageErrors: true }, metrics: { pageErrors: [] } }] }; +} +const aggregate = reports => aggregateShards(reports, manifest, identity.sourceSHA); +test("three independent full shards preserve the complete protocol and offline attribution", () => { + const result = aggregate([report(3), report(1), report(2)]); + assert.equal(result.verdict, "PASS"); assert.equal(result.protocolComplete, true); + assert.deepEqual(result.processes.map(run => run.process), [1, 2, 3]); + assert.equal(result.attribution, "pending"); + assert.ok(result.processes.every(run => run.attribution.reasons.includes("heap-retainer-and-control-evidence-required"))); +}); +test("one complete process never satisfies the aggregate protocol", () => { + assert.equal(completeShard(report(1)), true); assert.throws(() => aggregate([report(1)]), /three complete/); +}); +for (const [name, mutate] of [ + ["duplicate shard", reports => { reports[2] = report(1); }], + ["different commit", reports => { reports[1].identity.sourceSHA = "b".repeat(40); }], + ["different build", reports => { reports[1].identity.buildSHA256 = "other-build"; }], + ["dirty source", reports => { reports[1].identity.sourceStatus = " M source.ts"; }], + ["another workflow attempt", reports => { reports[1].shard.executionId = "123:2"; }], + ["different fixture", reports => { reports[1].fixtures.windowed.label = "short-fixture"; }], + ["short cycles", reports => { reports[1].cycles = 127; }], + ["missing checkpoint", reports => { reports[1].processes[0].samples.splice(5, 1); }], + ["missing heap snapshot", reports => { reports[1].processes[0].snapshots.pop(); }], + ["different browser", reports => { reports[1].processes[0].browser = "another-browser"; }], + ["missing checks", reports => { reports[1].processes[0].checks = {}; }], + ["page error", reports => { reports[1].processes[0].metrics.pageErrors.push("boom"); }], + ["persistent DOM drift despite claimed pass", reports => { reports[1].processes[0].samples.at(-1).dom.nodes++; }], + ["unfinished operations despite claimed pass", reports => { reports[1].processes[0].samples.at(-1).lifecycle.activeOperations++; }], + ["missing token identity", reports => { delete reports[1].processes[0].samples[0].lifecycle.liveRenderTokenIds; }], +]) test(`aggregate rejects ${name}`, () => { + const reports = [report(1), report(2), report(3)]; mutate(reports); assert.throws(() => aggregate(reports)); +}); +test("requested head and manifest protocol must match", () => { + assert.throws(() => aggregateShards([report(1), report(2), report(3)], manifest, "wrong-head")); + assert.throws(() => aggregateShards([report(1), report(2), report(3)], { ...manifest, protocol: { ...MEMORY_PROTOCOL, viewport: { width: 1, height: 1 } } }, identity.sourceSHA)); +}); diff --git a/desktop/frontend/bench/app-memory.mjs b/desktop/frontend/bench/app-memory.mjs new file mode 100644 index 0000000000..a22b1357cf --- /dev/null +++ b/desktop/frontend/bench/app-memory.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync, createWriteStream } from "node:fs"; +import { once } from "node:events"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { startPreviewServer } from "./vite-preview-server.mjs"; +import { chooseAppLayout } from "./app-page-actions.mjs"; +import { attributeRetention, buildIdentity, evidenceIntegrity, retainedCohorts, screeningBlockers, summarizeHeap } from "./app-memory-evidence.mjs"; +import { completeShard, verifyIdentity, MEMORY_FIXTURES, MEMORY_PROTOCOL } from "./app-memory-shards.mjs"; + +const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers" + ? path.join(frontendDir, ".pw-browsers") + : process.env.PLAYWRIGHT_BROWSERS_PATH; +// Playwright reads PLAYWRIGHT_BROWSERS_PATH at module evaluation; import it +// only after the path normalization above. +const { chromium } = await import("playwright"); + +function integerEnv(name, fallback) { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +const CYCLES = integerEnv("REASONIX_APP_MEMORY_CYCLES", 128); +const MIXED_CYCLES = integerEnv("REASONIX_APP_MEMORY_MIXED_CYCLES", 512); +const SHARD = process.env.REASONIX_APP_MEMORY_SHARD === undefined ? null : Number(process.env.REASONIX_APP_MEMORY_SHARD); +if (SHARD !== null && ![1, 2, 3].includes(SHARD)) throw new Error("memory shard must be 1, 2 or 3"); +const PROCESSES = SHARD === null ? integerEnv("REASONIX_APP_MEMORY_PROCESSES", 3) : 1; +const preparedFile = process.env.REASONIX_APP_MEMORY_PREPARED; +const prepared = preparedFile ? JSON.parse(readFileSync(preparedFile, "utf8")) : null; +if (SHARD !== null && (!prepared || CYCLES !== 128 || MIXED_CYCLES !== 512)) throw new Error("memory shard requires the shared build and complete 128/512 protocol"); +const PORT = integerEnv("REASONIX_APP_MEMORY_PORT", 4647); +const artifacts = path.resolve(process.env.REASONIX_APP_MEMORY_ARTIFACTS ?? path.join(frontendDir, "bench/app-memory-artifacts")); +mkdirSync(artifacts, { recursive: true }); + +const fixtures = MEMORY_FIXTURES; + +async function ensureBuild() { + if (prepared) { + verifyIdentity(buildIdentity(frontendDir), prepared.identity); + if (!prepared.executionId || prepared.identity.sourceSHA !== process.env.EXPECTED_SOURCE_SHA) throw new Error("prepared memory build belongs to another commit"); + return; + } + // Each run owns a fresh production build; an unverified dist is not evidence. + await new Promise((resolve, reject) => { + const child = spawn("pnpm", ["build"], { cwd: frontendDir, stdio: "inherit" }); + child.once("exit", (code) => code === 0 ? resolve() : reject(new Error(`pnpm build exited ${code}`))); + }); +} + +async function settleFrames(page, count = 6) { + await page.evaluate((frames) => new Promise((resolve) => { + const tick = () => --frames <= 0 ? resolve() : requestAnimationFrame(tick); + requestAnimationFrame(tick); + }), count); +} + +async function selectFixture(page, fixture) { + const active = await page.locator(".project-tree__topic--active .project-tree__topic-label").textContent().catch(() => ""); + if (active?.includes(fixture.label)) throw new Error(`invalid repeated navigation: ${fixture.label}`); + await page.locator(`.project-tree__topic-main:has-text("${fixture.label}")`).click(); + await page.waitForFunction(({ label, marker }) => { + const activeLabel = document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent ?? ""; + const transcript = document.querySelector(".transcript"); + return activeLabel.includes(label) + && transcript?.dataset.transcriptHydrating === "false" + && transcript.textContent?.includes(marker) + && !document.querySelector(".transcript-navigation-overlay"); + }, fixture, { timeout: 45_000, polling: "raf" }); + await settleFrames(page); +} + +async function forceGc(cdp, page) { + await cdp.send("HeapProfiler.collectGarbage"); + await settleFrames(page, 2); + await cdp.send("HeapProfiler.collectGarbage"); + await settleFrames(page, 2); + const [heap, dom, lifecycle, performance] = await Promise.all([ + cdp.send("Runtime.getHeapUsage"), + cdp.send("Memory.getDOMCounters"), + page.evaluate(() => window.__reasonixAppLifecycle?.snapshot()), + page.evaluate(() => ({ entries: window.performance.getEntries().length, attachedElements: document.querySelectorAll("*").length })), + ]); + if (!lifecycle) throw new Error("App lifecycle probe was not published by the production build"); + return { heap, dom, lifecycle, performance }; +} + +async function enterSafety(page) { + await selectFixture(page, fixtures.windowed); + await page.evaluate(() => { + const transcript = document.querySelector(".transcript"); + if (!(transcript instanceof HTMLElement)) throw new Error("transcript viewport missing"); + Object.defineProperty(transcript, "scrollHeight", { configurable: true, get: () => Number.NaN }); + transcript.dispatchEvent(new Event("scroll")); + }); + await page.waitForFunction(() => ( + document.querySelector(".transcript__projection")?.getAttribute("data-transcript-safe-fallback") === "true" + ), undefined, { timeout: 15_000, polling: "raf" }); + await page.evaluate(() => { + const transcript = document.querySelector(".transcript"); + if (transcript instanceof HTMLElement) delete transcript.scrollHeight; + }); + await settleFrames(page); +} + +async function heapSnapshot(cdp, name) { + const file = path.join(artifacts, `${name}.heapsnapshot`); + const output = createWriteStream(file); + const listener = ({ chunk }) => output.write(chunk); + cdp.on("HeapProfiler.addHeapSnapshotChunk", listener); + try { await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false, captureNumericValue: true }); } + finally { cdp.off("HeapProfiler.addHeapSnapshotChunk", listener); output.end(); } + await once(output, "finish"); + const summary = summarizeHeap(JSON.parse(readFileSync(file, "utf8"))); + writeFileSync(path.join(artifacts, `${name}.summary.json`), JSON.stringify(summary, null, 2)); + return { file: path.basename(file), summary }; +} + +async function runProcess(index) { + const browser = await chromium.launch({ + headless: true, + args: ["--enable-precise-memory-info", "--disable-dev-shm-usage"], + }); + const context = await browser.newContext({ viewport: MEMORY_PROTOCOL.viewport }); + const page = await context.newPage(); + const pageErrors = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + const cdp = await context.newCDPSession(page); + try { + await page.goto(`http://127.0.0.1:${PORT}/?mock=bench&bench=1&app-lifecycle-probe=1`, { waitUntil: "domcontentloaded" }); + await page.locator("textarea.composer__input:not([aria-hidden=true])").waitFor(); + await selectFixture(page, fixtures.geometry); + await selectFixture(page, fixtures.full); + await enterSafety(page); + await selectFixture(page, fixtures.full); + for (const [label, className] of [["Creation", "app--creation"], ["Workbench", "app--workbench"]]) { + await chooseAppLayout(page, label, className); + await settleFrames(page); + } + const samples = [{ phase: "baseline", roundTrips: 0, ...await forceGc(cdp, page) }]; + const snapshots = [await heapSnapshot(cdp, `${index}-baseline`)]; + for (const phase of ["full", "windowed", "safety", "mixed"]) { + const count = phase === "mixed" ? MIXED_CYCLES : CYCLES; + for (let round = 1; round <= count; round++) { + const safety = phase === "safety" || phase === "mixed" && round % 3 === 0; + if (safety) await enterSafety(page); + else await selectFixture(page, phase === "full" || phase === "mixed" && round % 3 === 1 ? fixtures.geometry : fixtures.windowed); + await selectFixture(page, fixtures.full); + if (round % 32 === 0 || round === count) { + const sample = { phase, roundTrips: round, ...await forceGc(cdp, page) }; + samples.push(sample); + writeFileSync(path.join(artifacts, `${index}-samples.json`), JSON.stringify(samples, null, 2)); + process.stdout.write(`[app-memory] process=${index} phase=${phase} roundTrips=${round} nodes=${sample.dom.nodes} listeners=${sample.dom.jsEventListeners} tokens=${sample.lifecycle.liveRenderTokens}\n`); + } + } + snapshots.push(await heapSnapshot(cdp, `${index}-${phase}`)); + } + // The classifier blocks on a displaced final tail, so the tail must be + // measured at rest: mid-cleanup listener blips (614 vs the 512 baseline) + // resolve a few tasks after the last navigation. Settle, GC, and take the + // quiescent confirmation sample the verdict actually judges. + await settleFrames(page, 12); + const settled = { phase: "settled", roundTrips: MIXED_CYCLES, ...await forceGc(cdp, page) }; + samples.push(settled); + writeFileSync(path.join(artifacts, `${index}-samples.json`), JSON.stringify(samples, null, 2)); + process.stdout.write(`[app-memory] process=${index} phase=settled nodes=${settled.dom.nodes} listeners=${settled.dom.jsEventListeners} tokens=${settled.lifecycle.liveRenderTokens}\n`); + return { + process: index, + browser: browser.version(), + samples, + snapshots, + cohorts: retainedCohorts(samples), + attribution: "pending", + checks: { + evidenceIntegrity: evidenceIntegrity(samples), + instrumentedOperationsReleased: samples.every(sample => sample.lifecycle.activeOperations === 0), + noPageErrors: pageErrors.length === 0, + }, + metrics: { pageErrors }, + }; + } finally { + await context.close(); + await browser.close(); + } +} + +await ensureBuild(); +const preview = await startPreviewServer(frontendDir, PORT); +const report = { identity: buildIdentity(frontendDir), fixtures, startedAt: new Date().toISOString(), cycles: CYCLES, mixedCycles: MIXED_CYCLES, + ...(SHARD === null ? {} : { shard: { id: SHARD, total: 3, executionId: prepared.executionId } }), processes: [] }; +try { + for (let index = 1; index <= PROCESSES; index += 1) { + const result = await runProcess(SHARD ?? index); + result.attribution = attributeRetention(result.samples, result.cohorts); + report.processes.push(result); + process.stdout.write(`[app-memory] process ${index}: ${JSON.stringify({ checks: result.checks, attribution: result.attribution, metrics: result.metrics })}\n`); + } +} catch (error) { + report.failure = error.message; +} finally { + await preview.close(); +} +report.finishedAt = new Date().toISOString(); +report.protocolComplete = CYCLES >= 128 && MIXED_CYCLES >= 512 && report.processes.length >= 3; +report.shardComplete = SHARD !== null && completeShard(report); +// The automated gate passes on clean screening: protocol complete, every +// integrity/release/page-error check true, and no disqualifying attribution +// reason. Heap-retainer and control attribution stays an offline duty +// recorded in each run's attribution reasons. +report.verdict = !report.failure && (report.protocolComplete || report.shardComplete) + && report.processes.every((run) => Object.values(run.checks).every(Boolean) + && screeningBlockers(run.attribution?.reasons ?? ["missing-attribution"]).length === 0) + ? SHARD === null ? "PASS" : "SHARD_PASS" : report.failure ? "FAIL" : "NEEDS_ATTRIBUTION"; +writeFileSync(path.join(artifacts, "report.json"), JSON.stringify(report, null, 2)); +process.stdout.write(`[app-memory] verdict ${report.verdict}\n`); +process.exitCode = report.verdict === "PASS" || report.verdict === "SHARD_PASS" ? 0 : 1; diff --git a/desktop/frontend/bench/app-page-actions.mjs b/desktop/frontend/bench/app-page-actions.mjs new file mode 100644 index 0000000000..e6b40cf4c7 --- /dev/null +++ b/desktop/frontend/bench/app-page-actions.mjs @@ -0,0 +1,9 @@ +/** Shared production page navigation used by behavior and memory fixtures. */ +export async function chooseAppLayout(page, label, className) { + await page.locator('button:has(svg.lucide-settings)').last().click(); + await page.locator('.settings-screen').waitFor(); + await page.locator('.settings-screen .set-seg__btn').filter({ hasText: new RegExp(`^${label}$`) }).click(); + await page.locator(`.app.${className}`).waitFor(); + await page.locator('.settings-screen .management-screen__back').click(); + await page.locator('.settings-screen').waitFor({ state: 'detached' }); +} diff --git a/desktop/frontend/eslint.config.js b/desktop/frontend/eslint.config.js index d183402435..df1b1f4e7d 100644 --- a/desktop/frontend/eslint.config.js +++ b/desktop/frontend/eslint.config.js @@ -23,4 +23,9 @@ export default defineConfig([ "react-hooks/rules-of-hooks": "error", }, }, + { + files: ["src/app-runtime/**/*.{ts,tsx}", "src/app-shell/**/*.{ts,tsx}", + "src/app-features/**/*.{ts,tsx}", "src/app-domain/**/*.{ts,tsx}", "src/lib/useCommitted*.ts"], + rules: { "react-hooks/exhaustive-deps": "error" }, + }, ]); diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index b23b95ef08..cec6356102 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -41,7 +41,10 @@ "test:isolated-worktree": "tsx src/__tests__/isolated-worktree.test.ts", "test:updater": "tsx src/__tests__/updater-shared-state.test.tsx", "test:window-state": "tsx src/__tests__/window-state-ordering.test.ts", - "test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance" + "test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance", + "test:app-lifecycle": "tsx src/__tests__/app-lifecycle.test.tsx && tsx src/__tests__/committed-command-lifecycle.test.tsx && tsx src/__tests__/committed-command-execution.test.tsx && tsx src/__tests__/navigation-surface-lifecycle.test.tsx && tsx src/__tests__/app-lifecycle-probe.test.ts && tsx src/__tests__/subscription-scope.test.ts && tsx src/__tests__/composer-source-operations.test.tsx && tsx src/__tests__/session-prompt-lifecycle.test.tsx && tsx src/__tests__/desktop-preferences-lifecycle.test.tsx && tsx src/__tests__/onboarding-commands.test.tsx && tsx src/__tests__/topicbar-actions-lifecycle.test.tsx && tsx src/__tests__/decision-slots-lifecycle.test.tsx && tsx src/__tests__/session-experience-settings.test.tsx && tsx src/__tests__/project-topic-lifecycle.test.tsx && tsx src/__tests__/conversation-projection.test.ts && tsx src/__tests__/remote-composer-presentation.test.tsx && tsx src/__tests__/remote-composer-commands.test.tsx && tsx src/__tests__/terminal-panel-commands.test.tsx && tsx src/__tests__/workspace-panel-commands.test.tsx && tsx src/__tests__/desktop-navigation-lifecycle.test.tsx && tsx src/__tests__/runtime-status-lifecycle.test.tsx && tsx src/__tests__/session-control-commands.test.ts && tsx src/__tests__/automation-navigation-lifecycle.test.tsx && tsx src/__tests__/mock-remote-catalog.test.ts && tsx src/__tests__/topicbar-region.test.tsx && tsx src/__tests__/controller-profile-lifecycle.test.tsx && tsx src/__tests__/session-submission-lifecycle.test.tsx && tsx src/__tests__/pending-plan-revision-lifecycle.test.tsx && tsx src/__tests__/session-undo-lifecycle.test.tsx && tsx src/__tests__/session-clear-commands.test.tsx && tsx src/__tests__/turn-verification-commands.test.tsx && tsx src/__tests__/delivery-continue-commands.test.tsx && tsx src/__tests__/active-tab-mirror.test.tsx && tsx src/__tests__/windows-maximised-sync.test.tsx && tsx src/__tests__/topic-summary-commands.test.tsx && tsx src/__tests__/worktree-merge-commands.test.tsx && tsx src/__tests__/composer-insert-commands.test.tsx && node --test bench/app-memory-evidence.test.mjs && node --test bench/app-memory-shards.test.mjs bench/app-memory-paths.test.mjs", + "test:app-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-browser.mjs", + "test:app-memory": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-memory.mjs" }, "dependencies": { "@modelcontextprotocol/ext-apps": "1.7.5", diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index e95a068adb..045d5f1e1a 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -391,8 +391,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // measure 2496.4 KiB locally; retain the smallest bounded ceiling. // The context truncation-rescue notice and its three locale strings measure // 2496.6 KiB; retain the smallest bounded ceiling. -// The complete block renderer replaces Virtuoso and measures 2371.5 KiB -// on the settings + pure-kernel baseline. Keep the smallest bounded ceiling. -const rawInitialBudgetKiB = 2_371.6; +// Source-bound command owners and lifecycle composition measure 2408.0 KiB. +// Deferred presentation extraction in the next slice is budgeted separately. +const rawInitialBudgetKiB = 2_408.1; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/frontend/scripts/run-tests.mjs b/desktop/frontend/scripts/run-tests.mjs index ee628017e3..eeb99151f7 100644 --- a/desktop/frontend/scripts/run-tests.mjs +++ b/desktop/frontend/scripts/run-tests.mjs @@ -40,6 +40,11 @@ const OWNED_ELSEWHERE = new Map(Object.entries({ "raf-batch.test.ts": "test:stream", "stream-delta-batch.test.ts": "test:stream", "use-controller-stream-progress.test.ts": "test:stream", + "transcript-kernel.test.ts": "test:transcript", + "transcript-kernel-races.test.ts": "test:transcript", + "transcript-timeline.test.ts": "test:transcript", + "transcript-viewport.test.tsx": "test:transcript", + "transcript-question-jump.test.ts": "test:transcript", "nested-scroll-handoff.test.ts": "test:transcript", "creation-transcript-scrollbar.test.ts": "test:transcript", "markdown-table-virtual.test.tsx": "test:transcript", @@ -75,9 +80,10 @@ for (const [name, owner] of OWNED_ELSEWHERE) { } } -// Suites that statically import CSS (e.g. HeartbeatPanel's heartbeat.css) need -// the css-stub loader hook so tsx resolves the import under node. -const CSS_STUB_SUITES = new Set(["settings-provider-normalization.test.ts", "provider-image-input.test.tsx", "heartbeat-editor.test.tsx", "heartbeat-next-run.test.ts", "settings-page-navigation.test.tsx", "automation-management.test.tsx", "trash-management.test.tsx", "capabilities-panel-actions.test.ts", "provider-access-card.test.tsx", "provider-editor-model-picker.test.tsx", "provider-name-readonly.test.tsx", "settings-refresh-snapshot.test.tsx", "shell-support-install.test.tsx", "shortcuts-recorder-focus.test.tsx"]); +// CSS is a browser asset, not executable Node code. All discovered component +// graphs share this loader contract, including transitive imports after region +// extraction. CSS/layout correctness remains owned by syntax and browser gates. +const assetArgs = ["--import", pathToFileURL(resolve(SCRIPTS_DIR, "css-stub-register.mjs")).href]; const suites = files.filter((name) => !OWNED_ELSEWHERE.has(name)); console.log(`run-tests: ${suites.length} discovered suites (${OWNED_ELSEWHERE.size} owned by dedicated scripts)`); @@ -90,12 +96,7 @@ for (const name of suites) { // Node's built-in navigator.language follows the machine's ICU locale, and // suites assert English UI strings. const env = { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" }; - const extraArgs = CSS_STUB_SUITES.has(name) - // --import needs an absolute file URL: a bare relative path is resolved as - // a package specifier by Node and fails with ERR_MODULE_NOT_FOUND. - ? ["--import", pathToFileURL(resolve(SCRIPTS_DIR, "css-stub-register.mjs")).href] - : []; - const result = spawnSync(process.execPath, [tsxCli, ...extraArgs, path], { stdio: "inherit", env }); + const result = spawnSync(process.execPath, [tsxCli, ...assetArgs, path], { stdio: "inherit", env }); if (result.error) console.error(`run-tests: spawn failed for ${path}: ${result.error.message}`); if (result.status !== 0) { if (!keepGoing) { diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 51a0e4f265..8ad7a6f19b 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -1,5556 +1,677 @@ -import { ManagementSurface } from "./components/ManagementSurface"; -import { useManagementWorkspace } from "./lib/useManagementWorkspace"; -import { useAppNavigationStore } from "./store/appNavigation"; -import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from "react"; -import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand"; -import { - Activity, - Command, - Copy as RestoreIcon, - Minus, - Search, - Server, - Square, - SquarePen, - PanelLeft, - PanelRight, - FileText, - GitBranch, - MessageSquare, - Settings as SettingsIcon, - RotateCw, - Trash2, - AlarmClock, - BarChart3, - Brain, - Cpu, - Palette, - Puzzle, - X, - TerminalSquare, -} from "lucide-react"; +import { useLayoutEffect, useMemo, useRef, useState, lazy, type CSSProperties } from "react"; +import { useCommittedCommand } from "./lib/useCommittedCommand"; +import { openExternal } from "./lib/bridge"; +import { useT, useI18n, type Translator } from "./lib/i18n"; import { useToast } from "./lib/toast"; import { useGoalActionHandler } from "./lib/goalAction"; -import { useWailsResizeFix } from "./lib/useWailsResizeFix"; -import { asArray } from "./lib/array"; -import { activeLeaseBlockedTab, createBoundedRefreshCoordinator, sameTabMetaLists, seedActiveTabMetaList, shouldRefreshTabMetaForEvent, TAB_META_MAX_IN_FLIGHT } from "./lib/tabMetaRefresh"; -import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, t, useI18n, useT, type Translator } from "./lib/i18n"; -import { useActiveRemoteSession } from "./lib/useRemoteSession"; -import { useRemoteTabOpened } from "./lib/useRemoteTabOpened"; -import { publishNavigationIntent } from "./lib/useNavigationIntentFence"; -import { renameCurrentRemoteSession } from "./lib/remoteSessionActions"; -import { localizedNoticeText, useController, type HistoryLoadTrigger, type Item } from "./lib/useController"; -import { app, onEvent, onProjectTreeChanged, onReady, onRemoteForwards, onRemoteServer, onRemoteStatus, onRuntimeRebuilt, openExternal } from "./lib/bridge"; -import { useConfigLoadWarnings } from "./lib/useConfigLoadWarnings"; -import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music"; -import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound"; -import { NoticeCard, Transcript } from "./components/Transcript"; -import { Composer } from "./components/Composer"; -import { TodoPanel } from "./components/TodoPanel"; -import { ApprovalModal } from "./components/ApprovalModal"; -import { AskCard } from "./components/AskCard"; -import { ClearContextCard } from "./components/ClearContextCard"; -import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard"; -import { decisionSurfaceMockFromInput, type DecisionSurfaceKind as MockDecisionSurfaceKind } from "./lib/decisionSurfaceMock"; -const UndoRewindBanner = lazy(() => import("./components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner }))); -const SessionTakeoverDialog = lazy(() => import("./components/SessionTakeoverDialog").then((module) => ({ default: module.SessionTakeoverDialog }))); -const ProjectTree = lazy(() => import("./components/ProjectTree").then((module) => ({ default: module.ProjectTree }))); -const RemoteSessionSurface = lazy(() => import("./components/RemoteSessionSurface").then((module) => ({ default: module.RemoteSessionSurface }))); -const ExtensionFormDialog = lazy(() => import("./components/ExtensionFormDialog").then((module) => ({ default: module.ExtensionFormDialog }))); -const MCPInteractionCard = lazy(() => import("./components/MCPInteractionCard").then((module) => ({ default: module.MCPInteractionCard }))); -const WorktreeMergeModal = lazy(() => import("./components/WorktreeMergeModal").then((module) => ({ default: module.WorktreeMergeModal }))); -/** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */ -type DecisionSurfaceKind = MockDecisionSurfaceKind | "extension_form"; -import { StatusBar } from "./components/StatusBar"; -import { RemoteHostKeyDialog } from "./components/RemoteHostKeyDialog"; -import { RemoteSecretDialog } from "./components/RemoteSecretDialog"; -import { RemoteConnectionTimeoutError, useRemoteStore, waitForRemoteConnection } from "./store/remote"; -import { RemoteWorkspaceLaunchGate, resolveRemoteWorkspace } from "./lib/remoteWorkspace"; -import { CommandPalette, type PaletteItem } from "./components/CommandPalette"; -import { UpdateBanner } from "./components/UpdateBanner"; -import { UpdaterProvider } from "./lib/useUpdater"; -import { Tooltip } from "./components/Tooltip"; -import { StartupSplash } from "./components/StartupSplash"; -import { OnboardingOverlay } from "./components/OnboardingOverlay"; -import { dismissOnboarding, shouldOpenOnboarding } from "./lib/onboarding"; -import { AppChrome } from "./components/AppChrome"; -import { ShortcutsCheatsheet } from "./components/ShortcutsCheatsheet"; -import { WorktreeBadge } from "./components/WorktreeBadge"; -import { CopyButton } from "./components/CopyButton"; -import { ExternalOpener, shouldMountExternalOpener } from "./components/ExternalOpener"; -import { TopicbarSessionActions } from "./components/TopicbarSessionActions"; -import { RemoteReclaimBanner } from "./components/RemoteReclaimBanner"; -import { startTerminalEventBridge } from "./lib/terminalEvents"; -import { applyTerminalThemePreference } from "./lib/terminalTheme"; -import { formatTerminalOutputForComposer } from "./lib/terminalOutput"; -import { useTerminalStore } from "./store/terminal"; -import { hydrateReasoningDisplayMode, setReasoningDisplayPending } from "./lib/reasoningDisplayPreference"; -import { hydrateSessionExperience } from "./lib/sessionExperience"; -import { parseTodos } from "./lib/tools"; -import { - dismissedTodoKeyForScope, - resolveTodoPanelTodos, - scopedTodoBatchKey, - scopedTodoDismissalKey, - shouldShowTodoPanel, - todoBatchKey, - todoContinueTarget, - todoDismissalKey, - todoPanelScope, -} from "./lib/todoVisibility"; -import { - type BotConnectionView, - type BotRuntimeStatusView, - type BotSettingsView, - type ActiveWorkView, - type BackgroundRuntimeView, - type CollaborationMode, - type ComposerInsertRequest, - type DesktopStartupSettingsView, - type Mode, - type RewindResultView, - type RemoteHostView, - type SessionMeta, - type SettingsView, - type QualityFloor, - type TabMeta, - type ToolApprovalMode, - type WireCompletionSummary, - type WorkspaceConflictView, -} from "./lib/types"; -import { runWorktreeMergeLifecycle } from "./lib/worktreeMergeLifecycle"; -import { showWorktreeCleanupNotice } from "./lib/worktreeCleanupNotice"; -import { requestSessionVersions } from "./lib/sessionRecoveryVersionHostBridge"; -import type { WorkspaceVerificationRevealRequest } from "./components/WorkspacePanel"; -import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay"; -import type { RewindUndoState } from "./lib/rewindTypes"; -import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext"; -import { resolveTaskMonitorSession } from "./lib/taskMonitorNavigation"; -import { - composerProfileFromMeta, - composerProfileFromTab, - composerProfileMode, - controllerComposerProfileCollaborationMode, - defaultComposerProfile, - displayedComposerProfileCollaborationMode, - hydrateComposerProfileFromMeta, - hydrateComposerProfilesFromTabs, - patchComposerProfile, - pruneUserPlanModeIntents, - resolvePlanRestoreTabId, - shouldRestoreUserPlanModeForProfile, - updateUserPlanModeIntent, - type ComposerProfile, - type ComposerProfileField, - type UserPlanModeIntents, -} from "./lib/composerProfile"; -import { - toggleYoloToolApprovalMode, - type RestorableToolApprovalMode, -} from "./lib/toolApprovalMode"; -import { useComposerModeActions } from "./lib/useComposerModeActions"; -import { openRemoteNewSession, useRemoteComposerProfileSync, useRemoteComposerRuntimeActions, useRemoteComposerSend } from "./lib/useRemoteComposerIntegration"; -import { - CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH, - CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, - CREATION_SIDEBAR_MIN_WIDTH, - RIGHT_DOCK_MIN_RENDER_WIDTH, - RIGHT_DOCK_TREE_MIN_WIDTH, - type RightDockMode, - SIDEBAR_MAX_WIDTH, - SIDEBAR_MIN_WIDTH, - TERMINAL_DEFAULT_HEIGHT, - TERMINAL_MIN_HEIGHT, - applyLayoutStyleDefaults, - clampCreationRightDockTreeWidth, - clampCreationSidebarWidth, - clampRightDockTreeWidth, - clampSidebarWidth, - clampTerminalHeight, - defaultCreationRightDockTreeWidth, - defaultCreationSidebarWidth, - defaultRightDockTreeWidth, - defaultSidebarWidth, - saveRightDockTreeWidth, - saveSidebarCollapsed, - saveSidebarWidth, - saveTerminalHeight, - saveTerminalPanelOpen, - terminalMaxHeight, - saveWorkspacePanelOpen, - loadWorkspacePanelOpen, - useLayoutStore, -} from "./store/layout"; -import { useOverlayStore } from "./store/overlays"; -import { recordFrontendDiagnostic } from "./lib/frontendDiagnosticBridge"; -import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems, type StatusBarItemId } from "./lib/statusBarItems"; -import { paletteSessionDisplayTitle, paletteSessionHint, paletteSessionKeywords, sessionActivityTime } from "./lib/session"; -import { enqueueNavigationRequest, type PendingNavigationRequest } from "./lib/openTopicCoalescing"; -import { - guardBackendNavigationResult, -} from "./lib/navigationSurfaceTransition"; +import { useActiveRemoteSession, type RemoteSessionApi } from "./lib/useRemoteSession"; +import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel"; +import { setReasoningDisplayPending } from "./lib/reasoningDisplayPreference"; +import { type RestorableToolApprovalMode } from "./lib/toolApprovalMode"; +import { type ComposerProfile, type UserPlanModeIntents } from "./lib/composerProfile"; +import { type TabMeta } from "./lib/types"; +import { type HistoryViewState } from "./app-runtime/historyViewProjection"; import { useNavigationSurface } from "./lib/useNavigationSurface"; -import { - applyTheme, - clearLegacyThemePreference, - getTheme, - getThemeStyle, - isThemeStyle, - normalizeThemePreference, - normalizeThemeStyleForTheme, - readLegacyThemePreference, - type Theme, -} from "./lib/theme"; -import { applyConversationWidth } from "./lib/conversationWidth"; -import { applyConfiguredBaseAppearance, applyThemePack, applyThemeScene, clearThemePack } from "./lib/themePack"; +import { projectNavigationSurfaceTarget } from "./app-runtime/conversationProjection"; +import { useSessionOperations } from "./app-runtime/useSessionOperations"; +import { createSessionSurfaceFence, sessionIdentityKey } from "./app-runtime/sessionTarget"; +import { commitAppRenderToken, createAppRenderToken } from "./app-runtime/appLifecycleProbe"; +import { useAppRuntimeAdapter } from "./app-runtime/useAppRuntimeAdapter"; +import { useAppShellStores } from "./app-runtime/useAppShellStores"; +import { useAppSessionComposition } from "./app-runtime/useAppSessionComposition"; +import { useAppNavigationComposition } from "./app-runtime/useAppNavigationComposition"; +import { useTopicTimeFilter, type TopicTimeFilter } from "./app-runtime/useLocalUiLifecycles"; +import { ShellExpandProvider } from "./lib/shellExpand"; +import { RemoteNavigationContext } from "./lib/remoteNavigationCommands"; +import { UpdaterProvider } from "./lib/useUpdater"; +import { type State } from "./lib/useController"; +import { ShellHotkeys, TextSizeHotkeys } from "./app-shell/HotkeyRegistrations"; +import { WindowChromeLifecycle } from "./app-runtime/WindowChromeLifecycle"; +import { StartupGateLifecycle } from "./app-runtime/StartupGateLifecycle"; +import { AppRuntimeEffects } from "./app-runtime/AppRuntimeEffects"; import { ThemeBackground } from "./components/ThemeBackground"; -import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize"; -import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState"; -import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout"; -import { createPointerResizeLifecycle, createRafResizeUpdater } from "./lib/resizeDrag"; -import { formatShortcutCombo, resolvedShortcutCombo, useGlobalShortcut } from "./lib/keyboardShortcuts"; -import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel"; -import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts"; -import { composerDraftKeyForTab } from "./lib/composerDraftKey"; -import { continueDelivery } from "./lib/deliveryContinue"; -import { activateGoalAndSubmitOnTab } from "./lib/goalSubmit"; -import logoWordmark from "./assets/logo-wordmark.svg"; +import { AppChrome } from "./components/AppChrome"; +import { SidebarRegion } from "./app-shell/SidebarRegion"; +import { TopicbarRegion } from "./app-shell/TopicbarRegion"; +import { buildTopicbarView, TopicbarActionsStack } from "./app-shell/TopicbarActionsStack"; +import { DockToggleButton } from "./app-shell/DockToggleButton"; +import { SessionStatusBanners } from "./app-shell/SessionStatusBanners"; +import { ChatPaneRegion } from "./app-shell/ChatPaneRegion"; +import { DecisionFooterRegion } from "./app-shell/DecisionFooterRegion"; +import { WorkspaceDockRegion } from "./app-shell/WorkspaceDockRegion"; +import { AppBottomRegions } from "./app-shell/AppBottomRegions"; +import { AppOverlayHost } from "./app-shell/AppOverlayHost"; +import { buildAppShellClassNames, buildSessionStatusBannerProps, buildSidebarRegionProps } from "./app-shell/chromeRegionBuilders"; +import { buildBottomRegionsProps, buildWorkspaceDockProps } from "./app-shell/dockRegionBuilders"; +import { buildOverlayHostProps } from "./app-shell/overlayBuilders"; +import { buildComposerSurface, buildDecisionFooterSurface, buildFooterTodo, buildFooterUndo } from "./app-shell/decisionFooterBuilders"; + + // Hold reasoning UI until the authoritative desktop startup settings arrive; // this prevents a hidden preference from flashing content during first paint. setReasoningDisplayPending(); -function noticePreviewMockEnabled(): boolean { - const value = browserMockScenarioParam(); - return value === "notice" || value === "notices" || value === "notice-preview"; -} -function noticePreviewItems(): Item[] { - const notice = (index: number, level: "info" | "warn", text: string, detail: string, code?: string): Item => ({ - kind: "notice", - id: `notice-preview-${index}`, - level, - text: localizedNoticeText(text, code), - detail, - }); - return [ - { - kind: "notice", - id: "notice-preview-delivery", - level: "info", - variant: "delivery", - title: t("notice.deliveryIncompleteTitle"), - text: t("notice.deliveryIncompleteBody"), - detail: "final-answer readiness failed 3 times: missing verification, review_report, and complete_step receipts", - action: "continue_delivery", - }, - notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying", "empty_final"), - notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute", "executor_handoff"), - notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing", "tool_budget"), - notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts", "loop_guard"), - notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"), - notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"), - notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"), - notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"), - notice(9, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"), - notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"), - notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"), - notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"), - notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"), - notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""), - notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""), - notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""), - notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"), - notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"), - notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"), - notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"), - notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"), - ]; -} -function NoticePreviewPanel() { - return ( -
-
- {noticePreviewItems().map((item) => { - if (item.kind !== "notice") return null; - return ( - undefined : undefined} - onAccept={item.action === "continue_delivery" ? () => undefined : undefined} - /> - ); - })} -
-
- ); -} - -const TranscriptSelectionMenu = lazy(() => import("./components/TranscriptSelectionMenu").then((module) => ({ default: module.TranscriptSelectionMenu }))); -const ContextPanel = lazy(() => import("./components/ContextPanel").then((module) => ({ default: module.ContextPanel }))); -const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel }))); -const SessionRecoveryVersionsHost = lazy(() => import("./components/SessionRecoveryVersionsHost").then((module) => ({ default: module.SessionRecoveryVersionsHost }))); -const loadTrashPage = () => import("./components/TrashPage").then((module) => ({ default: module.TrashPage })); -const loadAutomationPage = () => import("./custom/features/heartbeat/HeartbeatPanel").then((module) => ({ default: module.HeartbeatView })); -const loadSettingsPage = () => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel })); -const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel }))); -const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel }))); -const TaskMonitorPanel = lazy(() => import("./components/TaskMonitorPanel").then((module) => ({ default: module.TaskMonitorPanel }))); -const WorkspacePanel = lazy(async () => { - const [module] = await Promise.all([ - import("./components/WorkspacePanel"), - import("./components/WorkspacePanelStability.css"), - ]); - return { default: module.WorkspacePanel }; -}); - -const CHAT_MIN_WIDTH = 400; -const WORKSPACE_RESIZER_WIDTH = 8; -function stripLegacyGoalBudgetFlags(arg: string): string { - const parts = arg.trim().split(/\s+/).filter(Boolean); - while (parts.length > 0) { - const flag = parts[0].toLowerCase(); - if (flag !== "--research" && flag !== "--auto-research" && flag !== "--deep" && flag !== "--simple" && flag !== "--no-research") break; - parts.shift(); - } - return parts.join(" "); -} -function hasLegacyGoalBudgetFlag(arg: string): boolean { - const first = arg.trim().split(/\s+/, 1)[0]?.toLowerCase(); - return first === "--research" || first === "--auto-research" || first === "--deep" || first === "--simple" || first === "--no-research"; -} - -function isThemeMode(value: string): value is Theme { - return value === "auto" || value === "light" || value === "dark"; -} - -type DesktopLayoutStyle = "classic" | "workbench" | "creation"; - -function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutStyle { - if (style === "creation") return "creation"; - if (style === "classic") return "classic"; - return "workbench"; -} -const SHOW_CONTEXT_DOCK = true; -const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys"; -const MAX_DISMISSED_TODO_KEYS = 160; -type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string }; -type WorkspaceInsertTarget = "composer" | "planRevision"; -type DesktopPlatform = "darwin" | "windows" | "linux"; -const MACOS_WORKBENCH_TITLEBAR_HEIGHT = 46; - -function isMacOSWorkbenchSidebarTitlebar(target: HTMLElement | null, clientY: number, platform: DesktopPlatform): boolean { - if (platform !== "darwin") return false; - const sidebar = target?.closest(".sidebar--workbench"); - if (!(sidebar instanceof HTMLElement)) return false; - const offsetY = clientY - sidebar.getBoundingClientRect().top; - return offsetY >= 0 && offsetY < MACOS_WORKBENCH_TITLEBAR_HEIGHT; -} - -function useWindowsMaximised(enabled: boolean): readonly [boolean, () => void] { - const [maximised, setMaximised] = useState(false); - const syncGenerationRef = useRef(0); - - const syncMaximised = useCallback(() => { - if (!enabled) return; - const generation = ++syncGenerationRef.current; - void app.IsMainWindowMaximised() - .then((value) => { - if (generation === syncGenerationRef.current) setMaximised(value); - }) - .catch(() => { - if (generation === syncGenerationRef.current) setMaximised(false); - }); - }, [enabled]); - - useEffect(() => { - if (!enabled) { - syncGenerationRef.current += 1; - setMaximised(false); - return; - } - syncMaximised(); - window.addEventListener("resize", syncMaximised); - window.addEventListener("focus", syncMaximised); - return () => { - syncGenerationRef.current += 1; - window.removeEventListener("resize", syncMaximised); - window.removeEventListener("focus", syncMaximised); - }; - }, [enabled, syncMaximised]); - - return [maximised, syncMaximised] as const; -} - -function WindowsWindowControls({ - maximised, - syncMaximised, -}: { - maximised: boolean; - syncMaximised: () => void; -}) { - const toggleMaximise = useCallback(() => { - void app.ToggleMaximiseMainWindow() - .then(() => window.setTimeout(syncMaximised, 80)) - .catch(() => undefined); - }, [syncMaximised]); - - return ( -
- - - -
- ); -} -type HistoryViewState = - | { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] } - | { kind: "history"; source: "all"; sessions: SessionMeta[] }; -type SidebarImPlatform = "qq" | "feishu" | "lark" | "weixin"; -type SidebarImStatus = "connected" | "disabled" | "pending" | "error" | "disconnected"; -type SidebarImConnection = { - id: string; - connectionId: string; - platform: SidebarImPlatform; - title: string; - platformLabel: string; - subtitle: string; - status: SidebarImStatus; - statusLabel: string; - remoteId: string; - sessionId: string; - sessionSource: string; - scope: "global" | "project"; - workspaceRoot: string; - allowAll: boolean; - allowlistEnabled: boolean; - allowlistUsers: string[]; - allowlistMatched: boolean; -}; -type DesktopNavigationIntent = - | { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string } - | { kind: "blank"; scope: string; workspaceRoot: string } - | { kind: "isolated-worktree"; workspaceRoot: string } - | { kind: "sidebar-im"; connection: SidebarImConnection } - | { kind: "resume-session"; session: SessionMeta }; -type DesktopNavigationInput = DesktopNavigationIntent & { navigationIntentSeq: number }; -type PendingDesktopNavigationRequest = PendingNavigationRequest; -type SidebarImTopicSource = { - platform: SidebarImPlatform; - label: string; - title: string; - remoteId: string; - connectionId: string; -}; -type SidebarImConnectionDetailProps = { - connection: SidebarImConnection; - onClose: () => void; - onOpenSession: () => void; - onOpenSettings: () => void; - onManageAllowlist: () => void; -}; - -function loadDismissedTodoKeys(): Set { - try { - const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY); - if (!saved) return new Set(); - const parsed = JSON.parse(saved) as unknown; - if (!Array.isArray(parsed)) return new Set(); - return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0)); - } catch { - return new Set(); - } -} - -function saveDismissedTodoKeys(keys: ReadonlySet): void { - try { - window.localStorage.setItem( - DISMISSED_TODO_STORAGE_KEY, - JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)), - ); - } catch { - /* ignore quota errors */ - } -} - -function isSidebarImConnection(connection: BotConnectionView): boolean { - return connection.provider === "feishu" || connection.provider === "weixin"; -} - -function sidebarImPlatform(connection: BotConnectionView): SidebarImPlatform { - if (connection.provider === "weixin") return "weixin"; - return connection.domain === "lark" ? "lark" : "feishu"; -} - -function sidebarImPlatformLabel(platform: SidebarImPlatform, translate: Translator): string { - if (platform === "qq") return "QQ"; - if (platform === "lark") return "Lark"; - if (platform === "weixin") return translate("settings.botWeixin"); - return translate("settings.botFeishu"); -} - -function botMappingScope(mapping: BotConnectionView["sessionMappings"][number] | null | undefined, connectionWorkspaceRoot: string): "global" | "project" { - if (mapping?.scope === "project") return "project"; - if ((mapping?.workspaceRoot ?? "").trim()) return "project"; - return connectionWorkspaceRoot.trim() ? "project" : "global"; -} - -function botMappingWorkspaceRoot( - mapping: BotConnectionView["sessionMappings"][number] | null | undefined, - connectionWorkspaceRoot: string, -): string { - const workspaceRoot = (mapping?.workspaceRoot ?? "").trim() || connectionWorkspaceRoot.trim(); - return botMappingScope(mapping, connectionWorkspaceRoot) === "project" ? workspaceRoot : ""; -} - -function compactRemoteId(value: string): string { - const trimmed = value.trim(); - if (trimmed.length <= 28) return trimmed; - return `${trimmed.slice(0, 12)}…${trimmed.slice(-8)}`; -} - -function botMappingIdentityLabel(mapping: BotConnectionView["sessionMappings"][number] | null | undefined): string { - const chatType = (mapping?.chatType ?? "").trim(); - const userId = (mapping?.userId ?? "").trim(); - const threadId = (mapping?.threadId ?? "").trim(); - if (threadId) return compactRemoteId(threadId); - if ((chatType === "group" || chatType === "guild") && userId) return compactRemoteId(userId); - return ""; -} - -function sidebarImStatus(connection: BotConnectionView, botEnabled: boolean): SidebarImStatus { - if (!botEnabled || !connection.enabled) return "disabled"; - if (connection.status === "connected") return "connected"; - if (connection.status === "pending") return "pending"; - if (connection.status === "error") return "error"; - return "disconnected"; -} - -function sidebarImStatusLabel(status: SidebarImStatus, translate: Translator): string { - switch (status) { - case "connected": - return translate("sidebar.imConnected"); - case "disabled": - return translate("sidebar.imDisabled"); - case "pending": - return translate("sidebar.imPending"); - case "error": - return translate("sidebar.imError"); - default: - return translate("sidebar.imDisconnected"); - } -} - -function uniqueTrimmedValues(values: string[]): string[] { - return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); -} - -function sidebarImAllowlistUsers(bot: BotSettingsView, platform: SidebarImPlatform): string[] { - if (platform === "qq") return uniqueTrimmedValues(asArray(bot.allowlist.qqUsers)); - if (platform === "weixin") return uniqueTrimmedValues(asArray(bot.allowlist.weixinUsers)); - return uniqueTrimmedValues(asArray(bot.allowlist.feishuUsers)); -} - -function sidebarImQQAdded(qq: BotSettingsView["qq"]): boolean { - return Boolean(qq.enabled || qq.secretSet || qq.appId.trim()); -} - -function sidebarImQQStatus(bot: BotSettingsView, runtimeStatus: BotRuntimeStatusView | null | undefined): SidebarImStatus { - const appId = bot.qq.appId.trim(); - if (!bot.enabled || !bot.qq.enabled) return "disabled"; - if (!appId || !bot.qq.secretSet) return "disconnected"; - if (typeof window !== "undefined" && !window.runtime) return "pending"; - if (!runtimeStatus) return "pending"; - const status = runtimeStatus.status.trim().toLowerCase(); - if (runtimeStatus.running && runtimeStatus.connections > 0 && status === "running") { - return "connected"; - } - if (status === "error" || status === "blocked" || status === "degraded") return "error"; - if (status === "stopped") return "disconnected"; - return "pending"; -} - -async function loadBotRuntimeStatus(): Promise { - if (typeof window !== "undefined" && !window.runtime) return null; - try { - return await app.BotRuntimeStatus(); - } catch (e) { - console.warn("bot runtime status failed", e); - return null; - } -} - -function sidebarImQQConnection(bot: BotSettingsView, translate: Translator, runtimeStatus?: BotRuntimeStatusView | null): SidebarImConnection | null { - if (!sidebarImQQAdded(bot.qq)) return null; - const remoteId = bot.qq.appId.trim(); - const status = sidebarImQQStatus(bot, runtimeStatus); - const statusLabel = sidebarImStatusLabel(status, translate); - const allowlistUsers = sidebarImAllowlistUsers(bot, "qq"); - const subtitleParts = [ - remoteId ? compactRemoteId(remoteId) : "QQ", - statusLabel, - ].filter(Boolean); - return { - id: "__qq_bot__", - connectionId: "__qq_bot__", - platform: "qq", - title: "QQ Bot", - platformLabel: "QQ", - subtitle: subtitleParts.join(" · "), - status, - statusLabel, - remoteId, - sessionId: "", - sessionSource: "", - scope: "global", - workspaceRoot: "", - allowAll: bot.allowlist.allowAll, - allowlistEnabled: bot.allowlist.enabled, - allowlistUsers, - allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false, - }; -} - -function sidebarImConnectionsFromBot( - bot: BotSettingsView | null | undefined, - translate: Translator, - runtimeStatus?: BotRuntimeStatusView | null, -): SidebarImConnection[] { - if (!bot) return []; - const qqConnection = sidebarImQQConnection(bot, translate, runtimeStatus); - const connectionItems: SidebarImConnection[] = []; - for (const connection of asArray(bot.connections)) { - if (!isSidebarImConnection(connection)) continue; - const mappings = connection.sessionMappings.filter((mapping) => mapping.sessionId.trim() || mapping.remoteId.trim()); - const rowMappings = mappings.length > 0 ? mappings : [null]; - rowMappings.forEach((mapping, index) => { - const platform = sidebarImPlatform(connection); - const platformLabel = sidebarImPlatformLabel(platform, translate); - const remoteId = mapping?.remoteId.trim() ?? ""; - const sessionId = mapping?.sessionId.trim() ?? ""; - const sessionSource = mapping?.sessionSource.trim() ?? ""; - const scope = botMappingScope(mapping, connection.workspaceRoot); - const workspaceRoot = botMappingWorkspaceRoot(mapping, connection.workspaceRoot); - const status = sidebarImStatus(connection, bot.enabled); - const title = connection.label.trim() || platformLabel; - const allowlistUsers = sidebarImAllowlistUsers(bot, platform); - const identityLabel = botMappingIdentityLabel(mapping); - const mappedUserId = mapping?.userId.trim() ?? ""; - const subtitleParts = [ - remoteId ? compactRemoteId(remoteId) : platformLabel, - identityLabel, - connection.model.trim() || "", - sidebarImStatusLabel(status, translate), - ].filter(Boolean); - connectionItems.push({ - id: mapping ? `${connection.id}:mapping:${index}` : connection.id, - connectionId: connection.id, - platform, - title, - platformLabel, - subtitle: subtitleParts.join(" · "), - status, - statusLabel: sidebarImStatusLabel(status, translate), - remoteId, - sessionId, - sessionSource, - scope, - workspaceRoot, - allowAll: bot.allowlist.allowAll, - allowlistEnabled: bot.allowlist.enabled, - allowlistUsers, - allowlistMatched: remoteId - ? allowlistUsers.includes(remoteId) || (mappedUserId ? allowlistUsers.includes(mappedUserId) : false) - : false, - }); - }); - } - return qqConnection ? [qqConnection, ...connectionItems] : connectionItems; -} - -function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null { - const trimmed = sessionId.trim(); - if (!trimmed) return null; - const lower = trimmed.toLowerCase(); - if (lower.startsWith("path:")) { - const value = trimmed.slice(5).trim(); - return value ? { kind: "path", value } : null; - } - if (lower.startsWith("topic:")) { - const value = trimmed.slice(6).trim(); - return value ? { kind: "topic", value } : null; - } - if (trimmed.endsWith(".jsonl") || trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith("~")) { - return { kind: "path", value: trimmed }; - } - return { kind: "topic", value: trimmed }; -} - -function taskSessionIDFromPath(path: string): string { - const base = path.replace(/\\/g, "/").split("/").pop() || ""; - const extension = base.lastIndexOf("."); - return extension > 0 ? base.slice(0, extension) : base; -} - -function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null { - return mappedSessionTarget(connection.sessionId); -} - -function isChannelSession(session: SessionMeta): boolean { - return session.kind === "channel" || session.sessionSource === "auto"; -} - -function sidebarImTopicSourcesFromBot(bot: BotSettingsView | null | undefined, translate: Translator): Record { - if (!bot?.connections?.length) return {}; - const sources: Record = {}; - for (const connection of bot.connections) { - if (!isSidebarImConnection(connection)) continue; - const platform = sidebarImPlatform(connection); - const label = sidebarImPlatformLabel(platform, translate); - const title = connection.label.trim() || label; - for (const mapping of asArray(connection.sessionMappings)) { - const scope = botMappingScope(mapping, connection.workspaceRoot); - if (scope !== "global") continue; - const target = mappedSessionTarget(mapping.sessionId); - if (!target || target.kind !== "topic") continue; - if (sources[target.value]) continue; - sources[target.value] = { - platform, - label, - title, - remoteId: mapping.remoteId.trim(), - connectionId: connection.id, - }; - } - } - return sources; -} - -function sidebarImScopeLabel(connection: SidebarImConnection, translate: Translator): string { - if (connection.scope === "project") return translate("botDetail.scopeProject", { name: connection.workspaceRoot || "Project" }); - return translate("botDetail.scopeGlobal"); -} - -function sidebarImSessionLabel(connection: SidebarImConnection, translate: Translator): string { - const target = sidebarImSessionTarget(connection); - if (!target) { - return connection.remoteId ? translate("botDetail.readOnlyChannel") : translate("botDetail.noSession"); - } - if (connection.sessionSource === "auto") return translate("botDetail.readOnlyChannel"); - if (target.kind === "path") return target.value.split(/[\\/]/).pop() || target.value; - return target.value; -} - -function sidebarImAccessModeLabel(connection: SidebarImConnection, translate: Translator): string { - if (connection.allowAll) return translate("botDetail.accessAllowAll"); - if (connection.allowlistEnabled) return translate("botDetail.accessWhitelist"); - return translate("botDetail.accessDisabled"); -} - -function sidebarImAccessStatusLabel(connection: SidebarImConnection, translate: Translator): string { - if (connection.allowAll) return translate("botDetail.accessOpen"); - if (!connection.remoteId) return translate("botDetail.accessUnknown"); - return connection.allowlistMatched ? translate("botDetail.accessMatched") : translate("botDetail.accessMissing"); -} - -function sidebarImAccessStatusClass(connection: SidebarImConnection): string { - if (connection.allowAll || connection.allowlistMatched) return "ok"; - if (!connection.remoteId) return "muted"; - return "warn"; -} - -function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenSettings, onManageAllowlist }: SidebarImConnectionDetailProps) { - const translate = useT(); - const target = sidebarImSessionTarget(connection); - const accessStatusClass = sidebarImAccessStatusClass(connection); - return ( -
-
- -
- {translate("botDetail.subtitle")} -

{connection.title}

-
- {connection.platformLabel} - {connection.statusLabel} - {sidebarImScopeLabel(connection, translate)} -
-
-
- - - -
-
- -
-
- {translate("botDetail.access")} -
- {connection.remoteId ? ( - - ) : null} - -
-
-
-
- {translate("botDetail.accessMode")} - {sidebarImAccessModeLabel(connection, translate)} -
-
- {translate("botDetail.accessCurrentUser")} - {connection.remoteId || "—"} -
-
- {translate("botDetail.accessStatus")} - - {sidebarImAccessStatusLabel(connection, translate)} - -
-
-
- {translate("botDetail.channelAllowlistUsers")} -
- {connection.allowlistUsers.length > 0 ? ( - connection.allowlistUsers.map((id) => ( - - {id} - - )) - ) : ( - {translate("botDetail.emptyAllowlistUsers")} - )} -
-
-
- -
-
- {translate("botDetail.summary")} -
-
-
- {translate("botDetail.remoteId")} - {connection.remoteId || "—"} -
-
- {translate("botDetail.localTopic")} - {sidebarImSessionLabel(connection, translate)} -
-
- {translate("botDetail.scope")} - {sidebarImScopeLabel(connection, translate)} -
-
-
-
- ); -} - -function normalizeDesktopPlatform(value: string): DesktopPlatform { - if (value === "darwin" || value === "windows") return value; - return "linux"; -} - -function browserPlatformOverride(): DesktopPlatform | null { - if (typeof window === "undefined" || window.runtime) return null; - const value = new URLSearchParams(window.location.search).get("platform"); - if (value === "darwin" || value === "windows" || value === "linux") return value; - return null; -} - -const GUIDANCE_QUEUE_MOCK_ITEMS = [ - "先确认发送后输入框为什么残留刚发的消息,再决定修哪里。", - "保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。", - "最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。", -] as const; - -function browserMockScenarioParam(): string { - if (typeof window === "undefined" || window.runtime) return ""; - return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? ""; -} - -function isGuidanceMockScenario(value: string): boolean { - return value === "guidance" || value === "guide" || value === "steer"; -} - -function detectBrowserPlatform(): DesktopPlatform { - const override = browserPlatformOverride(); - if (override) return override; - if (typeof navigator === "undefined") return "linux"; - const marker = `${navigator.platform} ${navigator.userAgent}`; - if (/Win/i.test(marker)) return "windows"; - if (/Mac/i.test(marker)) return "darwin"; - return "linux"; -} - -function tabWorkspaceTitle(tab?: TabMeta): string { - if (!tab) return "Global"; - if (tab.scope === "project") return tab.workspaceName || tab.workspaceRoot || "Project"; - if (tab.scope === "global") return tab.workspaceName || "Global"; - return tab.workspaceName || tab.workspaceRoot || "Global"; -} - -function topicTitle(tab?: TabMeta): string { - if (!tab) return "Global"; - const workspaceTitle = tabWorkspaceTitle(tab); - const topic = tab.topicTitle || (tab.scope === "global" ? workspaceTitle : "Untitled"); - return topic === workspaceTitle ? workspaceTitle : `${workspaceTitle} / ${topic}`; -} - -function topicDisplayTitle(tab?: TabMeta): string { - if (!tab) return "Global"; - return tab.topicTitle || (tab.scope === "global" ? tabWorkspaceTitle(tab) : "Untitled"); -} - -function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): SessionMeta[] { - if (filter.scope === "project") { - return sessions.filter((session) => session.scope === "project" && session.workspaceRoot === filter.workspaceRoot); - } - return sessions.filter((session) => (session.scope || "global") === "global"); -} - -function isMissingSessionError(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err ?? ""); - return /no such file|cannot find the file|file does not exist|session is pending cleanup|session .*not found/i.test(message); -} - -function workspaceDisplayName(path?: string): string { - if (!path) return ""; - const parts = path.split(/[/\\]/).filter(Boolean); - return parts.length > 0 ? parts[parts.length - 1] : path; -} - -function safeFilename(name: string): string { - const cleaned = name.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80); - return cleaned || "reasonix-session"; -} - -/** Global hotkey handler for shell-expand toggle (Ctrl/Cmd+B). */ -function ShellHotkeys() { - const shellExpand = useShellExpand(); - useGlobalShortcut("shell.toggle", () => shellExpand?.toggleLast(), [shellExpand], Boolean(shellExpand)); - return null; -} -/** Global hotkey handler for text-size shortcuts (Ctrl/Cmd + Plus/Minus/0). */ -function TextSizeHotkeys() { - useGlobalShortcut("textSize.increase", () => applyTextSize(nextTextSize(getTextSize(), 1))); - useGlobalShortcut("textSize.decrease", () => applyTextSize(nextTextSize(getTextSize(), -1))); - useGlobalShortcut("textSize.reset", () => applyTextSize(DEFAULT_TEXT_SIZE)); - return null; -} +/** + * Composition root: owns the controller adapter, the session identity/fence, + * the navigation surface and every store-backed state, then delegates all + * command domains to the session/navigation compositions and the tree to the + * shell view. Wiring only — no domain logic lives here. + */ export default function App() { - const { - state, - liveStore, - activeTabId, - sendToTab, - recoverDeliveryToTab, - runShellForTab, - steerForTab, - notice, - cancel, - approve, - resolvePlanDecision, - resolveRecovery, - answerQuestion, - answerMCPInteraction, - setControllerMode, - dismissExtensionForm, - drainExtensionNotifications, - setCollaborationMode: setControllerCollaborationMode, - setToolApprovalMode: setControllerToolApprovalMode, - setQualityFloor: setControllerQualityFloor, - setComposerProfileForTab: setControllerComposerProfileForTab, - setGoalForTab: setControllerGoalForTab, - resumeGoalForTab: resumeControllerGoalForTab, - pauseGoalForTab: pauseControllerGoalForTab, - clearGoal: clearControllerGoal, - clearGoalForTab: clearControllerGoalForTab, - clearSession, - newSession, - listSessions, - listTrashedSessions, - resumeSession, - openChannelSession, - previewSession, - deleteSession, - restoreSession, - purgeTrashedSession, - renameSession, - loadOlderHistory, - retrySessionHistory, - refreshMeta, - pickWorkspace, - switchWorkspace, - rewindForTab, - rewindForTabDetailed, - undoRewindForTab, - setModel, - setEffort, - cancelJob, - switchTab, - switchRemoteTab, - openProjectTab, - createIsolatedWorktree, - openGlobalTab, - closeTab, - reorderTabs, - openTopicSession, - activateTopic, - noteNavigationIntent, - registeredNavigationIntent, - isNavigationIntentCurrent, - reassertVisibleTabAfterStaleNavigation, - syncActiveTab, - ensureBlankTab, - ensureBlankSurface, - commitSingleSurfaceNavigation, - } = useController(); - const { locale, setPref: setLocalePref } = useI18n(); + const appRenderToken = createAppRenderToken(); + useLayoutEffect(() => commitAppRenderToken(appRenderToken)); + const runtime = useAppRuntimeAdapter(); + const { state, liveStore, activeTabId, notice } = runtime.snapshot; const t = useT(); + const { locale } = useI18n(); + const { showToast } = useToast(); + const { runGoalAction, handleGoalActionError } = useGoalActionHandler(); const [composerProfilesByTab, setComposerProfilesByTab] = useState>({}); const yoloRestoreToolApprovalModesRef = useRef>({}); const userPlanModeByTabRef = useRef({}); const [tabMetas, setTabMetas] = useState([]); const [tabOrderIds, setTabOrderIds] = useState([]); - const { - surface: navigationSurface, intent: navigationSurfaceIntent, transitioning: runtimeTransitioning, dataReady: navigationTargetDataReady, - preserved: preservedTranscriptSurface, renderedRef: renderedTranscriptSurfaceRef, begin: beginNavigationSurface, maskTarget: settleNavigationSurface, commitPaint: commitNavigationSurfacePaint, - } = useNavigationSurface({ - activeTabId, ready: state.meta?.ready === true, backendActivationPending: Boolean(state.backendActivationPending), hydrating: Boolean(state.hydrating), hydrateError: state.hydrateError, + const activeTab = useMemo( + () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), + [activeTabId, tabMetas], + ); + const { active: remoteSurfaceActive, session: remoteSession, ready: remoteComposerReady, onSend: remoteSend, onCancel: remoteCancel } = useActiveRemoteSession(activeTab, showToast); + const activeSessionIdentity = sessionIdentityKey({ + tabId: activeTabId, + sessionPath: activeTab?.sessionPath ?? state.meta?.sessionPath, + sessionGeneration: activeTab?.sessionGeneration ?? state.meta?.sessionGeneration ?? state.sessionGen, + scope: activeTab?.scope, + workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd, + topicId: activeTab?.topicId, }); + const sessionSurfaceFenceRef = useRef | null>(null); + if (!sessionSurfaceFenceRef.current) sessionSurfaceFenceRef.current = createSessionSurfaceFence(); + const sessionSurfaceFence = sessionSurfaceFenceRef.current; + const sessionOperations = useSessionOperations({ + visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + resources: [ + { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + ...tabMetas.filter(tab => tab.id !== activeTabId).map(tab => ({ + tabId: tab.id, + sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath, + sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }), + })), + ], + }); + useLayoutEffect(() => { + sessionSurfaceFence.commit(activeTabId, activeSessionIdentity); + return () => sessionSurfaceFence.dispose(); + }, [activeSessionIdentity, activeTabId, sessionSurfaceFence]); + const navigationSurface = useNavigationSurface(projectNavigationSurfaceTarget({ + activeTabId, sessionKey: activeSessionIdentity, local: state, remote: remoteSurfaceActive ? remoteSession : undefined, + })); + const shell = useAppShellStores(); const [tabRevealSignal, setTabRevealSignal] = useState(0); const [transcriptRevealSignal, setTranscriptRevealSignal] = useState(0); - const startupSplashVisible = useOverlayStore((s) => s.startupSplashVisible); - const setStartupSplashVisible = useOverlayStore((s) => s.setStartupSplashVisible); - // null until the mount probe resolves; true shows the first-run guide. - const needsOnboarding = useOverlayStore((s) => s.needsOnboarding); - const setNeedsOnboarding = useOverlayStore((s) => s.setNeedsOnboarding); - const [providerSetupNeeded, setProviderSetupNeeded] = useState(false); - const page = useAppNavigationStore((s) => s.page); - const managementActive = page.kind !== "workspace"; - const settingsTarget = page.kind === "settings" ? page.tab : null; - const openPage = useAppNavigationStore((s) => s.openPage); - const returnToWorkspace = useAppNavigationStore((s) => s.returnToWorkspace); - const enterConversation = useAppNavigationStore((s) => s.enterConversation); - const visitedTrash = useAppNavigationStore((s) => s.visitedTrash); - const visitedAutomation = useAppNavigationStore((s) => s.visitedAutomation); - const automationReturn = useAppNavigationStore((s) => s.automationReturn); - const setSettingsTarget = useAppNavigationStore((s) => s.setSettingsTarget); - const settingsFocus = useAppNavigationStore((s) => s.settingsFocus); - const setSettingsFocus = useAppNavigationStore((s) => s.setSettingsFocus); - const [desktopLayoutStyle, setDesktopLayoutStyle] = useState("workbench"); - const singleSurfaceLayout = desktopLayoutStyle === "workbench" || desktopLayoutStyle === "creation"; - const { configLoadWarnings, applySnapshot: applyConfigWarningSnapshot, reload: reloadConfigWarnings, dismiss: dismissConfigWarnings } = useConfigLoadWarnings(); - const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState(null); const [histView, setHistView] = useState(null); - const paletteOpen = useOverlayStore((s) => s.paletteOpen); - const setPaletteOpen = useOverlayStore((s) => s.setPaletteOpen); - const paletteExtensionActions = useOverlayStore((s) => s.paletteExtensionActions); - const setPaletteExtensionActions = useOverlayStore((s) => s.setPaletteExtensionActions); - const remoteExplorerOpen = useRemoteStore((s) => s.explorerOpen); - const remoteExplorerHostId = useRemoteStore((s) => s.explorerHostId); - const remoteHosts = useRemoteStore((s) => s.hosts); - const remoteStatuses = useRemoteStore((s) => s.statuses); - const { showToast } = useToast(); - const { runGoalAction, handleGoalActionError } = useGoalActionHandler(); - const setRemoteHosts = useRemoteStore((s) => s.setHosts); - const hydrateRemoteStatuses = useRemoteStore((s) => s.hydrateStatuses); - const requestRemoteExplorer = useRemoteStore((s) => s.openExplorer); - const closeRemoteExplorerRequest = useRemoteStore((s) => s.closeExplorer); - const applyRemoteStatus = useRemoteStore((s) => s.applyStatus); - const requestRemoteStatusPopover = useRemoteStore((s) => s.requestStatusPopover); - const setRemoteForwards = useRemoteStore((s) => s.setForwards); - const setRemoteServer = useRemoteStore((s) => s.setServer); - - const shortcutsOpen = useOverlayStore((s) => s.shortcutsOpen); - const setShortcutsOpen = useOverlayStore((s) => s.setShortcutsOpen); - const paletteSessions = useOverlayStore((s) => s.paletteSessions); - const setPaletteSessions = useOverlayStore((s) => s.setPaletteSessions); - const [sidebarImConnections, setSidebarImConnections] = useState([]); - const [imTopicSources, setImTopicSources] = useState>({}); const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState(""); - const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed); - const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed); - type TimeFilter = "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d"; - const [topicTimeFilter, setTopicTimeFilter] = useState(() => { - try { - const saved = localStorage.getItem("projectTree:timeFilter"); - if (saved === "all" || saved === "10" || saved === "20" || saved === "1h" || saved === "3h" || saved === "5h" || saved === "1d") return saved; - } catch { /* localStorage unavailable */ } - return "all"; - }); - useEffect(() => { - try { localStorage.setItem("projectTree:timeFilter", topicTimeFilter); } catch { /* ignore */ } - }, [topicTimeFilter]); - const sidebarWidth = useLayoutStore((s) => s.sidebarWidth); - const setSidebarWidth = useLayoutStore((s) => s.setSidebarWidth); - const [sidebarResizing, setSidebarResizing] = useState(false); + const [topicTimeFilter, setTopicTimeFilter] = useTopicTimeFilter(); const [tasksOpen, setTasksOpen] = useState(false); - const [takeoverDialogTab, setTakeoverDialogTab] = useState(null); - const [reclaimBusyTab, setReclaimBusyTab] = useState(null); - const [liveSidebarWidth, setLiveSidebarWidth] = useState(null); - const [viewportWidth, setViewportWidth] = useState(() => (typeof window === "undefined" ? 1440 : window.innerWidth)); - const [viewportHeight, setViewportHeight] = useState(() => (typeof window === "undefined" ? 720 : window.innerHeight)); - const workspacePanelOpen = useLayoutStore((s) => s.workspacePanelOpen); - const setWorkspacePanelOpen = useLayoutStore((s) => s.setWorkspacePanelOpen); - const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth); - const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth); - const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth); - const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive); - const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive); - const attentionChimeEvents = useRef(new Set()); const workspaceScopeActiveTabRef = useRef(activeTabId); const [workspaceControllerEpoch, setWorkspaceControllerEpoch] = useState(0); workspaceScopeActiveTabRef.current = activeTabId; - // ContextPanel still uses this turn sequence for usage/session metadata; - // WorkspacePanel listens to resource-level workspace revisions instead. - useEffect(() => { - startTerminalEventBridge(); - const unsub = onEvent((e) => { - recordFrontendDiagnostic("runtime", "runtime.event", { - action: e.kind, - status: e.err ? "error" : "ok", - }); - if (e.kind === "turn_done") { - setDockRefreshKey((v) => v + 1); - } - if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) { - playAttentionChime(); - } - if (e.kind === "turn_done") { - if (!e.err) playSuccessChime(); - } - }); - // Runtime rebuilds (model/effort/settings switch) replace the controller, - // whose approval/ask ids restart from "1" — stale dedupe keys would mute - // the first prompt after a rebuild. agent:ready fires when a (re)build - // completes; clear that tab's keys (or all, for tab-less ready events). - const unsubReady = onReady((readyTabId) => { - recordFrontendDiagnostic("runtime", "runtime.ready", { ready: true, hasActiveTab: Boolean(readyTabId) }); - clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId); - // A failed startup (lease blocked, no model) never emits agent events, - // so the tabMetas list would keep the stale "starting" runtime and the - // takeover banner/button would have nothing to render. Refresh the list - // on every ready signal — the coordinator bounds the fetch rate. - void refreshTabMetas(); - if (!readyTabId || readyTabId === workspaceScopeActiveTabRef.current) { - setWorkspaceControllerEpoch((value) => value + 1); - } - }); - // Model/effort/token-mode switches and clear-while-running replace the - // controller WITHOUT an agent:ready — they signal runtime:rebuilt instead - // (a ready here would trigger a full session reload the UI already did). - const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => { - recordFrontendDiagnostic("runtime", "runtime.rebuilt", { ready: true, hasActiveTab: Boolean(rebuiltTabId) }); - clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId); - if (!rebuiltTabId || rebuiltTabId === workspaceScopeActiveTabRef.current) { - setWorkspaceControllerEpoch((value) => value + 1); - } - }); - // The backend pushes authoritative per-tab meta after state changes that - // produce no agent events (e.g. a lease-blocked startup). Refresh the list - // so the takeover banner/button render even when the active tab differs. - return () => { - unsub(); - unsubReady(); - unsubRebuilt(); - }; - }, []); - - useEffect(() => { - recordFrontendDiagnostic("app", "app.surface", { - hasActiveTab: Boolean(activeTabId), - tabCount: tabMetas.length, - }); - }, [activeTabId, tabMetas.length]); - - const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false); - const [liveWorkspacePanelRenderWidth, setLiveWorkspacePanelRenderWidth] = useState(null); - const [liveTerminalHeight, setLiveTerminalHeight] = useState(null); - const terminalResizing = liveTerminalHeight !== null; - const workspacePanelMaximized = useLayoutStore((s) => s.workspacePanelMaximized); - const setWorkspacePanelMaximized = useLayoutStore((s) => s.setWorkspacePanelMaximized); - const rightDockMode = useLayoutStore((s) => s.rightDockMode); - const setRightDockMode = useLayoutStore((s) => s.setRightDockMode); - const terminalPanelOpen = useLayoutStore((s) => s.terminalPanelOpen); - const setTerminalPanelOpen = useLayoutStore((s) => s.setTerminalPanelOpen); - const { mounted: terminalContentVisible, fitEnabled: terminalFitEnabled, prefetch: prefetchTerminalPanel } = useWarmTerminalPanel(terminalPanelOpen, terminalResizing, !managementActive); - const terminalHeight = useLayoutStore((s) => s.terminalHeight); - const setTerminalHeight = useLayoutStore((s) => s.setTerminalHeight); + const { mounted: terminalContentVisible, fitEnabled: terminalFitEnabled, prefetch: prefetchTerminalPanel } = useWarmTerminalPanel(shell.terminalPanelOpen, shell.terminalResizing, !shell.managementActive); const [dockRefreshKey, setDockRefreshKey] = useState(0); const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0); - const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []); + const refreshComposerFileRefs = useCommittedCommand(() => setFileRefRefreshKey((value) => value + 1)); const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`; const [projectRevision, setProjectRevision] = useState(0); - const [activeTopicTurns, setActiveTopicTurns] = useState(undefined); - const [composerInsertRequestsByTab, setComposerInsertRequestsByTab] = useState>({}); - const [selectedTextRequestsByTab, setSelectedTextRequestsByTab] = useState>({}); - const selectedTextRequestIdRef = useRef(0); - const [planRevisionInsertRequest, setPlanRevisionInsertRequest] = useState<{ - tabId: string; - approvalId: string; - request: ComposerInsertRequest; - } | null>(null); - const [workspaceInsertTarget, setWorkspaceInsertTarget] = useState("composer"); - const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal); - const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal); - const [desktopPlatform, setDesktopPlatform] = useState(detectBrowserPlatform); - const windowsFramelessChrome = desktopPlatform === "windows"; - const [mainWindowMaximised, syncMainWindowMaximised] = useWindowsMaximised(windowsFramelessChrome); - useWailsResizeFix(windowsFramelessChrome, mainWindowMaximised); - const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text"); - const [statusBarItems, setStatusBarItems] = useState(() => [...DEFAULT_STATUS_BAR_ITEMS]); - const [renamingTopicId, setRenamingTopicId] = useState(null); - const [topicTitleDraft, setTopicTitleDraft] = useState(""); - const topicExportOpen = useOverlayStore((s) => s.topicExportOpen); - const setTopicExportOpen = useOverlayStore((s) => s.setTopicExportOpen); - const sidebarSearchOpen = useOverlayStore((s) => s.sidebarSearchOpen); - const setSidebarSearchOpen = useOverlayStore((s) => s.setSidebarSearchOpen); - - const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal); - const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal); - const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false); - const [clearContextPending, setClearContextPending] = useState(false); - const [backgroundRuntimes, setBackgroundRuntimes] = useState([]); - const [workspaceConflict, setWorkspaceConflict] = useState(null); - const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null); - const [worktreeMergeTabId, setWorktreeMergeTabId] = useState(null); - const topicRenameSkipCommitRef = useRef(false); - const prevDecisionSurfaceRef = useRef(null); - const decisionSurfaceRef = useRef(null); - const topicRenameCommitHandledRef = useRef(false); - const appRef = useRef(null); - const layoutRef = useRef(null); - useManagementWorkspace(layoutRef, managementActive); - const workspacePanelResizeFinishRef = useRef<(() => void) | null>(null); - const sidebarTogglePressTimerRef = useRef(null); - - // Persist window geometry across launches. - useWindowStatePersistence(); - useViewportHeightVar(); - useEffect(() => () => workspacePanelResizeFinishRef.current?.(), []); - useEffect(() => { - document.documentElement.setAttribute("data-platform", desktopPlatform); - }, [desktopPlatform]); - - const refreshBackgroundRuntimes = useCallback(async () => { - try { - setBackgroundRuntimes(await app.BackgroundRuntimes()); - } catch { - // The global recovery entry is supplementary; the active-tab job list - // remains available even when the detached-runtime list is unavailable. - } - }, []); - - useEffect(() => { - let disposed = false; - const refresh = async () => { - if (disposed) return; - await refreshBackgroundRuntimes(); - }; - void refresh(); - const timer = window.setInterval(() => void refresh(), 1000); - return () => { - disposed = true; - window.clearInterval(timer); - }; - }, [refreshBackgroundRuntimes]); - - useEffect(() => { - if (!activeTabId || !state.running) { - setWorkspaceConflict(null); - return; - } - let disposed = false; - const inspect = async () => { - try { - const conflict = await app.WorkspaceConflictForTab(activeTabId); - if (!disposed) setWorkspaceConflict(conflict.state === "none" ? null : conflict); - } catch { - if (!disposed) setWorkspaceConflict(null); - } - }; - void inspect(); - const timer = window.setInterval(() => void inspect(), 500); - return () => { - disposed = true; - window.clearInterval(timer); - }; - }, [activeTabId, state.running]); - - const closeTransientOverlays = useCallback(() => { - setTransientOverlayDismissSignal((signal) => signal + 1); - }, []); - - const reloadSidebarImConnections = useCallback(async () => { - const [settings, runtimeStatus] = await Promise.all([ - app.DesktopStartupSettings(), - loadBotRuntimeStatus(), - ]); - setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); - setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); - }, [t]); - const refreshSidebarImConnectionsFromSettings = useCallback(async (settings: Pick) => { - const runtimeStatus = await loadBotRuntimeStatus(); - setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); - setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); - }, [t]); - - const openBotSettings = useCallback(() => { - closeTransientOverlays(); - setSidebarImDetailConnectionId(""); - setSettingsFocus(null); - setSettingsTarget("bots"); - }, [closeTransientOverlays]); - - const openBotAllowlistSettings = useCallback((connectionId: string) => { - closeTransientOverlays(); - setSidebarImDetailConnectionId(""); - setSettingsFocus({ target: "bot-allowlist", connectionId }); - setSettingsTarget("bots"); - }, [closeTransientOverlays]); - - const pulseSidebarToggle = useCallback(() => { - if (typeof window === "undefined") return; - if (sidebarTogglePressTimerRef.current !== null) { - window.clearTimeout(sidebarTogglePressTimerRef.current); - } - setSidebarTogglePressed(true); - sidebarTogglePressTimerRef.current = window.setTimeout(() => { - sidebarTogglePressTimerRef.current = null; - setSidebarTogglePressed(false); - }, 260); - }, []); - - const anchorAppScrollToChat = useCallback(() => { - if (typeof window === "undefined") return; - const el = appRef.current; - if (!el) return; - const pin = () => { - el.scrollLeft = 0; - }; - pin(); - window.requestAnimationFrame(pin); - window.setTimeout(pin, 300); - }, []); - - useEffect(() => { - return () => { - if (sidebarTogglePressTimerRef.current !== null) { - window.clearTimeout(sidebarTogglePressTimerRef.current); - } - }; - }, []); - - useEffect(() => { - let cancelled = false; - const override = browserPlatformOverride(); - if (override) { - setDesktopPlatform(override); - return () => { - cancelled = true; - }; - } - void app.Platform() - .then((value) => { - if (!cancelled) setDesktopPlatform(normalizeDesktopPlatform(value)); - }) - .catch((e) => { - console.warn("platform probe failed", e); - }); - return () => { - cancelled = true; - }; - }, []); - - const applyDesktopPreferences = useCallback( - (settings: Pick & { sessionExperience?: "standard" | "deep"; reasoningDisplayMode?: string; reasoningDisplayModeExplicit?: boolean }) => { - const nextTheme = normalizeThemePreference(settings.desktopTheme); - const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme); - applyConfiguredBaseAppearance(nextTheme, nextStyle); - applyTerminalThemePreference(settings.desktopTerminalTheme); - applyConversationWidth(settings.conversationWidth); - const nextLayoutStyle = normalizeDesktopLayoutStyle(settings.desktopLayoutStyle); - setDesktopLayoutStyle(nextLayoutStyle); - applyLayoutStyleDefaults(nextLayoutStyle); - setLocalePref(normalizeLangPref(settings.desktopLanguage)); - setStartupUpdateChecksEnabled(settings.checkUpdates !== false); - setStatusBarStyle(settings.statusBarStyle === "text" ? "text" : "icon"); - setStatusBarItems(normalizeStatusBarItems(settings.statusBarItems)); - hydrateSessionExperience(settings.sessionExperience); - hydrateReasoningDisplayMode(settings.sessionExperience === "deep" ? "expanded" : "auto", settings.sessionExperience === "deep"); + const session = useAppSessionComposition({ + runtime, + t, + showToast, + shell, + core: { + state, liveStore, activeTabId, notice, activeTab, remoteSurfaceActive, remoteSession, remoteComposerReady, + remoteSend, remoteCancel, activeSessionIdentity, sessionSurfaceFence, sessionOperations, }, - [setLocalePref], - ); - - useEffect(() => { - setReasoningDisplayPending(); - let cancelled = false; - const syncDesktopPreferences = async () => { - const legacyLanguage = readLegacyLangPref(); - const legacyTheme = readLegacyThemePreference(); - if (legacyLanguage || legacyTheme.hasValue) { - await app.MigrateDesktopPreferences(legacyLanguage, legacyTheme.theme, legacyTheme.style); - clearLegacyLangPref(); - clearLegacyThemePreference(); - } - const [settings, runtimeStatus] = await Promise.all([ - app.DesktopStartupSettings(), - loadBotRuntimeStatus(), - ]); - if (cancelled) return; - applyDesktopPreferences(settings); - applyConfigWarningSnapshot(settings.configWarnings, settings.configWarningsRevision); - // Session experience is the canonical user-facing preference. Legacy - // display mode is intentionally not hydrated, so an old compact value - // cannot override the two-state experience during startup. - setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus)); - setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t)); - // Load unified theme experience after base appearance so pack tokens win. - { - try { - const { loadThemeExperience, applyExperienceToDOM } = await import("./lib/themeExperience"); - const exp = await loadThemeExperience(); - if (cancelled) return; - applyExperienceToDOM(exp); - } catch (err) { - console.warn("theme experience load failed", err); - try { - const active = await app.GetActiveThemePack(); - if (cancelled) return; - if (active?.pack) applyThemePack(active.pack); - else clearThemePack(); - } catch { - clearThemePack(); - } - } - } - }; - void syncDesktopPreferences().catch((e) => { - console.warn("desktop preferences sync failed", e); - setStartupUpdateChecksEnabled(true); - hydrateSessionExperience("standard"); - hydrateReasoningDisplayMode("auto", false); - }); - return () => { - cancelled = true; - }; - }, [applyConfigWarningSnapshot, applyDesktopPreferences, t]); - - useEffect(() => { - setSidebarImDetailConnectionId((current) => { - if (!current) return ""; - return sidebarImConnections.some((connection) => connection.id === current) ? current : ""; - }); - }, [sidebarImConnections]); - - // Open settings when the native menu item (CmdOrCtrl+,) is activated. - useEffect(() => { - if (typeof window === "undefined" || !window.runtime) return; - return window.runtime.EventsOn("app:open-settings", () => { - closeTransientOverlays(); - setSettingsTarget(useAppNavigationStore.getState().lastSettingsTarget); - }); - }, [closeTransientOverlays]); - useEffect(() => { - if (typeof window === "undefined") return; - const onResize = () => { - setViewportWidth(window.innerWidth); - setViewportHeight(window.innerHeight); - }; - window.addEventListener("resize", onResize); - return () => window.removeEventListener("resize", onResize); - }, []); - - const [pendingPlanRevisionsByTab, setPendingPlanRevisionsByTab] = useState>({}); - const [invocationMetadataByTab, setInvocationMetadataByTab] = useState>({}); - const pendingPlanRevisionSendingTabsRef = useRef(new Set()); - const [footerHeight, setFooterHeight] = useState(0); - const footerHeightRef = useRef(0); - const footerRef = useRef(null); - const activeTabIdRef = useRef(activeTabId); - const commitThenSendRef = useRef<( - tabId: string, - displayText: string, - submitText?: string, - structured?: StructuredInvocationSubmit, - initialGoal?: { - goal: string; - collaborationMode: CollaborationMode; - toolApprovalMode: ToolApprovalMode; - }, - ) => Promise>(async () => {}); - const handleInvocationMetadataChange = useCallback((metadata: InvocationMetadataMap) => { - const sourceTabId = activeTabIdRef.current; - if (!sourceTabId) return; - setInvocationMetadataByTab((current) => { - const previous = current[sourceTabId] ?? {}; - const names = Object.keys(metadata); - if (names.length === Object.keys(previous).length && names.every((name) => ( - previous[name]?.kind === metadata[name]?.kind && previous[name]?.color === metadata[name]?.color - ))) return current; - return { ...current, [sourceTabId]: metadata }; - }); - }, []); - const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive; - // The dock keeps one width across tab switches (context/files/changed): - // the tree width is the single source so toggling tabs never resizes the - // sidebar. Preview detail stays inside the dock without widening it. - const preferredWorkspacePanelWidth = rightDockTreeWidth; - const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; - const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth; - const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive - ? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH - : RIGHT_DOCK_MIN_RENDER_WIDTH; - const workspacePanelMinWidth = rightDockTreeMinWidth; - const chatReservedWidth = CHAT_MIN_WIDTH; - const workspacePanelAvailableWidth = availableWorkspacePanelWidth({ - viewportWidth, - sidebarCollapsed, - sidebarWidth, - chatMinWidth: chatReservedWidth, - resizerWidth: WORKSPACE_RESIZER_WIDTH, - }); - const { - renderWidth: workspacePanelRenderWidth, - overlay: workspacePanelOverlay, - // The automation page fills the main content area; the workbench dock must - // not overlay it. main-v2 keeps automation as a popup so its placement - // helper has no view concept — apply the exclusion here on top. - renderable: workspacePanelRenderable, - gridOpen: workspacePanelGridOpen, - } = resolveWorkspacePanelPlacement({ - viewportWidth, sidebarCollapsed, sidebarWidth, chatMinWidth: chatReservedWidth, - resizerWidth: WORKSPACE_RESIZER_WIDTH, open: workspacePanelOpen, - maximized: workspacePanelMaximized, preferredWidth: preferredWorkspacePanelWidth, - minWidth: workspacePanelMinWidth, minRenderWidth: rightDockMinRenderWidth, - liveWidth: liveWorkspacePanelRenderWidth, - }); - const resolveLiveWorkspacePanelRenderWidth = useCallback( - (preferredWidth: number, nextSidebarWidth = sidebarWidth) => - resolveLiveWorkspacePanelWidth({ - viewportWidth, - sidebarCollapsed, - sidebarWidth: nextSidebarWidth, - chatMinWidth: chatReservedWidth, - resizerWidth: WORKSPACE_RESIZER_WIDTH, - open: workspacePanelOpen, - maximized: workspacePanelMaximized, - preferredWidth, - minWidth: workspacePanelMinWidth, - }), - [chatReservedWidth, sidebarCollapsed, sidebarWidth, viewportWidth, workspacePanelMaximized, workspacePanelMinWidth, workspacePanelOpen], - ); - const activeTab = useMemo( - () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), - [activeTabId, tabMetas], - ); - const { active: remoteSurfaceActive, session: remoteSession, ready: remoteComposerReady, onSend: remoteSend, onCancel: remoteCancel } = useActiveRemoteSession(activeTab, showToast); - - // Remote tab became ready: refresh the tab list so the spectator banner - // (takenOver) renders. The agent:ready event only fires for local tabs; - // remote tabs publish readiness via remote-tab::state, which - const visibleRuntimeState = remoteSurfaceActive ? remoteSession.transcript : state; - const localWorkspaceDockBlocked = remoteSurfaceActive && (rightDockMode === "files" || rightDockMode === "changed"); - const surfaceWorkspacePanelRenderable = workspacePanelRenderable && !localWorkspaceDockBlocked; - const surfaceWorkspacePanelGridOpen = workspacePanelGridOpen && !localWorkspaceDockBlocked; - const terminalSurfaceOpen = terminalPanelOpen && !remoteSurfaceActive; - const activePlanRevisionInsertRequest = - planRevisionInsertRequest && - planRevisionInsertRequest.tabId === activeTabId && - planRevisionInsertRequest.approvalId === state.approval?.id - ? planRevisionInsertRequest.request - : null; - const composerInsertRequest = activeTabId ? composerInsertRequestsByTab[activeTabId] ?? null : null; - const handleRevisionActiveChange = useCallback((active: boolean) => { - setWorkspaceInsertTarget(active ? "planRevision" : "composer"); - }, []); - const selectedTextRequest = activeTabId ? selectedTextRequestsByTab[activeTabId] ?? null : null; - const prefillSubagentCommand = useCallback((command: string) => { - if (!activeTabId) return; - setComposerInsertRequestsByTab((current) => ({ - ...current, - [activeTabId]: { id: Date.now(), text: command, mode: "prefix" }, - })); - }, [activeTabId]); - const composerSessionKey = useMemo(() => { - return composerDraftKeyForTab(activeTab, activeTabId); - }, [activeTab, activeTabId]); - const transcriptGeometrySessionKey = useMemo(() => { - const sessionPath = (activeTab?.sessionPath ?? state.meta?.sessionPath ?? "").trim(); - const sessionGeneration = activeTab?.sessionGeneration ?? state.meta?.sessionGeneration ?? state.sessionGen; - if (sessionPath) return ["session", sessionPath, String(sessionGeneration ?? 0)].join("\u0000"); - return [ - "topic", - activeTab?.scope ?? "", - activeTab?.workspaceRoot ?? state.meta?.cwd ?? "", - activeTab?.topicId ?? "", - activeTabId ?? "", - ].join("\u0000"); - }, [activeTab, activeTabId, state.meta?.cwd, state.meta?.sessionGeneration, state.meta?.sessionPath, state.sessionGen]); - const workspaceScopeKey = [ - activeTabId ?? "", - activeTab?.sessionPath ?? "", - state.meta?.sessionPath ?? "", - state.meta?.cwd ?? "", - state.sessionGen, - workspaceControllerEpoch, - ].join("\u0000"); - // Workspace navigation belongs to the project, not to a single conversation. - // A session switch inside the same project must therefore retain the dock, - // tree and selection state. - const workspaceTreeMemoryKey = [ - activeTab?.scope ?? "", - activeTab?.workspaceRoot ?? state.meta?.cwd ?? "", - ].join("\u0000"); - const restoreWorkspaceDockWidths = useCallback((treeWidth: number, _previewWidth: number) => { - // Single-width dock: only the tree width is meaningful; clamp it to the - // dynamic available width (chat keeps its 400px floor), never a fixed - // 560 ceiling, so the user's remembered width is preserved when reopened. - setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth, workspacePanelAvailableWidth)); - }, [rightDockTreeWidthClamp, workspacePanelAvailableWidth]); - const sidebarImDetailConnection = useMemo( - () => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null, - [sidebarImConnections, sidebarImDetailConnectionId], - ); - useEffect(() => { - let cancelled = false; - if (!activeTab?.topicId) { - setActiveTopicTurns(undefined); - return () => { - cancelled = true; - }; - } - void app.GetTopicSummary({ - scope: activeTab.scope === "global" ? "global" : "project", - workspaceRoot: activeTab.scope === "global" ? "" : activeTab.workspaceRoot, - topicId: activeTab.topicId, - }) - .then((topic) => { - if (!cancelled) setActiveTopicTurns(topic.turns); - }) - .catch(() => { - if (!cancelled) setActiveTopicTurns(undefined); - }); - return () => { - cancelled = true; - }; - }, [activeTab?.scope, activeTab?.topicId, activeTab?.workspaceRoot, projectRevision]); - const visibleUserTurns = visibleRuntimeState.items.reduce((count, item) => (item.kind === "user" ? count + 1 : count), 0); - const currentTabTurns = Math.max(visibleRuntimeState.checkpoints.length, visibleUserTurns); - const sessionTurns = currentTabTurns > 0 ? currentTabTurns : remoteSurfaceActive ? 0 : activeTopicTurns ?? 0; - const startupSplashHold = !activeTabId && state.meta?.ready !== true && !state.meta?.startupErr; - const activeComposerProfile = activeTabId ? composerProfilesByTab[activeTabId] : undefined; - const backendActiveComposerProfile = useMemo(() => { - if (state.meta) { - return composerProfileFromMeta( - state.meta, - activeTab ? composerProfileMode(composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode)) : undefined, - activeComposerProfile?.toolApprovalMode, - ); - } - return composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode); - }, [activeComposerProfile?.toolApprovalMode, activeTab, state.meta]); - const composerProfile = activeTabId - ? activeComposerProfile ?? backendActiveComposerProfile - : defaultComposerProfile; - const goal = composerProfile.goal; - const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile); - const toolApprovalMode = composerProfile.toolApprovalMode; - const remoteComposerProfileReady = useRemoteComposerProfileSync({ activeTabId, remote: remoteSurfaceActive, - remoteProfile: remoteSession.composerProfile, collaborationMode, toolApprovalMode, goal, - qualityFloor: composerProfile.qualityFloor, pending: composerProfile.pending, setProfiles: setComposerProfilesByTab }); - const controllerReady = - state.meta?.ready === true && - (!state.meta.runtime || state.meta.runtime.phase === "ready") && - !state.meta.startupErr && - !state.backendActivationPending && - !runtimeTransitioning; - - useEffect(() => { - recordFrontendDiagnostic("app", "app.runtime-state", { - ready: controllerReady, - running: state.running, - hydrating: state.hydrating, - runtimeTransitioning, - contentRevision: state.historyLayoutRevision, - }); - }, [controllerReady, runtimeTransitioning, state.hydrating, state.historyLayoutRevision, state.running]); - // Single footer decision surface. Composer stays mounted underneath and is - // only visually/a11y-hidden so per-session draft caches survive. - const decisionSurface = useMemo((): DecisionSurfaceKind | null => { - if (state.approval) { - return state.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval"; - } - if (state.ask) return "ask"; - if (state.mcpInteraction) return "mcp_interaction"; - if (state.extensionForm) return "extension_form"; - if (workspaceConflict) return "workspace_conflict"; - if (pendingClose) return "close_active"; - if (clearContextPending) return "clear_context"; - return null; - }, [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]); - const visibleDecisionSurface = decisionSurface; - const composerSurfaceHidden = runtimeTransitioning || Boolean(decisionSurface); - decisionSurfaceRef.current = decisionSurface; - useEffect(() => { - // Close composer menus/popovers when a decision takes over the footer. - if (decisionSurface) { - closeTransientOverlays(); - prevDecisionSurfaceRef.current = decisionSurface; - return; - } - // Restore composer focus on the next frame only if the tab did not switch - // and no new decision arrived (remote resolution / rapid consecutive prompts). - const hadDecision = prevDecisionSurfaceRef.current != null; - prevDecisionSurfaceRef.current = null; - if (!hadDecision) return; - const tabAtRelease = activeTabId; - const frame = requestAnimationFrame(() => { - if (decisionSurfaceRef.current != null) return; - if (activeTabIdRef.current !== tabAtRelease) return; - const input = document.getElementById("composer-input") as HTMLTextAreaElement | null; - input?.focus({ preventScroll: true }); - }); - return () => cancelAnimationFrame(frame); - }, [activeTabId, closeTransientOverlays, decisionSurface]); - - // Extension form surface (stage 8b2): submit delivers the structured values - // to the owning sidecar; cancel reports values{"cancelled": true} over the - // same channel. A failed cancel still dismisses — the sidecar that could not - // be reached is gone either way. - const [extensionFormBusy, setExtensionFormBusy] = useState(false); - const submitExtensionForm = useCallback(async (values: Record) => { - const pending = state.extensionForm; - if (!pending || !activeTabId || extensionFormBusy) return; - setExtensionFormBusy(true); - try { - await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, values); - dismissExtensionForm(); - } catch (err) { - showToast(err instanceof Error ? err.message : String(err), "error"); - } finally { - setExtensionFormBusy(false); - } - }, [activeTabId, dismissExtensionForm, extensionFormBusy, showToast, state.extensionForm]); - const cancelExtensionForm = useCallback(async () => { - const pending = state.extensionForm; - if (!pending || extensionFormBusy) return; - setExtensionFormBusy(true); - try { - if (activeTabId) { - await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, { cancelled: true }).catch(() => {}); - } - dismissExtensionForm(); - } finally { - setExtensionFormBusy(false); - } - }, [activeTabId, dismissExtensionForm, extensionFormBusy, state.extensionForm]); - - // Extension notifications queue in per-tab state (the reducer cannot reach - // the toast context); drain the active tab's queue into toasts here. - useEffect(() => { - const pending = state.extensionNotifications; - if (!pending || pending.length === 0) return; - for (const notification of pending) { - const level = notification.severity === "error" ? "error" : notification.severity === "warn" ? "warn" : "info"; - showToast(notification.body ? `${notification.title} — ${notification.body}` : notification.title, level); - } - drainExtensionNotifications(); - }, [state.extensionNotifications, showToast, drainExtensionNotifications]); - const extensionStatusList = useMemo(() => Object.values(state.extensionStatuses ?? {}), [state.extensionStatuses]); - const patchActiveComposerProfile = useCallback( - (patch: Partial>, pendingFields: ComposerProfileField[]) => { - if (!activeTabId) return; - setComposerProfilesByTab((current) => patchComposerProfile(current, activeTabId, composerProfile, patch, pendingFields)); + surface: navigationSurface, + stores: { + composerProfilesByTab, setComposerProfilesByTab, tabMetas, setTabMetas, tabOrderIds, setTabOrderIds, + yoloRestoreToolApprovalModesRef, userPlanModeByTabRef, }, - [activeTabId, composerProfile], - ); - const patchComposerProfileForTab = useCallback( - (tabId: string, patch: Partial>, pendingFields: ComposerProfileField[]) => { - if (!tabId) return; - setComposerProfilesByTab((current) => { - const base = current[tabId] ?? composerProfileFromTab(tabMetas.find((tab) => tab.id === tabId)); - return patchComposerProfile(current, tabId, base, patch, pendingFields); - }); + local: { + setHistView, setTabRevealSignal, setTranscriptRevealSignal, + sidebarImDetailConnectionId, setSidebarImDetailConnectionId, + workspaceScopeActiveTabRef, workspaceControllerEpoch, setWorkspaceControllerEpoch, + dockRefreshKey, setDockRefreshKey, fileRefRefreshKey, setFileRefRefreshKey, projectRevision, setProjectRevision, }, - [tabMetas], - ); - const topicbarEditing = Boolean(activeTab && (activeTab.remote ? activeTab.id : activeTab.topicId) === renamingTopicId && (activeTab.remote || activeTab.topicId)); - const visibleTabId = activeTabId; - const visibleTabs = useMemo(() => { - const byId = new Map(tabMetas.map((tab) => [tab.id, tab])); - const ordered = tabOrderIds.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab)); - const missing = tabMetas.filter((tab) => !tabOrderIds.includes(tab.id)); - return [...ordered, ...missing].map((tab) => { - const profile = composerProfilesByTab[tab.id] ?? composerProfileFromTab(tab); - return { - ...tab, - running: tab.id === visibleTabId ? tab.running || state.running : tab.running, - mode: composerProfileMode(profile), - collaborationMode: displayedComposerProfileCollaborationMode(profile), - toolApprovalMode: profile.toolApprovalMode, - goal: profile.goal, - active: tab.id === visibleTabId, - }; - }); - }, [composerProfilesByTab, state.running, tabMetas, tabOrderIds, visibleTabId]); - - useEffect(() => { - const ids = tabMetas.map((tab) => tab.id); - setTabOrderIds((current) => { - const next = current.filter((id) => ids.includes(id)); - for (const id of ids) { - if (!next.includes(id)) next.push(id); - } - return next.join("\u0000") === current.join("\u0000") ? current : next; - }); - }, [tabMetas]); - - useEffect(() => { - const ids = new Set(tabMetas.map((tab) => tab.id)); - for (const id of Object.keys(yoloRestoreToolApprovalModesRef.current)) { - if (!ids.has(id)) delete yoloRestoreToolApprovalModesRef.current[id]; - } - userPlanModeByTabRef.current = pruneUserPlanModeIntents(userPlanModeByTabRef.current, ids); - setComposerProfilesByTab((current) => hydrateComposerProfilesFromTabs(current, tabMetas)); - }, [tabMetas]); - - useEffect(() => { - const activeRenameId = activeTab?.remote ? activeTab.id : activeTab?.topicId; - if (!renamingTopicId || activeRenameId === renamingTopicId) return; - topicRenameSkipCommitRef.current = false; - topicRenameCommitHandledRef.current = false; - setRenamingTopicId(null); - setTopicTitleDraft(""); - }, [activeTab?.topicId, renamingTopicId]); - - useEffect(() => { - if (!activeTabId || !state.meta) return; - setComposerProfilesByTab((current) => hydrateComposerProfileFromMeta(current, activeTabId, state.meta!)); - }, [activeTabId, state.meta]); - - const syncModeToController = useCallback((m: Mode) => setControllerMode(m), [setControllerMode]); - - useEffect(() => { - void app.SetTrayLocale(locale).catch(() => {}); - }, [locale]); - - const { applyMode, applyCollaborationMode, applyToolApprovalMode } = useComposerModeActions({ - activeTabId, remote: remoteSurfaceActive, collaborationMode, toolApprovalMode, goal, - planIntentRef: userPlanModeByTabRef, yoloRestoreRef: yoloRestoreToolApprovalModesRef, - patchProfile: patchActiveComposerProfile, setControllerMode: syncModeToController, - setControllerCollaborationMode, setControllerToolApprovalMode, clearControllerGoal, drainRemoteApprovals: remoteSession.drainApprovals, - showError: (message) => showToast(message, "error"), + goal: { runGoalAction, handleGoalActionError }, }); - const applyQualityFloor = useCallback( - (floor: QualityFloor) => { - if (!activeTabId) return; - if (remoteSurfaceActive) { - void remoteSession.setQualityFloor(floor).catch((error) => showToast(error instanceof Error ? error.message : String(error), "error")); - return; - } - patchActiveComposerProfile({ qualityFloor: floor }, ["qualityFloor"]); - void setControllerQualityFloor(floor); - }, - [activeTabId, patchActiveComposerProfile, remoteSession, remoteSurfaceActive, setControllerQualityFloor, showToast], - ); - const toggleYoloApprovalMode = useCallback(() => { - if (!activeTabId) return; - const next = toggleYoloToolApprovalMode( - toolApprovalMode, - yoloRestoreToolApprovalModesRef.current[activeTabId], - ); - if (next.restore) { - yoloRestoreToolApprovalModesRef.current[activeTabId] = next.restore; - } - applyToolApprovalMode(next.mode); - }, [activeTabId, applyToolApprovalMode, toolApprovalMode]); - const patchActivatedGoalForTab = useCallback( - (tabId: string, nextGoal: string): void => { - const trimmed = nextGoal.trim(); - patchComposerProfileForTab(tabId, { - collaborationMode: trimmed ? "goal" : "normal", - goalDraftMode: false, - goal: trimmed, - }, ["collaborationMode", "goal"]); - userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, tabId, false); - }, - [patchComposerProfileForTab], - ); - const applyGoalForTab = useCallback( - async (tabId: string, nextGoal: string): Promise => { - if (!tabId) return; - const trimmed = nextGoal.trim(); - // Activate the backend Goal first. Only then patch the local profile so a - // failed SetGoalForTab cannot leave the Composer thinking a Goal is active. - if (tabMetas.some((tab) => tab.id === tabId && tab.remote)) { - await app.SetRemoteTabGoal(tabId, trimmed); - patchActivatedGoalForTab(tabId, trimmed); - return; - } - await (trimmed ? setControllerGoalForTab(tabId, trimmed) : clearControllerGoalForTab(tabId)); - patchActivatedGoalForTab(tabId, trimmed); - }, - [clearControllerGoalForTab, patchActivatedGoalForTab, setControllerGoalForTab, tabMetas], - ); - const applyGoal = useCallback( - async (nextGoal: string): Promise => { - if (!activeTabId) return; - await applyGoalForTab(activeTabId, nextGoal); - }, - [activeTabId, applyGoalForTab], - ); - const remoteComposerSend = useRemoteComposerSend(activeTab?.remote, activeTabId, collaborationMode, goal, - remoteSession, remoteSend, applyGoalForTab, useCallback(() => setClearContextPending(true), [])); - const cancelRuntimeJob = useCallback(async (tabId: string, jobId: string): Promise => { - try { - const cancelled = await app.CancelJobForTab(tabId, jobId); - await refreshBackgroundRuntimes(); - return cancelled; - } catch (err) { - showToast(err instanceof Error ? err.message : String(err), "error"); - return false; - } - }, [refreshBackgroundRuntimes, showToast]); - // Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the - // tool-permission axis while preserving the Ask/Auto base mode. - const cycleMode = useCallback(() => { - runGoalAction(() => applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan")); - }, [applyCollaborationMode, collaborationMode, runGoalAction]); - // Switching models rebuilds the controller, which starts in normal mode — re-apply - // it or the pill would say plan/YOLO while the fresh controller uses normal gating. - const switchModel = useCallback( - async (name: string) => { - if (remoteSurfaceActive && activeTabId) return remoteSession.setModel(name).then(() => true); - const switched = await setModel(name); - if (!switched) return false; - if (!activeTabId) return false; - const profileApplied = await setControllerComposerProfileForTab( - activeTabId, - controllerComposerProfileCollaborationMode(composerProfile), - toolApprovalMode, - goal, - { propagateError: true }, - ); - return profileApplied; - }, - [activeTabId, composerProfile, goal, remoteSession, remoteSurfaceActive, setControllerComposerProfileForTab, setModel, toolApprovalMode], - ); - - // Startup and workspace/model rebuilds create a fresh controller in normal - // mode. Re-apply the UI mode once the controller is ready, including the case - // where the user picked YOLO while boot was still loading and the legacy - // SetBypass binding was a harmless no-op. - useEffect(() => { - if (!controllerReady || !activeTabId || remoteSurfaceActive) return; - runGoalAction(async () => { - await setControllerComposerProfileForTab( - activeTabId, - controllerComposerProfileCollaborationMode(composerProfile), - toolApprovalMode, - goal, - { propagateError: true }, - ); - }); - }, [activeTabId, composerProfile, controllerReady, goal, remoteSurfaceActive, runGoalAction, setControllerComposerProfileForTab, toolApprovalMode]); - - // The live task list pinned above the composer comes from the most recent - // successful top-level todo_write result; failed or still-running attempts do - // not advance the canonical panel state. Incomplete lists are always shown so - // a stale local dismissal cannot hide work that still blocks final readiness; - // every new list starts collapsed while its header keeps showing live progress - // and the current task. Live completion briefly shows 3/3 before retirement; - // restored completed lists stay in transcript only. The dismissal key is - // still based on stable todo content/state so history reloads do not - // resurrect the same finished list. The status-agnostic batch key prevents - // false new batches; dismissal remains session-scoped and sidecar-persisted. - const todoEntry = useMemo(() => { - for (let i = visibleRuntimeState.items.length - 1; i >= 0; i--) { - const it = visibleRuntimeState.items[i]; - if (it.kind === "tool" && it.name === "todo_write" && !it.parentId && it.status === "done" && !it.error) { - return { item: it, index: i }; - } - } - return null; - }, [visibleRuntimeState.items]); - const todoItem = todoEntry?.item ?? null; - const metaTodos = remoteSurfaceActive ? undefined : state.meta?.canonicalTodos; - const todos = useMemo( - () => resolveTodoPanelTodos(metaTodos, todoItem ? parseTodos(todoItem.args) : undefined), - [metaTodos, todoItem], - ); - const [dismissedTodoKeys, setDismissedTodoKeys] = useState>(loadDismissedTodoKeys); - const todoKey = useMemo(() => todoDismissalKey(todos), [todos]); - const todoBatch = useMemo(() => todoBatchKey(todos), [todos]); - const todoScope = useMemo( - () => todoPanelScope({ activeTab, activeTabId, eventChannel: remoteSurfaceActive ? undefined : state.meta?.eventChannel }), - [activeTab, activeTabId, remoteSurfaceActive, state.meta?.eventChannel], - ); - const dismissedTodo = useMemo( - () => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey), - [dismissedTodoKeys, todoKey, todoScope], - ); - const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]); - const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]); - const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos, { batchKey: todoBatch, batches: !remoteSurfaceActive && state.meta?.sessionPath === activeTab?.sessionPath ? state.meta?.dismissedTodoBatches : undefined }); - const dismissTodos = useCallback(() => { - if (!scopedTodoKey) return; - setDismissedTodoKeys((current) => { - if (current.has(scopedTodoKey)) return current; - const next = new Set(current); - next.add(scopedTodoKey); - saveDismissedTodoKeys(next); - return next; - }); - if (!remoteSurfaceActive && activeTabId && todoBatch) void app.DismissTodoBatchForTab(activeTabId, todoBatch).catch(() => undefined); - }, [activeTabId, remoteSurfaceActive, scopedTodoKey, todoBatch]); - const handleTodoContinue = useCallback(() => { - const targetTabId = todoContinueTarget(activeTabId, activeTabIdRef.current, { - ready: remoteSurfaceActive ? remoteComposerReady : controllerReady, - readOnly: Boolean(activeTab?.readOnly), - running: visibleRuntimeState.running, - pendingPrompt: visibleRuntimeState.pendingPrompt, - }); - if (!targetTabId) return; - const prompt = t("todo.continue"); - if (remoteSurfaceActive) { - void remoteSend(prompt); - return; - } - void sendToTab(targetTabId, prompt); - }, [activeTab?.readOnly, activeTabId, controllerReady, remoteComposerReady, remoteSend, remoteSurfaceActive, sendToTab, t, visibleRuntimeState.pendingPrompt, visibleRuntimeState.running]); - - const sessionTitle = topicTitle(activeTab); - const exportItems = remoteSurfaceActive ? remoteSession.transcript.items : state.items; - const exportLive = remoteSurfaceActive - ? remoteSession.transcript.live - : liveStore.getSnapshot(activeTabId) ?? state.live; - const sessionHasContent = exportItems.length > 0 || Boolean(exportLive?.text || exportLive?.reasoning); - - // Theme pack scene: home when the session is empty, task once content exists. - useEffect(() => { - applyThemeScene(sessionHasContent ? "task" : "home"); - }, [sessionHasContent]); - const getSessionMarkdown = useCallback( - async () => (await import("./lib/sessionExportData")).sessionItemsToMarkdown(sessionTitle, exportItems, exportLive), - [exportItems, exportLive, sessionTitle], - ); - const getSessionJson = useCallback( - async () => (await import("./lib/sessionExportData")).sessionItemsToJson(sessionTitle, exportItems, exportLive), - [exportItems, exportLive, sessionTitle], - ); - - useEffect(() => { - if (!topicExportOpen) return; - const onDown = (event: MouseEvent) => { - const target = event.target as Element | null; - if (!target?.closest(".topicbar__export")) setTopicExportOpen(false); - }; - document.addEventListener("mousedown", onDown); - return () => document.removeEventListener("mousedown", onDown); - }, [topicExportOpen]); - - const exportSession = useCallback( - async (format: "markdown" | "json" | "pdf" | "image") => { - const base = safeFilename(sessionTitle); - setTopicExportOpen(false); - try { - if (format === "json") { - const path = await app.PickExportFile(`${base}.json`, "application/json"); - if (path) { - await app.SaveExportFile(path, await getSessionJson(), false); - showToast(t("topicBar.exportSuccess", { count: 1 }), "info"); - } - } else if (format === "pdf") { - const path = await app.PickExportFile(`${base}.pdf`, "application/pdf"); - if (!path) return; - const { blobToBase64, renderSessionPdfBlob } = await import("./lib/sessionExport"); - const blob = await renderSessionPdfBlob(await getSessionMarkdown(), sessionTitle); - await app.SaveExportFile(path, await blobToBase64(blob), true); - showToast(t("topicBar.exportSuccess", { count: 1 }), "info"); - } else if (format === "image") { - const path = await app.PickExportFile(`${base}.png`, "image/png"); - if (!path) return; - const { renderSessionImageBase64Payloads } = await import("./lib/sessionExport"); - const payloads = await renderSessionImageBase64Payloads(await getSessionMarkdown()); - await app.SaveExportImageFiles(path, payloads); - showToast( - payloads.length > 1 - ? t("topicBar.exportImageParts", { count: payloads.length }) - : t("topicBar.exportSuccess", { count: 1 }), - "info", - ); - } else { - const path = await app.PickExportFile(`${base}.md`, "text/markdown"); - if (path) { - await app.SaveExportFile(path, await getSessionMarkdown(), false); - showToast(t("topicBar.exportSuccess", { count: 1 }), "info"); - } - } - } catch (err) { - console.error("Failed to export session", err); - showToast( - t("topicBar.exportFailed", { error: err instanceof Error ? err.message : String(err) }), - "error", - { durationMs: 8000 }, - ); - } - }, - [getSessionJson, getSessionMarkdown, sessionTitle, showToast, t], - ); - - useEffect(() => { - if (!activeTabId || state.running) return; - const text = pendingPlanRevisionsByTab[activeTabId]; - if (!text || pendingPlanRevisionSendingTabsRef.current.has(activeTabId)) return; - pendingPlanRevisionSendingTabsRef.current.add(activeTabId); - void commitThenSendRef.current(activeTabId, text) - .then(() => { - setPendingPlanRevisionsByTab((current) => { - if (current[activeTabId] !== text) return current; - const next = { ...current }; - delete next[activeTabId]; - return next; - }); - }) - .catch((err) => { - console.warn("Failed to submit pending plan revision", err); - }) - .finally(() => { - pendingPlanRevisionSendingTabsRef.current.delete(activeTabId); - }); - }, [activeTabId, pendingPlanRevisionsByTab, state.running]); - - useEffect(() => { - setClearContextPending(false); - setWorkspaceInsertTarget("composer"); - }, [activeTabId]); - - const cancelClearContext = useCallback(() => { - setClearContextPending(false); - }, []); - - const confirmClearContext = useCallback(async () => { - setClearContextPending(false); - try { - if (remoteSurfaceActive && activeTabId) { - await app.ClearRemoteTabSession(activeTabId); - await remoteSession.retryHydration(); - } else { - await clearSession(); - } - setDockRefreshKey((v) => v + 1); - notice(t("clearContext.done")); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - notice(msg || t("clearContext.failed"), "warn"); - } - }, [activeTabId, clearSession, notice, remoteSession, remoteSurfaceActive, t]); - - useEffect(() => { - activeTabIdRef.current = activeTabId; - }, [activeTabId]); - // handleSend reserves only commands that need a desktop-native UI action. - const handleSend = useCallback( - async (displayText: string, submitText = displayText, requestedTabId = activeTabId, structured?: StructuredInvocationSubmit) => { - const sourceTabId = requestedTabId || activeTabId; - if (!sourceTabId) throw new Error(t("composer.workspaceStarting")); - const trimmed = displayText.trim(); - // "!" runs a shell command directly, bypassing the model. - if (trimmed.startsWith("!")) { - const cmd = trimmed.slice(1).trim(); - if (!cmd) { - notice("usage: ! (e.g. !ls -la)"); - return; - } - await runShellForTab(sourceTabId, cmd); - return; - } - const model = /^\/model\s+(\S+)$/.exec(trimmed); - if (model) { - await switchModel(model[1]); - return; - } - if (trimmed === "/memory") { - if (activeTabIdRef.current !== sourceTabId) return; - closeTransientOverlays(); - setSettingsTarget("memory"); - return; - } - if (trimmed === "/clear") { - if (activeTabIdRef.current !== sourceTabId) return; - setClearContextPending(true); - return; - } - if (trimmed === "/new") { - if (activeTabIdRef.current !== sourceTabId) return; - await newSession(); - return; - } - const decisionMock = typeof window !== "undefined" && !window.runtime - ? decisionSurfaceMockFromInput(trimmed) - : null; - if (decisionMock === "workspace_conflict" || decisionMock === "mode_jobs" || decisionMock === "close_active" || decisionMock === "clear_context") { - if (activeTabIdRef.current !== sourceTabId) return; - closeTransientOverlays(); - setWorkspaceConflict(null); - setPendingClose(null); - setClearContextPending(false); - const mockWork: ActiveWorkView = { - running: true, - pendingPrompt: false, - cancellable: true, - jobs: [ - { id: "mock-decision-build", kind: "bash", label: "pnpm build", status: "running", startedAt: Date.now() - 42_000 }, - { id: "mock-decision-test", kind: "bash", label: "go test ./...", status: "running", startedAt: Date.now() - 18_000 }, - ], - }; - if (decisionMock === "workspace_conflict") { - setWorkspaceConflict({ - state: "local", - ownerTabId: "mock-workspace-writer", - ownerTitle: t("mock.topicDevStandard"), - ownerWork: mockWork, - canReveal: true, - canCreateWorktree: true, - }); - } else if (decisionMock === "close_active") { - setPendingClose({ tabId: sourceTabId, work: mockWork, stopping: false }); - } else { - setClearContextPending(true); - } - return; - } - const goalCommand = /^\/goal(?:\s+(.*))?$/.exec(trimmed); - if (goalCommand) { - const arg = (goalCommand[1] ?? "").trim(); - const displayGoal = stripLegacyGoalBudgetFlags(arg); - if (displayGoal && !["status", "clear", "off", "stop", "done", "pause", "resume"].includes(displayGoal.toLowerCase())) { - if (hasLegacyGoalBudgetFlag(arg)) { - userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, activeTabId, false); - patchActiveComposerProfile({ - collaborationMode: "goal", - goalDraftMode: false, - goal: displayGoal, - }, ["collaborationMode", "goal"]); - } else { - await applyGoal(displayGoal); - } - } else if (["clear", "off", "stop", "done"].includes(displayGoal.toLowerCase())) { - await applyGoal(""); - } - if (!controllerReady) return; - await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim()); - return; - } - if (collaborationMode === "goal" && !goal.trim()) { - if (!controllerReady) return; - await activateGoalAndSubmitOnTab({ - tabId: sourceTabId, - displayText: trimmed, - submitText, - structured, - sendToTab: (tabId, nextGoal, display, routedSubmit, routedStructured) => - commitThenSendRef.current( - tabId, - display, - routedSubmit, - routedStructured, - { - goal: nextGoal, - collaborationMode: controllerComposerProfileCollaborationMode(composerProfile), - toolApprovalMode, - }, - ), - }); - patchActivatedGoalForTab(sourceTabId, trimmed); - return; - } - const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed); - if (theme) { - const arg = theme[1]?.toLowerCase(); - if (!arg) { - const cur = getTheme(); - notice(t("settings.themeCurrent", { theme: cur, style: getThemeStyle(cur) })); - return; - } - if (arg === "reset" || arg === "default" || arg === "clear") { - try { - await app.ResetThemePack(); - clearThemePack(); - notice(t("settings.themeReset")); - } catch (err) { - showToast(err instanceof Error ? err.message : String(err), "error"); - } - return; - } - if (isThemeMode(arg)) { - const next = arg; - const style = getThemeStyle(next); - try { - await app.SetDesktopAppearance(next, style); - applyTheme(next, style); - notice(t("settings.themeChanged", { theme: next, style })); - } catch (err) { - showToast(err instanceof Error ? err.message : String(err), "error"); - } - return; - } - if (isThemeStyle(arg)) { - const cur = getTheme(); - try { - await app.SetDesktopAppearance(cur, arg); - applyTheme(cur, arg); - notice(t("settings.themeChanged", { theme: cur, style: arg })); - } catch (err) { - showToast(err instanceof Error ? err.message : String(err), "error"); - } - return; - } - notice(t("settings.themeUnknown", { name: arg }), "warn"); - return; - } - if (!controllerReady) return; - const profileApplied = await setControllerComposerProfileForTab( - sourceTabId, - controllerComposerProfileCollaborationMode(composerProfile), - toolApprovalMode, - goal, - ); - if (!profileApplied) return; - await commitThenSendRef.current(sourceTabId, trimmed, submitText.trim(), structured); + const navigation = useAppNavigationComposition({ + runtime, + t, + notice, + showToast, + shell, + state, + activeTab, + activeTabId, + activeSessionIdentity, + remoteSurfaceActive, + surface: navigationSurface, + local: { + setHistView, setProjectRevision, + setSidebarImDetailConnectionId, setTasksOpen, }, - [activeTabId, applyGoal, closeTransientOverlays, collaborationMode, composerProfile, controllerReady, goal, notice, runShellForTab, - patchActivatedGoalForTab, setControllerComposerProfileForTab, switchModel, t, toolApprovalMode, showToast], - ); - - const handleSteer = useCallback(async (text: string, requestedTabId = activeTabId) => { - const sourceTabId = requestedTabId || activeTabId; - if (!sourceTabId) throw new Error(t("composer.workspaceStarting")); - if (tabMetas.some((tab) => tab.id === sourceTabId && tab.remote)) { - await app.SteerRemoteTab(sourceTabId, text.trim()); - return; - } - await steerForTab(sourceTabId, text.trim()); - }, [activeTabId, steerForTab, t, tabMetas]); - - const setCollaborationModeFromUi = useCallback((mode: CollaborationMode) => { - runGoalAction(() => applyCollaborationMode(mode)); - }, [applyCollaborationMode, runGoalAction]); - const clearGoalFromUi = useCallback(() => { - runGoalAction(() => applyGoal("")); - }, [applyGoal, runGoalAction]); - const { pauseGoal: pauseGoalFromUi, resumeGoal: resumeGoalFromUi, setEffort: setEffortFromUi } = useRemoteComposerRuntimeActions({ - activeTabIdRef, remote: remoteSurfaceActive, session: remoteSession, runGoalAction, - pauseLocal: pauseControllerGoalForTab, resumeLocal: resumeControllerGoalForTab, - setLocalEffort: setEffort, showError: (message) => showToast(message, "error"), + session, }); - const switchModelFromUi = useCallback(async (name: string): Promise => { - try { - return await switchModel(name); - } catch (error) { - handleGoalActionError(error); - return false; - } - }, [handleGoalActionError, switchModel]); - - const tabMetaRefreshCoordinatorRef = useRef> | null>(null); - if (!tabMetaRefreshCoordinatorRef.current) { - tabMetaRefreshCoordinatorRef.current = createBoundedRefreshCoordinator(TAB_META_MAX_IN_FLIGHT); - } - const refreshTabMetas = useCallback(async ( - apply?: () => boolean, - options?: { afterMutation?: boolean }, - ): Promise => { - const result = await tabMetaRefreshCoordinatorRef.current!.run( - async () => asArray(await app.ListTabs().catch(() => [] as TabMeta[])), - options?.afterMutation ? { invalidate: true } : undefined, - ); - const tabs = result.value; - if (result.latest && (!apply || apply())) { - setTabMetas((current) => sameTabMetaLists(current, tabs) ? current : tabs); - } - return tabs; - }, []); - const seedActiveTabMeta = useCallback((tab: TabMeta): void => { - setTabMetas((current) => seedActiveTabMetaList(current, tab)); - setTabOrderIds((current) => current.includes(tab.id) ? current : [...current, tab.id]); - }, []); - const updateRemoteTabMeta = useCallback((tab: TabMeta): void => { - setTabMetas((current) => current.map((existing) => existing.id === tab.id - ? { ...existing, ...tab, active: existing.active } - : existing)); - }, []); - - useRemoteTabOpened(activeTabIdRef, seedActiveTabMeta, updateRemoteTabMeta, switchRemoteTab); - - useEffect(() => { - const unsub = onEvent((e) => { - if (shouldRefreshTabMetaForEvent(e.kind)) { - void refreshTabMetas(undefined, { afterMutation: true }); - } - if (e.kind !== "turn_done") return; - const turnTabId = resolvePlanRestoreTabId(e.tabId, activeTabIdRef.current); - window.setTimeout(() => { - setProjectRevision((value) => value + 1); - refreshTabMetas(undefined, { afterMutation: true }).then((tabs) => { - if (!turnTabId) return; - const tab = tabs.find((item) => item.id === turnTabId); - const baseProfile = tab ? composerProfileFromTab(tab) : defaultComposerProfile; - if (!shouldRestoreUserPlanModeForProfile(userPlanModeByTabRef.current, turnTabId, baseProfile)) { - if (baseProfile.goal.trim()) { - userPlanModeByTabRef.current = updateUserPlanModeIntent(userPlanModeByTabRef.current, turnTabId, false); - } - return; - } - setComposerProfilesByTab((current) => patchComposerProfile( - current, - turnTabId, - current[turnTabId] ?? baseProfile, - { collaborationMode: "plan", goalDraftMode: false, goal: "" }, - ["collaborationMode", "goal"], - )); - if (activeTabIdRef.current === turnTabId) { - void setControllerCollaborationMode("plan"); - } - }); - }, 250); - }); - return unsub; - }, [refreshTabMetas, setControllerCollaborationMode]); - - const blankSessionTarget = useCallback(() => { - const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : ""; - const scope = activeWorkspaceRoot ? "project" : "global"; - return { scope, workspaceRoot: activeWorkspaceRoot }; - }, [activeTab?.scope, activeTab?.workspaceRoot]); - - useEffect(() => { - let live = true; - const ready = import("./lib/workspaceRefreshStore") - .then(({ default: startWorkspaceFocusReconciliation }) => live ? startWorkspaceFocusReconciliation(activeTabId, workspaceScopeKey, refreshTabMetas) : undefined) - .catch(() => undefined); - const stopProjectTree = onProjectTreeChanged(() => { - setProjectRevision((value) => value + 1); - void refreshTabMetas(undefined, { afterMutation: true }); - }); - return () => { - live = false; - stopProjectTree(); - void ready.then((stop) => stop?.()); - }; - }, [activeTabId, refreshTabMetas, workspaceScopeKey]); - - // Bridge remote:* events into the remote store once, app-wide, so the - // StatusBar chip, host manager, and explorer all see the same live state. - useEffect(() => { - const offStatus = onRemoteStatus((s) => { - applyRemoteStatus(s); - if (s.state === "stopped" && s.error) requestRemoteStatusPopover(s.hostId); - }); - const offForwards = onRemoteForwards((e) => setRemoteForwards(e.hostId, e.forwards)); - const offServer = onRemoteServer((s) => setRemoteServer(s)); - return () => { - offStatus(); - offForwards(); - offServer(); - }; - }, [applyRemoteStatus, requestRemoteStatusPopover, setRemoteForwards, setRemoteServer]); - - useEffect(() => { - let cancelled = false; - void app.RemoteHosts() - .then((hosts) => { - if (!cancelled) setRemoteHosts(hosts); - }) - .catch(() => {}); - void app.RemoteConnectionStatuses() - .then((statuses) => { - if (!cancelled) hydrateRemoteStatuses(statuses); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [hydrateRemoteStatuses, setRemoteHosts]); - - const refreshProviderSetupState = useCallback(async () => { - const needs = await app.NeedsOnboarding(); - setProviderSetupNeeded(needs); - return needs; - }, []); - - useEffect(() => { - let cancelled = false; - (async () => { - try { - const needs = await app.NeedsOnboarding(); - if (!cancelled) { - setProviderSetupNeeded(needs); - setNeedsOnboarding(shouldOpenOnboarding(needs)); - } - } catch { - // Bridge unavailable (browser dev seam) — skip the gate; a real key - // failure still surfaces via the topbar startupError banner. - if (!cancelled) setNeedsOnboarding(false); - } - })(); - return () => { - cancelled = true; - }; - }, [setNeedsOnboarding]); - - useEffect(() => { - const el = footerRef.current; - if (!el || typeof ResizeObserver === "undefined") return; - let frame = 0; - const update = () => { - if (frame) window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => { - frame = 0; - const next = Math.round(el.getBoundingClientRect().height); - if (Math.abs(footerHeightRef.current - next) < 2) return; - footerHeightRef.current = next; - setFooterHeight(next); - }); - }; - update(); - const observer = new ResizeObserver(update); - observer.observe(el); - return () => { - if (frame) window.cancelAnimationFrame(frame); - observer.disconnect(); - }; - }, []); - - // Run the ambient engine only while the agent is generating. - useEffect(() => { - if (state.running && isGenerativeMusicEnabled()) { - generativeMusic.start(); - } else { - generativeMusic.stop(); - } - return () => generativeMusic.stop(); - }, [state.running]); - - // playTokenNote no-ops unless the engine is running, so subscribe unconditionally. - useEffect(() => { - const unsub = onEvent((e) => { - if (e.kind === "text" || e.kind === "reasoning" || e.kind === "tool_dispatch") { - generativeMusic.playTokenNote(); - } - }); - return unsub; - }, []); - - const toggleSidebar = useCallback(() => { - closeTransientOverlays(); - pulseSidebarToggle(); - anchorAppScrollToChat(); - const nextCollapsed = !sidebarCollapsed; - if (nextCollapsed) setSidebarSearchOpen(false); - setSidebarCollapsed(nextCollapsed); - saveSidebarCollapsed(nextCollapsed); - }, [anchorAppScrollToChat, closeTransientOverlays, pulseSidebarToggle, sidebarCollapsed]); - - const sidebarWidthClamp = desktopLayoutStyle === "creation" ? clampCreationSidebarWidth : clampSidebarWidth; - const sidebarRenderWidth = liveSidebarWidth ?? sidebarWidth; - const sidebarResizeMinWidth = desktopLayoutStyle === "creation" ? CREATION_SIDEBAR_MIN_WIDTH : SIDEBAR_MIN_WIDTH; - - useEffect(() => { - if (desktopLayoutStyle === "creation" || sidebarWidth >= SIDEBAR_MIN_WIDTH) return; - setSidebarWidth(SIDEBAR_MIN_WIDTH); - saveSidebarWidth(SIDEBAR_MIN_WIDTH); - }, [desktopLayoutStyle, sidebarWidth]); - - useEffect(() => { - if (desktopLayoutStyle === "creation") { - if (rightDockTreeWidth >= CREATION_RIGHT_DOCK_TREE_MIN_WIDTH) return; - setRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH); - saveRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH); - return; - } - if (rightDockTreeWidth >= RIGHT_DOCK_TREE_MIN_WIDTH) return; - setRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH); - saveRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH); - }, [desktopLayoutStyle, rightDockTreeWidth]); - - // Creation no longer exposes the overview tab. If a previous session left - // rightDockMode on "context", coerce it to files so 文件 stays selected. - useEffect(() => { - if (desktopLayoutStyle !== "creation") return; - if (rightDockMode !== "context") return; - setRightDockMode("files"); - }, [desktopLayoutStyle, rightDockMode, setRightDockMode]); - - const setExpandedSidebarWidth = useCallback((width: number) => { - closeTransientOverlays(); - const next = sidebarWidthClamp(width); - setSidebarWidth(next); - saveSidebarWidth(next); - }, [closeTransientOverlays, sidebarWidthClamp]); - - const startSidebarResize = useCallback( - (event: ReactPointerEvent) => { - if (sidebarCollapsed) return; - const layout = layoutRef.current; - if (!layout) return; - event.preventDefault(); - closeTransientOverlays(); - setSidebarResizing(true); - let nextWidth = sidebarWidth; - const liveResize = createRafResizeUpdater({ - target: layout, - separator: event.currentTarget, - cssVar: "--sidebar-expanded-width", - onApply: setLiveSidebarWidth, - }); - const dockLiveResize = createRafResizeUpdater({ - target: layout, - cssVar: "--workspace-width", - onApply: setLiveWorkspacePanelRenderWidth, - }); - const onMove = (moveEvent: PointerEvent) => { - nextWidth = sidebarWidthClamp(moveEvent.clientX); - liveResize.schedule(nextWidth); - dockLiveResize.schedule(resolveLiveWorkspacePanelRenderWidth(preferredWorkspacePanelWidth, nextWidth)); - }; - const onDone = () => { - liveResize.flush(); - dockLiveResize.flush(); - setSidebarWidth(nextWidth); - saveSidebarWidth(nextWidth); - setLiveSidebarWidth(null); - setLiveWorkspacePanelRenderWidth(null); - setSidebarResizing(false); - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onDone); - window.removeEventListener("pointercancel", onDone); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onDone); - window.addEventListener("pointercancel", onDone); - }, - [closeTransientOverlays, preferredWorkspacePanelWidth, resolveLiveWorkspacePanelRenderWidth, sidebarCollapsed, sidebarWidth, sidebarWidthClamp], - ); - - const resizeSidebarWithKeyboard = useCallback( - (event: KeyboardEvent) => { - if (sidebarCollapsed) return; - if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - event.preventDefault(); - setExpandedSidebarWidth(sidebarWidth + (event.key === "ArrowRight" ? 16 : -16)); - } else if (event.key === "Home") { - event.preventDefault(); - setExpandedSidebarWidth(sidebarResizeMinWidth); - } else if (event.key === "End") { - event.preventDefault(); - setExpandedSidebarWidth(SIDEBAR_MAX_WIDTH); - } - }, - [setExpandedSidebarWidth, sidebarCollapsed, sidebarWidth, sidebarResizeMinWidth], - ); - - const setSavedWorkspacePanelWidth = useCallback( - (width: number) => { - closeTransientOverlays(); - const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth); - setRightDockTreeWidth(next); - saveRightDockTreeWidth(next); - }, - [closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth], - ); - - const ensureWorkspacePanelWidth = useCallback( - (width: number) => { - closeTransientOverlays(); - if (rightDockMode === "context") return; - const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth); - setRightDockTreeWidth(next); - saveRightDockTreeWidth(next); - }, - [closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth], - ); - - const startWorkspacePanelResize = useCallback( - (event: ReactPointerEvent) => { - if (event.button !== 0 || !workspacePanelOpen) return; - const layout = layoutRef.current; - if (!layout) return; - event.preventDefault(); - workspacePanelResizeFinishRef.current?.(); - closeTransientOverlays(); - setWorkspacePanelResizing(true); - const separator = event.currentTarget; - const pointerId = event.pointerId; - const startX = event.clientX; - const startDockWidth = workspacePanelRenderWidth; - let nextDockWidth = startDockWidth; - const liveResize = createRafResizeUpdater({ - target: layout, - separator, - cssVar: "--workspace-width", - onApply: setLiveWorkspacePanelRenderWidth, - }); - const onMove = (moveEvent: PointerEvent) => { - const delta = moveEvent.clientX - startX; - nextDockWidth = startDockWidth - delta; - nextDockWidth = rightDockTreeWidthClamp(nextDockWidth, workspacePanelAvailableWidth); - liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth)); - }; - const lifecycle = createPointerResizeLifecycle({ - separator, - pointerId, - onMove, - onFinish: () => { - liveResize.flush(); - setSavedWorkspacePanelWidth(nextDockWidth); - setLiveWorkspacePanelRenderWidth(null); - setWorkspacePanelResizing(false); - workspacePanelResizeFinishRef.current = null; - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }, - }); - workspacePanelResizeFinishRef.current = lifecycle.finish; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - }, - [closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelOpen, workspacePanelRenderWidth], - ); - - const resizeWorkspacePanelWithKeyboard = useCallback( - (event: KeyboardEvent) => { - if (event.key === "ArrowLeft" || event.key === "ArrowRight") { - event.preventDefault(); - setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16)); - } else if (event.key === "Home") { - event.preventDefault(); - setSavedWorkspacePanelWidth(rightDockTreeMinWidth); - } else if (event.key === "End") { - event.preventDefault(); - setSavedWorkspacePanelWidth(workspacePanelAvailableWidth); - } - }, - [rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelRenderWidth], - ); - - const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight); - const terminalResizeMaxHeight = terminalMaxHeight(viewportHeight); - const setSavedTerminalHeight = useCallback( - (height: number) => { - const next = clampTerminalHeight(height, viewportHeight); - setTerminalHeight(next); - saveTerminalHeight(next); - }, - [setTerminalHeight, viewportHeight], - ); - - const startTerminalResize = useCallback( - (event: ReactPointerEvent) => { - if (!terminalPanelOpen) return; - const layout = layoutRef.current; - if (!layout) return; - event.preventDefault(); - closeTransientOverlays(); - const startY = event.clientY; - const startHeight = terminalRenderHeight; - let nextHeight = startHeight; - const liveResize = createRafResizeUpdater({ - target: layout, - separator: event.currentTarget, - cssVar: "--terminal-height", - onApply: setLiveTerminalHeight, - }); - const onMove = (moveEvent: PointerEvent) => { - const delta = startY - moveEvent.clientY; - nextHeight = clampTerminalHeight(startHeight + delta, viewportHeight); - liveResize.schedule(nextHeight); - }; - const onDone = () => { - liveResize.flush(); - setLiveTerminalHeight(null); - setSavedTerminalHeight(nextHeight); - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onDone); - window.removeEventListener("pointercancel", onDone); - document.body.style.cursor = ""; - document.body.style.userSelect = ""; - }; - document.body.style.cursor = "row-resize"; - document.body.style.userSelect = "none"; - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onDone); - window.addEventListener("pointercancel", onDone); - }, - [closeTransientOverlays, setLiveTerminalHeight, setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, viewportHeight], - ); - - const resizeTerminalWithKeyboard = useCallback( - (event: KeyboardEvent) => { - if (!terminalPanelOpen) return; - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - event.preventDefault(); - setSavedTerminalHeight(terminalRenderHeight + (event.key === "ArrowUp" ? 16 : -16)); - } else if (event.key === "Home") { - event.preventDefault(); - setSavedTerminalHeight(TERMINAL_MIN_HEIGHT); - } else if (event.key === "End") { - event.preventDefault(); - setSavedTerminalHeight(terminalResizeMaxHeight); - } - }, - [setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight], - ); - - const activeWorkspaceRoot = activeTab?.workspaceRoot ?? state.meta?.cwd ?? ""; - - const openWorkspacePanel = useCallback( - (mode: RightDockMode = rightDockMode) => { - closeTransientOverlays(); - if (mode === "context" || mode !== rightDockMode) { - setWorkspacePreviewActive(false); - } - setRightDockMode(mode); - let nextMaximized = workspacePanelMaximized; - if (mode === "context") { - nextMaximized = false; - setWorkspacePanelMaximized(false); - } else { - // Keep file/change views docked; the rendered dock width is clamped to - // the viewport so opening it reflows instead of forcing maximize. - nextMaximized = false; - setWorkspacePanelMaximized(false); - } - if (workspacePanelOpen && workspacePanelMaximized === nextMaximized) { - return; - } - setWorkspacePanelOpen(true); - saveWorkspacePanelOpen(true, activeWorkspaceRoot); - }, - [activeWorkspaceRoot, closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen], - ); - - const closeWorkspacePanel = useCallback(() => { - closeTransientOverlays(); - if (!workspacePanelOpen) { - return; - } - setLiveWorkspacePanelRenderWidth(null); - setWorkspacePanelMaximized(false); - setWorkspacePanelOpen(false); - saveWorkspacePanelOpen(false, activeWorkspaceRoot); - }, [activeWorkspaceRoot, closeTransientOverlays, workspacePanelOpen]); - - // Restore the right dock's open/closed state per project: switching to a - // different workspace root (or a global session) restores that scope's own - // preference instead of carrying the previous project's state over. - useEffect(() => { - setWorkspacePanelOpen(loadWorkspacePanelOpen(activeWorkspaceRoot)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeWorkspaceRoot]); - - const toggleWorkspacePanel = useCallback(() => { - if (workspacePanelRenderable) { - closeWorkspacePanel(); - return; - } - // Creation hides the overview tab; never reopen into the invisible "context" - // mode or neither 文件/改动 will show an active selection. - if (desktopLayoutStyle === "creation") { - openWorkspacePanel(rightDockMode === "changed" ? "changed" : "files"); - return; - } - // Reopen with the previously active tab (rightDockMode is kept in the - // store across close/open) instead of forcing "context". - openWorkspacePanel(); - }, [closeWorkspacePanel, desktopLayoutStyle, workspacePanelRenderable, openWorkspacePanel, rightDockMode]); - - const openRightDockMode = useCallback( - (mode: RightDockMode) => { - openWorkspacePanel(mode); - }, - [openWorkspacePanel], - ); - - const verificationRevealSequenceRef = useRef(0); - const [verificationRevealRequest, setVerificationRevealRequest] = useState(null); - const openTurnVerification = useCallback((summary: WireCompletionSummary) => { - openRightDockMode("changed"); - verificationRevealSequenceRef.current += 1; - setVerificationRevealRequest({ - id: verificationRevealSequenceRef.current, - summary, - tabId: activeTabId ?? "", - turnStartAt: state.turnStartAt, - currentSummary: state.completionSummary, - }); - }, [activeTabId, openRightDockMode, state.completionSummary, state.turnStartAt]); - - useEffect(() => { setVerificationRevealRequest(null); }, [activeTabId, state.completionSummary, state.turnStartAt]); - - const toggleTerminalPanel = useCallback(() => { if (remoteSurfaceActive) return; - setTerminalPanelOpen((prev) => { - const next = !prev; - saveTerminalPanelOpen(next); - return next; - }); - }, [remoteSurfaceActive, setTerminalPanelOpen]); - - const openTerminalForPath = useCallback( - (path = ".") => { if (remoteSurfaceActive) return; - setTerminalPanelOpen(true); - saveTerminalPanelOpen(true); - if (!activeTabId) return; - void useTerminalStore.getState().createSession(activeTabId, path || ".", "default").catch(() => {}); - }, - [activeTabId, remoteSurfaceActive, setTerminalPanelOpen], - ); - - useGlobalShortcut("terminal.toggle", () => { - toggleTerminalPanel(); - }, [toggleTerminalPanel], !managementActive); - useGlobalShortcut("terminal.newSession", () => { - if (!activeTabId || remoteSurfaceActive) return; - setTerminalPanelOpen(true); saveTerminalPanelOpen(true); - void useTerminalStore.getState().createSession(activeTabId, ".", "default").catch(() => {}); - }, [activeTabId, remoteSurfaceActive, setTerminalPanelOpen], !managementActive); - - useEffect(() => { - if (!remoteExplorerOpen) return; - openRightDockMode("remote"); - closeRemoteExplorerRequest(); - }, [closeRemoteExplorerRequest, openRightDockMode, remoteExplorerOpen]); - - useEffect(() => { - if (remoteHosts.length > 0 || rightDockMode !== "remote") return; - setRightDockMode("files"); - }, [remoteHosts.length, rightDockMode, setRightDockMode]); - - const openRemoteDock = useCallback(() => { - const fallback = remoteHosts.find((host) => { - const state = useRemoteStore.getState().statuses[host.id]?.state; - return state === "connected" || state === "degraded"; - }) ?? remoteHosts[0]; - const hostId = remoteExplorerHostId && remoteHosts.some((host) => host.id === remoteExplorerHostId) - ? remoteExplorerHostId - : fallback?.id; - if (hostId) requestRemoteExplorer(hostId); - }, [remoteExplorerHostId, remoteHosts, requestRemoteExplorer]); - - const remoteWorkspaceLaunchGate = useRef(new RemoteWorkspaceLaunchGate()); - const launchRemoteWorkspace = useCallback(async (host: RemoteHostView, requestSeq: number) => { - const lastWorkspace = await app.RemoteLastWorkspace(host.id).catch(() => ""); - const workspace = resolveRemoteWorkspace(lastWorkspace, host.defaultWorkspace); - if (!remoteWorkspaceLaunchGate.current.isCurrent(host.id, requestSeq)) return; - await publishNavigationIntent("remote-workspace"); - await app.OpenRemoteWorkspace(host.id, workspace); - }, []); - - const openRemoteWorkspaceFromStatus = useCallback((host: RemoteHostView) => { - const requestSeq = remoteWorkspaceLaunchGate.current.begin(host.id); - void launchRemoteWorkspace(host, requestSeq).catch((err) => { - showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 }); - }); - }, [launchRemoteWorkspace, showToast]); - - const connectAndOpenRemoteWorkspace = useCallback(function connectRemoteWorkspace(host: RemoteHostView) { - const requestSeq = remoteWorkspaceLaunchGate.current.begin(host.id); - void (async () => { - try { - const status = useRemoteStore.getState().statuses[host.id]?.state; - if (status !== "connected" && status !== "degraded") { - // Clear any stale failure before the new generation starts; otherwise a - // previous stopped+error snapshot could make the waiter reject before - // the kernel's fresh connecting event reaches the frontend. - useRemoteStore.getState().applyStatus({ hostId: host.id, state: "connecting" }); - await app.ConnectRemoteHost(host.id); - await waitForRemoteConnection(host.id); - } - } catch (err) { - if (err instanceof RemoteConnectionTimeoutError) { - showToast(t("remote.error.timeout", { host: host.label }), "error", { - actionLabel: t("remote.error.stopAndRetry"), - durationMs: 10_000, - onAction: () => { - void app.DisconnectRemoteHost(host.id) - .catch(() => undefined) - .then(() => connectRemoteWorkspace(host)); - }, - }); - return; - } - // Connection failures are host-scoped. Keep the persistent error and its - // recovery actions beside the Remote SSH status entry instead of - // stretching a raw backend error across the native titlebar. - requestRemoteStatusPopover(host.id); - return; - } - - try { - await launchRemoteWorkspace(host, requestSeq); - } catch (err) { - showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 }); - } - })(); - }, [launchRemoteWorkspace, requestRemoteStatusPopover, showToast, t]); - - const handleWorkspacePreviewModeChange = useCallback( - (active: boolean) => { - if (workspacePreviewActive === active) return; - closeTransientOverlays(); - setWorkspacePreviewActive(active); - }, - [closeTransientOverlays, workspacePreviewActive], - ); - - const layoutStyle = useMemo( - () => - ({ - "--sidebar-expanded-width": `${sidebarRenderWidth}px`, - "--chat-min-width": `${chatReservedWidth}px`, - "--workspace-width": `${workspacePanelRenderWidth}px`, - "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, - "--terminal-height": `${terminalSurfaceOpen ? liveTerminalHeight ?? terminalRenderHeight : 0}px`, - }) as CSSProperties, - [chatReservedWidth, liveTerminalHeight, sidebarRenderWidth, terminalRenderHeight, terminalSurfaceOpen, workspacePanelRenderWidth], - ); - - const setWorkspacePanel = useCallback((open: boolean) => { - if (open) { - openWorkspacePanel(); - } else { - closeWorkspacePanel(); - } - }, [closeWorkspacePanel, openWorkspacePanel]); - - const addWorkspaceTextToComposer = useCallback((text: string) => { - if (activeTabId && workspaceInsertTarget === "planRevision" && state.approval?.tool === "exit_plan_mode") { - setPlanRevisionInsertRequest({ - tabId: activeTabId, - approvalId: state.approval.id, - request: { id: Date.now(), text }, - }); - return; - } - if (activeTabId) { - setComposerInsertRequestsByTab((current) => ({ - ...current, - [activeTabId]: { id: Date.now(), text }, - })); - } - }, [activeTabId, state.approval, workspaceInsertTarget]); - - const addTerminalOutputToComposer = useCallback(async (sessionId: string) => { - if (!activeTabId) return; - try { - const output = await app.TerminalOutputForTab(activeTabId, sessionId); - const formatted = formatTerminalOutputForComposer(output); - if (!formatted) { - showToast(t("terminal.noOutput"), "info"); - return; - } - addWorkspaceTextToComposer(formatted); - } catch (error) { - showToast(error instanceof Error ? error.message : String(error), "error"); - } - }, [activeTabId, addWorkspaceTextToComposer, showToast, t]); - - const addSelectedTextToComposer = useCallback((text: string, source?: SelectedTextInsertRequest["source"]) => { - const selected = text.trim(); - if (!activeTabId || !selected) return; - selectedTextRequestIdRef.current += 1; - setSelectedTextRequestsByTab((current) => ({ - ...current, - [activeTabId]: { id: selectedTextRequestIdRef.current, text: selected, ...(source ? { source } : {}) }, - })); - }, [activeTabId]); - - const addTerminalSelectionToComposer = useCallback((text: string) => addSelectedTextToComposer(text, "terminal"), [addSelectedTextToComposer]); - const addWorkspaceCodeToComposer = useCallback((path: string, code: string) => { - if (!activeTabId || !code.trim()) return; - if (workspaceInsertTarget === "planRevision" && state.approval?.tool === "exit_plan_mode") { - // The plan-revision input is plain text and only consumes request.text, - // so hand it the fenced rendering instead of a structured reference. - setPlanRevisionInsertRequest({ - tabId: activeTabId, - approvalId: state.approval.id, - request: { id: Date.now(), text: formatSelectionReference(path, code) }, - }); - return; - } - selectedTextRequestIdRef.current += 1; - setSelectedTextRequestsByTab((current) => ({ - ...current, - [activeTabId]: { id: selectedTextRequestIdRef.current, text: code, path }, - })); - }, [activeTabId, state.approval, workspaceInsertTarget]); - - // Coalesce tab-bar switches through the same last-click-wins scheduler that - // openTopic/blank/resume navigation uses, so rapidly clicking between two - // running sessions can't run two switchTab() calls concurrently. Concurrent - // switches race on the backend SetActiveTab/confirmBackendActiveTab ordering, - // which lands events + hydration on the wrong session (#5352). switchTab's own - // loadSessionDataForTab is already seq-guarded; this serializes the backend - // activation around it. - const tabSwitchSeqRef = useRef(0); - const tabSwitchRunningRef = useRef(false); - const tabSwitchPendingRef = useRef | null>(null); - const enterChatViewForTabNavigation = useCallback(() => { - enterConversation(); - }, [enterConversation]); - const enqueueTabSwitch = useCallback( - (tabId: string, optimisticTab?: TabMeta): Promise => { - enterChatViewForTabNavigation(); - // Claim the shared navigation epoch at click time, before this request - // can wait behind an older tab switch. That immediately invalidates any - // in-flight blank/topic completion from a previous user intent. - const navigationIntentSeq = noteNavigationIntent(); - beginNavigationSurface(navigationIntentSeq); - return enqueueNavigationRequest( - { seqRef: tabSwitchSeqRef, runningRef: tabSwitchRunningRef, pendingRef: tabSwitchPendingRef }, - { tabId, optimisticTab, navigationIntentSeq }, - async (request) => { - try { - if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return; - if (request.optimisticTab?.remote) await switchRemoteTab(request.optimisticTab, request.navigationIntentSeq); - else await switchTab(request.tabId, request.optimisticTab, request.navigationIntentSeq); - if (!isNavigationIntentCurrent(request.navigationIntentSeq)) return; - await refreshTabMetas( - () => isNavigationIntentCurrent(request.navigationIntentSeq), - { afterMutation: true }, - ); - } finally { - settleNavigationSurface(request.navigationIntentSeq); - } - }, - ); - }, - [beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, settleNavigationSurface, switchRemoteTab, switchTab], - ); - - const revealBackgroundRuntime = useCallback(async (tabId: string): Promise => { - enterChatViewForTabNavigation(); - const navigationIntentSeq = noteNavigationIntent(); - beginNavigationSurface(navigationIntentSeq); - try { - const meta = await app.RevealBackgroundRuntime(tabId); - if (!await guardBackendNavigationResult({ - intent: navigationIntentSeq, - targetTabId: meta.id, - kind: "tab.reveal-background", - isIntentCurrent: isNavigationIntentCurrent, - reassert: reassertVisibleTabAfterStaleNavigation, - })) return; - await switchTab(meta.id, meta, navigationIntentSeq); - if (!isNavigationIntentCurrent(navigationIntentSeq)) return; - await refreshTabMetas( - () => isNavigationIntentCurrent(navigationIntentSeq), - { afterMutation: true }, - ); - } catch (err) { - if (isNavigationIntentCurrent(navigationIntentSeq)) showToast(err instanceof Error ? err.message : String(err), "error"); - } finally { - settleNavigationSurface(navigationIntentSeq); - } - }, [beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]); - - const handleTabChange = useCallback((id: string) => { - closeTransientOverlays(); - const selected = tabMetas.find((tab) => tab.id === id); - setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === id }))); - void enqueueTabSwitch(id, selected); - setTabRevealSignal((signal) => signal + 1); - }, [closeTransientOverlays, enqueueTabSwitch, tabMetas]); - - const finishTabClose = useCallback(async ( - id: string, - policy: "keep_running" | "stop_and_close", - ): Promise => { - closeTransientOverlays(); - const closed = await closeTab(id, policy); - if (!closed) { - showToast(t("runtime.closeFailed"), "error"); - return false; - } - setComposerProfilesByTab((current) => { - if (!(id in current)) return current; - const next = { ...current }; - delete next[id]; - return next; - }); - setTabMetas((current) => { - if (current.length <= 1) return current; - const closingIndex = current.findIndex((tab) => tab.id === id); - if (closingIndex < 0) return current; - const closingTab = current[closingIndex]; - const remaining = current.filter((tab) => tab.id !== id); - if (!closingTab.active && closingTab.id !== activeTabId) return remaining; - const nextIndex = Math.min(closingIndex, remaining.length - 1); - const nextActiveId = remaining[nextIndex]?.id; - return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId })); - }); - await refreshTabMetas(undefined, { afterMutation: true }); - await refreshBackgroundRuntimes(); - setTabRevealSignal((signal) => signal + 1); - return true; - }, [activeTabId, closeTab, closeTransientOverlays, refreshBackgroundRuntimes, refreshTabMetas, showToast, t]); - - const handleTabClose = useCallback(async (id: string) => { - try { - const work = await app.ActiveWorkForTab(id); - if (work.running || work.pendingPrompt || work.jobs.length > 0) { - setPendingClose({ tabId: id, work, stopping: false }); - return; - } - } catch { - // CloseTabWithPolicy re-checks the controller state atomically. - } - await finishTabClose(id, "stop_and_close"); - }, [finishTabClose]); - - const resolvePendingClose = useCallback(async (policy: "keep_running" | "stop_and_close") => { - const request = pendingClose; - if (!request || request.stopping) return; - if (policy === "stop_and_close") setPendingClose({ ...request, stopping: true }); - const closed = await finishTabClose(request.tabId, policy); - if (closed) setPendingClose(null); - else setPendingClose((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current); - }, [finishTabClose, pendingClose]); - const revealWorkspaceWriter = useCallback(async () => { - if (!activeTabId) return; - enterChatViewForTabNavigation(); - const navigationIntentSeq = noteNavigationIntent(); - beginNavigationSurface(navigationIntentSeq); - try { - const meta = await app.RevealWorkspaceWriterForTab(activeTabId); - if (!await guardBackendNavigationResult({ - intent: navigationIntentSeq, - targetTabId: meta.id, - kind: "tab.reveal-workspace-writer", - isIntentCurrent: isNavigationIntentCurrent, - reassert: reassertVisibleTabAfterStaleNavigation, - })) return; - setWorkspaceConflict(null); - await switchTab(meta.id, meta, navigationIntentSeq); - if (!isNavigationIntentCurrent(navigationIntentSeq)) return; - await refreshTabMetas( - () => isNavigationIntentCurrent(navigationIntentSeq), - { afterMutation: true }, - ); - } catch (err) { - if (isNavigationIntentCurrent(navigationIntentSeq)) showToast(err instanceof Error ? err.message : String(err), "error"); - } finally { - settleNavigationSurface(navigationIntentSeq); - } - }, [activeTabId, beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]); - - const continueInDeliveryWorktree = useCallback(async () => { - const root = state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd; - if (!root) return; - cancel(); - setWorkspaceConflict(null); - const navigationIntentSeq = noteNavigationIntent(); - beginNavigationSurface(navigationIntentSeq); - try { - await createIsolatedWorktree(root, navigationIntentSeq); - await refreshTabMetas(undefined, { afterMutation: true }); - } catch (err) { - if (isNavigationIntentCurrent(navigationIntentSeq)) showToast(err instanceof Error ? err.message : String(err), "error"); - } finally { - settleNavigationSurface(navigationIntentSeq); - } - }, [beginNavigationSurface, cancel, createIsolatedWorktree, isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, settleNavigationSurface, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]); - - const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => { - closeTransientOverlays(); - const currentIds = tabMetas.map((tab) => tab.id); - const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index); - if (targets.length === 0) return; - for (const id of targets) { - let work: ActiveWorkView | null = null; - try { - work = await app.ActiveWorkForTab(id); - } catch { /* the close path remains authoritative */ } - if (work && (work.running || work.pendingPrompt || work.jobs.length > 0)) { - setPendingClose({ tabId: id, work, stopping: false }); - return; - } - await finishTabClose(id, "stop_and_close"); - } - if (nextActiveTabId && currentIds.includes(nextActiveTabId)) { - const selected = tabMetas.find((tab) => tab.id === nextActiveTabId); - setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === nextActiveTabId }))); - void enqueueTabSwitch(nextActiveTabId, selected); - } - await refreshTabMetas(undefined, { afterMutation: true }); - setTabRevealSignal((signal) => signal + 1); - }, [closeTransientOverlays, enqueueTabSwitch, finishTabClose, refreshTabMetas, tabMetas]); - - const handleTabsReorder = useCallback(async (ids: string[]) => { - setTabOrderIds(ids); - setTabMetas((current) => { - const byId = new Map(current.map((tab) => [tab.id, tab])); - const ordered = ids.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab)); - return ordered.length === current.length ? ordered : current; - }); - await reorderTabs(ids); - await refreshTabMetas(undefined, { afterMutation: true }); - setTabRevealSignal((signal) => signal + 1); - }, [refreshTabMetas, reorderTabs]); - - const [rewindSignal, setRewindSignal] = useState(0); - - // ── Immediate rewind ────────────────────────────────────────────────── - // On confirm, call Go prepare+commit immediately. Only after success does - // the UI truncate the transcript, refresh files, and fill the composer. - // Real backend undo uses UndoRewindForTab when a transaction id is available. - const [rewindStatesByTab, setRewindStatesByTab] = useState>({}); - const rewindStatesByTabRef = useRef(rewindStatesByTab); - rewindStatesByTabRef.current = rewindStatesByTab; - const [rewindCommittingByTab, setRewindCommittingByTab] = useState>({}); - const rewindState = activeTabId ? rewindStatesByTab[activeTabId] ?? null : null; - const rewindCommitting = Boolean(activeTabId && rewindCommittingByTab[activeTabId]); - - const setRewindStateForTab = useCallback((tabId: string, nextState: RewindUndoState | null) => { - if (!tabId) return; - const next = { ...rewindStatesByTabRef.current }; - if (nextState) next[tabId] = nextState; - else delete next[tabId]; - rewindStatesByTabRef.current = next; - setRewindStatesByTab(next); - }, []); - - const setRewindCommittingForTab = useCallback((tabId: string, committing: boolean) => { - setRewindCommittingByTab((current) => { - const next = { ...current }; - if (committing) next[tabId] = true; - else delete next[tabId]; - return next; - }); - }, []); - - const handleSessionRevertCommitted = useCallback((sourceTabId: string, outcome: RewindResultView) => { - if (!sourceTabId || !outcome.ok) return; - setRewindStateForTab(sourceTabId, { - turnDiff: 0, - transactionId: outcome.transactionId, - undoAvailable: outcome.undoAvailable, - filesRestored: outcome.written ?? [], - filesRemoved: outcome.deleted ?? [], - }); - setDockRefreshKey((value) => value + 1); - setProjectRevision((value) => value + 1); - }, [setRewindStateForTab]); - - const hydratePlaceholderActive = Boolean( - state.hydrating && - state.items.length === 0 && - state.hydratePlaceholderItems?.length, + return ( + ); - const transcriptHydrating = state.hydrating && !state.hydrateHistoryLoaded; - // Creation hero only after history hydration settles on a truly empty session. - // Avoid flash while switching tabs: items may be empty while placeholders show. - // Exclude IM/Bot detail: hero CSS collapses .main, which also hosts that panel. - // (desktopLayoutStyle is available here; sidebarCreation is declared later.) - const creationEmptyHero = - desktopLayoutStyle === "creation" && - !runtimeTransitioning && - !sidebarImDetailConnection && - !sessionHasContent && - !transcriptHydrating && - !hydratePlaceholderActive && - !state.hydrateError; - const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items; - const handleLoadOlderHistory = useCallback((targetTurn?: number, trigger: HistoryLoadTrigger = "retry") => { - return activeTabId ? loadOlderHistory(activeTabId, targetTurn, trigger) : Promise.resolve(false); - }, [activeTabId, loadOlderHistory]); - - // Display items: backend history is authoritative after immediate commit. - // rewindState only drives the undo banner, not optimistic truncation. - const displayItems = transcriptItems; - // Keep a render-level snapshot of the last stable transcript. Navigation - // starts before the controller swaps activeTabId, so this ref gives the - // transition layer a synchronous, immutable surface to retain as its - // background instead of rendering the target tab's empty state. - if (!runtimeTransitioning) { - renderedTranscriptSurfaceRef.current = { - tabId: activeTabId, - items: displayItems, - geometrySessionKey: transcriptGeometrySessionKey, - }; - } - const visibleTranscriptSurface = runtimeTransitioning && !navigationTargetDataReady && preservedTranscriptSurface - ? preservedTranscriptSurface - : null; - const visibleTranscriptItems = visibleTranscriptSurface?.items ?? displayItems; - const visibleTranscriptTabId = visibleTranscriptSurface?.tabId ?? activeTabId; - const visibleTranscriptGeometryKey = visibleTranscriptSurface?.geometrySessionKey ?? transcriptGeometrySessionKey; - const surfaceCommitToken = navigationTargetDataReady && navigationSurfaceIntent !== null - ? `navigation-${navigationSurfaceIntent}-${activeTabId ?? "blank"}` - : undefined; - const handleSurfacePaintReady = useCallback((token: string, outcome: "ready" | "degraded") => { - const match = /^navigation-(\d+)-/.exec(token); - if (!match) return; - const intent = Number(match[1]); - if (navigationSurface?.intent !== intent) return; - if (singleSurfaceLayout && activeTabId) commitSingleSurfaceNavigation(activeTabId); - commitNavigationSurfacePaint(intent, outcome); - }, [activeTabId, commitNavigationSurfacePaint, commitSingleSurfaceNavigation, navigationSurface?.intent, singleSurfaceLayout]); - const latestGuidanceConsumed = useMemo(() => { - for (let i = state.items.length - 1; i >= 0; i--) { - const item = state.items[i]; - if (item.kind === "notice" && item.text.startsWith("↪ ")) { - return { key: item.id, itemId: item.inboxItemId, text: item.text.slice(2) }; - } - } - return null; - }, [state.items]); - - // send wrapper: clear local undo banner state before sending a new turn - // (new mutation invalidates undo). Rewind itself already committed immediately. - const commitThenSend = useCallback(async ( - sourceTabId: string, - displayText: string, - submitText?: string, - structured?: StructuredInvocationSubmit, - initialGoal?: { - goal: string; - collaborationMode: CollaborationMode; - toolApprovalMode: ToolApprovalMode; - }, - ) => { - const sourceTab = tabMetas.find((tab) => tab.id === sourceTabId); - if (!sourceTab) throw new Error(t("composer.workspaceStarting")); - if (sourceTab.readOnly) throw new Error(t("composer.readOnlyChannel")); - if ( - sourceTab.ready !== true || - (sourceTab.runtime && sourceTab.runtime.phase !== "ready") || - sourceTab.startupErr - ) { - throw new Error(sourceTab.runtime?.issue?.message || sourceTab.startupErr || t("composer.workspaceStarting")); - } - // New turn invalidates the last undo slot. - if (rewindStatesByTabRef.current[sourceTabId]) { - setRewindStateForTab(sourceTabId, null); - } - await sendToTab(sourceTabId, displayText, submitText, undefined, structured, initialGoal); - }, [sendToTab, setRewindStateForTab, t, tabMetas]); - - const handleTranscriptPrompt = useCallback((text: string) => { - if (!activeTabId || !controllerReady) return; - void commitThenSend(activeTabId, text).catch((err) => { - console.warn("Failed to submit transcript prompt", err); - }); - }, [activeTabId, commitThenSend, controllerReady]); - - const handleDeliveryContinue = useCallback(async () => { - await continueDelivery({ - tabId: activeTabIdRef.current, - ready: controllerReady, - goal: state.meta?.goal, - activeTabId: () => activeTabIdRef.current, - resumeGoal: resumeControllerGoalForTab, - send: (tabId) => recoverDeliveryToTab(tabId, t("notice.deliveryIncompleteContinuePrompt")), - }); - }, [controllerReady, recoverDeliveryToTab, resumeControllerGoalForTab, state.meta?.goal, t]); - commitThenSendRef.current = commitThenSend; - - const handleMessageAction = useCallback((turn: number, scope: string) => { - const sourceTabId = activeTabId; - if (!sourceTabId || activeTab?.readOnly) return; - if (hydratePlaceholderActive) return; - if (scope === "fork") { - // Fork still goes through the controller (not optimistic). - rewindForTab(sourceTabId, turn, scope).then((ok) => { - if (!ok) return; - void refreshTabMetas(undefined, { afterMutation: true }); - setProjectRevision((v) => v + 1); - }); - return; - } - - // Code-only rewind only affects files — no message truncation, - // no optimistic UI needed. Execute immediately. - if (scope === "code") { - setRewindCommittingForTab(sourceTabId, true); - void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => { - setRewindCommittingForTab(sourceTabId, false); - if (!outcome.ok) return; - setRewindStateForTab(sourceTabId, { - turnDiff: 0, - transactionId: outcome.transactionId, - undoAvailable: outcome.undoAvailable, - filesRestored: outcome.written ?? [], - filesRemoved: outcome.deleted ?? [], - }); - setDockRefreshKey((v) => v + 1); - setProjectRevision((v) => v + 1); - }); - return; - } - - // Summarize only compresses the conversation log — no files touched, - // no optimistic UI needed. Execute immediately like code-only rewind. - if (scope === "summ-from" || scope === "summ-upto") { - rewindForTab(sourceTabId, turn, scope).then((ok) => { - if (!ok) return; - setDockRefreshKey((v) => v + 1); - setProjectRevision((v) => v + 1); - }); - return; - } - - const items = state.items; - const hasCheckpointTurns = items.some((it) => it.kind === "user" && it.checkpointTurn != null); - let boundaryIdx = -1; - let userCount = 0; - let targetUserCount = -1; - for (let i = 0; i < items.length; i++) { - if (items[i].kind === "user") { - const item = items[i] as Extract; - const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn; - if (matches) { - boundaryIdx = i; - targetUserCount = userCount; - break; - } - userCount++; - } - } - if (boundaryIdx < 0) { - rewindForTab(sourceTabId, turn, scope).then((ok) => { - if (!ok) return; - if (scope === "both") { - setDockRefreshKey((v) => v + 1); - setProjectRevision((v) => v + 1); - } - }); - return; - } - - const prevUserCount = items.filter((it) => it.kind === "user").length; - const turnDiff = prevUserCount - targetUserCount; - const userItem = items[boundaryIdx]?.kind === "user" ? items[boundaryIdx] as Extract : undefined; - const prompt = userItem?.text ?? ""; - - // Immediate backend commit — only update UI after success. - setRewindCommittingForTab(sourceTabId, true); - void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => { - setRewindCommittingForTab(sourceTabId, false); - if (!outcome.ok) { - // Keep conversation/files as-is; notices already carry the reason. - return; - } - const targetTabId = outcome.tabId || sourceTabId; - setRewindStateForTab(targetTabId, { - turnDiff: outcome.tabId ? 0 : turnDiff, - transactionId: outcome.transactionId, - undoAvailable: outcome.undoAvailable, - undoTabId: sourceTabId, - filesRestored: outcome.written ?? [], - filesRemoved: outcome.deleted ?? [], - }); - const insertId = Date.now(); - setComposerInsertRequestsByTab((current) => ({ - ...current, - [targetTabId]: { id: insertId, text: prompt, mode: "replace" }, - })); - setRewindSignal((v) => v + 1); - if (scope === "both" || scope === "code") { - setDockRefreshKey((v) => v + 1); - setProjectRevision((v) => v + 1); - } - }); - }, [activeTab?.readOnly, activeTabId, hydratePlaceholderActive, state.items, rewindForTab, rewindForTabDetailed, refreshTabMetas, setRewindStateForTab, setRewindCommittingForTab]); - - const handleEditPrompt = useCallback(async (turn: number, displayText: string, submitText?: string): Promise => { - const sourceTabId = activeTabId; - if (!sourceTabId || activeTab?.readOnly || !controllerReady || hydratePlaceholderActive || rewindStatesByTabRef.current[sourceTabId] || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending) return false; - const next = displayText.trim(); - if (!next) return false; - const submit = (submitText ?? displayText).trim(); - const hasCheckpointTurns = state.items.some((it) => it.kind === "user" && it.checkpointTurn != null); - let original = ""; - let userCount = 0; - for (const item of state.items) { - if (item.kind !== "user") continue; - const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn; - if (matches) { - original = (item.submitText ?? item.text).trim(); - break; - } - userCount++; - } - const outcome = await rewindForTabDetailed(sourceTabId, turn, "conversation"); - if (!outcome.ok) return false; - setRewindSignal((v) => v + 1); - const targetTabId = outcome.tabId || sourceTabId; - try { - await sendToTab(targetTabId, next, submit, original); - return true; - } catch { - return false; - } - }, [activeTab?.readOnly, activeTabId, clearContextPending, controllerReady, hydratePlaceholderActive, sendToTab, state.approval, state.ask, state.items, state.messageAction, state.running, rewindForTabDetailed]); - - const openTrash = useCallback(async () => { - closeTransientOverlays(); - setHistView(null); - openPage({ kind: "trash" }); - }, [closeTransientOverlays, openPage]); - const closeHistory = useCallback(() => { - closeTransientOverlays(); - setHistView(null); - }, [closeTransientOverlays]); - const refreshHistoryView = useCallback(async () => { - const sessions = await listSessions().catch(() => null); - if (!sessions) return; - setHistView((cur) => - cur === null || cur.kind !== "history" - ? cur - : cur.source === "scope" - ? { ...cur, sessions: sessionsForScope(sessions, cur.filter) } - : { ...cur, sessions }, - ); - }, [listSessions]); - - const automationLinkRef = useRef<{ intent: number; generation: number } | null>(null); - useEffect(() => useAppNavigationStore.subscribe((next, previous) => { - if (next.generation !== previous.generation && automationLinkRef.current) { - automationLinkRef.current = null; - noteNavigationIntent(); - } - }), [noteNavigationIntent]); - const navigationSeqRef = useRef(0); - const navigationRunningRef = useRef(false); - const navigationPendingRef = useRef(null); - const runNavigationRequest = useCallback(async (request: PendingDesktopNavigationRequest) => { - const latest = () => request.seq === navigationSeqRef.current && isNavigationIntentCurrent(request.navigationIntentSeq); - if (!latest()) return; - const refreshLatestTabMetas = async (): Promise => { - const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[])); - if (latest()) setTabMetas(tabs); - return tabs; - }; - const openTopicTarget = async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise => { - if (singleSurfaceLayout) return activateTopic(scope, workspaceRoot, topicId, sessionPath || "", request.navigationIntentSeq); - if (sessionPath) return openTopicSession(scope, workspaceRoot, topicId, sessionPath, request.navigationIntentSeq); - if (scope === "global") return openGlobalTab(topicId, request.navigationIntentSeq); - return openProjectTab(workspaceRoot, topicId, request.navigationIntentSeq); - }; - const openBlankTarget = async (scope: string, workspaceRoot: string): Promise => { - const root = scope === "project" ? workspaceRoot : ""; - return singleSurfaceLayout - ? ensureBlankSurface(scope, root, request.navigationIntentSeq) - : ensureBlankTab(scope, root, request.navigationIntentSeq); - }; - - try { - if (request.kind === "topic") { - const openedTab = await openTopicTarget(request.scope, request.workspaceRoot, request.topicId, request.sessionPath); - if (!latest()) return; - seedActiveTabMeta(openedTab); - const link = automationLinkRef.current; - if (link?.intent === request.navigationIntentSeq) { - useAppNavigationStore.getState().returnFromAutomationLink(link.generation); - automationLinkRef.current = null; - } - void refreshLatestTabMetas(); - setTabRevealSignal((signal) => signal + 1); - setTranscriptRevealSignal((signal) => signal + 1); - return; - } - - if (request.kind === "blank") { - const openedTab = await openBlankTarget(request.scope, request.workspaceRoot); - if (!latest()) return; - seedActiveTabMeta(openedTab); - setProjectRevision((value) => value + 1); - await refreshLatestTabMetas(); - if (!latest()) return; - setTabRevealSignal((signal) => signal + 1); - setTranscriptRevealSignal((signal) => signal + 1); - return; - } - - if (request.kind === "isolated-worktree") { - const result = await createIsolatedWorktree(request.workspaceRoot, request.navigationIntentSeq); - if (!latest()) return; - seedActiveTabMeta(result.tab); - setProjectRevision((value) => value + 1); - await refreshLatestTabMetas(); - if (!latest()) return; - showToast( - result.sourceDirty - ? t("projectTree.worktreeCreatedDirty", { branch: result.branch }) - : t("projectTree.worktreeCreated", { branch: result.branch }), - result.sourceDirty ? "warn" : "info", - { durationMs: result.sourceDirty ? 7000 : 3500 }, - ); - setTabRevealSignal((signal) => signal + 1); - setTranscriptRevealSignal((signal) => signal + 1); - return; - } - - if (request.kind === "sidebar-im") { - const { connection } = request; - const target = sidebarImSessionTarget(connection); - if (!target) { - if (latest()) showToast(t("sidebar.imWaiting", { name: connection.title })); - return; - } - let openedTab: TabMeta | undefined; - if (connection.sessionSource === "auto" && target.kind === "path") { - openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot); - if (!latest()) return; - await openChannelSession(target.value, openedTab.id, request.navigationIntentSeq); - } else if (target.kind === "path") { - openedTab = await openBlankTarget(connection.scope, connection.workspaceRoot); - if (!latest()) return; - await resumeSession(target.value, openedTab.id, request.navigationIntentSeq); - } else { - openedTab = await openTopicTarget(connection.scope, connection.workspaceRoot, target.value); - } - if (!latest()) return; - if (openedTab) seedActiveTabMeta(openedTab); - await refreshLatestTabMetas(); - if (!latest()) return; - setTabRevealSignal((value) => value + 1); - setTranscriptRevealSignal((value) => value + 1); - setProjectRevision((value) => value + 1); - return; - } - - const { session } = request; - const scope = session.scope || (session.workspaceRoot ? "project" : "global"); - let targetTab: TabMeta; - if (isChannelSession(session)) { - targetTab = await openBlankTarget(scope === "project" ? "project" : "global", scope === "project" ? session.workspaceRoot || "" : ""); - if (!latest()) return; - await openChannelSession(session.path, targetTab.id, request.navigationIntentSeq); - } else if (scope === "project" && session.workspaceRoot && session.topicId) { - targetTab = await openTopicTarget("project", session.workspaceRoot, session.topicId, session.path); - } else if (scope === "global" && session.topicId) { - targetTab = await openTopicTarget("global", "", session.topicId, session.path); - } else { - throw new Error(scope === "global" && !session.topicId - ? t("history.failedOpenSession") - : (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession"))); - } - if (!latest()) return; - seedActiveTabMeta(targetTab); - setHistView(null); - void refreshLatestTabMetas(); - setTabRevealSignal((value) => value + 1); - setTranscriptRevealSignal((value) => value + 1); - } catch (err: any) { - if (!latest()) return; - if (request.kind === "topic" || request.kind === "blank") { - console.warn("desktop navigation failed", err); - showToast(t("history.failedOpenSession"), "error"); - void refreshLatestTabMetas(); - return; - } - if (request.kind === "isolated-worktree") { - console.warn("isolated Delivery workspace creation failed", err); - showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 }); - return; - } - if (request.kind === "sidebar-im") { - console.warn("bot sidebar open failed", err); - showToast(t("sidebar.imOpenFailed", { name: request.connection.title })); - return; - } - await refreshHistoryView(); - if (!latest() || isMissingSessionError(err)) return; - setHistView(null); - const session = request.session; - const scope = session.scope || (session.workspaceRoot ? "project" : "global"); - if (scope === "project" && session.workspaceRoot) { - const name = workspaceDisplayName(session.workspaceRoot); - showToast(t("history.failedOpenProject", { name, path: session.workspaceRoot })); - } else { - showToast(err?.message || String(err)); - } - } - }, [activateTopic, createIsolatedWorktree, ensureBlankSurface, ensureBlankTab, isNavigationIntentCurrent, openChannelSession, openGlobalTab, openProjectTab, openTopicSession, refreshHistoryView, resumeSession, seedActiveTabMeta, showToast, singleSurfaceLayout, t]); - - const enqueueNavigationWithIntent = useCallback((input: DesktopNavigationIntent, navigationIntentSeq: number): Promise => { - beginNavigationSurface(navigationIntentSeq); - return enqueueNavigationRequest( - { seqRef: navigationSeqRef, runningRef: navigationRunningRef, pendingRef: navigationPendingRef }, - { ...input, navigationIntentSeq } as DesktopNavigationInput, - async (request) => { - try { - await runNavigationRequest(request); - } finally { - settleNavigationSurface(request.navigationIntentSeq); - } - }, - ); - }, [beginNavigationSurface, runNavigationRequest, settleNavigationSurface]); - - const openAutomationTopic = useCallback((scope: string, workspaceRoot: string, topicId: string) => { - const intent = noteNavigationIntent(); - automationLinkRef.current = { intent, generation: useAppNavigationStore.getState().generation }; - void enqueueNavigationWithIntent({ kind: "topic", scope, workspaceRoot, topicId }, intent); - }, [noteNavigationIntent, enqueueNavigationWithIntent]); - - const enqueueNavigation = useCallback((input: DesktopNavigationIntent): Promise => { - // Any navigation (open topic / new session / resume) leaves the automation - // view and returns to the chat workspace. - enterConversation(); - // Invalidate any in-flight activation's stale apply at ENQUEUE time. The - // queue serializes requests, so a click made while another request runs - // only advances the controller's navigation epoch when it eventually - // starts — too late: the running request's ActivateTopic would resolve, - // pass the controller-local guard, flip the visible tab, and prune the - // newer surface's cached state (#6613 review). - const navigationIntentSeq = noteNavigationIntent(); - return enqueueNavigationWithIntent(input, navigationIntentSeq); - }, [enqueueNavigationWithIntent, noteNavigationIntent, enterConversation]); - - const openBlankSession = useCallback((scope: string, workspaceRoot: string): Promise => - enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }), - [enqueueNavigation]); +} - const handleNewTab = useCallback(async () => { - closeTransientOverlays(); - setSidebarImDetailConnectionId(""); - if (activeTab?.remote) return openRemoteNewSession(activeTab.remote, remoteSession.retryHydration); - const target = blankSessionTarget(); - await openBlankSession(target.scope, target.workspaceRoot); - }, [activeTab?.remote, blankSessionTarget, closeTransientOverlays, openBlankSession, remoteSession]); - const handleOpenTopic = useCallback((scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise => { - closeTransientOverlays(); - setSidebarImDetailConnectionId(""); - return enqueueNavigation({ kind: "topic", scope, workspaceRoot, topicId, sessionPath }); - }, [closeTransientOverlays, enqueueNavigation]); +const WindowsWindowControls = lazy(() => import("./app-shell/WindowsWindowControls").then((module) => ({ default: module.WindowsWindowControls }))); - const openSidebarImConnectionSession = useCallback((connection: SidebarImConnection): Promise => { - setSidebarImDetailConnectionId(""); - return enqueueNavigation({ kind: "sidebar-im", connection }); - }, [enqueueNavigation]); - const onResumeSession = useCallback((session: SessionMeta): Promise => { - if (state.running && !singleSurfaceLayout) return Promise.resolve(); - return enqueueNavigation({ kind: "resume-session", session }); - }, [enqueueNavigation, singleSurfaceLayout, state.running]); +const WORKSPACE_RESIZER_WIDTH = 8; - const onRecoveryCreated = useCallback(() => { - setProjectRevision((value) => value + 1); - void refreshTabMetas(undefined, { afterMutation: true }); - }, [refreshTabMetas]); - const onRecoveryLineageChanged = useCallback(() => { - setProjectRevision((value) => value + 1); - void refreshHistoryView(); - }, [refreshHistoryView]); +const SHOW_CONTEXT_DOCK = true; - const openTaskMonitorSession = useCallback(async (tabID: string, taskID: string): Promise => { - if (state.running && !singleSurfaceLayout) { - throw new Error(t("history.failedOpenSession")); - } - // Claim the navigation epoch before the first Wails await. If the user - // switches tabs while the task/session lookup is pending, its completion is - // stale and must not enqueue a newer navigation request. - const navigationIntentSeq = noteNavigationIntent(); - beginNavigationSurface(navigationIntentSeq); - let session: SessionMeta | null; - try { - session = await resolveTaskMonitorSession({ - tabID, - taskID, - intentSeq: navigationIntentSeq, - isIntentCurrent: isNavigationIntentCurrent, - openTaskSessionForTab: (sourceTabID, sourceTaskID) => app.OpenTaskSessionForTab(sourceTabID, sourceTaskID), - listSessionsForTab: async (sourceTabID) => asArray(await app.ListSessionsForTab(sourceTabID)), - sessionIDFromPath: taskSessionIDFromPath, - }); - } catch (error) { - settleNavigationSurface(navigationIntentSeq); - throw error; - } - if (!session) { - settleNavigationSurface(navigationIntentSeq); - return false; - } - await enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq); - return isNavigationIntentCurrent(navigationIntentSeq); - }, [beginNavigationSurface, enqueueNavigationWithIntent, isNavigationIntentCurrent, noteNavigationIntent, settleNavigationSurface, singleSurfaceLayout, state.running, t]); - // Command palette: ⌘K / Ctrl+K opens a fuzzy navigator over commands and - // recent sessions. Sessions are snapshotted on open so the list is stable - // while the palette is up; extension actions follow the same snapshot rule. - const openPalette = useCallback(async () => { - closeTransientOverlays(); - setPaletteOpen(true); - setPaletteSessions(await listSessions().catch(() => [])); - setPaletteExtensionActions(await app.ExtensionActions(activeTabIdRef.current ?? "").catch(() => [])); - }, [closeTransientOverlays, listSessions, setPaletteExtensionActions]); - useGlobalShortcut("commandPalette.open", () => { - setPaletteOpen((current) => { - if (!current) void openPalette(); - return !current; // ← fix: toggle the state so the palette actually opens/closes - }); - }, [openPalette]); - useGlobalShortcut("app.newSession", () => void handleNewTab(), [handleNewTab]); - useGlobalShortcut("settings.open", () => { - closeTransientOverlays(); - setSettingsTarget(useAppNavigationStore.getState().lastSettingsTarget); - }, [closeTransientOverlays]); - useGlobalShortcut("tab.close", () => { - if (managementActive) returnToWorkspace(); - else if (activeTabId) void handleTabClose(activeTabId); - }, [activeTabId, handleTabClose, managementActive, returnToWorkspace], managementActive || Boolean(activeTabId)); - useGlobalShortcut("shortcuts.show", () => setShortcutsOpen(true)); - useGlobalShortcut("sidebar.toggle", toggleSidebar, [toggleSidebar], !managementActive); +type Runtime = ReturnType; - // --- Topic shortcut navigation (Cmd/Ctrl+1-9) --- - const visibleTopicsRef = useRef([]); - const handleVisibleTopicsChange = useCallback((topics: TopicShortcutEntry[]) => { - visibleTopicsRef.current = topics; - }, []); - const handleNavigateTopic = useCallback((entry: TopicShortcutEntry) => { - void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath); - }, [handleOpenTopic]); - const { showBadges: showTopicBadges } = useTopicShortcuts(!sidebarCollapsed && !managementActive, desktopPlatform); +type Shell = ReturnType; - // Register Cmd/Ctrl+1-9 shortcuts for topic navigation - useEffect(() => { - if (sidebarCollapsed || managementActive) return; - const onKeydown = (event: globalThis.KeyboardEvent) => { - const idx = topicShortcutIndexFromEvent(event, desktopPlatform); - if (idx === null) return; - event.preventDefault(); - const topics = visibleTopicsRef.current; - if (idx < topics.length) { - handleNavigateTopic(topics[idx]); - } - }; - document.addEventListener("keydown", onKeydown); - return () => document.removeEventListener("keydown", onKeydown); - }, [sidebarCollapsed, managementActive, desktopPlatform, handleNavigateTopic]); +type SessionComposition = ReturnType; - const paletteItems = useMemo(() => { - const cmds: PaletteItem[] = [ - { id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), icon: , compact: true, keywords: ["new", "新建"], run: () => void handleNewTab() }, - { id: "cmd-automation", group: t("palette.group.commands"), title: t("sidebar.automation"), icon: , compact: true, keywords: ["automation", "自动化"], run: () => openPage({ kind: "automation" }) }, - { id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: , compact: true, keywords: ["trash", "回收站"], run: () => void openTrash() }, - { id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: , compact: true, keywords: ["settings", "设置"], run: () => setSettingsTarget(useAppNavigationStore.getState().lastSettingsTarget) }, - { id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: , compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => setSettingsTarget("appearance") }, - { - id: "cmd-theme-reset", - group: t("palette.group.commands"), - title: t("settings.themeLibrary.reset"), - icon: , - compact: true, - keywords: ["theme", "reset", "default", "恢复默认", "主题"], - run: () => { - void app.ResetThemePack() - .then(() => { - clearThemePack(); - notice(t("settings.themeReset")); - }) - .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); - }, - }, - { id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: , compact: true, keywords: ["memory", "记忆"], run: () => setSettingsTarget("memory") }, - { id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: , compact: true, keywords: ["model", "模型"], run: () => setSettingsTarget("models") }, - { - id: "cmd-usage-stats", - group: t("palette.group.commands"), - title: t("palette.cmd.usageStats"), - icon: , - compact: true, - keywords: ["usage", "stats", "statistics", "用量", "统计"], - run: () => { - setSettingsFocus((current) => ({ - target: "model-stats", - requestId: (current?.requestId ?? 0) + 1, - })); - setSettingsTarget("models"); - }, - }, - { id: "cmd-task-center", group: t("palette.group.commands"), title: t("palette.cmd.taskCenter"), icon: , compact: true, keywords: ["task", "tasks", "center", "任务", "任务中心"], run: () => setTasksOpen("all") }, - { id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: , compact: true, keywords: ["terminal", "shell", "终端"], run: () => toggleTerminalPanel() }, - { - id: "cmd-reload-runtime", - group: t("palette.group.commands"), - title: t("palette.cmd.reloadRuntime"), - icon: , - compact: true, - keywords: ["reload", "runtime", "重载", "运行时"], - run: () => { - const tabID = activeTab?.id; - if (!tabID) return; - // Success/queued feedback arrives as a tab notice; only hard failures need a toast. - void app.ReloadRuntime(tabID).catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); - }, - }, - ]; - const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); - const dayLabel = (ms: number) => { - const days = Math.round((startOfDay(new Date()) - startOfDay(new Date(ms))) / 86_400_000); - if (days <= 0) return t("history.today"); - if (days === 1) return t("history.yesterday"); - return new Date(ms).toLocaleDateString(); - }; - const sessionItems: PaletteItem[] = paletteSessions.slice(0, 12).map((s) => ({ - id: `sess-${s.path}`, - group: t("palette.group.sessions"), - title: paletteSessionDisplayTitle(s, t("history.emptySession")), - hint: paletteSessionHint(s), - keywords: paletteSessionKeywords(s), - meta: dayLabel(sessionActivityTime(s)), - badge: t(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns }), - run: () => void onResumeSession(s), - })); - const remoteItems: PaletteItem[] = remoteHosts.map((host) => { - const status = remoteStatuses[host.id]; - const connected = status?.state === "connected" || status?.state === "degraded"; - const target = `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}`; - return { - id: `remote-${host.id}`, - group: t("palette.group.remote"), - title: connected - ? t("palette.remote.open", { host: host.label }) - : t("palette.remote.connect", { host: host.label }), - hint: host.defaultWorkspace || target, - icon: , - keywords: ["ssh", "remote", "远程", "连接", host.label, host.host], - run: () => { - if (connected) openRemoteWorkspaceFromStatus(host); - else connectAndOpenRemoteWorkspace(host); - }, - }; - }); - const extensionItems: PaletteItem[] = paletteExtensionActions.map((action) => ({ - id: `ext-${action.slash}`, - group: t("palette.group.extensions"), - title: action.description || action.slash, - hint: action.slash, - icon: , - keywords: ["extension", "扩展", action.plugin, action.action, action.slash], - run: () => { - const tabID = activeTab?.id; - if (!tabID) return; - // The extension's result message is user-facing feedback; only hard - // failures need an error toast. - void app.InvokeExtensionAction(tabID, action.slash, {}) - .then((message) => { - if (message) showToast(message, "info"); - }) - .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); - }, - })); - return [...(remoteSurfaceActive ? cmds.filter((item) => item.id !== "cmd-terminal" && item.id !== "cmd-reload-runtime") : cmds), ...extensionItems, ...remoteItems, ...sessionItems]; - }, [t, paletteSessions, paletteExtensionActions, remoteHosts, remoteStatuses, activeTab?.id, handleNewTab, openTrash, onResumeSession, openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace, openRightDockMode, remoteSurfaceActive, showToast]); - // Delete / rename act on disk, then re-fetch so the panel reflects the change. - const onDeleteSession = useCallback( - async (path: string) => { - if (state.running) return; - try { - await deleteSession(path); - } catch { - await refreshHistoryView(); - return; - } - // Local state removal: filter the deleted session out of the current - // history view instead of re-fetching the full list from the backend. - setHistView((cur) => - cur === null || cur.kind !== "history" - ? cur - : { ...cur, sessions: cur.sessions.filter((s) => s.path !== path) }, - ); - }, - [state.running, deleteSession, refreshHistoryView], - ); - const onRenameHistorySession = useCallback( - async (session: SessionMeta, title: string) => { - if (state.running) return; - if (session.topicId) await app.RenameTopic(session.topicId, title); - else await renameSession(session.path, title); - const sessions = await listSessions(); - setHistView((cur) => - cur === null - ? null - : cur.kind === "history" - ? { ...cur, sessions: cur.source === "scope" ? sessionsForScope(sessions, cur.filter) : sessions } - : cur, - ); - }, - [state.running, renameSession, listSessions], - ); - // Workspace: open the folder chooser and switch projects. The hook resets the - // transcript and refreshes meta on a pick. A cancel is a no-op. - const switchFolder = useCallback(async (path?: string) => { - enterConversation(); - const navigationIntentSeq = noteNavigationIntent(); - beginNavigationSurface(navigationIntentSeq); - try { - const picked = path === undefined - ? await pickWorkspace(navigationIntentSeq) - : await switchWorkspace(path, navigationIntentSeq); - if (!isNavigationIntentCurrent(navigationIntentSeq)) return picked; - if (picked) { - setProjectRevision((value) => value + 1); - await refreshTabMetas( - () => isNavigationIntentCurrent(navigationIntentSeq), - { afterMutation: true }, - ); - } - return picked; - } finally { - settleNavigationSurface(navigationIntentSeq); - } - }, [enterConversation, beginNavigationSurface, isNavigationIntentCurrent, noteNavigationIntent, pickWorkspace, refreshTabMetas, settleNavigationSurface, switchWorkspace]); +type NavigationComposition = ReturnType; - const refreshProjectsAndTabs = useCallback(async () => { - setProjectRevision((value) => value + 1); - const tabs = await refreshTabMetas(undefined, { afterMutation: true }); - if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) { - await syncActiveTab(false); - } - }, [activeTabId, refreshTabMetas, syncActiveTab]); +type LiveStore = Runtime["snapshot"]["liveStore"]; - const renameTopic = useCallback(async (topicId: string, title: string) => { - const nextTitle = title.trim(); - if (!topicId || !nextTitle) return; - try { - await app.RenameTopic(topicId, nextTitle); - await refreshProjectsAndTabs(); - } catch (err) { - showToast(err instanceof Error ? err.message : String(err), "error"); - } - }, [refreshProjectsAndTabs, showToast]); - const startActiveTopicRename = useCallback(() => { - if (!activeTab?.remote && !activeTab?.topicId) return; - topicRenameSkipCommitRef.current = false; - topicRenameCommitHandledRef.current = false; - setRenamingTopicId(activeTab.remote ? activeTab.id : activeTab.topicId); - setTopicTitleDraft(activeTab.topicTitle || ""); - }, [activeTab?.id, activeTab?.remote, activeTab?.topicId, activeTab?.topicTitle]); +export type AppRuntimeViewProps = { + core: { + state: State; + activeTab: TabMeta | undefined; + activeTabId: string | undefined; + liveStore: LiveStore; + remoteSurfaceActive: boolean; + remoteSession: RemoteSessionApi; + remoteComposerReady: boolean; + remoteCancel: (queuedItemIDs?: string[]) => Promise; + surface: ReturnType; + t: Translator; + locale: string; + onOpenLink: (url: string) => void; + }; + shell: Shell; + session: SessionComposition; + navigation: NavigationComposition; + runtime: Runtime; + local: { + tasksOpen: false | "session" | "all"; + setTasksOpen: React.Dispatch>; + topicTimeFilter: TopicTimeFilter; + setTopicTimeFilter: (value: TopicTimeFilter) => void; + sidebarImDetailConnectionId: string; + setSidebarImDetailConnectionId: React.Dispatch>; + tabRevealSignal: number; + transcriptRevealSignal: number; + histView: HistoryViewState | null; + projectRevision: number; + dockRefreshKey: number; + composerFileRefRefreshKey: string; + refreshComposerFileRefs: () => void; + terminalContentVisible: boolean; + terminalFitEnabled: boolean; + prefetchTerminalPanel: () => void; + }; +}; - const cancelActiveTopicRename = useCallback(() => { - topicRenameSkipCommitRef.current = true; - topicRenameCommitHandledRef.current = true; - setRenamingTopicId(null); - setTopicTitleDraft(""); - }, []); - const commitActiveTopicRename = useCallback(async () => { - if (topicRenameSkipCommitRef.current) { - topicRenameSkipCommitRef.current = false; - topicRenameCommitHandledRef.current = false; - setRenamingTopicId(null); - return; - } - if (topicRenameCommitHandledRef.current) return; - topicRenameCommitHandledRef.current = true; - const topicId = renamingTopicId; - setRenamingTopicId(null); - if (!topicId) return; - const nextTitle = topicTitleDraft.trim(); - if (!nextTitle) return; - try { - if (await renameCurrentRemoteSession(activeTab, nextTitle)) return; - await renameTopic(topicId, nextTitle); - } catch { - /* keep the app usable if a stale topic cannot be renamed */ - } - }, [activeTab, renameTopic, renamingTopicId, topicTitleDraft]); +/** + * Pure assembly of the App shell tree: every region receives its props from + * the session/navigation composition bags and the caller's stores. No hooks + * beyond value memoization live here; ownership stays in the compositions. + */ +export function AppRuntimeView(props: AppRuntimeViewProps) { + const { core, shell, session, navigation, runtime, local } = props; + const { state, activeTab, activeTabId, t, locale } = core; + const { sidebarWorkbench, sidebarCreation, windowsFramelessChrome, managementActive, mainWindowMaximised } = shell; + const { + conversationView, visibleRuntimeState, sidebarImDetailConnection, + surfaceWorkspacePanelRenderable, surfaceWorkspacePanelGridOpen, surfaceWorkspacePanelOverlay, terminalSurfaceOpen, + controllerReady, decisionSurface, visibleDecisionSurface, composerSurfaceHidden, + shellGeometry, appRef, layoutRef, footerHeight, footerRef, + } = session; + const { chromeCommands, navigationCommands } = navigation; + const runtimeTransitioning = core.surface.transitioning; + const browserPreviewChrome = navigation.browserPreviewChrome; - const sidebarExpandBlocked = false; - const sidebarToggleTitle = sidebarCollapsed - ? t("sidebar.expand") - : t("sidebar.collapse"); - const sidebarNavTooltipDisabled = !sidebarCollapsed; - const browserPreviewChrome = typeof window !== "undefined" && !window.runtime; - const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : ""; - const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined; - const workspacePanelResetWidth = desktopLayoutStyle === "creation" - ? defaultCreationRightDockTreeWidth() - : defaultRightDockTreeWidth(); - const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(workspacePanelMinWidth, workspacePanelRenderWidth); - const workspacePanelResizeMaxWidth = workspacePanelAvailableWidth; - const sidebarCreation = desktopLayoutStyle === "creation"; - // Command palette shortcut label (⌘K / Ctrl+K), platform-aware. - const commandPaletteShortcut = formatShortcutCombo( - resolvedShortcutCombo("commandPalette.open", desktopPlatform), - desktopPlatform, - ); - // Dock collapse/expand toggle. Rendered in the dock's own tools row when the - // dock is open (its top-right corner), and in the topic bar when closed. - const dockToggleButton = ( - - - - ); - const topicbarTitle = sidebarImDetailConnection ? t("botDetail.title", { name: sidebarImDetailConnection.title }) : topicDisplayTitle(activeTab); - const topicbarWorkspaceLabel = sidebarImDetailConnection ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : ""; - const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : ""; - const topicbarImSource = activeTab?.scope === "global" && activeTab.topicId ? imTopicSources[activeTab.topicId] : undefined; - const topicbarImSourceLabel = sidebarImDetailConnection - ? sidebarImDetailConnection.platformLabel - : topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : ""; - const topicbarImSourcePlatform = sidebarImDetailConnection?.platform ?? topicbarImSource?.platform; - const topicbarSubtitleVisible = !sidebarCreation && Boolean(activeTab?.isolatedWorktree || topicbarImSourceLabel); - const topicbarSubtitleTitle = sidebarImDetailConnection - ? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ") - : [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · "); - const topicbarCanRename = !sidebarImDetailConnection && (Boolean(activeTab?.topicId) || Boolean(activeTab?.remote)); - const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1)); - const sidebarWorkbench = desktopLayoutStyle === "workbench"; - // The Wails drag runtime ignores anything with detail !== 1, so a double click - // on a --wails-draggable region never reaches the OS. Both platforms that hide - // their native title bar need this handled here. - const chromeDoubleClickZooms = windowsFramelessChrome || desktopPlatform === "darwin"; - const handleChromeTitlebarDoubleClick = useCallback((event: ReactMouseEvent) => { - if (!chromeDoubleClickZooms) return; - const target = event.target as HTMLElement | null; - const onChromeSurface = target?.closest(".app-chrome, .topicbar, .workbench-dock__tools, .management-screen__chrome"); - const onMacOSWorkbenchSidebarTitlebar = isMacOSWorkbenchSidebarTitlebar(target, event.clientY, desktopPlatform); - if (!onChromeSurface && !onMacOSWorkbenchSidebarTitlebar) return; - if (target?.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return; - event.preventDefault(); - void app.ToggleMaximiseMainWindow() - .then(() => window.setTimeout(syncMainWindowMaximised, 80)) - .catch(() => undefined); - }, [chromeDoubleClickZooms, desktopPlatform, syncMainWindowMaximised]); // Creation keeps the classic sidebar/chat structure while gating chrome tweaks // behind its own style flag so classic/workbench remain unchanged. const appChromeHidden = sidebarWorkbench || sidebarCreation; const workbenchChromeHidden = sidebarWorkbench; const sidebarClassName = [ "sidebar", - sidebarCollapsed ? "sidebar--collapsed" : "", + shell.sidebarCollapsed ? "sidebar--collapsed" : "", sidebarWorkbench ? "sidebar--workbench" : "", ].filter(Boolean).join(" "); + const startupSplashHold = !activeTabId && state.meta?.ready !== true && !state.meta?.startupErr; + + const layoutStyle = useMemo( + () => + ({ + "--sidebar-expanded-width": `${shellGeometry.sidebarRenderWidth}px`, + "--chat-min-width": `${shellGeometry.chatReservedWidth}px`, + "--workspace-width": `${shellGeometry.workspacePanelRenderWidth}px`, + "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, + "--terminal-height": `${terminalSurfaceOpen ? shell.liveTerminalHeight ?? shellGeometry.terminalRenderHeight : 0}px`, + }) as CSSProperties, + [shellGeometry.chatReservedWidth, shell.liveTerminalHeight, shellGeometry.sidebarRenderWidth, shellGeometry.terminalRenderHeight, terminalSurfaceOpen, shellGeometry.workspacePanelRenderWidth], + ); + + const shellClassNames = buildAppShellClassNames({ + platform: shell.desktopPlatform, + windowsFrameless: windowsFramelessChrome, + browserPreview: browserPreviewChrome, + workbench: sidebarWorkbench, + creation: sidebarCreation, + imDetailActive: Boolean(sidebarImDetailConnection), + sidebarCollapsed: shell.sidebarCollapsed, + sidebarResizing: shell.sidebarResizing, + dockGridOpen: surfaceWorkspacePanelGridOpen, + dockOverlay: surfaceWorkspacePanelOverlay, + terminalOpen: terminalSurfaceOpen, + terminalResizing: shell.terminalResizing, + dockOpen: shell.workspacePanelOpen, + dockMaximized: shell.workspacePanelMaximized, + dockResizing: shell.workspacePanelResizing, + }); + const footerTodo = buildFooterTodo({ + show: session.todoPanel.showTodos, + identity: session.todoPanel.scopedTodoBatch, + todos: session.todoPanel.todos, + running: visibleRuntimeState.running, + pendingPrompt: visibleRuntimeState.pendingPrompt, + continueReady: Boolean(activeTabId && !activeTab?.readOnly && (core.remoteSurfaceActive ? core.remoteComposerReady : controllerReady)), + onContinue: session.todoPanel.handleTodoContinue, + onDismiss: session.todoPanel.dismissTodos, + }); + const footerUndo = buildFooterUndo({ rewindState: session.sessionUndo.rewindState, activeTabId, onUndo: session.sessionUndo.handleUndoRewind }); + const decisionFooterSurface = buildDecisionFooterSurface({ + view: { + surface: visibleDecisionSurface, + activeTabId, + cwd: state.meta?.cwd, + workspaceScopeKey: session.workspaceScopeKey, + approval: state.approval, + ask: state.ask, + mcpInteraction: state.mcpInteraction, + extensionForm: state.extensionForm, + workspaceConflict: session.workspaceConflict, + toolApprovalMode: session.profileProjection.toolApprovalMode, + insertRequest: session.insertCommands.activePlanRevisionInsertRequest, + }, + prompts: session.promptCommands, + extension: session.extensionSurface, + tabs: session.tabBarCommands, + clear: session.clearCommands, + onStop: () => void session.controlCommands.handleCancelActive(), + cancelWorkspaceConflict: session.controlCommands.cancelWorkspaceConflict, + onOpenLink: core.onOpenLink, + onRevisionActiveChange: session.insertCommands.handleRevisionActiveChange, + t, + }); return ( + + + +
- {sidebarWorkbench &&
{dockToggleButton}
} + {sidebarWorkbench &&
}
{!appChromeHidden && ( void handleTabChange(id)} - onTabClose={(id) => void handleTabClose(id)} - onTabsClose={(ids, nextActiveTabId) => void handleTabsClose(ids, nextActiveTabId)} - onTabsReorder={(ids) => void handleTabsReorder(ids)} - onNewTab={() => void handleNewTab()} - onOpenPalette={() => void openPalette()} + onToggleSidebar={shellGeometry.toggleSidebar} + onToggleWorkspacePanel={session.workspacePanelCommands.toggleWorkspacePanel} + onTabChange={(id) => void session.tabBarCommands.handleTabChange(id)} + onTabClose={(id) => void session.tabBarCommands.handleTabClose(id)} + onTabsClose={(ids, nextActiveTabId) => void session.tabBarCommands.handleTabsClose(ids, nextActiveTabId)} + onTabsReorder={(ids) => void session.tabBarCommands.handleTabsReorder(ids)} + onNewTab={() => void navigationCommands.handleNewTab()} + onOpenPalette={() => void navigation.paletteCommands.openPalette()} /> )} {t("shortcuts.skipToComposer")} - - - )} - -
-
- {automationReturn && } - {workbenchChromeHidden && ( - - - - )} -
-
- {topicbarEditing ? ( -
- setTopicTitleDraft(event.target.value)} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={(event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - void commitActiveTopicRename(); - } - if (event.key === "Escape") { - event.preventDefault(); - cancelActiveTopicRename(); - } - }} - onBlur={() => void commitActiveTopicRename()} - /> -
- ) : topicbarCanRename ? ( -

- -

- ) : ( -

{topicbarTitle}

- )} - {topicbarWorkspaceLabel && ( - - {topicbarWorkspaceLabel} - - )} -
- {topicbarSubtitleVisible && ( -
- {activeTab?.isolatedWorktree && } - {activeTab?.isolatedWorktree && ( - - )} - {topicbarImSourcePlatform && ( - - {topicbarImSourceLabel} - - )} -
- )} -
-
-
- - - - {shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && ( - - )} - {!sidebarImDetailConnection && ( - void exportSession(format)} - toggleTerminal={toggleTerminalPanel} terminalEnabled={!remoteSurfaceActive} - terminalOpen={terminalPanelOpen && !remoteSurfaceActive} - prefetchTerminal={prefetchTerminalPanel} - openSessionSummary={() => setTasksOpen((open) => open ? false : "session")} - tasksOpen={Boolean(tasksOpen)} - /> - )} - {sidebarCreation && dockToggleButton} - {tasksOpen && ( -
- - setTasksOpen(false)} - onOpenSession={openTaskMonitorSession} - /> - -
- )} -
-
- - {activeTab?.takenOver ? ( - { - if (reclaimBusyTab) return; - setReclaimBusyTab(tabId); - (activeTab.remote ? app.ReclaimRemoteTabSession(tabId) : app.TakeoverSession(tabId, "wait")) - .catch((error) => console.warn("[takeover] reclaim failed", error)) - .finally(() => setReclaimBusyTab(null)); - }} + void navigationCommands.handleNewTab(), + onOpenTrash: () => void navigation.historyCommands.openTrash(), + onOpenAutomation: () => shell.openPage({ kind: "automation" }), + onOpenSettings: chromeCommands.openSidebarSettings, + onToggleSearch: chromeCommands.toggleSidebarSearch, + onToggle: shellGeometry.toggleSidebar, + onOpenTopic: navigationCommands.handleOpenTopic, + }, + })} /> + +
+ shell.openPage({ kind: "automation" }), toggleSidebar: shellGeometry.toggleSidebar, + setTitleDraft: navigation.projectTopicCommands.setTopicTitleDraft, commitRename: navigation.projectTopicCommands.commitActiveTopicRename, cancelRename: navigation.projectTopicCommands.cancelActiveTopicRename, + startRename: navigation.projectTopicCommands.startActiveTopicRename, openWorktree: navigation.worktreeMergeCommands.openWorktreeMerge, + }}> + void navigation.paletteCommands.openPalette()} + activeTab={activeTab} + activeTabId={activeTabId} + imDetailActive={Boolean(sidebarImDetailConnection)} + dismissSignal={shell.transientOverlayDismissSignal} + sessionHasContent={session.sessionHasContent} + exportCommands={session.sessionExportCommands} + terminal={{ toggle: session.terminalPanelCommands.toggleTerminalPanel, enabled: !core.remoteSurfaceActive, open: shell.terminalPanelOpen && !core.remoteSurfaceActive, prefetch: local.prefetchTerminalPanel }} + tasksOpen={local.tasksOpen} + setTasksOpen={local.setTasksOpen} + onCloseTasks={() => local.setTasksOpen(false)} + onOpenTaskSession={navigationCommands.openTaskMonitorSession} + creation={sidebarCreation} + dockToggle={} /> - ) : null} - {(() => { - const blocked = activeLeaseBlockedTab(tabMetas, activeTab?.id ?? activeTabId); - if (blocked) { - return ( -
- {t("topbar.startupError", { msg: blocked.runtime!.issue!.message })} - - -
- ); - } - return state.meta?.startupErr ? ( -
- {t("topbar.startupError", { msg: state.meta.startupErr })} -
- ) : null; - })()} - {takeoverDialogTab ? ( - - setTakeoverDialogTab(null)} /> - - ) : null} - {configLoadWarnings.length > 0 && ( -
- - {t("config.loadWarning", { msg: configLoadWarnings[0] })} - - - - - {t("config.doctorHint")} - -
- )} - {providerSetupNeeded && !needsOnboarding && ( -
- {t("onboarding.inlinePrompt")} - - -
- )} - - { - const version = latest.replace(/^(?:desktop-)?v/, ""); - void openExternal(`https://reasonix.io/changelog/v${version}/`); +
+ + + + local.setSidebarImDetailConnectionId(""), + onOpenSettings: chromeCommands.openBotSettings, + onManageAllowlist: chromeCommands.openBotAllowlistSettings, + onOpenSession: (connection) => void navigationCommands.openSidebarImConnectionSession(connection), + } : null} + remote={activeTab?.remote ? { tab: activeTab, session: core.remoteSession } : undefined} + transcript={{ + state, + items: session.transcript.visibleTranscriptItems, + tabId: session.transcript.visibleTranscriptTabId, + geometrySessionKey: session.transcript.visibleTranscriptGeometryKey, + footerHeight, + revealSignal: local.transcriptRevealSignal, + invocationMetadata: session.transcript.visibleTranscriptTabId ? session.invocation.invocationMetadataByTab[session.transcript.visibleTranscriptTabId] : undefined, + surfaceCommitToken: core.surface.surfaceCommitToken, + liveStore: core.liveStore, + transcriptHydrating: session.transcript.transcriptHydrating, + navigationDataReady: core.surface.dataReady, + readOnly: Boolean(activeTab?.readOnly), + controllerReady, + hydratePlaceholderActive: session.hydratePlaceholderActive, + clearContextPending: session.clearCommands.clearContextPending, + creation: sidebarCreation, + rewind: { stateActive: session.sessionUndo.rewindState != null, committing: session.sessionUndo.rewindCommitting, signal: session.sessionUndo.rewindSignal }, }} - /> - -
- {sidebarImDetailConnection && !runtimeTransitioning ? ( - setSidebarImDetailConnectionId("")} - onOpenSettings={openBotSettings} - onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)} - onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)} - /> - ) : noticePreviewMockEnabled() ? ( - - ) : activeTab?.remote ? ( - - ) : ( - <> -
-
{ - if (!node) return; - (node as HTMLElement & { inert?: boolean }).inert = runtimeTransitioning; - }} - > - void handleDeliveryContinue()} - onAcceptDelivery={() => void app.AcceptDeliveryToTab(activeTabIdRef.current ?? "")} - onOpenChanges={() => openRightDockMode("changed")} - onOpenVerification={openTurnVerification} - onEditPrompt={handleEditPrompt} - onRewind={handleMessageAction} - checkpoints={state.checkpoints} - actionPending={state.messageAction != null} - rewindDisabled={Boolean(activeTab?.readOnly) || !controllerReady || hydratePlaceholderActive || rewindState != null || rewindCommitting || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending || runtimeTransitioning} - running={state.running || rewindCommitting} - turnStartAt={state.turnStartAt} - contentRevision={state.historyLayoutRevision} - historyMutation={state.historyMutation} - welcomeVariant={sidebarCreation ? "creation" : "default"} - creationMode={sidebarCreation} - actionHoverMenus={sidebarCreation && !hydratePlaceholderActive && !runtimeTransitioning} - rewindSignal={rewindSignal} - revealSignal={transcriptRevealSignal} - hydrating={transcriptHydrating || (runtimeTransitioning && !navigationTargetDataReady)} - hasOlderHistory={!runtimeTransitioning && state.historyHasOlder && !rewindState} - historyStartTurn={state.historyStartTurn} - historyTotalTurns={state.historyTotalTurns} - loadingOlderHistory={state.historyOlderLoading} - olderHistoryError={state.historyOlderError} - onLoadOlderHistory={handleLoadOlderHistory} - invocationMetadata={visibleTranscriptTabId ? invocationMetadataByTab[visibleTranscriptTabId] : undefined} - surfaceCommitToken={surfaceCommitToken} - onSurfacePaintReady={handleSurfacePaintReady} - /> -
- {runtimeTransitioning ? ( -
-
- ) : null} -
- {!runtimeTransitioning && state.hydrateError ?
{state.hydrateError}
: null} - - )} -
- - {!sidebarImDetailConnection && ( -
0 ? { height: footerHeight, minHeight: footerHeight, boxSizing: "border-box" } : undefined} inert={runtimeTransitioning || undefined} aria-hidden={runtimeTransitioning || undefined} - > - {showTodos && ( - - )} - {rewindState && ( - { - const tabId = activeTabId; - if (!tabId) return; - const tx = rewindState.transactionId; - const undoTabId = rewindState.undoTabId || tabId; - const undo = tx && rewindState.undoAvailable ? undoRewindForTab(undoTabId, tx) : Promise.resolve(true); - void undo.then((ok) => { - if (!ok) return; - setRewindStateForTab(tabId, null); - setComposerInsertRequestsByTab((current) => ({ - ...current, - [tabId]: { id: Date.now(), text: "", mode: "replace" }, - })); - setRewindSignal((v) => v + 1); - setDockRefreshKey((v) => v + 1); - setProjectRevision((v) => v + 1); - }); - }, - }} - /> - )} - {visibleDecisionSurface === "tool_approval" || visibleDecisionSurface === "plan_approval" - ? state.approval && ( - { - // Approving an exit_plan_mode plan leaves plan mode; await the - // mode switch before sending the approval so the controller - // observes the updated state before it unblocks. - if (state.approval!.tool === "exit_plan_mode") { - if (allow) { - await applyCollaborationMode("normal"); - resolvePlanDecision(state.approval!.id, "start_execution"); - } else { - resolvePlanDecision(state.approval!.id, "revise_plan"); - } - return; - } - approve(state.approval!.id, allow, session, persist); - }} - onResolveRecovery={(action, feedback) => { - resolveRecovery(state.approval!.id, action, feedback ?? ""); - }} - onRevisePlan={(text) => { - if (activeTabId) { - setPendingPlanRevisionsByTab((current) => ({ ...current, [activeTabId]: text })); - } - resolvePlanDecision(state.approval!.id, "revise_plan"); - }} - onExitPlan={async () => { - await applyCollaborationMode("normal"); - resolvePlanDecision(state.approval!.id, "exit_plan"); - }} - onStop={() => { - cancel(); - }} - toolApprovalMode={toolApprovalMode} - /> - ) - : visibleDecisionSurface === "ask" - ? state.ask && ( - answerQuestion(state.ask!.id, [])} - onStop={() => { - cancel(); - }} - /> - ) - : visibleDecisionSurface === "mcp_interaction" - ? state.mcpInteraction && ( - - answerMCPInteraction(id, action, content)} - onOpenLink={(url) => openExternal(url)} - /> - - ) - : visibleDecisionSurface === "extension_form" - ? state.extensionForm && ( - - void submitExtensionForm(values)} - onCancel={() => void cancelExtensionForm()} - /> - - ) - : visibleDecisionSurface === "workspace_conflict" && workspaceConflict ? ( - { - cancel(); - setWorkspaceConflict(null); - }} - actions={[ - ...(workspaceConflict.canReveal ? [{ - key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"), - onClick: () => void revealWorkspaceWriter(), - }] : []), - ...(workspaceConflict.canCreateWorktree ? [{ - key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"), - onClick: () => void continueInDeliveryWorktree(), - }] : []), - ]} - secondaryAction={{ - key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"), - onClick: () => { cancel(); setWorkspaceConflict(null); }, - }} - /> - ) - : visibleDecisionSurface === "close_active" && pendingClose ? ( - setPendingClose(null)} - actions={[ - { - key: "1", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"), - onClick: () => void resolvePendingClose("keep_running"), disabled: pendingClose.stopping, - }, - { - key: "2", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"), - description: t("runtime.stopAndCloseDesc"), onClick: () => void resolvePendingClose("stop_and_close"), - danger: true, disabled: pendingClose.stopping, - }, - ]} - secondaryAction={{ - key: "Esc", label: t("runtime.returnToTask"), description: t("runtime.closeCancelDesc"), - onClick: () => setPendingClose(null), disabled: pendingClose.stopping, - }} - /> - ) - : visibleDecisionSurface === "clear_context" ? ( - { - void confirmClearContext(); - }} - /> - ) : null} - {/* Composer stays mounted under a decision so per-session draft - caches (text, attachments, paste blocks, guidance) survive. */} - -
- )} -
- - {surfaceWorkspacePanelGridOpen && ( - - )} - - - {remoteHosts.length > 0 && ( - - )} -
-
-
- {rightDockMode === "remote" ? ( - - setWorkspacePanel(false)} /> - - ) : rightDockMode === "context" && desktopLayoutStyle !== "creation" ? ( - - - - ) : ( - - setWorkspacePanel(false)} - onToggleMaximized={() => { - closeTransientOverlays(); - setWorkspacePanelMaximized((value) => !value); - }} - onPreviewModeChange={handleWorkspacePreviewModeChange} - onAddToChat={addWorkspaceTextToComposer} - onAddCodeToChat={addWorkspaceCodeToComposer} - onRequestPanelWidth={ensureWorkspacePanelWidth} - onFileTreeRefresh={refreshComposerFileRefs} - onSessionRevertCommitted={handleSessionRevertCommitted} - onOpenInTerminal={remoteSurfaceActive ? undefined : openTerminalForPath} - initialViewMode={rightDockMode === "changed" ? "changed" : "files"} - completionSummary={state.completionSummary} - turnStartAt={state.turnStartAt} - verificationRevealRequest={verificationRevealRequest} - qualityFloor={composerProfile.qualityFloor} - showViewTabs={false} - creationMode={sidebarCreation} - /> - - )} -
- - )} - <> - - `); assert(dom.window.document.querySelector("header")!.closest(selector)); assert.equal(dom.window.document.querySelector("button")!.closest(selector), null); -assert.match(app, /desktopPlatform === "darwin"/); -assert.match(app, /windowsFramelessChrome \|\| desktopPlatform/); -assert.match(app, /target\?\.closest\("button, input, textarea, select, a,/); +assert.match(chromeCommands, /input\.platform === "darwin"/); +assert.match(chromeCommands, /input\.windowsFrameless \|\| input\.platform/); +assert.match(chromeCommands, /target\?\.closest\("button, input, textarea, select, a,/); dom.window.close(); console.log("PASS shared management geometry, input isolation, terminal retention and native titlebar dispatch"); diff --git a/desktop/frontend/src/__tests__/bundle-contract.test.ts b/desktop/frontend/src/__tests__/bundle-contract.test.ts index 715400feab..321d30de89 100644 --- a/desktop/frontend/src/__tests__/bundle-contract.test.ts +++ b/desktop/frontend/src/__tests__/bundle-contract.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import ts from "typescript"; let passed = 0; let failed = 0; @@ -18,7 +19,25 @@ function ok(cond: boolean, label: string) { } const here = dirname(fileURLToPath(import.meta.url)); +function lazyRuntimeImport(owner: string, target: string): boolean { + const file = resolve(here, owner); + const tree = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true); + let dynamic = false; + let eager = false; + function visit(node: ts.Node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) + && node.moduleSpecifier.text === target && !node.importClause?.isTypeOnly) eager = true; + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword + && node.arguments[0] && ts.isStringLiteral(node.arguments[0]) && node.arguments[0].text === target) dynamic = true; + ts.forEachChild(node, visit); + } + visit(tree); + return dynamic && !eager; +} const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); +const exportOwnerSource = readFileSync(resolve(here, "../app-runtime/useSessionExportCommands.ts"), "utf8"); +const historyOwnerSource = readFileSync(resolve(here, "../app-runtime/useHistoryCommands.ts"), "utf8"); +const paletteOwnerSource = readFileSync(resolve(here, "../app-runtime/usePaletteCommands.tsx"), "utf8"); const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8"); const settingsEntrySource = readFileSync(resolve(here, "../components/SettingsPanelEntry.tsx"), "utf8"); const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8"); @@ -38,8 +57,8 @@ ok( "App keeps session export code out of the initial chunk", ); ok( - appSource.includes('import("./lib/sessionExportData")') && - appSource.includes('import("./lib/sessionExport")'), + exportOwnerSource.includes('import("../lib/sessionExportData")') && + exportOwnerSource.includes('import("../lib/sessionExport")'), "App loads session export code on demand", ); ok( @@ -48,17 +67,17 @@ ok( "App keeps secondary drawers out of the initial chunk", ); ok( - appSource.includes('import("./components/SettingsPanelEntry")') && - appSource.includes('import("./components/HistoryPanel")'), - "App loads secondary drawers on demand", + lazyRuntimeImport("../app-shell/AppOverlayHost.tsx", "../components/SettingsPanelEntry") && + lazyRuntimeImport("../app-shell/AppOverlayHost.tsx", "../components/HistoryPanel"), + "Overlay Host owns lazy secondary drawer imports without an eager runtime edge", ); ok( !/import\s+\{\s*ProjectTree\s*\}\s+from\s+["']\.\/components\/ProjectTree["']/.test(appSource), "App keeps the project tree out of the first-paint bundle", ); ok( - appSource.includes('import("./components/ProjectTree")'), - "App loads the project tree when the sidebar mounts", + lazyRuntimeImport("../app-shell/SidebarRegion.tsx", "../components/ProjectTree"), + "Sidebar Region owns the lazy project tree import without an eager runtime edge", ); ok( settingsEntrySource.includes('import "./CompactRatioSettings.css"') && @@ -82,9 +101,9 @@ ok( "App has no dedicated history-page entry points", ); ok( - appSource.includes('id: "cmd-trash"') && - appSource.includes("openTrash") && - appSource.includes("paletteSessions.slice(0, 12)") && + paletteOwnerSource.includes('id: "cmd-trash"') && + historyOwnerSource.includes("openTrash") && + paletteOwnerSource.includes("paletteSessions.slice(0, 12)") && projectTreeSource.includes('t("projectTree.searchPlaceholder")'), "Trash and existing session search remain available", ); diff --git a/desktop/frontend/src/__tests__/composer-insert-commands.test.tsx b/desktop/frontend/src/__tests__/composer-insert-commands.test.tsx new file mode 100644 index 0000000000..195d211d81 --- /dev/null +++ b/desktop/frontend/src/__tests__/composer-insert-commands.test.tsx @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useComposerInsertCommands, type ComposerInsertCommandsInput } from "../app-runtime/useComposerInsertCommands"; +import type { Translator } from "../lib/i18n"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); + +const t = ((key: string) => key) as Translator; +const toasts: string[] = []; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} + +const terminalReads: string[] = []; +let terminalGate: ReturnType> | null = null; + +const operations: ComposerInsertCommandsInput["operations"] = async (target, channel, input, execute) => { + const authority = { checkpoint() {}, ownsUI: () => true }; + try { + const value = await execute(input, authority); + return { status: "completed", value }; + } catch (error) { + return { status: "failed", error }; + } +}; + +let states!: ReturnType; +function Probe({ approval }: { approval?: { id: string; tool: string } | null }) { + states = useComposerInsertCommands({ + activeTabId: "A", + sessionKey: "A:1", + approval, + operations, + t, + showToast: (message) => { toasts.push(message); }, + ports: { + terminalOutput: async (tabId, sessionId) => { + terminalReads.push(`${tabId}:${sessionId}`); + return terminalGate ? terminalGate.promise : "last output"; + }, + }, + }); + return null; +} +const paint = (approval?: { id: string; tool: string } | null) => + act(async () => root.render()); + +try { + await paint(); + await act(async () => { states.addWorkspaceTextToComposer("hello"); }); + assert.equal(states.composerInsertRequest?.text, "hello", "plain workspace text lands in the composer"); + assert.equal(states.composerInsertRequest?.mode, undefined, "plain insert keeps the default append mode"); + + await act(async () => { states.prefillSubagentCommand("/run tests"); }); + assert.equal(states.composerInsertRequest?.mode, "prefix", "subagent prefill uses prefix mode"); + + await act(async () => { states.replaceComposerInsert("A", ""); }); + assert.equal(states.composerInsertRequest?.mode, "replace", "undo clears through a replace insert"); + + await act(async () => { states.addSelectedTextToComposer(" snippet "); }); + assert.equal(states.selectedTextRequest?.text, "snippet", "selected text is trimmed before insert"); + await act(async () => { states.addSelectedTextToComposer(" "); }); + assert.equal(states.selectedTextRequest?.text, "snippet", "blank selections insert nothing"); + + await act(async () => { states.addWorkspaceCodeToComposer("src/a.ts", "const a = 1;"); }); + assert.equal(states.selectedTextRequest?.path, "src/a.ts", "workspace code carries its path"); + + await act(async () => { states.handleRevisionActiveChange(true); }); + await paint({ id: "ap-1", tool: "exit_plan_mode" }); + await act(async () => { states.addWorkspaceTextToComposer("revise this"); }); + assert.equal(states.activePlanRevisionInsertRequest?.text, "revise this", "plan-revision target routes plain text to the revision input"); + assert.equal(states.composerInsertRequest?.mode, "replace", "plan-revision routing does not touch the composer"); + await act(async () => { states.addWorkspaceCodeToComposer("src/b.ts", "code"); }); + assert.equal(states.activePlanRevisionInsertRequest?.text?.includes("src/b.ts"), true, "code lands in the revision input as a fenced reference"); + + await paint({ id: "ap-2", tool: "exit_plan_mode" }); + assert.equal(states.activePlanRevisionInsertRequest, null, "a replacement approval id invalidates the pending revision insert"); + + await paint(null); + await act(async () => { await states.addTerminalOutputToComposer("term-9"); }); + assert.deepEqual(terminalReads, ["A:term-9"], "terminal output reads through the session port"); + assert.equal(states.composerInsertRequest?.text?.includes("last output"), true, "terminal output is formatted into the composer"); + + terminalGate = deferred(); + const pending = states.addTerminalOutputToComposer("term-10"); + await act(async () => { terminalGate!.resolve(""); await pending; }); + assert.deepEqual(toasts, ["terminal.noOutput"], "empty terminal output reports once"); + + await act(async () => root.unmount()); + console.log("composer insert commands: routing, plan-revision target, selection trimming and terminal output chains passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/composer-source-operations.test.tsx b/desktop/frontend/src/__tests__/composer-source-operations.test.tsx new file mode 100644 index 0000000000..61b35ff6d0 --- /dev/null +++ b/desktop/frontend/src/__tests__/composer-source-operations.test.tsx @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useSessionOperations } from "../app-runtime/useSessionOperations"; +import { useComposerModeActions } from "../lib/useComposerModeActions"; +import { executeComposerMode, type ComposerModePorts } from "../app-runtime/composerModeOwner"; + +const dom = new JSDOM("
", { url: "http://localhost" }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +const effects: string[] = []; +let release!: () => void; +let gate = new Promise(resolve => { release = resolve; }); +const resetGate = () => { effects.length = 0; gate = new Promise(resolve => { release = resolve; }); }; +const planIntentsRef = { current: {} }; +const yoloRestoreRef = { current: {} }; +// The hook owns rememberPlan/rememberApproval through these refs; the owner +// still accepts them as ports, exercised directly below. +const ports: Omit = { + setMode: async id => { effects.push(`mode:${id}`); }, + setCollaboration: async id => { effects.push(`collaboration:${id}`); }, + setApproval: async id => { effects.push(`approval:${id}`); }, + clearGoal: async id => { effects.push(`clear:${id}`); await gate; }, + setRemote: async id => { effects.push(`remote:${id}`); await gate; return ["approval-A"]; }, + drainRemote: id => { effects.push(`drain:${id}`); }, + patch: id => { effects.push(`patch:${id}`); }, +}; +let commands!: ReturnType; +let operations!: ReturnType; +function Probe({ id, remote = false, generation = "" }: { id: string; remote?: boolean; generation?: string }) { + operations = useSessionOperations({ visible: { tabId: id, sessionKey: id + generation }, resources: ["A", "B"].map(tabId => ({ tabId, sessionKey: tabId + generation })) }); + commands = useComposerModeActions({ + remote, collaborationMode: "goal", toolApprovalMode: "ask", goal: "task", + target: { tabId: id, sessionKey: id + generation }, operations, ports, + planIntentsRef, yoloRestoreRef, + showError: message => effects.push(`error:${message}`), + }); + return null; +} +async function paint(id: string, remote = false, generation = "") { + await act(async () => root.render()); +} +try { + await paint("A"); + const pending = commands.applyCollaborationMode("normal"); + assert.deepEqual(effects, ["clear:A"]); + await paint("B"); + release(); + await act(async () => { await pending; }); + assert.deepEqual(effects, ["clear:A", "collaboration:A", "patch:A"], "every continuation mutates the captured source, never B"); + assert.equal(planIntentsRef.current["A"], undefined, "normal mode records no plan intent for the source tab"); + resetGate(); + await paint("A", true); + const remote = commands.applyCollaborationMode("normal"); + await paint("B", true); + await paint("A", true); + release(); + await act(async () => { await remote; }); + assert.deepEqual(effects, ["remote:A", "patch:A"], "A→B→A preserves source data but never revives approval-drain UI ownership"); + + resetGate(); + await paint("A"); + const replaced = commands.applyCollaborationMode("normal"); + await paint("A", false, ":new"); + release(); + await act(async () => { await replaced; }); + assert.deepEqual(effects, ["clear:A"], "reused tab with a new session identity blocks every stale continuation"); + + resetGate(); + await paint("A"); + const rerendered = commands.applyCollaborationMode("normal"); + const stop = await operations({ tabId: "A", sessionKey: "A" }, "stop", "A", async (id, authority) => { + authority.checkpoint(); effects.push(`stop:${id}`); + }); + assert.equal(stop.status, "completed", "waiting profile does not block stop"); + await paint("A"); + release(); + await act(async () => { await rerendered; }); + assert.deepEqual(effects, ["clear:A", "stop:A", "collaboration:A", "patch:A"], "ordinary commit does not cancel an in-flight source request"); + + resetGate(); + const stale = commands.applyCollaborationMode("normal"); + const releaseStale = release; + gate = new Promise(resolve => { release = resolve; }); + const latest = commands.applyCollaborationMode("plan"); + releaseStale(); + await act(async () => { await stale; }); + assert.deepEqual(effects, ["clear:A", "clear:A"], "superseded continuation has zero side effects"); + release(); + await act(async () => { await latest; }); + assert.deepEqual(effects, ["clear:A", "clear:A", "collaboration:A", "patch:A"], "old finally cannot release the new request"); + + resetGate(); + const disposed = commands.applyCollaborationMode("normal"); + const oldEntry = commands; + await act(async () => root.unmount()); + release(); + await disposed; + oldEntry.applyMode("normal"); + assert.deepEqual(effects, ["clear:A"], "unmount synchronously revokes commands and pending continuations"); + const writes: unknown[][] = []; + const remotePorts: ComposerModePorts = { ...ports, + rememberPlan: id => { effects.push(`plan:${id}`); }, + rememberApproval: id => { effects.push(`remember:${id}`); }, + setRemote: async (...args) => { writes.push(args); return []; }, + clearGoal: async () => { throw new Error("atomic remote transition cannot use local goal clearing"); }, + setCollaboration: async () => { throw new Error("atomic remote transition cannot use local mode changes"); }, + setMode: () => { throw new Error("remote mode cannot use local mode changes"); }, + setApproval: () => { throw new Error("remote approval cannot use local mode changes"); }, + }; + for (const request of [{ kind: "collaboration", mode: "normal" }, { kind: "approval", mode: "yolo" }] as const) { + await executeComposerMode({ target: { tabId: "A", sessionKey: "A" }, request, + remote: true, collaborationMode: "goal", toolApprovalMode: "ask", goal: "task", ports: remotePorts, + }, { checkpoint() {}, ownsUI: () => true }); + } + assert.deepEqual(writes, [["A", "normal", "ask", ""], ["A", "goal", "yolo", "task"]], "each remote change sends every axis in one atomic profile transaction"); + console.log("composer source operations: source isolation, ABA, identity replacement, lanes, finally and disposal passed"); +} finally { + if (document.getElementById("root")?.hasChildNodes()) await act(async () => root.unmount()); + dom.window.close(); +} diff --git a/desktop/frontend/src/__tests__/controller-profile-lifecycle.test.tsx b/desktop/frontend/src/__tests__/controller-profile-lifecycle.test.tsx new file mode 100644 index 0000000000..ea4e9a8af2 --- /dev/null +++ b/desktop/frontend/src/__tests__/controller-profile-lifecycle.test.tsx @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useControllerProfileCommands } from "../lib/useControllerProfileCommands"; +import { useSessionOperations } from "../app-runtime/useSessionOperations"; +import type { ControllerProfileResource } from "../app-runtime/controllerProfileOwner"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +function deferred() { + let resolve!: (value: boolean) => void; let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const calls: string[] = [], errors: unknown[] = []; +let pending = deferred(); +let profileFailure: Error | undefined; +let profileGate: ReturnType | undefined; +const ports = { + model: async (tab: string, name: string) => { calls.push(`model:${tab}:${name}`); return pending.promise; }, + profile: async (tab: string, collaboration: string, approval: string, goal: string) => { + calls.push(`profile:${tab}:${collaboration}:${approval}:${goal}`); + if (profileGate) return profileGate.promise; + if (profileFailure) throw profileFailure; + return true; + }, +}; +let commands!: ReturnType; +function Probe({ tab, generation, plan, ready, epoch, remote }: { tab: string; generation: number; plan: boolean; ready: boolean; epoch: string; remote: boolean }) { + const profiles: ControllerProfileResource[] = ["A", "B"].map(tabId => ({ + target: { tabId, sessionKey: tabId + generation }, remote: remote && tabId === "A", + profile: { collaboration: tabId === "A" && plan ? "plan" : "normal", approval: "ask", goal: tabId === "B" ? "B goal" : "" }, + })); + const target = profiles.find(value => value.target.tabId === tab)!.target; + const operations = useSessionOperations({ visible: target, resources: profiles.map(value => value.target) }); + commands = useControllerProfileCommands({ target, profiles, ready, runtimeEpoch: epoch, remote: remote && tab === "A", operations, ports, + remoteModel: async name => { calls.push(`remote:${tab}:${name}`); await pending.promise; }, report: error => errors.push(error) }); + return null; +} +const paint = (tab = "A", plan = true, generation = 1, ready = false, epoch = "runtime-1", remote = false) => act(async () => root.render( + )); +try { + await paint(); + const change = commands.switchModel("first"); + await paint("B", false); + pending.resolve(true); assert.equal(await change, false, "a completed source write does not regain UI ownership on B"); + assert.deepEqual(calls, ["model:A:first", "profile:A:normal:ask:"], "post-rebuild profile is the latest committed source value, not the old render or B"); + + calls.length = 0; pending = deferred(); await paint(); + const stale = commands.switchModel("replaced"); + await paint("A", true, 2); pending.resolve(true); + assert.equal(await stale, false); + assert.deepEqual(calls, ["model:A:replaced"], "replacement session rejects old post-model profile writes"); + + calls.length = 0; pending = deferred(); await paint(); + const first = commands.switchModel("old"); + const old = pending; pending = deferred(); + const second = commands.switchModel("new"); + old.resolve(true); assert.equal(await first, false); + pending.resolve(true); assert.equal(await second, true); + assert.deepEqual(calls, ["model:A:old", "model:A:new", "profile:A:plan:ask:"], "superseded model cannot restore its profile or clear the new request"); + + calls.length = 0; errors.length = 0; pending = deferred(); + const failure = commands.switchModelFromUi("failure"); + await paint("B"); await paint("A"); pending.reject(Error("old source failure")); + assert.equal(await failure, false); assert.deepEqual(errors, [], "A-B-A cannot revive old error UI"); + + pending = deferred(); + const currentFailure = commands.switchModelFromUi("current-failure"); + const error = Error("model failed"); pending.reject(error); + assert.equal(await currentFailure, false); assert.deepEqual(errors, [error], "UI failure is presented exactly once"); + errors.length = 0; pending = deferred(); + const directFailure = commands.switchModel("slash-model"); pending.reject(error); + await assert.rejects(directFailure, error); + assert.deepEqual(errors, [], "awaiting callers retain the reject contract without duplicate UI handling"); + + profileFailure = Error("restore failed"); + assert.equal(await commands.applyProfile("A", false), false, "send readiness retains false-on-failure semantics"); + await paint("A", true, 1, true); + assert.deepEqual(errors, [profileFailure], "background restoration reports its real error once"); + profileFailure = undefined; errors.length = 0; await paint(); + + calls.length = 0; await paint("A", true, 1, true); + assert.deepEqual(calls, ["profile:A:plan:ask:"], "ready restoration shares source-profile execution"); + await paint("A", false, 1, true); + assert.equal(calls.at(-1), "profile:A:normal:ask:"); + calls.length = 0; + await paint("A", false, 1, true, "runtime-2"); + assert.deepEqual(calls, ["profile:A:normal:ask:"], "same profile on a replacement runtime is restored without relying on object churn"); + + for (const modelFirst of [true, false]) { + await paint(); calls.length = 0; errors.length = 0; + profileGate = deferred(); pending = deferred(); + if (!modelFirst) await paint("A", true, 1, true); + const overlapping = commands.switchModelFromUi("overlap"); + await act(async () => pending.resolve(true)); + if (modelFirst) await paint("A", true, 1, true); + const sharedError = Error("one Controller application failed"); + await act(async () => profileGate!.reject(sharedError)); + assert.equal(await overlapping, false); + assert.deepEqual(errors, [sharedError], "model and readiness observers share one failure owner in either completion order"); + profileGate = undefined; + } + + calls.length = 0; pending = deferred(); await paint("A", true, 1, true, "runtime-1", true); + const remote = commands.switchModel("remote-model"); + await paint("B", true, 1, false, "runtime-1", true); + pending.resolve(true); assert.equal(await remote, false); + assert.deepEqual(calls, ["remote:A:remote-model"], "remote model stays source-bound and never uses local profile restoration"); + + await paint(); calls.length = 0; pending = deferred(); + const disposed = commands.switchModel("disposed"); + await act(async () => root.unmount()); pending.resolve(true); await disposed; + commands.switchModel("after-unmount"); + assert.deepEqual(calls, ["model:A:disposed"], "unmount revokes continuation and stable entry immediately"); + console.log("controller profile lifecycle: committed source, replacement, ordering, ABA, ready restore and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/conversation-projection.test.ts b/desktop/frontend/src/__tests__/conversation-projection.test.ts new file mode 100644 index 0000000000..c5d560abc5 --- /dev/null +++ b/desktop/frontend/src/__tests__/conversation-projection.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { WorkspaceDockRegion } from "../app-shell/WorkspaceDockRegion"; +import type { Translator } from "../lib/i18n"; +import { projectConversation, projectConversationLayout } from "../app-runtime/conversationProjection"; +import { initialState } from "../lib/useController"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; +import type { BackgroundRuntimeView } from "../lib/types"; + +const local = { ...initialState, running: true, activeTurnId: "local-turn", turnTokens: 999, + turnStartAt: 111, sessionTokens: 999, sessionCost: 999, sessionCurrency: "USD", + context: { used: 999, window: 999, sessionTokens: 999 }, balance: { available: true, display: "LOCAL" }, + meta: { ready: true, eventChannel: "local-events", cwd: "local-cwd", label: "local-model", workspaceName: "local-project", gitBranch: "local-branch", + imageInputEnabled: true, visionFallbackEnabled: true, pinnedFiles: [{ path: "local-file", sizeBytes: 10, tokenEstimate: 5 }] }, +} as typeof initialState; +const remote: Pick = { + transcript: { ...initialState, turnTokens: 7, turnStartAt: 222, sessionTokens: 8, sessionCost: 2, sessionCurrency: "CNY" }, + running: false, modelLabel: "remote-model", commands: [], +}; +const tab = { id: "remote", label: "remote-tab", remote: { hostId: "fixture", workspace: "remote-cwd" }, workspaceName: "remote-project" }; +const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView[]; +const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" }); +assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays"); +assert.equal(view.context.items, remote.transcript.items); +assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry"); +assert.equal(view.composer.modelLabel, "remote-model"); +assert.equal(view.composer.cwd, "remote-cwd"); +assert.equal(view.composer.turnTokens, 7); +assert.equal(view.composer.turnStartAt, 222); +assert.equal(view.composer.currency, "CNY"); +assert.equal(view.composer.pinnedFiles, undefined); +assert.equal(view.composer.attachmentInputEnabled, false); +assert.equal(view.composer.imageInputEnabled, false); +assert.equal(view.composer.imageUnderstandingEnabled, false); +assert.equal(view.composer.localDurableGuidance, false); +assert.equal(view.composer.turnId, undefined); +assert.equal(view.composer.context, remote.transcript.context); +assert.equal(view.composer.balance, remote.transcript.balance); +assert.equal(view.status.context, remote.transcript.context); +assert.equal(view.status.balance, remote.transcript.balance); +assert.deepEqual(view.status.backgroundRuntimes, []); +assert.equal(view.status.gitBranch, undefined); +assert.equal(view.status.workspaceName, "remote-project"); +assert.equal(view.status.cost, 2); +assert.equal(view.status.sessionTokens, 8); +const localView = projectConversation({ local, activeTabId: "local", backgroundRuntimes: background, connectingLabel: "connecting" }); +assert.equal(localView.runtime, local); +assert.equal(localView.status.backgroundRuntimes, background); +assert.equal(localView.composer.attachmentInputEnabled, true); +assert.equal(localView.composer.localDurableGuidance, true); +assert.equal(localView.context.tabId, "local"); +for (const chatVisible of [false, true]) for (const localToolsEnabled of [false, true]) for (const dockMode of ["files", "changed", "remote", "context"]) { + const layout = projectConversationLayout({ chatVisible, localToolsEnabled, dockMode, dockRenderable: true, + dockGridOpen: true, dockOverlay: true, dockOpen: true, dockMaximized: true, terminalOpen: true }); + const permitted = chatVisible && (localToolsEnabled || !["files", "changed"].includes(dockMode)); + assert.equal(layout.dockVisible, permitted); + assert.equal(layout.dockGridOpen, permitted); + assert.equal(layout.dockOverlay, permitted); + assert.equal(layout.terminalOpen, chatVisible && localToolsEnabled); + assert.equal(layout.dockMaximized, chatVisible, "automation masks stored maximization without modifying the preference"); + if (!localToolsEnabled && (dockMode === "files" || dockMode === "changed")) { + const noop = () => {}; + const markup = renderToStaticMarkup(createElement(WorkspaceDockRegion, { + visible: layout.dockVisible, overlay: layout.dockOverlay, mode: dockMode, + creation: false, remoteAvailable: true, showContext: true, t: ((key: string) => key) as Translator, + onMode: noop, onRemote: noop, remote: { onClose: noop }, context: view.context, + workspaceKey: "fixture", workspace: { open: layout.dockVisible, maximized: false, onClose: noop, onToggleMaximized: noop }, + })); + assert.equal(markup, "", "actual dock region never mounts local Files/Changes for a remote source"); + } +} +console.log("conversation projection: shared source identity, remote telemetry isolation and local-tool layout policy passed"); diff --git a/desktop/frontend/src/__tests__/decision-footer-lifecycle.test.tsx b/desktop/frontend/src/__tests__/decision-footer-lifecycle.test.tsx new file mode 100644 index 0000000000..d3f476b3f8 --- /dev/null +++ b/desktop/frontend/src/__tests__/decision-footer-lifecycle.test.tsx @@ -0,0 +1,83 @@ +import React, { act, type ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import assert from "node:assert/strict"; +import { DecisionFooterRegion } from "../app-shell/DecisionFooterRegion"; +import { LocaleProvider } from "../lib/i18n"; +import { ToastProvider } from "../lib/toast"; + +const dom = new JSDOM("
", { url: "http://localhost/", pretendToBeVisual: true }); +Object.assign(globalThis, { + window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, +}); +for (const name of ["Node", "Element", "HTMLElement", "HTMLTextAreaElement", "Event", "CustomEvent", "MutationObserver"]) { + Object.defineProperty(globalThis, name, { configurable: true, value: Reflect.get(dom.window, name) }); +} +Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); +Object.defineProperty(window, "matchMedia", { value: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }) }); +Object.assign(globalThis, { + requestAnimationFrame: () => 1, cancelAnimationFrame() {}, + ResizeObserver: class { observe() {} disconnect() {} unobserve() {} }, +}); +Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { value() {} }); +Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { value() {} }); +const root = createRoot(document.getElementById("root")!); +type Props = ComponentProps; +const noop = () => {}; +const composer: Props["composer"] = { hidden: false, inert: false, hero: false, props: { + running: false, collaborationMode: "normal", toolApprovalMode: "ask", goal: "", cwd: "/fixture", + modelLabel: "fixture-model", ready: true, + onSend: noop, onCancel: noop, onCycleMode: noop, onSetMode: noop, + onSetCollaborationMode: noop, onSetToolApprovalMode: noop, onToggleYoloApprovalMode: noop, + onClearGoal: noop, onSwitchModel: noop, onSetEffort: noop, + insertRequest: { id: 1, text: "retained draft", mode: "replace" }, +} }; +let props: Props = { + hidden: false, className: "footer", footerRef: noop, composer, + todo: { identity: "todo-a", props: { stateKey: "todo-a", todos: [{ content: "visible work", status: "in_progress" }], + running: true, pendingPrompt: false, onDismiss: noop } }, + undo: { identity: "undo-a", props: { meta: { turns: 1, filesRestored: [], filesRemoved: [], onUndo: noop } } }, +}; +async function paint() { + await act(async () => { + root.render(); + await Promise.all([import("../components/TodoPanel"), import("../components/UndoRewindBanner"), import("../components/ClearContextCard")]); + }); +} + +try { + await paint(); + const textarea = document.querySelector("#composer-input")!; + assert.ok(textarea); + assert.equal(textarea.value, "retained draft"); + const undo = document.querySelector(".undo-rewind")!; + assert.ok(undo); + const todo = Array.from(document.querySelectorAll(".prompt-shelf")).find((node) => node.textContent?.includes("visible work"))!; + assert.ok(todo); + + props = { ...props, composer: { ...composer, hidden: true, inert: true } }; + await paint(); + const host = document.querySelector(".composer-decision-host")!; + assert.equal(host.hidden, false, "navigation mask preserves the composer footprint"); + assert.ok(host.classList.contains("composer-decision-host--footprint-hidden")); + assert.ok(host.hasAttribute("inert"), "masked composer rejects interactive input"); + assert.ok(document.querySelector("footer")?.hasAttribute("inert")); + assert.equal(document.querySelector(".undo-rewind"), undo, "target rewind stays laid out below the mask"); + assert.ok(todo.isConnected, "target Todo stays mounted below the mask"); + assert.equal(document.querySelector("#composer-input"), textarea); + assert.equal(textarea.value, "retained draft"); + + props = { ...props, composer, decision: { kind: "clear-context", identity: "clear-a", props: { onCancel: noop, onConfirm: noop } } }; + await paint(); + assert.equal(document.querySelector("#composer-input"), textarea, "decision card does not remount Composer"); + assert.equal(host.hidden, true, "decision card hides the mounted Composer"); + props = { ...props, decision: undefined }; + await paint(); + assert.equal(document.querySelector("#composer-input"), textarea); + assert.equal(textarea.value, "retained draft", "dismissed decision restores the original draft"); + console.log("PASS Decision Footer preserves masked layout, Todo/rewind hosts, and Composer identity/draft"); +} finally { + await act(async () => root.unmount()); + dom.window.close(); +} diff --git a/desktop/frontend/src/__tests__/decision-slots-lifecycle.test.tsx b/desktop/frontend/src/__tests__/decision-slots-lifecycle.test.tsx new file mode 100644 index 0000000000..5396ce3399 --- /dev/null +++ b/desktop/frontend/src/__tests__/decision-slots-lifecycle.test.tsx @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { DecisionFooterSlots } from "../app-shell/DecisionFooterRegion"; + +const dom = new JSDOM("
", { url: "http://localhost", pretendToBeVisual: true }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +let ready = true; +let release!: () => void; +const gate = new Promise(resolve => { release = resolve; }); +function Decision() { if (!ready) throw gate; return ; } +const paint = () => act(async () => root.render(todo} undo={} decision={} />)); +try { + await paint(); + const todo = document.getElementById("todo")!; + const undo = document.getElementById("undo")!; + undo.focus(); + ready = false; + await paint(); + assert.equal(document.getElementById("todo"), todo); + assert.equal(document.getElementById("undo"), undo); + assert.equal(undo.style.display, "", "a loading decision never hides the existing undo action"); + assert.equal(todo.style.display, "", "a loading decision never hides existing work status"); + assert.equal(document.activeElement, undo); + await act(async () => { ready = true; release(); }); + assert.equal(document.getElementById("undo"), undo); + console.log("decision slots: independent Suspense preserves visible siblings and focus"); +} finally { await act(async () => root.unmount()); dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/delivery-continue-commands.test.tsx b/desktop/frontend/src/__tests__/delivery-continue-commands.test.tsx new file mode 100644 index 0000000000..596fbf5db0 --- /dev/null +++ b/desktop/frontend/src/__tests__/delivery-continue-commands.test.tsx @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useDeliveryContinueCommands } from "../app-runtime/useDeliveryContinueCommands"; +import { createSessionSurfaceFence } from "../app-runtime/sessionTarget"; +import type { Translator } from "../lib/i18n"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); + +const t = ((key: string) => key) as Translator; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} + +const calls: string[] = []; +let resumeGate: ReturnType> | null = null; +let resumeResult = true; +const fence = createSessionSurfaceFence(); + +let states!: ReturnType; +function Probe({ ready = true, goal }: { ready?: boolean; goal?: string }) { + states = useDeliveryContinueCommands({ + surfaceFence: fence, + ready, + goal, + t, + ports: { + resumeGoal: async (tabId) => { + calls.push(`resume:${tabId}`); + if (resumeGate) return resumeGate.promise; + return resumeResult; + }, + recoverDelivery: async (tabId, prompt) => { calls.push(`send:${tabId}:${prompt}`); }, + }, + }); + return null; +} +const paint = (props?: { ready?: boolean; goal?: string }) => act(async () => root.render()); + +try { + await paint(); + fence.commit("A", "A:1"); + + await paint({ ready: false }); + await act(async () => { await states.handleDeliveryContinue(); }); + assert.deepEqual(calls, [], "a controller that is not ready continues nothing"); + + await paint({ ready: true }); + await act(async () => { await states.handleDeliveryContinue(); }); + assert.deepEqual(calls, ["send:A:notice.deliveryIncompleteContinuePrompt"], + "a goal-less delivery sends the recovery prompt to the committed tab"); + calls.length = 0; + + await paint({ goal: "ship it" }); + await act(async () => { await states.handleDeliveryContinue(); }); + assert.deepEqual(calls, ["resume:A", "send:A:notice.deliveryIncompleteContinuePrompt"], + "a goal tab resumes its goal before the recovery send"); + calls.length = 0; + + resumeResult = false; + await act(async () => { await states.handleDeliveryContinue(); }); + assert.deepEqual(calls, ["resume:A"], "a goal that refuses to resume is not poked further"); + resumeResult = true; + + resumeGate = deferred(); + calls.length = 0; + let stale: Promise | undefined; + await act(async () => { stale = states.handleDeliveryContinue(); }); + fence.commit("B", "B:1"); + await act(async () => { + resumeGate!.resolve(true); + await stale; + }); + assert.deepEqual(calls, ["resume:A"], "a mid-flight tab switch revokes the captured ownership and blocks the send"); + resumeGate = null; + fence.commit("A", "A:1"); + calls.length = 0; + await act(async () => { await states.handleDeliveryContinue(); }); + assert.deepEqual(calls, ["resume:A", "send:A:notice.deliveryIncompleteContinuePrompt"], + "a fresh capture on the restored tab owns the UI again"); + + fence.dispose(); + calls.length = 0; + await act(async () => { await states.handleDeliveryContinue(); }); + assert.deepEqual(calls, [], "without a committed surface there is no continuation target"); + + console.log("delivery continue commands: ready gate, goal resume chain, stale-ownership fence and empty-target gate passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/desktop-navigation-lifecycle.test.tsx b/desktop/frontend/src/__tests__/desktop-navigation-lifecycle.test.tsx new file mode 100644 index 0000000000..826f5b4d39 --- /dev/null +++ b/desktop/frontend/src/__tests__/desktop-navigation-lifecycle.test.tsx @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useDesktopNavigation } from "../app-runtime/useDesktopNavigation"; +import type { DesktopNavigationPorts } from "../app-runtime/desktopNavigationOwner"; +import type { SessionMeta, TabMeta } from "../lib/types"; +import type { SidebarImConnection } from "../app-runtime/sidebarImProjection"; +import type { Translator } from "../lib/i18n"; +import { __emitMockRemoteTabOpened } from "../lib/remoteTabEvents"; +import { useRemoteTabOpened } from "../lib/useRemoteTabOpened"; + +function deferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +const tab = (id: string) => ({ id, label: id } as TabMeta); +const pending = new Map>>(); +const calls: string[] = []; +const acceptedTopics: number[] = []; +let intent = 0; +let registration: ReturnType> | undefined; +let api!: ReturnType; +const activate = (id: string) => { calls.push(`open:${id}`); const request = deferred(); pending.set(id, request); return request.promise; }; +const ports: Parameters[0]["ports"] = { + isNavigationIntentCurrent: seq => seq === intent, + registeredNavigationIntent: async seq => registration ? registration.promise : String(seq), + openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`), + switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); }, + activateTopic: async (_scope, _workspace, id) => activate(id), + openTopicSession: async (_scope, _workspace, id) => { calls.push("classic-session"); return activate(id); }, + openGlobalTab: async id => { calls.push("classic-global"); return activate(id); }, + openProjectTab: async (_workspace, id) => { calls.push("classic-project"); return activate(id); }, + ensureBlankSurface: async (_scope, workspace) => activate(`blank:${workspace}`), + ensureBlankTab: async (_scope, workspace) => { calls.push("classic-blank"); return activate(`blank:${workspace}`); }, + createIsolatedWorktree: async workspace => ({ tab: await activate(`worktree:${workspace}`), branch: "fixture", sourceDirty: true }) as Awaited>, + openChannelSession: async (path, id) => { calls.push(`channel:${id}:${path}`); }, + resumeSession: async (path, id) => { calls.push(`resume:${id}:${path}`); }, + listTabs: async () => [], applyTabs: () => { calls.push("tabs"); }, seedTab: value => { calls.push(`seed:${value.id}`); }, + listSessions: async () => { calls.push("history-refresh"); return []; }, + topicAccepted: seq => { acceptedTopics.push(seq); }, +}; +function Probe({ visible = "A", single = true }: { visible?: string; single?: boolean }) { + useRemoteTabOpened(meta => { calls.push(`resource:${meta.id}`); }, () => {}); + api = useDesktopNavigation({ visible: { tabId: visible, sessionKey: visible }, singleSurface: single, ports, + setTabRevealSignal: () => { calls.push("reveal-tab"); }, setTranscriptRevealSignal: () => { calls.push("reveal-transcript"); }, + setProjectRevision: () => { calls.push("project"); }, setHistory: () => { calls.push("history-close"); }, + t: ((key: string) => key) as Translator, showToast: message => { calls.push(`notice:${message}`); }, + noteIntent: () => ++intent, beginSurface: seq => { calls.push(`begin:${seq}`); }, + settleSurface: seq => { if (seq === intent) calls.push(`settle:${seq}`); }, showChat: () => {}, + }); + return null; +} +const paint = (visible = "A", single = true) => act(async () => root.render()); +const topic = (id: string) => api.enqueueNavigation({ kind: "topic", scope: "project", workspaceRoot: "fixture", topicId: id }); +async function finish(id: string, task: Promise) { pending.get(id)!.resolve(tab(id)); await task; } +try { + await paint(); + const entry = api.enqueueNavigation; + const a = topic("A"), b = topic("B"), c = topic("C"); + await b; + assert.deepEqual(calls.filter(value => value.startsWith("open:")), ["open:A"]); + await finish("A", a); + assert.deepEqual(calls.filter(value => value.startsWith("open:")), ["open:A", "open:C"]); + await finish("C", c); + assert.deepEqual(calls.filter(value => value.startsWith("seed:")), ["seed:C"]); + assert.deepEqual(acceptedTopics, [3], "only the accepted queue target can release an automation link"); + assert.deepEqual(calls.filter(value => value.startsWith("settle:")), ["settle:3"], "old finally cannot settle the current surface"); + calls.length = 0; + const stale = topic("stale"); intent++; + await paint("B"); await paint("A"); + assert.equal(api.enqueueNavigation, entry); + await finish("stale", stale); + assert.deepEqual(calls.filter(value => /^(seed|tabs|notice|reveal|settle)/.test(value)), [], "ABA never restores old UI rights"); + + calls.length = 0; + const connection = { sessionId: "path:channel.jsonl", sessionSource: "auto", scope: "project", workspaceRoot: "im", title: "fixture" } as SidebarImConnection; + const im = api.enqueueNavigation({ kind: "sidebar-im", connection }); + intent++; + await finish("blank:im", im); + assert.ok(!calls.some(value => value.startsWith("channel:")), "cancellation between blank activation and hydrate prevents a second mutation"); + calls.length = 0; + const validIM = api.enqueueNavigation({ kind: "sidebar-im", connection }); + await finish("blank:im", validIM); + assert.ok(calls.includes("channel:blank:im:channel.jsonl")); + + calls.length = 0; + const isolated = api.enqueueNavigation({ kind: "isolated-worktree", workspaceRoot: "dirty" }); + await paint(); // Normal commits do not change the request epoch. + await finish("worktree:dirty", isolated); + assert.ok(calls.includes("notice:projectTree.worktreeCreatedDirty")); + assert.ok(calls.includes("project")); + + calls.length = 0; + await paint("A", false); + const history = api.enqueueNavigation({ kind: "resume-session", session: { scope: "global", topicId: "history", path: "history.jsonl" } as SessionMeta }); + await finish("history", history); + assert.ok(calls.includes("classic-session")); + assert.ok(calls.includes("history-close")); + + calls.length = 0; + const failed = topic("failed"); + pending.get("failed")!.reject(new Error("fixture failure")); await failed; + assert.deepEqual(calls.filter(value => value.startsWith("notice:")), ["notice:history.failedOpenSession"]); + + calls.length = 0; + registration = deferred(); + const waitingRemote = api.openRemoteProject({ hostId: "fixture", workspace: "waiting" }, { newSession: true }); + const winsRegistration = topic("wins-registration"); + registration.resolve("registered"); + assert.equal((await waitingRemote).status, "cancelled"); + assert.ok(!pending.has("remote:waiting"), "superseded registration cannot issue an OpenRemoteProjectTab request"); + await finish("wins-registration", winsRegistration); + registration = undefined; + + calls.length = 0; + const remote = api.openRemoteProject({ hostId: "fixture", workspace: "remote" }, { sessionName: "selected" }); + await act(async () => {}); + const remoteMeta = { ...tab("remote:remote"), remote: { hostId: "fixture", workspace: "remote" } }; + await act(async () => __emitMockRemoteTabOpened(remoteMeta)); + assert.deepEqual(calls.filter(value => /^(seed|remote-switch)/.test(value)), [], "opened event before the response cannot independently navigate"); + const localWins = topic("local-wins"); + pending.get("remote:remote")!.resolve(remoteMeta); + assert.equal((await remote).status, "cancelled"); + await finish("local-wins", localWins); + assert.ok(!calls.some(value => value.startsWith("remote-switch:"))); + calls.length = 0; + const successfulRemote = api.openRemoteProject({ hostId: "fixture", workspace: "success" }, {}); + await act(async () => {}); + pending.get("remote:success")!.resolve({ ...remoteMeta, id: "remote:success" }); + const outcome = await successfulRemote; + assert.equal(outcome.status, "completed"); + assert.ok(calls.includes(`remote-switch:remote:success:${intent}`), "the request's exact intent reaches dedicated remote activation"); + assert.ok(!calls.includes("classic-session")); + + calls.length = 0; + const retainedRemote = api.openRemoteProject; + const disposed = topic("disposed"), queued = topic("never"); + await act(async () => root.unmount()); + await finish("disposed", disposed); await queued; + entry({ kind: "blank", scope: "global", workspaceRoot: "" }); + assert.deepEqual(calls.filter(value => /^(open|seed|tabs|notice|reveal|settle)/.test(value)), ["open:disposed"], "unmount releases pending input and fences queued and running continuations"); + assert.deepEqual(await retainedRemote({ hostId: "fixture", workspace: "disposed" }, {}), { status: "cancelled", reason: "disposed" }); + console.log("desktop navigation: queue ownership, ABA, IM hydrate, dirty-worktree warning, Classic resume, failure and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/desktop-preferences-lifecycle.test.tsx b/desktop/frontend/src/__tests__/desktop-preferences-lifecycle.test.tsx new file mode 100644 index 0000000000..89f7aaf7d9 --- /dev/null +++ b/desktop/frontend/src/__tests__/desktop-preferences-lifecycle.test.tsx @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useDesktopPreferences } from "../app-runtime/useDesktopPreferences"; +import { getSessionExperience, hydrateSessionExperience } from "../lib/sessionExperience"; +import { LocaleProvider } from "../lib/i18n"; +import type { DesktopStartupSettingsView } from "../lib/types"; + +const dom = new JSDOM("
", { url: "http://localhost", pretendToBeVisual: true }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, + CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true }); +window.matchMedia = (() => ({ matches: false, addEventListener() {}, removeEventListener() {} })) as typeof window.matchMedia; +const listeners = new Map void>(); +let requests = 0; +let fullSettings = 0; +let resolveStartup!: (settings: DesktopStartupSettingsView) => void; +const startup = new Promise(resolve => { resolveStartup = resolve; }); +Object.assign(window, { + runtime: { EventsOn: (name: string, listener: (...args: unknown[]) => void) => { + listeners.set(name, listener); return () => { listeners.delete(name); }; + } }, + go: { main: { App: { + DesktopStartupSettings: () => { requests++; return startup; }, + Settings: () => { fullSettings++; throw new Error("startup must not request full Settings"); }, + BotRuntimeStatus: async () => null, + SetTrayLocale: async () => {}, + GetThemeExperience: async () => ({ themeMode: "light", baseStyle: "graphite", effectiveStyle: "graphite" }), + } } }, +}); +let current!: ReturnType; +function Probe() { current = useDesktopPreferences(); return
{current.configLoadWarnings.join("|")}
; } +const root = createRoot(document.getElementById("root")!); +const snapshot = { sessionExperience: "deep", desktopLayoutStyle: "creation", desktopTheme: "light", desktopThemeStyle: "graphite", + desktopLanguage: "en", checkUpdates: true, configWarnings: ["warning"], configWarningsRevision: 3 } as DesktopStartupSettingsView; +try { + localStorage.setItem("reasonix-process-fold", "auto"); + await act(async () => root.render()); + assert.equal(requests, 1); + await act(async () => { resolveStartup(snapshot); await import("../lib/themeExperience"); }); + assert.equal(current.desktopLayoutStyle, "creation"); + assert.equal(getSessionExperience(), "deep", "backend wins over an old localStorage mirror"); + assert.deepEqual(current.configLoadWarnings, ["warning"]); + await act(async () => { listeners.get("config:load-warnings")?.(["stale"], 2); }); + assert.deepEqual(current.configLoadWarnings, ["warning"], "stale runtime warning cannot replace startup snapshot"); + await act(async () => { listeners.get("config:load-warnings")?.(["current"], 4); }); + assert.deepEqual(current.configLoadWarnings, ["current"]); + await act(async () => { await current.reload({ ...snapshot, sessionExperience: undefined }); }); + assert.equal(getSessionExperience(), "standard", "old backend missing field resolves standard"); + assert.equal(fullSettings, 0, "preferences and IM projection never request full Settings"); + const oldReload = current.reload; + await act(async () => root.unmount()); + assert.equal(listeners.size, 0); + await oldReload(snapshot); + assert.equal(getSessionExperience(), "standard", "disposed commands cannot mutate global experience"); + const originalWarn = console.warn; + console.warn = () => {}; + const failedRoot = createRoot(document.getElementById("root")!); + try { + Object.assign(window.go!.main.App, { DesktopStartupSettings: async () => { throw new Error("offline"); } }); + hydrateSessionExperience("deep"); + await act(async () => failedRoot.render()); + await act(async () => { await current.reload(); }); + assert.equal(getSessionExperience(), "standard", "failed first snapshot uses canonical standard, not a legacy local preference"); + assert.equal(current.startupUpdateChecksEnabled, true); + } finally { await act(async () => failedRoot.unmount()); console.warn = originalWarn; } + console.log("desktop preferences: lightweight snapshot, legacy mirror, warning revision and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/external-opener.test.tsx b/desktop/frontend/src/__tests__/external-opener.test.tsx index f2a506e763..b1390a8d17 100644 --- a/desktop/frontend/src/__tests__/external-opener.test.tsx +++ b/desktop/frontend/src/__tests__/external-opener.test.tsx @@ -70,6 +70,9 @@ console.log("\nexternal opener"); const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8"); +// The App layering split (#9777) renders these actions inside the topicbar +// actions region; the key namespace contract lives there. +const topicbarActionsSource = readFileSync(new URL("../app-shell/TopicbarActionsRegion.tsx", import.meta.url), "utf8"); const sharedControlRule = stylesSource.match(/(?:^|\n)\.external-opener\s*\{([^}]*)\}/)?.[1] ?? ""; const sharedSegmentRule = stylesSource.match( /\.external-opener__primary,\s*\.external-opener__menu-trigger\s*\{([^}]*)\}/, @@ -101,8 +104,8 @@ ok( "preserves the slimmer Creation application artwork size", ); -ok(appSource.includes("key={`external-opener:${activeTab.id}`}"), "external opener has a distinct React key namespace"); -ok(appSource.includes("key={`session-actions:${activeTab?.id || \"none\"}`}"), "session actions have a distinct React key namespace"); +ok(topicbarActionsSource.includes('key="external-opener"') && topicbarActionsSource.includes("key={external.tabId}"), "external opener has a distinct React key namespace"); +ok(topicbarActionsSource.includes('key="session-actions"') && topicbarActionsSource.includes("key={sessionIdentity}"), "session actions have a distinct React key namespace"); ok(shouldMountExternalOpener({ id: "tab-project", scope: "project" }, false), "mounts for a Project tab"); ok(shouldMountExternalOpener({ id: "tab-global", scope: "global" }, false), "mounts for a Global tab without guessing from scope"); ok(!shouldMountExternalOpener({ id: "tab-global", scope: "global" }, true), "stays hidden while an IM detail surface owns the header"); diff --git a/desktop/frontend/src/__tests__/goal-action-errors.test.tsx b/desktop/frontend/src/__tests__/goal-action-errors.test.tsx index e83d7c9d30..7e8663ee61 100644 --- a/desktop/frontend/src/__tests__/goal-action-errors.test.tsx +++ b/desktop/frontend/src/__tests__/goal-action-errors.test.tsx @@ -7,6 +7,7 @@ import { JSDOM } from "jsdom"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { useGoalActionHandler } from "../lib/goalAction"; +import { useComposerGoalCommands } from "../app-runtime/useComposerGoalCommands"; import { ToastProvider } from "../lib/toast"; let passed = 0; @@ -46,6 +47,10 @@ window.addEventListener("unhandledrejection", onWindowUnhandledRejection); function Probe() { const { runGoalAction } = useGoalActionHandler(); + const { clearGoalFromUi, setCollaborationModeFromUi } = useComposerGoalCommands({ + applyGoal: async (goal) => { if (goal !== "") throw new Error("wrong goal capture"); throw new Error("stop goal bridge failed"); }, + applyCollaborationMode: async (mode) => { if (mode !== "plan") throw new Error("wrong mode capture"); throw new Error("switch mode bridge failed"); }, + }); const run = (label: string) => { runGoalAction(async () => { throw new Error(`${label} bridge failed`); @@ -53,8 +58,8 @@ function Probe() { }; return ( <> - - + + ); @@ -81,22 +86,17 @@ ok(errors.includes("background goal resync bridge failed"), "rejected background ok(unhandled.length === 0, "handled Goal action rejections do not emit unhandledrejection"); const here = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); +const appSource = readFileSync(resolve(here, "../app-runtime/useAppSessionComposition.ts"), "utf8"); ok( /runGoalAction\(\(\) => applyCollaborationMode\(collaborationMode === "plan" \? "normal" : "plan"\)\)/.test(appSource), "mode shortcut routes through the rejection handler", ); -ok( - /runGoalAction\(async \(\) => \{[\s\S]{0,260}setControllerComposerProfileForTab\([\s\S]{0,260}propagateError: true/.test(appSource), - "background Goal resync routes through the rejection handler", -); -ok(appSource.includes("onClearGoal={clearGoalFromUi}"), "Composer Stop Goal routes through the rejection handler"); -ok(appSource.includes("onSetCollaborationMode={setCollaborationModeFromUi}"), "Composer mode changes route through the rejection handler"); -ok(/if \(model\) \{\s*await switchModel\(model\[1\]\);/.test(appSource), "/model awaits Goal restoration failures"); -ok( - /await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource), - "failed Goal bridge calls cannot patch local Goal state or user intent", -); +// controller-profile-lifecycle.test.tsx drives the production restoration effect +// and verifies one error report, alongside the direct/awaited model reject contract. +ok(errors.filter(error => error === "stop goal bridge failed").length === 1, "production Composer Stop Goal adapter presents the failure exactly once"); +ok(errors.filter(error => error === "switch mode bridge failed").length === 1, "production Composer mode adapter presents the failure exactly once"); +// session-submission-lifecycle.test.tsx rejects real Goal activation and checks +// zero profile/intent patches or submit/undo side effects. await act(async () => { root.unmount(); diff --git a/desktop/frontend/src/__tests__/goal-activation-tab-routing.test.tsx b/desktop/frontend/src/__tests__/goal-activation-tab-routing.test.tsx index 033c06f4c7..003eccec61 100644 --- a/desktop/frontend/src/__tests__/goal-activation-tab-routing.test.tsx +++ b/desktop/frontend/src/__tests__/goal-activation-tab-routing.test.tsx @@ -8,7 +8,6 @@ import { JSDOM } from "jsdom"; import React, { act } from "react"; import { createRoot } from "react-dom/client"; import type { AppBindings } from "../lib/bridge"; -import { activateGoalAndSubmitOnTab } from "../lib/goalSubmit"; import { useController } from "../lib/useController"; import { historySliceFromMessages } from "./mockHistorySlice"; import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta } from "../lib/types"; @@ -207,23 +206,15 @@ eq(controller?.activeTabId, "tab-a", "harness starts on tab A"); const sourceTabId = "tab-a"; let pending!: Promise; await act(async () => { - pending = activateGoalAndSubmitOnTab({ - tabId: sourceTabId, - displayText: "Cross-tab safe goal", - submitText: "/ui-ux-pro-max Cross-tab safe goal", - structured: { - display: "/ui-ux-pro-max Cross-tab safe goal", - input: "Cross-tab safe goal", - invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], - }, - sendToTab: (tabId, goal, display, submit, structured) => { - if (!controller) throw new Error("controller missing"); - return controller.sendToTab(tabId, display, submit, undefined, structured, { - goal, - collaborationMode: "normal", - toolApprovalMode: "ask", - }); - }, + if (!controller) throw new Error("controller missing"); + pending = controller.sendToTab(sourceTabId, "Cross-tab safe goal", "/ui-ux-pro-max Cross-tab safe goal", undefined, { + display: "/ui-ux-pro-max Cross-tab safe goal", + input: "Cross-tab safe goal", + invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], + }, { + goal: "Cross-tab safe goal", + collaborationMode: "normal", + toolApprovalMode: "ask", }); await flushPromises(); }); @@ -264,21 +255,14 @@ const failInvokeCalls: string[] = []; let activationFailed = false; await act(async () => { try { - await activateGoalAndSubmitOnTab({ - tabId: "tab-a", - displayText: "Must not run skill", - submitText: "/ui-ux-pro-max Must not run skill", - structured: { - display: "/ui-ux-pro-max Must not run skill", - input: "Must not run skill", - invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], - }, - sendToTab: (tabId, goal, display, submit, structured) => - controller!.sendToTab(tabId, display, submit, undefined, structured, { - goal, - collaborationMode: "normal", - toolApprovalMode: "ask", - }), + await controller!.sendToTab("tab-a", "Must not run skill", "/ui-ux-pro-max Must not run skill", undefined, { + display: "/ui-ux-pro-max Must not run skill", + input: "Must not run skill", + invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], + }, { + goal: "Must not run skill", + collaborationMode: "normal", + toolApprovalMode: "ask", }); } catch (error) { activationFailed = error instanceof Error && error.message.includes("workbench target changed"); diff --git a/desktop/frontend/src/__tests__/helpers/RemoteNavigationHarness.tsx b/desktop/frontend/src/__tests__/helpers/RemoteNavigationHarness.tsx new file mode 100644 index 0000000000..9ba026a653 --- /dev/null +++ b/desktop/frontend/src/__tests__/helpers/RemoteNavigationHarness.tsx @@ -0,0 +1,27 @@ +import React, { useRef, type ReactNode } from "react"; +import { useDesktopNavigation } from "../../app-runtime/useDesktopNavigation"; +import { RemoteNavigationContext } from "../../lib/remoteNavigationCommands"; +import { app } from "../../lib/bridge"; +import { useNavigationIntentFence } from "../../lib/useNavigationIntentFence"; +import { useT } from "../../lib/i18n"; + +const noop = () => {}; +const unavailable = async (): Promise => { throw new Error("unexpected local navigation in remote fixture"); }; +/** Component fixtures use the production owner and registration fence, not a second navigation implementation. */ +export function RemoteNavigationHarness({ children }: { children: ReactNode }) { + const sequence = useRef(0); + const fence = useNavigationIntentFence(); + const { openRemoteProject } = useDesktopNavigation({ visible: { tabId: "fixture", sessionKey: "fixture" }, singleSurface: true, + noteIntent: () => { const seq = ++sequence.current; fence.registerNavigationIntent(seq); return seq; }, + beginSurface: noop, settleSurface: noop, showChat: noop, + setTabRevealSignal: noop, setTranscriptRevealSignal: noop, setProjectRevision: noop, setHistory: noop, t: useT(), showToast: noop, + ports: { registeredNavigationIntent: fence.registeredNavigationIntent, isNavigationIntentCurrent: seq => seq === sequence.current, + openRemoteProject: app.OpenRemoteProjectTab, switchRemoteTab: async () => {}, + activateTopic: unavailable, openTopicSession: unavailable, openGlobalTab: unavailable, openProjectTab: unavailable, + ensureBlankSurface: unavailable, ensureBlankTab: unavailable, createIsolatedWorktree: unavailable, + openChannelSession: unavailable, resumeSession: unavailable, + listTabs: async () => [], listSessions: async () => [], applyTabs: noop, seedTab: noop, + }, + }); + return {children}; +} diff --git a/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts b/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts index e41323570a..5a9915ca5e 100644 --- a/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts +++ b/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts @@ -7,7 +7,8 @@ import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const controller = readFileSync(join(root, "lib/useController.ts"), "utf8"); const store = readFileSync(join(root, "lib/transcriptStore.ts"), "utf8"); -const app = readFileSync(join(root, "App.tsx"), "utf8"); +const chatPane = readFileSync(join(root, "app-shell/ChatPaneRegion.tsx"), "utf8"); +const appView = readFileSync(join(root, "App.tsx"), "utf8"); assert.match(controller, /deferResetUntilHistory \?\? true/, "history reset waits for successful load"); assert.match(controller, /type: "hydrate_error"/, "history failure dispatches hydrate_error"); @@ -15,8 +16,6 @@ assert.match(controller, /applyHydrateErrorState|hydratePlaceholderItems/, "hydr assert.match(readFileSync(join(root, "lib/hydrateErrorState.ts"), "utf8"), /keptItems/, "hydrateErrorState preserves items"); assert.match(controller, /throw new Error\(t\("history\.failedLoadHistory"\)\)/, "listSessions does not swallow failures as empty"); assert.match(controller, /retrySessionHistory/, "retry path is exported"); -// Footer geometry and reader ownership are exercised by the real kernel -// geometry-commit and browser composer/reader suites after renderer cutover. assert.match(controller, /shouldPreferResidentHistory\(resetSurface, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot"); assert.match( controller, @@ -24,7 +23,7 @@ assert.match( "failed clear keeps the visible transcript instead of a resident snapshot", ); assert.match(store, /slice\.error/, "transcript store rejects slice.error as failure"); -assert.match(app, /retrySessionHistory/, "App wires history retry control"); -assert.match(app, /history-load-error/, "App surfaces hydrate error banner"); +assert.match(appView, /retrySessionHistory/, "App wires history retry control"); +assert.match(chatPane, /history-load-error/, "App surfaces hydrate error banner"); console.log(" PASS history load failure contract"); diff --git a/desktop/frontend/src/__tests__/isolated-worktree.test.ts b/desktop/frontend/src/__tests__/isolated-worktree.test.ts index 7d1cb6ad0d..304d0ffcf8 100644 --- a/desktop/frontend/src/__tests__/isolated-worktree.test.ts +++ b/desktop/frontend/src/__tests__/isolated-worktree.test.ts @@ -9,7 +9,6 @@ const source = (path: string) => readFileSync(resolve(dir, path), "utf8"); const bridge = source("../lib/bridge.ts"); const tree = source("../components/ProjectTree.tsx"); const tabs = source("../components/TabBar.tsx"); -const app = source("../App.tsx"); const badge = source("../components/WorktreeBadge.tsx"); const forkAction = source("../lib/forkWorktree.ts"); const message = source("../components/Message.tsx"); @@ -33,10 +32,12 @@ ok(/CreateIsolatedWorktree\(workspaceRoot: string\)/.test(bridge), "bridge expos ok(/app\.IsolatedWorktreeAvailability\(projectRoot\)/.test(tree), "project menu probes Git before enabling isolation"); ok(/disabled: isolatingProject !== null \|\| isolationAvailability\?\.available === false/.test(tree), "menu disables unavailable or duplicate creation"); ok(/onCreateIsolatedWorktree\?\.\(workspaceRoot\)/.test(tree), "project menu delegates isolated workspace creation"); -ok(/kind: "isolated-worktree"/.test(app) && /enqueueNavigation\(\{ kind: "isolated-worktree"/.test(app), "creation shares the last-click-wins navigation queue"); -ok(/sourceDirty[\s\S]*worktreeCreatedDirty/.test(app), "dirty source checkout receives an explicit warning"); +// Project commands drive the production coalescing queue under deferred work +// in project-topic-lifecycle.test.tsx; callback location is not a contract. +// desktop-navigation-lifecycle.test.tsx verifies the actual dirty-worktree notice. ok(/isolatedWorktree && act(async () => { await new Promise((resolve) => setTimeout(resolve, 20)); }); +// Lazy module resolution is I/O, not a twenty-millisecond rendering contract. +// Drain React/event-loop work until the observable worker handshake completes; +// the deadline is only a failure bound, never the success condition. +async function until(condition: () => boolean) { + const deadline = Date.now() + 5_000; + while (!condition()) { + if (Date.now() >= deadline) throw new Error("Markdown worker handshake did not settle"); + await act(async () => { await new Promise((resolve) => setImmediate(resolve)); }); + } +} + const server = await createServer({ appType: "custom", logLevel: "silent", @@ -116,7 +127,7 @@ console.log("\nmarkdown streaming → worker final parse"); await act(async () => { root.render(); }); - await flush(); + await until(() => parseCalls.length > 0); eq(parseCalls.length, 1, "stream completion requests exactly one final parse"); eq(parseCalls[0], finalText, "the final parse receives the complete text"); const tail = rootEl.querySelector(".md--stream-tail"); @@ -126,7 +137,7 @@ console.log("\nmarkdown streaming → worker final parse"); respond?.(); await new Promise((resolve) => setTimeout(resolve, 0)); }); - await flush(); + await until(() => rootEl.textContent === "WORKER-PARSED-FINAL"); ok(rootEl.querySelector(".md[data-markdown-blocks]"), "worker-parsed blocks swap in after completion"); eq(rootEl.textContent, "WORKER-PARSED-FINAL", "the swapped content is the worker render"); ok(!rootEl.querySelector(".md--stream-tail"), "the streaming tail unmounts after the swap"); diff --git a/desktop/frontend/src/__tests__/mcp-interaction.test.tsx b/desktop/frontend/src/__tests__/mcp-interaction.test.tsx index aadbcf57fd..bc6e21fd08 100644 --- a/desktop/frontend/src/__tests__/mcp-interaction.test.tsx +++ b/desktop/frontend/src/__tests__/mcp-interaction.test.tsx @@ -49,8 +49,9 @@ function ok(value: boolean, label: string) { type ControllerState = Parameters[0]; const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8"); +const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8"); ok( - /\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(appSource), + /\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource), "App decision surface recomputes when an MCP interaction arrives", ); diff --git a/desktop/frontend/src/__tests__/mock-remote-catalog.test.ts b/desktop/frontend/src/__tests__/mock-remote-catalog.test.ts new file mode 100644 index 0000000000..68e9aa7b48 --- /dev/null +++ b/desktop/frontend/src/__tests__/mock-remote-catalog.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import { app } from "../lib/bridge"; + +const dom = new JSDOM("", { url: "http://localhost/?mock=bench" }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage }); +try { + const local = (await app.ListTabs())[0]; + const remote = await app.OpenRemoteProjectTab("demo", "~/app", { sessionName: "intro" }); + assert.ok((await app.ListTabs()).some(tab => tab.id === remote.id), "remote open and ListTabs share the backend catalog"); + await app.SetActiveTab(remote.id); + assert.deepEqual((await app.ListTabs()).filter(tab => tab.active).map(tab => tab.id), [remote.id]); + await app.SetRemoteTabModel(remote.id, "fixture/model"); + assert.equal((await app.ListTabs()).find(tab => tab.id === remote.id)?.label, "fixture/model"); + const renewed = await app.OpenRemoteProjectTab("demo", "~/app", { newSession: true }); + assert.equal(renewed.id, remote.id, "remote new session reuses its workspace surface"); + assert.equal((await app.ListTabs()).filter(tab => tab.id === remote.id).length, 1); + assert.equal((await app.ListTabs()).find(tab => tab.id === remote.id)?.topicTitle, "New session"); + const status = await app.RemoteTabStatus(remote.id) as Record; + assert.equal(status.plan, false); + assert.equal(status.toolApprovalMode, "ask"); + assert.equal(status.goal, ""); + assert.deepEqual((await app.RemoteTabSnapshot(remote.id)).status, status, "snapshot and status share the authoritative composer profile"); + await app.SetActiveTab(local.id); + assert.deepEqual((await app.ListTabs()).filter(tab => tab.active).map(tab => tab.id), [local.id]); + await app.CloseRemoteTab(remote.id); + assert.ok(!(await app.ListTabs()).some(tab => tab.id === remote.id)); + await assert.rejects(app.SetActiveTab(remote.id), /not found/); + console.log("mock remote catalog: open, refresh, model, new session, selection and close agree"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/navigation-surface-lifecycle.test.tsx b/desktop/frontend/src/__tests__/navigation-surface-lifecycle.test.tsx new file mode 100644 index 0000000000..e59ba6371f --- /dev/null +++ b/desktop/frontend/src/__tests__/navigation-surface-lifecycle.test.tsx @@ -0,0 +1,54 @@ +import React, { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import assert from "node:assert/strict"; +import { useNavigationSurface } from "../lib/useNavigationSurface"; +import { projectNavigationSurfaceTarget } from "../app-runtime/conversationProjection"; +import { initialState } from "../lib/useController"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +let surface!: ReturnType; +function Probe({ tab, session, remote }: { tab: string; session: string; + remote?: Pick }) { + const next = useNavigationSurface(projectNavigationSurfaceTarget({ activeTabId: tab, sessionKey: session, + local: { ...initialState, meta: { ...initialState.meta, ready: !remote } as typeof initialState.meta, + backendActivationPending: Boolean(remote), hydrating: Boolean(remote) }, remote })); + useLayoutEffect(() => { surface = next; }); + return null; +} + +try { + await act(async () => root.render()); + act(() => { surface.begin(1); }); + act(() => { surface.maskTarget(1); }); + const firstToken = surface.surfaceCommitToken!; + assert.ok(firstToken); + await act(async () => root.render()); + const secondToken = surface.surfaceCommitToken!; + assert.notEqual(secondToken, firstToken, "replacement within the same intent receives a distinct paint receipt"); + act(() => { assert.equal(surface.commitPaint(firstToken, "ready"), null); }); + let receipt: unknown; + act(() => { receipt = surface.commitPaint(secondToken, "ready"); }); + assert.deepEqual(receipt, { token: secondToken, intent: 1, targetTabId: "a", targetSessionKey: "a:2" }); + act(() => { assert.equal(surface.commitPaint(secondToken, "ready"), null, "a receipt commits once"); }); + const remote = { state: "ready", hydrated: true, surfaceGeneration: 1, error: "" } as const; + await act(async () => root.render()); + act(() => { surface.begin(2); surface.maskTarget(2); }); + const remoteToken = surface.surfaceCommitToken!; + assert.ok(remoteToken, "remote readiness is independent of the inactive local controller's pending hydration"); + await act(async () => root.render()); + act(() => { assert.equal(surface.commitPaint(remoteToken, "ready"), null, "a former Serve generation cannot acknowledge the new surface"); }); + act(() => { assert.ok(surface.commitPaint(surface.surfaceCommitToken!, "ready")); }); + assert.equal(surface.transitioning, false, "the same paint transaction releases remote Composer readiness"); + await act(async () => root.render()); + act(() => { surface.begin(3); surface.maskTarget(3); }); + assert.equal(surface.transitioning, false, "remote hydration failure terminates the masked navigation, leaving recovery reachable"); + const retained = surface.begin; + act(() => { root.unmount(); retained(2); }); + console.log("PASS navigation receipts are unique, source-bound, and consumed exactly once"); +} finally { + dom.window.close(); +} diff --git a/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts b/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts index 2942964a1c..3ef66ea6fd 100644 --- a/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts +++ b/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts @@ -1,11 +1,14 @@ // Run: tsx src/__tests__/navigation-surface-transition.test.ts import { readFileSync } from "node:fs"; +import { navigateWorkspace } from "../app-runtime/navigationOwner"; import { advanceSurfacePaintCommit, beginNavigationSurfaceState, + createNavigationSurfaceTicket, guardBackendNavigationResult, markNavigationTargetMasked, + matchesNavigationSurfaceTicket, settleNavigationSurfaceIntent, settleNavigationSurfaceState, } from "../lib/navigationSurfaceTransition"; @@ -41,6 +44,14 @@ ok(surface?.intent === 9, "a stale paint terminal cannot release the latest mask surface = settleNavigationSurfaceState(surface, 9); ok(surface === null, "the matching paint terminal releases the mask"); +const ticketA1 = createNavigationSurfaceTicket(20, "tab-a", "session-a:1"); +const ticketB = createNavigationSurfaceTicket(21, "tab-b", "session-b:1"); +const ticketA2 = createNavigationSurfaceTicket(22, "tab-a", "session-a:1"); +ok(matchesNavigationSurfaceTicket(ticketA1, ticketA1.token, 20, "tab-a", "session-a:1"), "paint acknowledgement matches the complete ticket"); +ok(!matchesNavigationSurfaceTicket(ticketB, ticketA1.token, 21, "tab-b", "session-b:1"), "an old paint token cannot commit B"); +ok(!matchesNavigationSurfaceTicket(ticketA2, ticketA1.token, 22, "tab-a", "session-a:1"), "A → B → A cannot revive A's old paint token"); +ok(!matchesNavigationSurfaceTicket(ticketA1, ticketA1.token, 20, "tab-a", "session-a:2"), "same-tab replacement session rejects the old ticket"); + let paint = advanceSurfacePaintCommit({ attempts: 0, stableFrames: 0 }, { rendered: true, placementReady: true, geometryReady: true, geometryKey: "755:1200:445", }); @@ -99,32 +110,48 @@ ok(await staleAcceptedPromise === false, "a stale backend-activating result is r ok(reasserted === "tab.reveal-background:tab-stale", "stale reassertion receives the mutating target identity"); const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8"); +const chatPaneSource = readFileSync(new URL("../app-shell/ChatPaneRegion.tsx", import.meta.url), "utf8"); +const appViewSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8"); +const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8"); const surfaceHookSource = readFileSync(new URL("../lib/useNavigationSurface.ts", import.meta.url), "utf8"); +const tabBarSource = readFileSync(new URL("../app-runtime/useTabBarCommands.ts", import.meta.url), "utf8"); const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8"); ok(surfaceHookSource.includes("flushSync(() => {"), "navigation masking commits synchronously before the Wails await"); ok(surfaceHookSource.includes("setPreserved(rendered?.items.length ? rendered : null)"), "the last stable transcript is retained during navigation"); -ok(appSource.includes("items={visibleTranscriptItems}"), "the visible transcript is decoupled from the hydrating target"); -ok(appSource.includes("transcript-navigation-overlay"), "navigation renders a blocking transcript overlay"); +ok(sessionCompositionSource.includes("visibleTranscriptItems,") && appViewSource.includes("items: session.transcript.visibleTranscriptItems"), "the visible transcript is decoupled from the hydrating target"); +ok(chatPaneSource.includes("transcript-navigation-overlay"), "navigation renders a blocking transcript overlay"); ok(/\.transcript-navigation-overlay\s*\{[\s\S]*?background:\s*var\(--chat-bg, var\(--bg\)\)/.test(stylesSource), "the navigation overlay is opaque while target rows settle"); -ok(appSource.includes("live={runtimeTransitioning ? undefined : state.live}"), "App removes source live output during navigation"); -ok(appSource.includes("composer-decision-host--footprint-hidden"), "App preserves the composer footprint during navigation"); +ok(chatPaneSource.includes("live={transitioning ? undefined : state.live}"), "App removes source live output during navigation"); ok(!appSource.includes("hidden={composerSurfaceHidden || undefined}"), "navigation no longer collapses the composer footprint"); -ok(appSource.includes("inert={composerSurfaceHidden ? true : undefined}"), "the hidden composer is inert during navigation"); -ok(appSource.includes("{showTodos && ("), "target Todo footprint is laid out below the mask"); -ok(appSource.includes("{rewindState && ("), "target rewind footprint is laid out below the mask"); +// Masked Composer/Todo/rewind layout is exercised through the mounted production +// DecisionFooterRegion in decision-footer-lifecycle.test.tsx, not App source text. ok(/\.footer--navigation-hidden\s*\{[\s\S]*?visibility:\s*hidden;[\s\S]*?pointer-events:\s*none;/.test(stylesSource), "masked target footer cannot paint or receive input"); -ok(appSource.includes('style={navigationSurface?.phase === "source-retained"') && appSource.includes("const visibleDecisionSurface = decisionSurface"), "target-masked paint uses the target footer geometry"); -ok((appSource.match(/guardBackendNavigationResult\(\{/g) ?? []).length === 2, "both Reveal paths guard stale backend activation results"); -const switchFolderSource = appSource.slice( - appSource.indexOf("const switchFolder = useCallback"), - appSource.indexOf("const refreshProjectsAndTabs = useCallback"), -); -ok(switchFolderSource.includes("const navigationIntentSeq = noteNavigationIntent()"), "workspace navigation claims the shared intent before Wails"); -ok(switchFolderSource.includes("beginNavigationSurface(navigationIntentSeq)"), "workspace navigation masks the source surface before Wails"); -ok(switchFolderSource.includes("pickWorkspace(navigationIntentSeq)"), "folder-pick navigation carries the shared intent into the controller"); -ok(switchFolderSource.includes("switchWorkspace(path, navigationIntentSeq)"), "direct workspace navigation carries the shared intent into the controller"); -ok(switchFolderSource.includes("settleNavigationSurface(navigationIntentSeq)"), "workspace request completion advances the target under its surface mask"); +ok(appViewSource.includes('style={core.surface.surface?.phase === "source-retained"') && sessionCompositionSource.includes("const visibleDecisionSurface = decisionSurface"), "target-masked paint uses the target footer geometry"); +ok((tabBarSource.match(/guardBackendNavigationResult\(\{/g) ?? []).length === 2, "both Reveal paths guard stale backend activation results"); ok(surfaceHookSource.includes("navigation.paint-ready"), "surface settlement is diagnosed only from target paint readiness"); +let currentWorkspaceIntent = 30; +let releaseWorkspace!: (picked: string) => void; +const workspaceResult = new Promise((resolve) => { releaseWorkspace = resolve; }); +const workspaceCalls: string[] = []; +const staleWorkspace = navigateWorkspace("/workspace-a", { + claimIntent: () => currentWorkspaceIntent, + beginSurface: (intent) => workspaceCalls.push(`begin:${intent}`), + isIntentCurrent: (intent) => intent === currentWorkspaceIntent, + pickWorkspace: async () => "", + switchWorkspace: async (path, intent) => { + workspaceCalls.push(`switch:${intent}:${path}`); + return workspaceResult; + }, + markProjectChanged: (updater) => { updater(0); workspaceCalls.push("changed"); }, + refreshTabsAfterMutation: async (latest) => { workspaceCalls.push(`refresh:${latest() ? "current" : "stale"}`); }, + maskTarget: (intent) => workspaceCalls.push(`mask:${intent}`), +}); +currentWorkspaceIntent = 31; +releaseWorkspace("/workspace-a"); +ok(await staleWorkspace === "/workspace-a", "source workspace data may finish after a newer navigation"); +ok(!workspaceCalls.includes("changed") && !workspaceCalls.some((call) => call.startsWith("refresh:")), "stale workspace completion cannot mutate current UI"); +ok(workspaceCalls[workspaceCalls.length - 1] === "mask:30", "old workspace finally addresses only its own surface intent"); + console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/onboarding-commands.test.tsx b/desktop/frontend/src/__tests__/onboarding-commands.test.tsx new file mode 100644 index 0000000000..6dc878fa15 --- /dev/null +++ b/desktop/frontend/src/__tests__/onboarding-commands.test.tsx @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useOnboardingCommands } from "../app-runtime/useOnboardingCommands"; +import { useOverlayStore } from "../store/overlays"; +import { useAppNavigationStore } from "../store/appNavigation"; +import { onboardingWasDismissed } from "../lib/onboarding"; + +const dom = new JSDOM("
", { url: "http://localhost" }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, + localStorage: dom.window.localStorage, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +let commands!: ReturnType; +let completed = 0; +function Probe() { commands = useOnboardingCommands(() => { completed++; }); return null; } +await act(async () => root.render()); +commands.chooseOnboardingProvider(); +assert.deepEqual(useAppNavigationStore.getState().page, { kind: "settings", tab: "models" }); +assert.deepEqual(useAppNavigationStore.getState().settingsFocus, { target: "model-access" }); +assert.equal(useOverlayStore.getState().needsOnboarding, false); +commands.completeOnboarding(); +assert.equal(completed, 1); +commands.skipOnboarding(); +assert.equal(onboardingWasDismissed(), true); +await act(async () => root.unmount()); +commands.completeOnboarding(); +assert.equal(completed, 1, "unmounted owner cannot publish onboarding state"); +dom.window.close(); +console.log("onboarding commands: model access, completion, dismissal and disposal passed"); diff --git a/desktop/frontend/src/__tests__/pending-plan-revision-lifecycle.test.tsx b/desktop/frontend/src/__tests__/pending-plan-revision-lifecycle.test.tsx new file mode 100644 index 0000000000..ebcad1a7ea --- /dev/null +++ b/desktop/frontend/src/__tests__/pending-plan-revision-lifecycle.test.tsx @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { usePendingPlanRevisions } from "../lib/usePendingPlanRevisions"; +import { useSessionOperations } from "../app-runtime/useSessionOperations"; +import { useSessionSubmission } from "../lib/useSessionSubmission"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +function deferred() { + let resolve!: () => void; let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +const requests: { tab: string; text: string; gate: ReturnType }[] = []; +const errors: unknown[] = []; +let remember!: ReturnType; +function Probe({ tab, gen, running, ready }: { tab: string; gen: number; running: boolean; ready: boolean }) { + const resources = ["A", "B"].map(tabId => ({ tabId, sessionKey: tabId + gen })); + const visible = resources.find(target => target.tabId === tab)!; + const operations = useSessionOperations({ visible, resources }); + const submission = useSessionSubmission({ target: visible, operations, missingSource: "missing", + resources: resources.map(target => ({ target, remote: false, ready: true, unavailable: "", goalDraft: false, + collaboration: "normal", approval: "ask" })), + ports: { + send: (tab, text) => { const gate = deferred(); requests.push({ tab, text, gate }); return gate.promise; }, + clearUndo: () => {}, setGoal: async () => {}, patchGoal: () => {}, profile: async () => true, + }, + }); + remember = usePendingPlanRevisions({ visible, resources, running, ready, operations, + send: submission.sendRevision, + report: error => { errors.push(error); }, + }); + return null; +} +const paint = (tab = "A", gen = 1, running = false, ready = true) => act(async () => root.render()); +try { + await paint("A", 1, true); remember("A", "first"); + assert.equal(requests.length, 0, "running turn holds its revision"); + await paint("A", 1, false, false); + assert.equal(requests.length, 0, "an idle but uncommitted navigation surface cannot submit a revision"); + await paint("B"); assert.equal(requests.length, 0, "B does not submit A's pending revision"); + remember("B", "B revision"); assert.equal(requests[0].tab, "B"); + await paint("A"); assert.deepEqual(requests.map(({ tab, text }) => [tab, text]), [["B", "B revision"], ["A", "first"]]); + + remember("A", "same text"); remember("A", "same text"); + await paint("A", 2); remember("A", "replacement"); + assert.equal(requests.length, 3, "replacement resource can start while the old resource's transport is pending"); + remember("A", "latest"); + await act(async () => requests[1].gate.resolve()); + assert.equal(requests.length, 3, "old finally cannot release the new request or start its queued successor"); + await act(async () => requests[2].gate.resolve()); + assert.equal(requests[3].text, "latest", "matching completion starts exactly the replacement revision"); + await act(async () => requests[3].gate.resolve()); + await paint("A", 2); assert.equal(requests.length, 4, "terminal revisions leave no retried request"); + await act(async () => requests[0].gate.resolve()); + + remember("A", "failure"); await paint("B", 2); await paint("A", 2); + await act(async () => requests[4].gate.reject(Error("old failure"))); + assert.deepEqual(errors, [], "A-B-A does not restore failure UI ownership"); + await paint("B", 2); await paint("A", 2); + assert.equal(requests[5].text, "failure", "source data survives suppressed old error UI and can be retried on a new activation"); + await act(async () => requests[5].gate.resolve()); + remember("A", "retryable revision"); + await act(async () => requests[6].gate.reject(Error("current failure"))); + assert.equal(errors.length, 1); + await paint("A", 2); assert.equal(requests.length, 7, "unrelated commits do not loop on a failed revision"); + await paint("B", 2); await paint("A", 2); + assert.equal(requests[7].text, "retryable revision", "explicit source reactivation retains the existing retryable revision"); + await act(async () => requests[7].gate.resolve()); + remember("A", "disposed"); remember("A", "must not follow"); + await act(async () => root.unmount()); + remember("A", "after unmount"); + await act(async () => requests[8].gate.resolve()); + assert.equal(requests.length, 9, "synchronous disposal releases queue and revokes old follow-on work"); + console.log("pending plan revision lifecycle: source queues, request identity, replacement, ABA and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/project-topic-lifecycle.test.tsx b/desktop/frontend/src/__tests__/project-topic-lifecycle.test.tsx new file mode 100644 index 0000000000..e775353431 --- /dev/null +++ b/desktop/frontend/src/__tests__/project-topic-lifecycle.test.tsx @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useProjectTopicCommands } from "../app-runtime/useProjectTopicCommands"; +import type { ProjectTopicPorts } from "../app-runtime/projectTopicOwner"; +import type { RemoteSessionView } from "../lib/remoteTypes"; +import { enqueueNavigationRequest, type NavigationCoalescingRefs } from "../lib/openTopicCoalescing"; + +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +let commands!: ReturnType; +const effects: string[] = []; +let listing = deferred(); +const localRequests = new Map>>(); +type NavigationInput = { kind: "isolated-worktree"; workspaceRoot: string }; +const navigationRefs: NavigationCoalescingRefs = { + seqRef: { current: 0 }, runningRef: { current: false }, pendingRef: { current: null }, +}; +let navigationGate: ReturnType> | undefined; +const ports: ProjectTopicPorts = { + renameLocal: async (id, title) => { + effects.push(`rename:${id}:${title}`); + const gate = deferred(); localRequests.set(id, gate); await gate.promise; + }, + listRemote: async () => listing.promise, + renameRemote: async (_host, _workspace, name, title) => { effects.push(`remote:${name}:${title}`); }, + markChanged: () => { effects.push("refresh-projects"); }, + refreshTabs: async () => [], + syncActive: async () => { effects.push("sync-current"); }, +}; +const navigation = { + openBlank: async (scope: string, path: string) => { effects.push(`blank:${scope}:${path}`); }, + enqueue: (input: NavigationInput) => enqueueNavigationRequest(navigationRefs, input, async request => { + effects.push(`worktree:${request.workspaceRoot}`); + if (navigationGate) await navigationGate.promise; + if (request.seq === navigationRefs.seqRef.current && navigationGate) effects.push(`visible:${request.workspaceRoot}`); + }), + switchFolder: async (path?: string) => { effects.push(`project:${path}`); }, +}; +function Probe({ tab, remote = false }: { tab: string; remote?: boolean }) { + commands = useProjectTopicCommands({ visible: { tabId: tab, sessionKey: tab }, + topic: { id: tab, title: tab, target: remote + ? { kind: "remote", hostId: "fixture", workspace: "fixture", sessionPath: `${tab}.jsonl` } + : { kind: "local", topicId: tab } }, + ports, navigation, reportError: error => { throw error; }, + }); + return null; +} +async function paint(tab: string, remote = false) { await act(async () => root.render()); } +try { + await paint("A"); + const first = commands; + await paint("B"); + assert.equal(commands.onCreateTopic, first.onCreateTopic); + assert.equal(commands.onCreateIsolatedWorktree, first.onCreateIsolatedWorktree); + assert.equal(commands.onAddProject, first.onAddProject); + await commands.onCreateTopic("global", "ignored"); + await commands.onCreateIsolatedWorktree("worktree"); + await commands.onAddProject("project"); + assert.deepEqual(effects, ["blank:global:", "worktree:worktree", "project:project"]); + + effects.length = 0; + navigationGate = deferred(); + const firstNavigation = commands.onCreateIsolatedWorktree("A"); + const supersededNavigation = commands.onCreateIsolatedWorktree("B"); + const lastNavigation = commands.onCreateIsolatedWorktree("C"); + await supersededNavigation; + assert.deepEqual(effects, ["worktree:A"], "replaced requests do not execute while the first backend call is pending"); + navigationGate.resolve(); + await Promise.all([firstNavigation, lastNavigation]); + assert.deepEqual(effects, ["worktree:A", "worktree:C", "visible:C"], "real coalescing queue accepts the last worktree command and rejects old UI continuation"); + assert.equal(navigationRefs.pendingRef.current, null); + assert.equal(navigationRefs.runningRef.current, false); + navigationGate = undefined; + + effects.length = 0; + await act(async () => commands.startActiveTopicRename()); + await act(async () => commands.setTopicTitleDraft("changed")); + await act(async () => commands.cancelActiveTopicRename()); + await commands.commitActiveTopicRename(); + assert.deepEqual(effects, [], "escape followed by blur never submits a rename"); + await act(async () => commands.startActiveTopicRename()); + await paint("A"); + assert.equal(commands.topicbarEditing, false, "switching resources releases the former draft"); + + await paint("A", true); + await act(async () => commands.startActiveTopicRename()); + await act(async () => commands.setTopicTitleDraft("source title")); + let pending!: Promise; + await act(async () => { pending = commands.commitActiveTopicRename(); }); + await paint("B", true); await paint("A", true); + listing.resolve([{ name: "A", path: "A.jsonl", title: "A", turns: 1 }, { name: "B", path: "B.jsonl", title: "B", turns: 1, current: true }]); + await act(async () => { await pending; }); + assert.deepEqual(effects, ["remote:A:source title", "refresh-projects"], "remote current may change but rename retains A; ABA cannot resync the visible tab"); + + effects.length = 0; + const renameA = commands.renameTopic("A", "one"); + const renameB = commands.renameTopic("B", "two"); + localRequests.get("A")!.resolve(); await renameA; + localRequests.get("B")!.resolve(); await renameB; + assert.equal(effects.filter(effect => effect === "refresh-projects").length, 2, "unrelated topics retain independent operation lanes"); + + effects.length = 0; + listing = deferred(); + await act(async () => commands.startActiveTopicRename()); + await act(async () => { pending = commands.commitActiveTopicRename(); }); + await act(async () => root.unmount()); + listing.resolve([{ name: "A", path: "A.jsonl", title: "A", turns: 1 }]); await pending; + first.onAddProject("stale"); + assert.deepEqual(effects, [], "disposed feature cannot rename, refresh, or navigate"); + console.log("project/topic commands: stable entry, targeted rename, independent lanes, ABA, cancel and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx b/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx index 1d2041285f..8088e5d0cf 100644 --- a/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx +++ b/desktop/frontend/src/__tests__/provider-editor-model-picker.test.tsx @@ -274,6 +274,9 @@ const backendUnsupportedCustomProvider: ProviderView = { models: ["deepseek-v4-pro"], default: "deepseek-v4-pro", visionCapability: "unsupported", + modelCapabilities: [ + { model: "deepseek-v4-pro", inputModalities: ["text"], state: "unsupported", source: "adapter" }, + ], }; const legacyChatURLProvider: ProviderView = { diff --git a/desktop/frontend/src/__tests__/remote-composer-commands.test.tsx b/desktop/frontend/src/__tests__/remote-composer-commands.test.tsx new file mode 100644 index 0000000000..783246729d --- /dev/null +++ b/desktop/frontend/src/__tests__/remote-composer-commands.test.tsx @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useRemoteComposerSend, useRemoteComposerRuntimeActions } from "../lib/useRemoteComposerIntegration"; +import { useSessionOperations } from "../app-runtime/useSessionOperations"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; +import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands"; +import { RemoteNavigationHarness } from "./helpers/RemoteNavigationHarness"; +import { LocaleProvider } from "../lib/i18n"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +let gate = deferred(); +const calls: string[] = []; +Object.assign(window, { go: { main: { App: { + RegisterNavigationIntent: async () => { calls.push("navigation-intent"); }, + OpenRemoteProjectTab: async (host: string, workspace: string, options: { newSession?: boolean }) => { calls.push(`new:${host}:${workspace}:${options.newSession}`); }, +} } } }); +const session = { + setModel: async (value: string) => { calls.push(`model:${value}`); }, + setEffort: async (value: string) => { calls.push(`effort:${value}`); }, + compact: async (value: string) => { calls.push(`compact:${value}`); }, + runManagementCommand: async (value: string, hydrate?: boolean) => { calls.push(`manage:${value}:${hydrate}`); }, + pauseGoal: async () => { calls.push("remote-pause"); }, + resumeGoal: async () => { calls.push("remote-resume"); }, + retryHydration: async () => { calls.push("hydrate"); }, +} as unknown as RemoteSessionApi; +let send!: ReturnType; +let runtime!: ReturnType; +let action: Promise | undefined; +function Probe({ tab, generation = "1", remote = true }: { tab: string; generation?: string; remote?: boolean }) { + const navigateRemote = useRemoteNavigationCommand(); + const target = { tabId: tab, sessionKey: tab + generation }; + const operations = useSessionOperations({ visible: target, resources: ["A", "B"].map(tabId => ({ tabId, sessionKey: tabId + generation })) }); + send = useRemoteComposerSend({ hostId: "fixture", workspace: "fixture" }, tab, "goal", "", session, + async (display, submit) => { calls.push(`send:${tab}:${display}:${submit}`); }, + async (id, goal) => { calls.push(`goal:${id}:${goal}`); await gate.promise; }, + () => { calls.push("clear"); }, { target, operations, navigateRemote }); + runtime = useRemoteComposerRuntimeActions({ target, operations, remote, session, + runGoalAction: run => { action = Promise.resolve(run()); }, + pauseLocal: async id => { calls.push(`pause:${id}`); }, resumeLocal: async id => { calls.push(`resume:${id}`); }, + setLocalEffort: async (id, level) => { calls.push(`local-effort:${id}:${level}`); }, showError: message => { throw Error(message); } }); + return null; +} +const paint = (tab: string, generation = "1", remote = true) => act(async () => root.render()); +try { + await paint("A"); + await send("/model model-fixture"); await send("/effort high"); await send("/compact fixture"); + await send("/context"); await send("/clear"); + assert.deepEqual(calls, ["model:model-fixture", "effort:high", "compact:fixture", "manage:/context:false", "clear"]); + calls.length = 0; + await send("/new"); + assert.deepEqual(calls, ["navigation-intent", "new:fixture:fixture:true"], "new-session uses the common owner; Serve lifecycle hydrates the target instead of the captured source callback"); + calls.length = 0; + const pending = send("display", " submit bytes "); + assert.deepEqual(calls, ["goal:A:submit bytes"]); + await paint("B"); gate.resolve(); await pending; + assert.deepEqual(calls, ["goal:A:submit bytes", "send:A:display: submit bytes "], "goal and send keep source A and preserve provider-visible submit bytes"); + calls.length = 0; gate = deferred(); await paint("A"); + const replaced = send("next"); await paint("A", "2"); gate.resolve(); await replaced; + assert.deepEqual(calls, ["goal:A:next"], "replacement session receives no stale post-goal submit"); + calls.length = 0; + runtime.pauseGoal(); await action; runtime.resumeGoal(); await action; + assert.deepEqual(calls, ["remote-pause", "remote-resume"]); + calls.length = 0; await paint("A", "2", false); + runtime.pauseGoal(); await action; runtime.resumeGoal(); await action; runtime.setEffort("max"); + await act(async () => {}); + assert.deepEqual(calls, ["pause:A", "resume:A", "local-effort:A:max"]); + calls.length = 0; gate = deferred(); await paint("A"); + const disposed = send("disposed"); await act(async () => root.unmount()); gate.resolve(); await disposed; + runtime.pauseGoal(); + assert.deepEqual(calls, ["goal:A:disposed"]); + console.log("remote composer commands: management routing, source Goal/send, byte preservation, runtime ports and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/remote-composer-presentation.test.tsx b/desktop/frontend/src/__tests__/remote-composer-presentation.test.tsx new file mode 100644 index 0000000000..ceb96dd34c --- /dev/null +++ b/desktop/frontend/src/__tests__/remote-composer-presentation.test.tsx @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { Composer } from "../components/Composer"; +import { LocaleProvider } from "../lib/i18n"; +import { ToastProvider } from "../lib/toast"; +import { projectConversation } from "../app-runtime/conversationProjection"; +import { initialState } from "../lib/useController"; + +const dom = new JSDOM("
", { url: "http://localhost/", pretendToBeVisual: true }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: () => 1, cancelAnimationFrame() {}, + ResizeObserver: class { observe() {} disconnect() {} unobserve() {} }, +}); +for (const name of ["Node", "Element", "HTMLElement", "HTMLTextAreaElement", "Event", "CustomEvent", "MutationObserver", "File"]) { + Object.defineProperty(globalThis, name, { configurable: true, value: Reflect.get(dom.window, name) }); +} +Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); +window.matchMedia = (() => ({ matches: true, addEventListener() {}, removeEventListener() {} })) as typeof window.matchMedia; +Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { value() {} }); +Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { value() {} }); +const calls: string[] = []; +let localWrites = 0; +const forbidden = async () => { localWrites++; throw new Error("remote surface called local file/inbox mutation"); }; +Object.assign(window, { go: { main: { App: { SavePastedFile: forbidden, SavePastedImage: forbidden, + ModelsForTab: async () => [], ListInboxItems: async () => [], + EnqueueInboxFollowup: forbidden, EnqueueInboxSteer: forbidden, EnqueueInboxSteerForTurn: forbidden, + EnqueueInboxFollowupWithInvocations: forbidden, +} } } }); +const root = createRoot(document.getElementById("root")!); +const noop = () => {}; +const view = projectConversation({ local: initialState, remote: { transcript: initialState, running: true, + modelLabel: "remote fixture", commands: [] }, activeTabId: "remote-A", backgroundRuntimes: [], connectingLabel: "connecting" }); +try { + await act(async () => root.render( { calls.push("send"); }} onSteer={async (text, tab) => { calls.push(`steer:${tab}:${text}`); }} + onCancel={noop} onCycleMode={noop} onSetMode={noop} onSetCollaborationMode={noop} + onSetToolApprovalMode={noop} onToggleYoloApprovalMode={noop} onClearGoal={noop} + onSwitchModel={noop} onSetEffort={noop} insertRequest={{ id: 1, text: "remote guidance", mode: "replace" }} + />)); + const input = document.querySelector("input[type=file]")!; + assert.equal(input.disabled, true); + assert.equal(document.querySelector(".composer-wrap")!.style.getPropertyValue("--wails-drop-target"), ""); + await act(async () => { + Object.defineProperty(input, "files", { value: [new File(["fixture"], "fixture.txt")] }); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + const button = document.querySelector(".composer__btn--send")!; + assert.equal(button.disabled, false); + await act(async () => button.click()); + assert.deepEqual(calls, ["steer:remote-A:remote guidance"], "running remote input uses the remote steer port, never conversational submit"); + assert.equal(localWrites, 0); + assert.equal(document.querySelector("#composer-input")!.value, ""); + console.log("remote Composer: real file control, native drop boundary and running guidance preserve source routing"); +} finally { await act(async () => root.unmount()); dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/remote-connect-wizard.test.tsx b/desktop/frontend/src/__tests__/remote-connect-wizard.test.tsx index 7e1d88f4ec..1ada4a802c 100644 --- a/desktop/frontend/src/__tests__/remote-connect-wizard.test.tsx +++ b/desktop/frontend/src/__tests__/remote-connect-wizard.test.tsx @@ -1,6 +1,7 @@ // Run: tsx src/__tests__/remote-connect-wizard.test.tsx import React from "react"; +import { RemoteNavigationHarness } from "./helpers/RemoteNavigationHarness"; import { JSDOM } from "jsdom"; import { act } from "react"; @@ -13,6 +14,7 @@ import type { RemoteDirEntry, RemoteHostView } from "../lib/types"; let passed = 0; let failed = 0; +let mergedWorkspace = ""; function ok(value: boolean, label: string) { if (value) { process.stdout.write(` PASS ${label}\n`); @@ -165,19 +167,21 @@ window.go = { main: { App: { }, async AddRemoteProject(hostId: string, workspace: string) { tape.push(`AddRemoteProject:${hostId}:${workspace}`); - return { hostId, workspace }; + return { hostId, workspace: mergedWorkspace || workspace, merged: Boolean(mergedWorkspace) }; }, } as Partial as AppBindings } }; function WizardHarness() { return ( + { tape.push("refresh"); }} onClose={() => { tape.push("close"); }} /> + ); } @@ -484,13 +488,18 @@ await act(async () => { await flush(); }); ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish opens the selected workspace in a new remote session tab"); -const navigationRegistration = tape.findIndex((entry) => entry.startsWith("RegisterNavigationIntent:nav-remote-wizard-")); +const navigationRegistration = tape.findIndex((entry) => entry.startsWith("RegisterNavigationIntent:nav-")); ok(navigationRegistration >= 0 && navigationRegistration < tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "finish registers navigation before opening the remote tab"); ok(tape.includes("AddRemoteProject:gpu-box:/home/dev/projects"), "finish pins the selected remote workspace"); ok(tape.indexOf("AddRemoteProject:gpu-box:/home/dev/projects") < tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true"), "the workspace is pinned before its session tab opens"); ok(tape.indexOf("OpenRemoteProjectTab:gpu-box:/home/dev/projects:true") < tape.indexOf("refresh"), "the project tree refreshes after the session tab opens"); ok(tape.includes("close"), "wizard closes after a successful finish"); +mergedWorkspace = "/home/dev"; +await act(async () => { buttonByText("Connect and open")?.click(); await flush(); }); +ok(tape.includes("OpenRemoteProjectTab:gpu-box:/home/dev:true"), "a merged finish opens the canonical workspace through the navigation owner"); +mergedWorkspace = ""; + await act(async () => root.unmount()); // ── Second harness: brand-new host goes through AddRemoteHost ── @@ -537,11 +546,6 @@ await act(async () => secondRoot.unmount()); // ── Merged finish: source contract for overlapping workspaces ── const here = dirname(fileURLToPath(import.meta.url)); const wizardSource = readFileSync(resolve(here, "../components/RemoteConnectWizard.tsx"), "utf8"); -ok( - /const canonical = project\.merged \? project\.workspace : target;/.test(wizardSource) && - /OpenRemoteProjectTab\(hostId, canonical, \{ newSession: true \}\)/.test(wizardSource), - "a merged finish opens the tab on the canonical workspace", -); ok( /if \(!project\.merged\) \{[\s\S]*?RemoveRemoteProject\(hostId, target\)/.test(wizardSource), "rollback only removes a pin the wizard actually added (a merge owns none)", diff --git a/desktop/frontend/src/__tests__/remote-project-tree.test.tsx b/desktop/frontend/src/__tests__/remote-project-tree.test.tsx index 902af9c5e8..a2120fbf39 100644 --- a/desktop/frontend/src/__tests__/remote-project-tree.test.tsx +++ b/desktop/frontend/src/__tests__/remote-project-tree.test.tsx @@ -25,12 +25,10 @@ console.log("\nRemote project tree wiring"); const here = dirname(fileURLToPath(import.meta.url)); const source = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8"); const remoteSource = readFileSync(resolve(here, "../components/ProjectTreeRemoteGroups.tsx"), "utf8"); -const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); -const modeActionsSource = readFileSync(resolve(here, "../lib/useComposerModeActions.ts"), "utf8"); -const composerSource = readFileSync(resolve(here, "../components/Composer.tsx"), "utf8"); -const contentMenuSource = readFileSync(resolve(here, "../components/ComposerContentMenuActions.tsx"), "utf8"); -const remoteIntegrationSource = readFileSync(resolve(here, "../lib/useRemoteComposerIntegration.ts"), "utf8"); -const topicbarMenuSource = readFileSync(resolve(here, "../components/TopicbarSessionActions.tsx"), "utf8"); +const compositionSource = readFileSync(resolve(here, "../app-runtime/useAppSessionComposition.ts"), "utf8"); +const todoSource = readFileSync(resolve(here, "../app-runtime/useTodoPanelCommands.ts"), "utf8"); +const paletteSource = readFileSync(resolve(here, "../app-runtime/usePaletteCommands.tsx"), "utf8"); +const exportSource = readFileSync(resolve(here, "../app-runtime/useSessionExportCommands.ts"), "utf8"); const bridgeSource = readFileSync(resolve(here, "../lib/remoteProjectBridge.ts"), "utf8"); const remoteOpenSource = readFileSync(resolve(here, "../../../remote_projects.go"), "utf8"); const remotePendingSelectionSource = readFileSync(resolve(here, "../../../remote_tab_pending_selection.go"), "utf8"); @@ -75,9 +73,8 @@ ok( "remote groups swap out the local project menu", ); ok( - /publishNavigationIntent\("remote-project"\)[\s\S]*?app\.OpenRemoteProjectTab\(ref\.hostId, ref\.workspace,[\s\S]*?newSession: true/.test(remoteSource) && - /app\.ConnectRemoteHost\(ref\.hostId\)[\s\S]*?waitForRemoteConnection\(ref\.hostId\)[\s\S]*?publishNavigationIntent\("remote-workspace"\)[\s\S]*?app\.OpenRemoteWorkspace\(ref\.hostId, ref\.workspace\)/.test(remoteSource), - "remote navigation registers its intent before switching either surface", + /app\.ConnectRemoteHost\(ref\.hostId\)[\s\S]*?waitForRemoteConnection\(ref\.hostId\)[\s\S]*?publishNavigationIntent\("remote-workspace"\)[\s\S]*?app\.OpenRemoteWorkspace\(ref\.hostId, ref\.workspace\)/.test(remoteSource), + "separate remote window registers its intent before switching the external surface", ); ok( /app\.RemoveRemoteProject\(ref\.hostId, ref\.workspace\)/.test(remoteSource) && /void refresh\(\);/.test(remoteSource), @@ -105,42 +102,7 @@ ok( "an explicit session refresh preserves the last successful rows and cache when Serve fails", ); ok( - /useComposerModeActions\(\{[\s\S]*?remote: remoteSurfaceActive/.test(appSource) && - /if \(remote && activeTabId\)[\s\S]*?SetRemoteTabComposerProfile\(/.test(modeActionsSource), - "remote composer mode changes publish all axes through one remote transaction", -); -ok( - /tab\.id === tabId && tab\.remote[\s\S]*?SetRemoteTabGoal\(tabId, trimmed\)/.test(appSource) && - /onSend=\{remoteSurfaceActive \? remoteComposerSend : handleSend\}/.test(appSource), - "remote goal activation and goal-draft submission stay on the remote controller", -); -ok( - /remoteRuntimeCommand\(trimmed\)[\s\S]*?command\?\.method === "setModel"[\s\S]*?session\[command\.method\]\(command\.value\)[\s\S]*?await send/.test(remoteIntegrationSource) && - /\^\\\/\(model\|effort\)/.test(remoteIntegrationSource), - "remote model and effort slash commands bypass optimistic conversational submit", -); -ok( - /trimmed === "\/new"[\s\S]*?method: "newSession"/.test(remoteIntegrationSource) && - /trimmed === "\/clear"[\s\S]*?method: "clearSession"/.test(remoteIntegrationSource) && - /command\?\.method === "clearSession"[\s\S]*?requestClear\(\)/.test(remoteIntegrationSource) && - /command\?\.method === "newSession"[\s\S]*?openRemoteNewSession\(activeRemote, session\.retryHydration\)/.test(remoteIntegrationSource), - "remote clear and new commands bypass optimistic submit and use session rotation", -); -ok( - /verb === "compact"[\s\S]*?method: "compact"/.test(remoteIntegrationSource) && - /const management = new Set\(\[[\s\S]*?"context"[\s\S]*?"goal"[\s\S]*?"mcp"/.test(remoteIntegrationSource) && - /command\?\.method === "runManagementCommand"[\s\S]*?session\.runManagementCommand\(trimmed, command\.rehydrate\)/.test(remoteIntegrationSource) && - /command\?\.method === "compact"[\s\S]*?session\.compact\(command\.value\)/.test(remoteIntegrationSource) && - /verb === "goal" && remoteGoalCommandStartsTurn\(trimmed\)/.test(remoteIntegrationSource) && - /rehydrate: verb === "branch" \|\| verb === "switch" \|\| verb === "rewind"/.test(remoteIntegrationSource), - "remote non-turn management commands bypass optimistic conversational submit", -); -ok( - /if \(activeTab\?\.remote\) return openRemoteNewSession\(activeTab\.remote, remoteSession\.retryHydration\)/.test(appSource), - "global New Session routes the active remote tab through its Serve controller", -); -ok( - /item\.id !== "cmd-terminal" && item\.id !== "cmd-reload-runtime"/.test(appSource), + /item\.id !== "cmd-terminal" && item\.id !== "cmd-reload-runtime"/.test(paletteSource), "remote command palettes hide local-only terminal and runtime reload actions", ); ok( @@ -154,64 +116,16 @@ ok( "remote tab metadata updates refresh the affected session group", ); ok( - /attachmentInputEnabled=\{!remoteSurfaceActive\}/.test(appSource) && - /if \(!attachmentInputEnabled\) return;/.test(composerSource) && - /disabled=\{!attachmentInputEnabled\}/.test(composerSource) && - /attachmentInputEnabled \?/.test(contentMenuSource), - "remote composer disables local attachment input and native file paths", -); -ok( - /localDurableGuidance=\{!remoteSurfaceActive\}/.test(appSource) && - /if \(!localDurableGuidance && onSteer\)[\s\S]*?await onSteer\(guidanceSubmitText, submitTabId\)/.test(composerSource) && - /app\.SteerRemoteTab\(sourceTabId, text\.trim\(\)\)/.test(appSource), - "running remote guidance uses the Serve inbox instead of the local durable inbox", -); -ok( - /remoteSurfaceActive \? remoteSession\.transcript\.items : state\.items/.test(appSource) && - /sessionItemsToMarkdown\(sessionTitle, exportItems, exportLive\)/.test(appSource), + /remoteSurfaceActive \? remoteSession\.transcript\.items : state\.items/.test(compositionSource) && + /sessionItemsToMarkdown\(sessionTitle, Array\.from\(items\), live\)/.test(exportSource), "remote exports use the visible remote transcript", ); ok( - /const visibleRuntimeState = remoteSurfaceActive \? remoteSession\.transcript : state/.test(appSource) && - /tabId=\{remoteSurfaceActive \? undefined : activeTabId\}/.test(appSource) && - /onCancelJob=\{remoteSurfaceActive \? remoteSession\.cancelJob : cancelJob\}/.test(appSource) && - /backgroundRuntimes=\{remoteSurfaceActive \? \[\] : backgroundRuntimes\}/.test(appSource), - "remote status and context chrome never fall back to local session telemetry", -); -ok( - /turnPhase=\{visibleRuntimeState\.turnPhase\}/.test(appSource) && - /turnStartAt=\{visibleRuntimeState\.turnStartAt\}/.test(appSource) && - /liveStore=\{remoteSurfaceActive \? remoteSession\.liveStore : liveStore\}/.test(appSource) && - /goalRuntime=\{remoteSurfaceActive \? remoteSession\.goalRuntime : state\.meta\?\.goalRuntime\}/.test(appSource) && - /context=\{visibleRuntimeState\.context\}/.test(appSource), - "remote composer timing, tokens, live stream, and cost use the visible remote runtime", -); -ok( - /localWorkspaceDockBlocked = remoteSurfaceActive && \(rightDockMode === "files" \|\| rightDockMode === "changed"\)/.test(appSource) && - /surfaceWorkspacePanelRenderable = workspacePanelRenderable && !localWorkspaceDockBlocked/.test(appSource) && - /\{surfaceWorkspacePanelRenderable && \([\s\S]*?;/.test(bridgeSource), "the bridge exposes the explicit-intent ensure listing", diff --git a/desktop/frontend/src/__tests__/remote-session-surface.test.tsx b/desktop/frontend/src/__tests__/remote-session-surface.test.tsx index 747c212057..c9d96f3e40 100644 --- a/desktop/frontend/src/__tests__/remote-session-surface.test.tsx +++ b/desktop/frontend/src/__tests__/remote-session-surface.test.tsx @@ -1,6 +1,5 @@ -// Run: tsx src/__tests__/remote-session-surface.test.tsx - import React from "react"; +import { RemoteNavigationHarness } from "./helpers/RemoteNavigationHarness"; import { JSDOM } from "jsdom"; import { act } from "react"; @@ -260,7 +259,7 @@ async function flush(ticks = 4) { // in the shell). function RemoteSurfaceHarness({ tab }: { tab: TabMeta }) { const session = useRemoteSession(tab.id); - return ; + return ; } // ── Surface: shared Transcript renders reducer-driven items ── @@ -457,7 +456,7 @@ await act(async () => { await flush(); }); ok(tape.includes("open:gpu-box:~/app:"), "serve_down retry preserves the backend's parked session target"); - const reconnectNavigation = tape.findIndex((entry) => entry.startsWith("navigation:nav-remote-reconnect-")); ok(reconnectNavigation >= 0 && reconnectNavigation < tape.indexOf("open:gpu-box:~/app:"), "serve_down retry registers navigation before reopening the remote tab"); + const reconnectNavigation = tape.findIndex((entry) => entry.startsWith("navigation:nav-")); ok(reconnectNavigation >= 0 && reconnectNavigation < tape.indexOf("open:gpu-box:~/app:"), "serve_down retry registers navigation before reopening the remote tab"); failOpen = true; await act(async () => { warning?.querySelector("button")?.click(); diff --git a/desktop/frontend/src/__tests__/remote-tab-opened.test.tsx b/desktop/frontend/src/__tests__/remote-tab-opened.test.tsx index 78655f6e4a..5cd0b216fe 100644 --- a/desktop/frontend/src/__tests__/remote-tab-opened.test.tsx +++ b/desktop/frontend/src/__tests__/remote-tab-opened.test.tsx @@ -51,14 +51,9 @@ const remoteMeta: TabMeta = { }; function Harness() { - const activeTabIdRef = useRef("local-1"); useRemoteTabOpened( - activeTabIdRef, (meta) => seeded.push(meta.id), (meta) => updated.push(meta.id), - async (meta) => { - switched.push(meta.id); - }, ); return null; } @@ -67,11 +62,11 @@ const root = createRoot(document.getElementById("root")!); await act(async () => root.render()); await act(async () => __emitMockRemoteTabOpened(remoteMeta)); eq(seeded.join(","), "remote-1", "opened events seed the new remote tab metadata"); -eq(switched.join(","), "remote-1", "opened events activate through the dedicated remote switch"); +eq(switched.join(","), "", "opened notifications cannot acquire navigation ownership"); await act(async () => __emitMockRemoteTabUpdated({ ...remoteMeta, topicTitle: "Background title" })); eq(updated.join(","), "remote-1", "metadata updates patch the remote tab"); -eq(switched.join(","), "remote-1", "metadata updates never steal focus"); +eq(switched.join(","), "", "metadata updates never steal focus"); await act(async () => root.unmount()); diff --git a/desktop/frontend/src/__tests__/rewind-fork-routing.test.ts b/desktop/frontend/src/__tests__/rewind-fork-routing.test.ts index edd5b6cd96..d255743528 100644 --- a/desktop/frontend/src/__tests__/rewind-fork-routing.test.ts +++ b/desktop/frontend/src/__tests__/rewind-fork-routing.test.ts @@ -7,13 +7,13 @@ import { fileURLToPath } from "node:url"; import { dispatchPartialRewindNotice, partialRewindNotice, rewindFailureDetail, rewindOutcome } from "../lib/rewindCommit"; const testDir = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const undoSource = readFileSync(resolve(testDir, "../app-runtime/useSessionUndo.ts"), "utf8"); const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8"); -assert.match(appSource, /const targetTabId = outcome\.tabId \|\| sourceTabId/); -assert.match(appSource, /undoTabId: sourceTabId/); -assert.match(appSource, /const outcome = await rewindForTabDetailed\(sourceTabId, turn, "conversation"\)/); -assert.match(appSource, /sendToTab\(targetTabId, next, submit, original\)/); +assert.match(undoSource, /const targetTabId = outcome\.tabId \|\| sourceTabId/); +assert.match(undoSource, /undoTabId: sourceTabId/); +assert.match(undoSource, /const outcome = await ports\.rewindForTabDetailed\(sourceTabId, turn, "conversation"\)/); +assert.match(undoSource, /ports\.sendToTab\(targetTabId, next, submit, original\)/); assert.match(controllerSource, /settleRewindTarget\(result, tab => adoptReturnedTab\(tab, sourceTabId, forkNavigationSeq, "tab\.rewind"\)/); assert.match(controllerSource, /partialNotice = partialRewindNotice\(result\)/); assert.match(controllerSource, /dispatchPartialRewindNotice\(partialNotice, sourceTabId, outcome\.tabId,/); diff --git a/desktop/frontend/src/__tests__/runtime-job-owner.test.ts b/desktop/frontend/src/__tests__/runtime-job-owner.test.ts new file mode 100644 index 0000000000..a5e03b71a8 --- /dev/null +++ b/desktop/frontend/src/__tests__/runtime-job-owner.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { executeCancelRuntimeJob } from "../app-runtime/sessionRuntimeOwner"; + +const target = { tabId: "A", sessionKey: "A:1" }; +let owned = true; +const authority = { checkpoint() { if (!owned) throw new Error("stale"); }, ownsUI: () => owned }; +const calls: string[] = []; +const result = await executeCancelRuntimeJob(target, "job-1", { + cancelForTab: async (tabId, jobId) => { calls.push(`cancel:${tabId}:${jobId}`); return true; }, + refresh: async () => { calls.push("refresh"); }, +}, authority); +assert.equal(result, true); +assert.deepEqual(calls, ["cancel:A:job-1", "refresh"]); +calls.length = 0; +owned = false; +await assert.rejects(executeCancelRuntimeJob(target, "job-2", { + cancelForTab: async () => { calls.push("cancel"); return true; }, + refresh: async () => { calls.push("refresh"); }, +}, authority), /stale/); +assert.deepEqual(calls, [], "stale source cannot cancel a replacement session"); +console.log("runtime job owner: source/UI ownership passed"); diff --git a/desktop/frontend/src/__tests__/runtime-status-lifecycle.test.tsx b/desktop/frontend/src/__tests__/runtime-status-lifecycle.test.tsx new file mode 100644 index 0000000000..5bdf7d5053 --- /dev/null +++ b/desktop/frontend/src/__tests__/runtime-status-lifecycle.test.tsx @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { createPollingOwner, type PollClock } from "../app-runtime/pollingOwner"; +import { useRuntimeStatus } from "../app-runtime/useRuntimeStatus"; +import type { BackgroundRuntimeView, WorkspaceConflictView } from "../lib/types"; + +function deferred() { let resolve!: (value: T) => void; let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; } +let nextTimer = 0; +const timers = new Map void>(); +const clock: PollClock = { setTimeout: callback => { const id = ++nextTimer; timers.set(id, callback); return id; }, + clearTimeout: handle => { timers.delete(handle as number); } }; +const fire = () => { const queued = [...timers.values()]; timers.clear(); for (const callback of queued) callback(); }; +let gate = deferred(); let reads = 0; let operations = 0; +const results: number[] = []; const errors: unknown[] = []; +const owner = createPollingOwner({ target: { kind: "application" }, periodMs: 1000, clock, + read: () => { reads++; return gate.promise; }, publish: value => results.push(value), failed: error => errors.push(error), +}, delta => { operations += delta; }); +const first = owner.refresh(); +assert.equal(owner.refresh(), first, "manual refresh and timer share one in-flight request"); +fire(); assert.equal(reads, 1); assert.equal(operations, 1); +gate.resolve(1); await first; +assert.deepEqual(results, [1]); assert.equal(operations, 0); assert.equal(timers.size, 1); +gate = deferred(); fire(); assert.equal(reads, 2); +const second = owner.refresh(); gate.reject("fixture failure"); await second; +assert.deepEqual(errors, ["fixture failure"]); assert.equal(operations, 0); +const staleTimer = [...timers.values()][0]; +owner.dispose(); owner.dispose(); staleTimer(); await owner.refresh(); +assert.equal(reads, 2); assert.equal(timers.size, 0); + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const background = deferred(); +const requests: { tab: string; value: ReturnType> }[] = []; +let backgroundReads = 0; +Object.assign(window, { go: { main: { App: { + BackgroundRuntimes: () => { backgroundReads++; return background.promise; }, + WorkspaceConflictForTab: (tab: string) => { const value = deferred(); requests.push({ tab, value }); return value.promise; }, +} } } }); +const root = createRoot(document.getElementById("root")!); +let current!: ReturnType; +function Probe({ tab, generation = 1, running = true }: { tab: string; generation?: number; running?: boolean }) { + current = useRuntimeStatus({ tabId: tab, sessionKey: `${tab}:${generation}`, running }, clock); + return
{current.workspaceConflict?.ownerTitle ?? "clear"}
; +} +const paint = (tab: string, generation = 1, running = true) => act(async () => root.render()); +const conflict = (ownerTitle: string) => ({ state: "local", ownerTitle } as WorkspaceConflictView); +try { + await paint("A"); + const refresh = current.refreshBackgroundRuntimes; + const manual = refresh(); fire(); + assert.equal(backgroundReads, 1); + await paint("B"); await paint("A", 2); + assert.deepEqual(requests.map(request => request.tab), ["A", "B", "A"]); + await act(async () => { + requests[0].value.resolve(conflict("old A")); requests[1].value.resolve(conflict("B")); + requests[2].value.resolve(conflict("new A")); background.resolve([]); await manual; + }); + assert.equal(document.body.textContent, "new A", "only the current resource generation publishes a conflict"); + await paint("A", 2, false); + assert.equal(document.body.textContent, "clear"); + await act(async () => current.setWorkspaceConflict(conflict("fixture decision"))); + assert.equal(document.body.textContent, "fixture decision", "explicit decision fixtures remain reachable without a running turn"); + await act(async () => current.setWorkspaceConflict(null)); + const queuedTimers = [...timers.values()]; + await act(async () => root.unmount()); + assert.equal(timers.size, 0); + queuedTimers.forEach(callback => callback()); await refresh(); + assert.equal(backgroundReads, 1, "queued timer and retained manual refresh are inert after unmount"); + assert.equal(requests.length, 3); + console.log("runtime polling: single flight, deterministic timers, terminal counts, source replacement and synchronous disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/send-failed.test.ts b/desktop/frontend/src/__tests__/send-failed.test.ts index 77334e0f33..66cded9f86 100644 --- a/desktop/frontend/src/__tests__/send-failed.test.ts +++ b/desktop/frontend/src/__tests__/send-failed.test.ts @@ -5,11 +5,9 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { acceptsRuntimeEventEpoch, historyMessagesToItems, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController"; import { continueDelivery } from "../lib/deliveryContinue"; -import { - activateGoalAndSubmit, - activateGoalAndSubmitOnTab, -} from "../lib/goalSubmit"; import type { WireEvent } from "../lib/types"; +import { submitPlanDecision, type SessionActionPorts } from "../app-runtime/sessionActionOwner"; +import { createSessionSurfaceFence } from "../app-runtime/sessionTarget"; let passed = 0; let failed = 0; @@ -26,90 +24,11 @@ function eq(a: unknown, b: unknown, label: string) { console.log("\nsend failure feedback"); -{ - const calls: string[] = []; - await activateGoalAndSubmit({ - displayText: "List the existing notes", - submitText: "/ui-ux-pro-max List the existing notes", - structured: { - display: "/ui-ux-pro-max List the existing notes", - input: "List the existing notes", - invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], - }, - applyGoal: async (goal) => { - calls.push(`goal:${goal}`); - }, - send: async (display, submit, structured) => { - calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`); - }, - }); - eq(calls.join("|"), "goal:List the existing notes|send:List the existing notes:/ui-ux-pro-max List the existing notes:ui-ux-pro-max", "initial Goal activates before structured Skill submission"); -} - -{ - // Bridge failure must abort structured Skill submit: there is no `/goal` fallback. - const calls: string[] = []; - let threw = false; - try { - await activateGoalAndSubmit({ - displayText: "Ship the feature", - submitText: "/ui-ux-pro-max Ship the feature", - structured: { - display: "/ui-ux-pro-max Ship the feature", - input: "Ship the feature", - invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], - }, - applyGoal: async (goal) => { - calls.push(`goal:${goal}`); - throw new Error("SetGoalForTab: tab closed"); - }, - send: async (display, submit, structured) => { - calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`); - }, - }); - } catch (error) { - threw = error instanceof Error && error.message === "SetGoalForTab: tab closed"; - } - eq(threw, true, "Goal activation bridge failure propagates"); - eq(calls.join("|"), "goal:Ship the feature", "failed Goal activation does not submit the structured Skill"); -} - -{ - // Tab-scoped helper captures source tab and workbench target once; callbacks - // receive both even if a surrounding "active tab" concept changes mid-flight. - const calls: string[] = []; - let releaseSubmit!: () => void; - const submitGate = new Promise((resolve) => { - releaseSubmit = resolve; - }); - let activeTab = "tab-a"; - const pending = activateGoalAndSubmitOnTab({ - tabId: "tab-a", - displayText: "Cross-tab safe goal", - submitText: "/ui-ux-pro-max Cross-tab safe goal", - structured: { - display: "/ui-ux-pro-max Cross-tab safe goal", - input: "Cross-tab safe goal", - invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }], - }, - sendToTab: async (tabId, goal, display, submit, structured) => { - await submitGate; - calls.push( - `send:${tabId}:${goal}:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}:active=${activeTab}`, - ); - }, - }); - activeTab = "tab-b"; - calls.push("switched-to-tab-b"); - releaseSubmit(); - await pending; - eq( - calls.join("|"), - "switched-to-tab-b|send:tab-a:Cross-tab safe goal:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:active=tab-b", - "activateGoalAndSubmitOnTab keeps Goal and Skill on the captured source tab", - ); -} - +// The initial Goal + structured Skill scenarios formerly exercised the +// goalSubmit.ts shim. That wrapper is deleted; the same contracts are covered on +// the real chain by session-submission-lifecycle.test.tsx (atomic payload and +// activation ordering at the submission owner) and goal-activation-tab-routing +// .test.tsx (source-tab capture and fail-closed propagation at the controller). eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "starting", epoch: "e1" } }), false, "starting runtime cannot submit"); eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "lease_blocked", epoch: "e1" } }), false, "lease-blocked runtime cannot submit"); eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit"); @@ -352,25 +271,48 @@ eq( const here = dirname(fileURLToPath(import.meta.url)); const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); +const sessionCompositionSource = readFileSync(resolve(here, "../app-runtime/useAppSessionComposition.ts"), "utf8"); const typesSource = readFileSync(resolve(here, "../lib/types.ts"), "utf8"); const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8"); eq(typesSource.includes('"mcp_surface_ready"'), true, "TypeScript EventKind declares mcp_surface_ready"); eq(controllerSource.includes('e.kind === "mcp_surface_ready"'), true, "reducer handles mcp_surface_ready before optimistic confirmation"); -eq( - /if \(allow\) \{\s*await applyCollaborationMode\("normal"\);\s*resolvePlanDecision\(state\.approval!\.id, "start_execution"\);/.test(appSource), - true, - "plan approval clears the remembered plan restore intent and records start execution explicitly", -); -eq( - /onExitPlan=\{async \(\) => \{\s*await applyCollaborationMode\("normal"\);\s*resolvePlanDecision\(state\.approval!\.id, "exit_plan"\);\s*\}\}/.test(appSource), - true, - "exit-without-executing switches to Normal before recording the explicit plan exit", -); -eq( - /onRevisePlan=\{\(text\) => \{[\s\S]{0,260}resolvePlanDecision\(state\.approval!\.id, "revise_plan"\);/.test(appSource), - true, - "plan revision records a distinct revise decision", -); +{ + const calls: string[] = []; + const ports: SessionActionPorts = { + approveForTab: () => undefined, + resolvePlanForTab: (tabId, id, action) => calls.push(`resolve:${tabId}:${id}:${action}`), + resolveRecoveryForTab: () => undefined, + answerQuestionForTab: async () => undefined, + answerMCPForTab: () => undefined, + setCollaborationModeForTab: async (tabId, mode) => { calls.push(`mode:${tabId}:${mode}`); }, + clearGoalForTab: async (tabId) => { calls.push(`goal-clear:${tabId}`); }, + setRemoteComposerProfile: async () => [], + patchComposerProfile: (tabId, mode) => calls.push(`profile:${tabId}:${mode}`), + notePlanMode: (tabId, enabled) => calls.push(`plan:${tabId}:${enabled}`), + drainRemoteApprovals: () => undefined, + }; + const target = { tabId: "tab-source", sessionKey: "session-source:1", promptId: "approval-7" }; + await submitPlanDecision(target, { + action: "start_execution", leavePlanMode: true, remote: false, goal: "", toolApprovalMode: "ask", + }, ports, { checkpoint() {}, ownsUI: () => true }); + eq( + calls.join("|"), + "mode:tab-source:normal|plan:tab-source:false|profile:tab-source:normal|resolve:tab-source:approval-7:start_execution", + "plan approval clears source plan mode before recording start execution", + ); + + calls.length = 0; + await submitPlanDecision(target, { + action: "exit_plan", leavePlanMode: true, remote: false, goal: "", toolApprovalMode: "ask", + }, ports, { checkpoint() {}, ownsUI: () => true }); + eq(calls[calls.length - 1], "resolve:tab-source:approval-7:exit_plan", "exit-without-executing records the explicit source-bound plan exit last"); + + calls.length = 0; + await submitPlanDecision(target, { + action: "revise_plan", leavePlanMode: false, remote: false, goal: "", toolApprovalMode: "ask", + }, ports, { checkpoint() {}, ownsUI: () => true }); + eq(calls.join("|"), "resolve:tab-source:approval-7:revise_plan", "plan revision records only the source-bound revise decision"); +} eq( !/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource), true, @@ -387,34 +329,13 @@ eq( "execution-mode switch state is gone from the app shell", ); eq( - appSource.includes("!state.backendActivationPending &&") && appSource.includes("!runtimeTransitioning"), + sessionCompositionSource.includes("!state.backendActivationPending &&") && sessionCompositionSource.includes("!runtimeTransitioning"), true, "composer submit stays behind the controller-ready gate", ); -eq( - appSource.includes("activateGoalAndSubmitOnTab({") && - appSource.includes("tabId: sourceTabId") && - appSource.includes("goal: nextGoal") && - appSource.includes("collaborationMode: controllerComposerProfileCollaborationMode(composerProfile)") && - appSource.includes("toolApprovalMode,"), - true, - "initial Goal activation captures the submission tab", -); -eq( - appSource.includes("setControllerGoalForTab(tabId, trimmed)") && appSource.includes("clearControllerGoalForTab(tabId)"), - true, - "tab-scoped Goal activation updates the matching controller", -); -eq( - /await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource), - true, - "local Goal profile is patched only after backend activation succeeds", -); -eq( - /displayGoal && !\["status", "clear", "off", "stop", "done", "pause", "resume"\]\.includes/.test(appSource) && /else if \(\["clear", "off", "stop", "done"\]\.includes/.test(appSource), - true, - "Goal pause and resume do not clear the active Goal before lifecycle handling", -); +// session-submission-lifecycle.test.tsx mounts the production submission owner +// and adapter: explicit targets, failure-before-patch, pause/resume, and exact +// structured/unstructured first-Goal bytes replace the old App source locations. eq( controllerSource.includes("await app.SetGoalForTab(tabId, goal)") && !/SetGoalForTab\(tabId, goal\)\.catch\(\(\) => \{\}\)/.test(controllerSource), true, @@ -425,17 +346,8 @@ eq( true, "ClearGoalForTab failures also propagate to callers", ); -eq( - /await continueDelivery\(\{[\s\S]{0,240}goal: state\.meta\?\.goal,[\s\S]{0,240}resumeGoal: resumeControllerGoalForTab,/.test(appSource), - true, - "delivery recovery routes through continueDelivery with the backend Goal state", -); -eq( - controllerSource.includes("app.SubmitInitialGoalToTabWithID(") && - appSource.includes("patchActivatedGoalForTab(sourceTabId, trimmed)"), - true, - "the first Goal turn uses the atomic target-scoped backend contract", -); +// goal-activation-tab-routing.test.tsx retains real Controller/bridge coverage +// for the atomic target-scoped first Goal contract. const unsent = reducer(sent, { type: "unsend" }); eq(unsent.pendingUser, undefined, "unsend clears the pending marker"); @@ -510,6 +422,28 @@ const noGoal = await runContinueDelivery({ goal: undefined }); eq(noGoal.resumes.length, 0, "delivery recovery without a Goal skips the resume call"); eq(noGoal.sends.join(","), "tab-a", "delivery recovery without a Goal submits the continuation directly"); +{ + const fence = createSessionSurfaceFence(); + const ownership = fence.commit("tab-a", "session-a:1")!; + let releaseResume!: () => void; + const resumeGate = new Promise((resolve) => { releaseResume = resolve; }); + const sends: string[] = []; + const pending = continueDelivery({ + tabId: "tab-a", + ready: true, + goal: "ship", + uiOwnership: ownership, + ownsUI: fence.ownsUnknown, + resumeGoal: async () => { await resumeGate; return true; }, + send: async (tabId) => { sends.push(tabId); }, + }); + fence.commit("tab-b", "session-b:1"); + fence.commit("tab-a", "session-a:1"); + releaseResume(); + await pending; + eq(sends.length, 0, "delivery recovery cannot reacquire UI ownership after A → B → A"); +} + const blankGoal = await runContinueDelivery({ goal: " " }); eq(blankGoal.resumes.length, 0, "delivery recovery treats a blank Goal as absent"); eq(blankGoal.sends.join(","), "tab-a", "delivery recovery with a blank Goal still submits the continuation"); diff --git a/desktop/frontend/src/__tests__/session-clear-commands.test.tsx b/desktop/frontend/src/__tests__/session-clear-commands.test.tsx new file mode 100644 index 0000000000..bd8d658fed --- /dev/null +++ b/desktop/frontend/src/__tests__/session-clear-commands.test.tsx @@ -0,0 +1,106 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useSessionClearCommands, type SessionClearCommandsInput } from "../app-runtime/useSessionClearCommands"; +import type { Translator } from "../lib/i18n"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); + +const t = ((key: string) => key) as Translator; +const authority = { checkpoint() {}, ownsUI: () => true }; + +const operationCalls: { target: unknown; channel: string; input: unknown }[] = []; +const portCalls: string[] = []; +const notices: { text: string; level?: string }[] = []; +let dockRefreshes = 0; +let clearError: Error | null = null; + +const operations: SessionClearCommandsInput["operations"] = async (target, channel, input, execute) => { + operationCalls.push({ target, channel, input }); + try { + const value = await execute(input, authority); + return { status: "completed", value }; + } catch (error) { + return { status: "failed", error }; + } +}; + +let states!: ReturnType; +function Probe({ activeTabId, remote = false }: { activeTabId?: string; remote?: boolean }) { + states = useSessionClearCommands({ + activeTabId, + activeSessionIdentity: "A:1", + remote, + t, + notice: (text, level) => { notices.push({ text, level }); }, + operations, + refreshDock: () => { dockRefreshes += 1; }, + ports: { + clearSession: async () => { + portCalls.push("local"); + if (clearError) throw clearError; + }, + clearRemoteSession: async (tabId) => { portCalls.push(`remote:${tabId}`); }, + retryRemoteHydration: async () => { portCalls.push("hydrate"); }, + }, + }); + return null; +} +const paint = (props?: { activeTabId?: string; remote?: boolean }) => + act(async () => root.render()); + +try { + await paint(); + assert.equal(states.clearContextPending, false, "clear confirmation starts closed"); + await act(async () => { states.setClearContextPending(true); }); + assert.equal(states.clearContextPending, true, "requesting clear opens the confirmation"); + await act(async () => { states.cancelClearContext(); }); + assert.equal(states.clearContextPending, false, "cancel closes the confirmation"); + + await act(async () => { states.setClearContextPending(true); }); + await act(async () => { await states.confirmClearContext(); }); + assert.equal(states.clearContextPending, false, "confirm closes the confirmation before executing"); + assert.deepEqual(operationCalls, [{ target: { tabId: "A", sessionKey: "A:1" }, channel: "clear-context", input: { remote: false } }], + "confirm captures the committed tab and session identity at click time"); + assert.deepEqual(portCalls, ["local"], "local confirm clears through the controller port"); + assert.equal(dockRefreshes, 1, "a completed clear refreshes the dock"); + assert.deepEqual(notices, [{ text: "clearContext.done", level: undefined }], "a completed clear notices success"); + + operationCalls.length = 0; + portCalls.length = 0; + notices.length = 0; + dockRefreshes = 0; + await paint({ remote: true }); + await act(async () => { await states.confirmClearContext(); }); + assert.deepEqual(operationCalls[0]?.input, { remote: true }, "remote surfaces route the remote flag into the operation"); + assert.deepEqual(portCalls, ["remote:A", "hydrate"], "remote confirm clears the remote tab and retries hydration"); + assert.equal(dockRefreshes, 1, "remote completion still refreshes the dock"); + + operationCalls.length = 0; + portCalls.length = 0; + notices.length = 0; + dockRefreshes = 0; + await paint(); + clearError = new Error("boom"); + await act(async () => { await states.confirmClearContext(); }); + assert.deepEqual(notices, [{ text: "boom", level: "warn" }], "a failed clear surfaces the error as a warning"); + assert.equal(dockRefreshes, 0, "a failed clear does not refresh the dock"); + + notices.length = 0; + clearError = new Error(""); + await act(async () => { await states.confirmClearContext(); }); + assert.deepEqual(notices, [{ text: "clearContext.failed", level: "warn" }], "an empty error falls back to the localized failure notice"); + clearError = null; + + operationCalls.length = 0; + await paint({ activeTabId: undefined }); + await act(async () => { states.setClearContextPending(true); }); + await act(async () => { await states.confirmClearContext(); }); + assert.equal(operationCalls.length, 0, "confirm without an active tab runs no operation"); + assert.equal(states.clearContextPending, true, "confirm without a target leaves the confirmation untouched"); + + console.log("session clear commands: pending lifecycle, local/remote chains, failure notices and empty-target gate passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/session-clear-owner.test.ts b/desktop/frontend/src/__tests__/session-clear-owner.test.ts new file mode 100644 index 0000000000..8475dbfbbe --- /dev/null +++ b/desktop/frontend/src/__tests__/session-clear-owner.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { executeClearSession } from "../app-runtime/sessionRuntimeOwner"; + +const target = { tabId: "A", sessionKey: "A:1" }; +let owned = true; +const authority = { checkpoint() { if (!owned) throw new Error("stale"); }, ownsUI: () => owned }; +const calls: string[] = []; +const ports = { + clearSession: async () => { calls.push("local"); }, + clearRemoteSession: async (tabId: string) => { calls.push(`remote:${tabId}`); }, + retryRemoteHydration: async () => { calls.push("hydrate"); }, +}; + +await executeClearSession(target, { remote: false }, ports, authority); +assert.deepEqual(calls, ["local"]); +calls.length = 0; +await executeClearSession(target, { remote: true }, ports, authority); +assert.deepEqual(calls, ["remote:A", "hydrate"]); +calls.length = 0; +owned = false; +await assert.rejects(executeClearSession(target, { remote: false }, ports, authority), /stale/); +assert.deepEqual(calls, [], "stale source cannot clear a replacement session"); +console.log("session clear owner: source/UI ownership and local/remote paths passed"); diff --git a/desktop/frontend/src/__tests__/session-control-commands.test.ts b/desktop/frontend/src/__tests__/session-control-commands.test.ts new file mode 100644 index 0000000000..436e92edb7 --- /dev/null +++ b/desktop/frontend/src/__tests__/session-control-commands.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useSessionOperations, type SessionResource } from "../app-runtime/useSessionOperations"; +import { useSessionControlCommands } from "../app-runtime/useSessionControlCommands"; +import { sessionIdentityKey } from "../app-runtime/sessionTarget"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const resource = (tabId: string, generation = 1): SessionResource => ({ tabId, + sessionKey: sessionIdentityKey({ tabId, sessionPath: `/${tabId}`, sessionGeneration: generation }) }); +const a = resource("A"), b = resource("B"); +const calls: string[] = [], errors: string[] = []; +let finish: ((value: boolean) => void) | undefined; +let started: (() => void) | undefined; +let delayed = false; +let commands!: ReturnType; +function Probe({ visible = a, resources = [a, b] }: { visible?: SessionResource; resources?: SessionResource[] }) { + const operations = useSessionOperations({ visible, resources }); + commands = useSessionControlCommands({ activeTabId: visible.tabId, resources, operations, + showToast: message => errors.push(message), clearWorkspaceConflict() {}, ports: { + cancel: async () => ({ discardedItemIds: [] }), cancelForTab: async () => ({ discardedItemIds: [] }), + acceptDelivery: async () => {}, disconnectRemote: async () => {}, + cancelJobForTab: async (tab, job) => { + calls.push(`${tab}:${job}`); + if (!delayed) return true; + const result = new Promise(resolve => { finish = resolve; }); + started?.(); + return result; + }, refreshBackgroundRuntimes: async () => { calls.push("refresh"); }, + } }); + return null; +} +const root = createRoot(document.getElementById("root")!); +const paint = (resources = [a, b], visible = a) => act(async () => root.render(React.createElement(Probe, { resources, visible }))); +try { + await paint(); + const retained = commands.cancelRuntimeJob; + assert.equal(await retained("B", "background"), true); + assert.deepEqual(calls, ["B:background"], "background cancellation reaches its source port without taking active UI ownership"); + assert.equal(await retained("A", "active"), true); + assert.deepEqual(calls.slice(1), ["A:active", "refresh"]); + assert.equal(await retained("missing", "gone"), false); + assert.equal(calls.length, 3, "removed resources never reach the bridge"); + + delayed = true; + let entered = new Promise(resolve => { started = resolve; }); + const stale = retained("B", "old-generation"); + await entered; + await paint([a, resource("B", 2)]); + finish!(true); + assert.equal(await stale, false, "replacement invalidates an in-flight cancellation result"); + assert.equal(calls[calls.length - 1], "B:old-generation", "stale results cannot refresh replacement UI"); + + entered = new Promise(resolve => { started = resolve; }); + const switched = retained("B", "current-generation"); + await entered; + await paint([a, resource("B", 2)], resource("B", 2)); + finish!(true); + assert.equal(await switched, true, "switching visible tabs does not cancel a live source operation"); + assert.deepEqual(errors, []); + await act(async () => root.unmount()); + const count = calls.length; + await retained("B", "unmounted"); + assert.equal(calls.length, count, "retained commands are inert after unmount"); + console.log("session control commands: canonical background target, active target, replacement, navigation and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/session-prompt-lifecycle.test.tsx b/desktop/frontend/src/__tests__/session-prompt-lifecycle.test.tsx new file mode 100644 index 0000000000..d4a94b4fb7 --- /dev/null +++ b/desktop/frontend/src/__tests__/session-prompt-lifecycle.test.tsx @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useSessionOperations } from "../app-runtime/useSessionOperations"; +import { useSessionPromptCommands } from "../app-runtime/useSessionPromptCommands"; +import type { PromptPorts } from "../app-runtime/sessionPromptExecutor"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +function deferred() { let resolve!: () => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +let entered = deferred(); +let gate = deferred(); +let prompt = "approval-A"; +const calls: string[] = []; +const ports: PromptPorts = { + isPromptCurrentForTab: (tab, _kind, id) => tab === "A" && id === prompt, + approveForTab: tab => { calls.push(`approve:${tab}`); }, + resolvePlanForTab: (tab, id) => { calls.push(`resolve:${tab}:${id}`); }, + resolveRecoveryForTab: tab => { calls.push(`recover:${tab}`); }, + answerQuestionForTab: async tab => { calls.push(`question:${tab}`); }, + answerMCPForTab: tab => { calls.push(`mcp:${tab}`); }, + setCollaborationModeForTab: async tab => { calls.push(`mode:${tab}`); }, + clearGoalForTab: async tab => { calls.push(`clear:${tab}`); entered.resolve(); await gate.promise; }, + setRemoteComposerProfile: async tab => { calls.push(`remote:${tab}`); entered.resolve(); await gate.promise; return [prompt]; }, + patchComposerProfile: tab => { calls.push(`patch:${tab}`); }, + notePlanMode: tab => { calls.push(`remember:${tab}`); }, + drainRemoteApprovals: tab => { calls.push(`drain:${tab}`); }, + rememberRevision: tab => { calls.push(`revision:${tab}`); }, +}; +let commands!: ReturnType; +function Probe({ tab, generation = "1", remote = false }: { tab: string; generation?: string; remote?: boolean }) { + const target = { tabId: tab, sessionKey: tab + generation }; + const operations = useSessionOperations({ visible: target, resources: ["A", "B"].map(tabId => ({ tabId, sessionKey: tabId + generation })) }); + commands = useSessionPromptCommands({ target, approval: { id: prompt, tool: "exit_plan_mode" }, questionId: prompt, + remote, goal: "fixture", toolApprovalMode: "ask", ports, operations, reportError: error => { throw error; } }); + return null; +} +async function paint(tab: string, generation = "1", remote = false) { + await act(async () => root.render()); +} +function reset() { calls.length = 0; entered = deferred(); gate = deferred(); prompt = "approval-A"; } +try { + await paint("A"); + let pending = commands.handleApprovalAnswer(true, false, false); + await entered.promise; + await paint("B"); + gate.resolve(); await pending; + assert.deepEqual(calls, ["clear:A", "mode:A", "remember:A", "patch:A", "resolve:A:approval-A"]); + + reset(); await paint("A"); + pending = commands.handleExitPlan(); await entered.promise; + prompt = "replacement"; + gate.resolve(); await pending; + assert.deepEqual(calls, ["clear:A"], "replacement prompt revokes the entire continuation, including mode changes"); + + reset(); await paint("A"); + pending = commands.handleExitPlan(); await entered.promise; + await paint("A", "2"); gate.resolve(); await pending; + assert.deepEqual(calls, ["clear:A"], "same tab with a different session cannot resolve an old approval"); + + reset(); await paint("A", "1", true); + pending = commands.handleExitPlan(); await entered.promise; + await paint("B", "1", true); await paint("A", "1", true); + gate.resolve(); await pending; + assert.deepEqual(calls, ["remote:A", "remember:A", "patch:A", "resolve:A:approval-A"], "ABA never drains the new surface's approvals"); + + reset(); await paint("A"); + pending = commands.handleExitPlan(); await entered.promise; + await commands.handleApprovalAnswer(false, false, false); + gate.resolve(); await pending; + assert.deepEqual(calls, ["clear:A", "resolve:A:approval-A"], "new decision supersedes the older mode/approval chain"); + + reset(); await paint("A"); + pending = commands.handleExitPlan(); await entered.promise; + await act(async () => root.unmount()); + gate.resolve(); await pending; + commands.handleRecoveryAnswer("stop"); + assert.deepEqual(calls, ["clear:A"], "unmount revokes the stable entry and every in-flight continuation"); + console.log("session prompts: source, prompt identity, replacement, ABA, supersession and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/session-submission-lifecycle.test.tsx b/desktop/frontend/src/__tests__/session-submission-lifecycle.test.tsx new file mode 100644 index 0000000000..15e1f82d28 --- /dev/null +++ b/desktop/frontend/src/__tests__/session-submission-lifecycle.test.tsx @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useSessionSubmission } from "../lib/useSessionSubmission"; +import { useSessionOperations } from "../app-runtime/useSessionOperations"; +import type { SubmissionPorts, SubmissionResource } from "../app-runtime/sessionSubmissionOwner"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +const { createSubmissionPorts } = await import("../app-runtime/desktopSubmissionAdapter"); +function deferred() { + let resolve!: () => void; let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} +let goalGate: ReturnType | undefined, profileGate: ReturnType | undefined; +const calls: unknown[][] = []; +const ports: SubmissionPorts = { + send: async (...args) => { calls.push(["send", ...args]); }, + clearUndo: tab => { calls.push(["undo", tab]); }, + setGoal: async (...args) => { calls.push(["goal", ...args]); await goalGate?.promise; }, + patchGoal: (...args) => { calls.push(["patch", ...args]); }, + profile: async tab => { calls.push(["profile", tab]); await profileGate?.promise; return true; }, +}; +let commands!: ReturnType; +function Probe({ tab, gen, draft, readOnly }: { tab: string; gen: number; draft: boolean; readOnly: boolean }) { + const resources: SubmissionResource[] = ["A", "B"].map(tabId => ({ target: { tabId, sessionKey: tabId + gen }, + ready: true, remote: false, unavailable: readOnly ? "read-only" : "", goalDraft: draft, collaboration: "normal", approval: "ask" })); + const target = resources.find(source => source.target.tabId === tab)!.target; + const operations = useSessionOperations({ visible: target, resources: resources.map(source => source.target) }); + commands = useSessionSubmission({ target, resources, operations, ports, missingSource: "missing" }); + return null; +} +const paint = (tab = "A", gen = 1, draft = false, readOnly = false) => act(async () => root.render()); +try { + const adapterCalls: unknown[][] = []; + const adapter = createSubmissionPorts({ + send: async (...args) => { adapterCalls.push(["send", ...args]); }, + setGoal: async (...args) => { adapterCalls.push(["set", ...args]); }, + clearGoal: async (...args) => { adapterCalls.push(["clear", ...args]); }, + clearUndo: () => {}, patchGoal: () => {}, profile: async () => true, + }); + await adapter.setGoal("B", "goal", false); await adapter.setGoal("A", "", false); + await adapter.send("B", "display", " raw bytes ", undefined, { goal: "goal", collaborationMode: "normal", toolApprovalMode: "ask" }); + assert.deepEqual(adapterCalls, [["set", "B", "goal"], ["clear", "A"], ["send", "B", "display", " raw bytes ", undefined, undefined, + { goal: "goal", collaborationMode: "normal", toolApprovalMode: "ask" }]], "runtime adapter preserves explicit Controller targets, original-text slot and atomic Goal payload"); + await paint(); profileGate = deferred(); + const first = commands.submit("A", " display ", " provider bytes "); + await paint("B"); profileGate.resolve(); await first; + assert.deepEqual(calls, [["profile", "A"], ["undo", "A"], ["send", "A", "display", "provider bytes", undefined, undefined]], "ordinary continuation uses the source resource and preserves established trim semantics"); + + calls.length = 0; profileGate = deferred(); await paint(); + const replaced = commands.submit("A", "stale"); + await paint("A", 2); profileGate.resolve(); await replaced; + assert.deepEqual(calls, [["profile", "A"]], "replacement receives no stale undo invalidation or submit"); + + calls.length = 0; profileGate = undefined; goalGate = deferred(); await paint(); + const goal = commands.submit("A", "/goal source goal"); + await paint("A", 2); goalGate.resolve(); await goal; + assert.deepEqual(calls, [["goal", "A", "source goal", false]], "old Goal completion cannot patch or submit to a replacement"); + + calls.length = 0; goalGate = deferred(); await paint(); + const rejected = commands.applyGoal("bad goal"); + goalGate.reject(Error("activation failed")); await assert.rejects(rejected, /activation failed/); + assert.deepEqual(calls, [["goal", "A", "bad goal", false]], "failed activation leaves UI profile and undo untouched"); + + calls.length = 0; goalGate = undefined; await paint("A", 1, true); + const structured = { display: "skill", input: "/skill input", invocations: [{ name: "skill", kind: "skill" as const, offset: 0 }] }; + await commands.submit("A", " goal text ", " /skill input ", structured); + assert.deepEqual(calls, [["undo", "A"], ["send", "A", "goal text", "/skill input", structured, + { goal: "goal text", collaborationMode: "normal", toolApprovalMode: "ask" }], ["patch", "A", "goal text"]], "structured Goal uses one atomic source send and unchanged invocation bytes"); + calls.length = 0; + await commands.submit("A", " goal text ", " task bytes "); + assert.equal(calls[1][3], "/goal task bytes", "ordinary first Goal retains its existing prefix"); + + calls.length = 0; await paint(); + await commands.submit("A", "/goal pause"); await commands.submit("A", "/goal resume"); + assert.deepEqual(calls.filter(call => call[0] === "goal" || call[0] === "patch"), [], "pause and resume preserve Goal before backend command handling"); + assert.deepEqual(calls.filter(call => call[0] === "send").map(call => call[3]), ["/goal pause", "/goal resume"]); + calls.length = 0; await commands.applyGoalForTab("B", " target goal "); await commands.applyGoalForTab("B", ""); + assert.deepEqual(calls, [["goal", "B", "target goal", false], ["patch", "B", "target goal"], ["goal", "B", "", false], ["patch", "B", ""]], "activation and clear patch only their explicit source after backend success"); + + calls.length = 0; await paint(); + await commands.submit("A", "/goal --deep --research preserve flags"); + assert.deepEqual(calls, [["patch", "A", "preserve flags"], ["undo", "A"], + ["send", "A", "/goal --deep --research preserve flags", "/goal --deep --research preserve flags", undefined, undefined]], "legacy Goal flags remain backend-visible and do not invoke separate activation"); + + calls.length = 0; await paint("A", 1, false, true); + await assert.rejects(commands.commitThenSend("A", "direct"), /read-only/); + assert.deepEqual(calls, [], "read-only source preserves undo and sends nothing"); + await paint(); profileGate = deferred(); + const disposed = commands.submit("A", "disposed"); + await act(async () => root.unmount()); profileGate.resolve(); await disposed; + commands.commitThenSend("A", "after-unmount"); + assert.deepEqual(calls, [["profile", "A"]], "unmount revokes both continuation and committed direct entry"); + console.log("session submission lifecycle: source continuations, Goal atomicity/bytes, replacement, failure, read-only and disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/session-undo-lifecycle.test.tsx b/desktop/frontend/src/__tests__/session-undo-lifecycle.test.tsx new file mode 100644 index 0000000000..c14386ae4d --- /dev/null +++ b/desktop/frontend/src/__tests__/session-undo-lifecycle.test.tsx @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useSessionUndo, type RewindResultView } from "../app-runtime/useSessionUndo"; +import type { Item } from "../lib/useController"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +function deferred() { + let resolve!: (value: RewindResultView) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} +function user(text: string, checkpointTurn: number): Item { + return { kind: "user", id: `u:${text}`, text, submitText: text, checkpointTurn } as Item; +} +const calls: string[] = []; +const outcomes = new Map }>(); +let states!: ReturnType; +function Probe({ readOnly = false, hydrating = false }: { readOnly?: boolean; hydrating?: boolean }) { + const items: Item[] = hydrating ? [] : [user("one", 1), user("two", 2)]; + states = useSessionUndo({ + activeTabId: "A", activeTabReadOnly: readOnly, items, + hydratePlaceholderActive: hydrating, controllerReady: true, running: false, + messageActionOpen: false, approvalOpen: false, askOpen: false, clearContextPending: false, + ports: { + rewindForTab: async () => { calls.push("rewind"); return true; }, + rewindForTabDetailed: async (tabId, turn, scope) => { + calls.push(`detailed:${tabId}:${turn}:${scope}`); + const entry = outcomes.get(`${turn}:${scope}`); + if (entry?.gate) return entry.gate.promise; + return entry?.outcome ?? { ok: true }; + }, + refreshTabMetas: () => { calls.push("refresh-metas"); }, + undoRewindForTab: async () => { calls.push("undo"); return true; }, + sendToTab: async () => { calls.push("send"); }, + composeInsert: (_tabId, text) => { calls.push(`insert:${text}`); }, + refreshDock: () => { calls.push("dock"); }, + refreshProject: () => { calls.push("project"); }, + }, + }); + return null; +} +const paint = (options?: { readOnly?: boolean; hydrating?: boolean }) => act(async () => root.render()); +try { + await paint(); + await act(async () => { await states.handleMessageAction(0, "code"); }); + assert.equal(states.rewindState?.turnDiff, 0, "code-only rewind stores a zero-turn undo banner"); + assert.equal(states.rewindState?.transactionId, undefined, "empty backend result leaves no transaction id"); + assert.ok(calls.includes("dock") && calls.includes("project"), "code rewind refreshes files and project after success"); + assert.ok(!calls.some((call) => call.startsWith("insert:")), "code rewind never fills the composer"); + + outcomes.set("0:code", { outcome: { ok: true, transactionId: "tx-9", undoAvailable: true, written: ["a.txt"], deleted: [] } }); + calls.length = 0; + await act(async () => { await states.handleMessageAction(0, "code"); }); + assert.equal(states.rewindState?.transactionId, "tx-9", "code-only rewind retains the committed transaction id for real undo"); + assert.equal(states.rewindState?.undoAvailable, true, "undo stays available when the backend reports it"); + + calls.length = 0; + await act(async () => { states.setRewindStateForTab("A", null); }); + assert.equal(states.rewindState, null, "setRewindStateForTab clears the source banner"); + + await act(async () => { await states.handleMessageAction(5, "both"); }); + assert.ok(calls.includes("rewind"), "a turn with no matching user boundary falls back to the controller rewind"); + assert.ok(calls.includes("dock") && calls.includes("project"), "fallback refresh still runs for scope both"); + + outcomes.set("1:both", { gate: deferred() }); + calls.length = 0; + const full = states.handleMessageAction(1, "both"); + await act(async () => {}); + await act(async () => { + outcomes.get("1:both")!.gate!.resolve({ ok: true, transactionId: "tx-2", undoAvailable: true, written: [], deleted: [] }); + await full; + }); + assert.equal(states.rewindState?.transactionId, "tx-2", "full rewind records the committed transaction id"); + assert.ok(calls.includes("insert:one"), "successful full rewind fills the composer with the original prompt"); + assert.ok(!calls.includes("refresh-metas"), "full rewind does not trigger a tab-list refresh"); + assert.equal(states.rewindCommitting, false, "committing flag clears after success"); + + outcomes.set("2:both", { gate: deferred() }); + calls.length = 0; + const failed = states.handleMessageAction(2, "both"); + await act(async () => {}); + await act(async () => { + outcomes.get("2:both")!.gate!.resolve({ ok: false }); + await failed; + }); + assert.equal(states.rewindState?.transactionId, "tx-2", "failed rewind leaves the previous banner untouched"); + assert.ok(!calls.some((call) => call.startsWith("insert:")), "failed rewind inserts nothing"); + assert.equal(states.rewindCommitting, false, "committing flag clears after failure"); + + calls.length = 0; + await act(async () => { states.setRewindStateForTab("A", { turnDiff: 1, transactionId: "pending-tx", undoAvailable: true }); }); + await act(async () => { await states.handleEditPrompt(0, " edited ", " submit "); }); + assert.deepEqual(calls, [], "edit prompt is blocked while an undo banner owns the source tab"); + + await act(async () => { states.setRewindStateForTab("A", null); }); + calls.length = 0; + outcomes.set("0:conversation", { outcome: { ok: true, tabId: "A", transactionId: "edit-tx", undoAvailable: true } }); + await act(async () => { await states.handleEditPrompt(0, " edited ", " submit "); }); + assert.ok(calls.includes("detailed:A:0:conversation"), "allowed edit rewinds through the detailed backend"); + assert.ok(calls.includes("send"), "allowed edit resends the edited prompt after the conversation rewind"); + + console.log("session undo lifecycle: code transaction retention, banners, failed rewinds and edit gates passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/startup-settings-contract.test.ts b/desktop/frontend/src/__tests__/startup-settings-contract.test.ts index 752608d0df..c8d7b5c8a0 100644 --- a/desktop/frontend/src/__tests__/startup-settings-contract.test.ts +++ b/desktop/frontend/src/__tests__/startup-settings-contract.test.ts @@ -24,7 +24,7 @@ function ok(cond: boolean, label: string) { } const here = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); +const paletteSource = readFileSync(resolve(here, "../app-runtime/usePaletteCommands.tsx"), "utf8"); const bridgeSource = readFileSync(resolve(here, "../lib/bridge.ts"), "utf8"); const configWarningsSource = readFileSync(resolve(here, "../lib/useConfigLoadWarnings.ts"), "utf8"); const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8"); @@ -42,40 +42,14 @@ ok( bridgeSource.includes("DesktopStartupSettings()"), "bridge exposes a lightweight desktop startup settings call", ); -ok( - appSource.includes("app.DesktopStartupSettings()"), - "App loads startup chrome preferences through the lightweight settings call", -); -ok( - configWarningsSource.includes('EventsOn("config:load-warnings"') && - appSource.includes("useConfigLoadWarnings()") && - appSource.includes("settings.configWarningsRevision"), - "runtime config warnings update the persistent desktop banner", -); ok( configWarningsSource.includes("revision < latestRevision.current") && configWarningsSource.includes("seenKeys.current.has(key)"), "startup and reload barriers reject stale events while repeated session builds stay deduplicated", ); -ok( - appSource.includes('hydrateReasoningDisplayMode("auto", false);'), - "startup failure preserves legacy reasoning-display migration precedence", -); ok( bridgeSource.includes('displayMode: "standard", sessionExperience: "standard", reasoningDisplayMode: "auto", reasoningDisplayModeExplicit: false'), - "browser startup defaults match the classic standard/live-follow experience", -); -ok( - !/const\s+reloadSidebarImConnections[\s\S]*?app\.Settings\(\)[\s\S]*?\}, \[t\]\);/.test(appSource), - "sidebar IM refresh avoids rebuilding the full Settings payload", -); -ok( - !/const\s+syncDesktopPreferences[\s\S]*?app\.Settings\(\)[\s\S]*?\};/.test(appSource), - "startup preference sync avoids rebuilding the full Settings payload", -); -ok( - /onChooseProvider=\{\(\) => \{[\s\S]*?setSettingsFocus\(\{ target: "model-access" \}\);[\s\S]*?setSettingsTarget\("models"\);/.test(appSource), - "onboarding opens the model access flow instead of model usage", + "browser startup defaults include the canonical standard session experience", ); ok( /initialFocus\?\.target === "model-access"[\s\S]*?initialFocus\?\.target === "model-stats"[\s\S]*?"usage"/.test(settingsSource), @@ -86,7 +60,7 @@ ok( "each fresh model focus object can re-target the same subtab again", ); ok( - /setSettingsFocus\(\(current\) => \(\{[\s\S]*?target: "model-stats",[\s\S]*?requestId: \(current\?\.requestId \?\? 0\) \+ 1,[\s\S]*?\}\)\)/.test(appSource) && + /setSettingsFocus\(\(current\) => \(\{[\s\S]*?target: "model-stats",[\s\S]*?requestId: \(current\?\.requestId \?\? 0\) \+ 1,[\s\S]*?\}\)\)/.test(paletteSource) && /initialFocus\?\.requestId/.test(settingsSource), "usage statistics commands derive a monotonic request id from the shared focus state", ); @@ -150,15 +124,13 @@ ok( ), "GLM reasoning protocol is localized in every supported locale", ); -ok( - settingsSource.includes(" - ["settings.sessionExperience", "settings.sessionExperienceHint", "settings.sessionExperience.standard", "settings.sessionExperience.deep"] - .every((key) => source.includes(`"${key}"`))), + source.includes('"settings.sessionExperience"') && + source.includes('"settings.sessionExperienceHint"') && + source.includes('"settings.sessionExperience.standard"') && + source.includes('"settings.sessionExperience.deep"'), + ), "session experience labels are localized in every supported locale", ); ok( diff --git a/desktop/frontend/src/__tests__/subscription-scope.test.ts b/desktop/frontend/src/__tests__/subscription-scope.test.ts new file mode 100644 index 0000000000..547cb4add7 --- /dev/null +++ b/desktop/frontend/src/__tests__/subscription-scope.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import { createSubscriptionScope } from "../lib/subscriptionScope"; + +const effects: string[] = []; +const queued: Array<() => void> = []; +let subscriptions = 0; +const scope = createSubscriptionScope((delta) => { subscriptions += delta; }); +scope.listen((listener: () => void) => { + queued.push(listener); + return () => { queued[1](); throw new Error("first unsubscribe failed"); }; +}, () => { effects.push("old-first"); }); +scope.listen((listener: () => void) => { + queued.push(listener); + return () => { effects.push("second-released"); }; +}, () => { effects.push("old-second"); }); +assert.equal(subscriptions, 2); +assert.throws(() => scope.dispose(), /first unsubscribe failed/); +assert.equal(scope.size, 0); +assert.equal(subscriptions, 0, "all subscriptions release even if one source cleanup fails"); +assert.deepEqual(effects, ["second-released"], "cleanup revokes all listeners before touching the source"); +const replacement = createSubscriptionScope(); +replacement.listen((listener: () => void) => { queued.push(listener); return () => {}; }, () => { effects.push("new"); }); +for (const listener of queued) listener(); +assert.deepEqual(effects, ["second-released", "new"], "queued old notifications cannot acquire replacement rights"); +scope.dispose(); +assert.equal(subscriptions, 0, "repeated disposal cannot decrement accounting twice"); +replacement.dispose(); +const synchronous = createSubscriptionScope((delta) => { subscriptions += delta; }); +synchronous.listen((listener: () => void) => { listener(); return () => {}; }, () => synchronous.dispose()); +assert.equal(synchronous.size, 0); +assert.equal(subscriptions, 0, "disposal during synchronous registration cannot leak a lease"); +console.log("PASS subscription scopes fence queued notifications and release all resources"); diff --git a/desktop/frontend/src/__tests__/terminal-events.test.ts b/desktop/frontend/src/__tests__/terminal-events.test.ts index 2392ef4627..d16775298d 100644 --- a/desktop/frontend/src/__tests__/terminal-events.test.ts +++ b/desktop/frontend/src/__tests__/terminal-events.test.ts @@ -99,6 +99,25 @@ try { const removedExitSubscription = registerTerminalSink("removed-exit", (bytes) => removedExit.push(...bytes)); removedExitSubscription.dispose(); check(removedExit.length === 0, "removed terminal exit discards terminal history"); + + __resetTerminalEventBus(); + const releaseApp = startTerminalEventBridge(); + const releaseView = startTerminalEventBridge(); + const received: number[] = []; + const shared = registerTerminalSink("shared", (bytes) => received.push(...bytes)); + releaseApp(); + __emitMockTerminalOutput({ id: "shared", data: base64(new Uint8Array([1])) }); + check(received.length === 1, "one owner release cannot stop a still-mounted terminal view"); + releaseView(); + __emitMockTerminalOutput({ id: "shared", data: base64(new Uint8Array([2])) }); + check(received.length === 1, "the final owner release detaches terminal event delivery"); + const releaseReplacement = startTerminalEventBridge(); + releaseApp(); + releaseView(); + __emitMockTerminalOutput({ id: "shared", data: base64(new Uint8Array([3])) }); + check(JSON.stringify(received) === JSON.stringify([1, 3]), "old cleanups cannot release a replacement bridge"); + releaseReplacement(); + shared.dispose(); } finally { __resetTerminalEventBus(); if (previousWindow) globalThis.window = previousWindow; diff --git a/desktop/frontend/src/__tests__/terminal-output-owner.test.ts b/desktop/frontend/src/__tests__/terminal-output-owner.test.ts new file mode 100644 index 0000000000..ce0cd7c2ea --- /dev/null +++ b/desktop/frontend/src/__tests__/terminal-output-owner.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { executeTerminalOutputInsertion } from "../app-runtime/sessionRuntimeOwner"; + +const target = { tabId: "A", sessionKey: "A:1" }; +let owned = true; +const authority = { checkpoint() { if (!owned) throw new Error("stale"); }, ownsUI: () => owned }; +const calls: string[] = []; +const inserted = await executeTerminalOutputInsertion(target, "term-1", { + read: async (tabId, sessionId) => { calls.push(`read:${tabId}:${sessionId}`); return "output"; }, + apply: (text) => calls.push(`apply:${text}`), +}, (value) => value.toUpperCase(), authority); +assert.equal(inserted, true); +assert.deepEqual(calls, ["read:A:term-1", "apply:OUTPUT"]); +calls.length = 0; +owned = false; +await assert.rejects(executeTerminalOutputInsertion(target, "term-2", { + read: async () => "stale-output", + apply: () => calls.push("apply"), +}, value => value, authority), /stale/); +assert.deepEqual(calls, [], "stale source cannot insert terminal output"); +console.log("terminal output owner: source/UI ownership passed"); diff --git a/desktop/frontend/src/__tests__/terminal-panel-commands.test.tsx b/desktop/frontend/src/__tests__/terminal-panel-commands.test.tsx new file mode 100644 index 0000000000..7a84e460eb --- /dev/null +++ b/desktop/frontend/src/__tests__/terminal-panel-commands.test.tsx @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useTerminalPanelCommands } from "../app-runtime/useTerminalPanelCommands"; +import { useLayoutStore } from "../store/layout"; +import { useAppNavigationStore } from "../store/appNavigation"; +import { useTerminalStore } from "../store/terminal"; +import { AppBottomRegions } from "../app-shell/AppBottomRegions"; +import { TopicbarSessionActions } from "../components/TopicbarSessionActions"; +import { LocaleProvider, useT } from "../lib/i18n"; +import { ToastProvider } from "../lib/toast"; + +const dom = new JSDOM("
", { url: "http://localhost" }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, + KeyboardEvent: dom.window.KeyboardEvent, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +const originalCreate = useTerminalStore.getState().createSession; +const calls: string[] = []; +useTerminalStore.setState({ createSession: async (tab, path) => { calls.push(`${tab}:${path}`); return null; } }); +let commands!: ReturnType; +const noop = () => {}; +function Probe({ remote }: { remote: boolean }) { + const page = useAppNavigationStore(state => state.page); + commands = useTerminalPanelCommands({ tabId: "A", enabled: !remote, shortcutsEnabled: page.kind === "workspace" }); + const t = useT(); + return <> + ""} exportSession={noop} + toggleTerminal={commands.toggleTerminalPanel} terminalEnabled={!remote} terminalOpen={false} openSessionSummary={noop} tasksOpen={false} /> + {remote && } + ; +} +const paint = (remote: boolean) => act(async () => root.render()); +const key = (shiftKey = false) => document.dispatchEvent(new KeyboardEvent("keydown", { key: "`", ctrlKey: true, shiftKey, bubbles: true })); +try { + useLayoutStore.getState().setTerminalPanelOpen(false); + await paint(true); + const terminalButton = document.querySelector(".lucide-terminal")?.closest("button") + ?? [...document.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Terminal"); + assert.ok(terminalButton); + assert.equal(terminalButton.disabled, true); + assert.equal(document.querySelector(".terminal-drawer")?.childElementCount, 0, "remote surface cannot mount a warm local TerminalPanel"); + await act(async () => { key(); key(true); commands.openTerminalForPath("remote-path"); }); + assert.equal(useLayoutStore.getState().terminalPanelOpen, false); + assert.deepEqual(calls, [], "remote shortcut and direct commands share one local-tool capability gate"); + await paint(false); + await act(async () => useAppNavigationStore.getState().openPage({ kind: "automation" })); + await act(async () => key()); + assert.equal(useAppNavigationStore.getState().page.kind, "automation"); + assert.equal(useLayoutStore.getState().terminalPanelOpen, false, "management pages suppress workspace shortcuts without changing stored geometry"); + await act(async () => useAppNavigationStore.getState().returnToWorkspace()); + await act(async () => key()); + assert.equal(useLayoutStore.getState().terminalPanelOpen, true); + await act(async () => key(true)); + assert.deepEqual(calls, ["A:."]); + await act(async () => commands.closeTerminalPanel()); + assert.equal(useLayoutStore.getState().terminalPanelOpen, false); + await act(async () => root.unmount()); + commands.openTerminalForPath("stale"); key(true); + assert.deepEqual(calls, ["A:."], "unmount revokes commands and removes shortcut listeners"); + console.log("terminal commands: remote capability, warm mount exclusion, native key routing and disposal passed"); +} finally { useTerminalStore.setState({ createSession: originalCreate }); dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/theme-pack.test.ts b/desktop/frontend/src/__tests__/theme-pack.test.ts index 1c6327a3b5..541bfbaa9d 100644 --- a/desktop/frontend/src/__tests__/theme-pack.test.ts +++ b/desktop/frontend/src/__tests__/theme-pack.test.ts @@ -42,7 +42,9 @@ import { const testDir = dirname(fileURLToPath(import.meta.url)); const packSource = readFileSync(resolve(testDir, "../lib/themePack.ts"), "utf8"); const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8"); -const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const appViewSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const exportOwnerSource = readFileSync(resolve(testDir, "../app-runtime/useSessionExportCommands.ts"), "utf8"); +const composerRouterSource = readFileSync(resolve(testDir, "../app-runtime/useComposerRouter.ts"), "utf8"); const librarySource = readFileSync(resolve(testDir, "../components/ThemeLibrary.tsx"), "utf8"); const gallerySource = readFileSync(resolve(testDir, "../components/ThemeGallery.tsx"), "utf8"); const previewSurfaceSource = readFileSync(resolve(testDir, "../components/ThemePreviewSurface.tsx"), "utf8"); @@ -526,10 +528,9 @@ ok( "theme pack CSS does not apply backdrop-filter", ); ok(themeBgSlice.includes(".theme-bg__overlay"), "overlay wash element styled"); -ok(appSource.includes("applyThemeScene"), "App wires scene from session content"); -ok(appSource.includes("ThemeBackground"), "App mounts background layer"); -ok(appSource.includes("applyConfiguredBaseAppearance"), "App applies configured appearance without replacing an active pack"); -ok(appSource.includes("ResetThemePack") || appSource.includes("theme reset") || appSource.includes('arg === "reset"'), "reset entry exists"); +ok(exportOwnerSource.includes("applyThemeScene"), "session export owner wires scene from session content"); +ok(appViewSource.includes("ThemeBackground"), "App mounts background layer"); +ok(composerRouterSource.includes("ResetThemePack") || composerRouterSource.includes("theme reset") || composerRouterSource.includes('arg === "reset"'), "reset entry exists"); console.log("\nofficial themes (kind/grouping/i18n)"); diff --git a/desktop/frontend/src/__tests__/topic-summary-commands.test.tsx b/desktop/frontend/src/__tests__/topic-summary-commands.test.tsx new file mode 100644 index 0000000000..fad644d2d0 --- /dev/null +++ b/desktop/frontend/src/__tests__/topic-summary-commands.test.tsx @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useTopicSummary } from "../app-runtime/useTopicSummary"; +import type { TabMeta } from "../lib/types"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + +function tab(topicId: string, scope = "project", workspaceRoot = "/repo"): TabMeta { + return { id: `tab-${topicId}`, scope, workspaceRoot, topicId } as TabMeta; +} + +const requests: { scope: string; workspaceRoot: string; topicId: string }[] = []; +const gates = new Map>>(); +let failWith: Error | null = null; +window.go = { + main: { + App: { + GetTopicSummary: (request: { scope: string; workspaceRoot: string; topicId: string }) => { + requests.push(request); + if (failWith) return Promise.reject(failWith); + const gate = gates.get(request.topicId); + return gate ? gate.promise : Promise.resolve({ turns: request.topicId.length }); + }, + }, + }, +} as unknown as typeof window.go; + +let states!: ReturnType; +function Probe({ target, revision = 0 }: { target?: TabMeta; revision?: number }) { + states = useTopicSummary({ activeTab: target, revision }); + return null; +} +const paint = (props?: { target?: TabMeta; revision?: number }) => act(async () => root.render()); + +try { + await paint(); + assert.equal(states.activeTopicTurns, undefined, "no active tab yields no turns"); + assert.equal(requests.length, 0, "no active tab issues no summary request"); + + await paint({ target: tab("alpha") }); + assert.equal(states.activeTopicTurns, 5, "a topic target resolves its turn count"); + assert.deepEqual(requests, [{ scope: "project", workspaceRoot: "/repo", topicId: "alpha" }], + "project topics fetch with their workspace root"); + + await paint({ target: tab("alpha"), revision: 1 }); + assert.equal(requests.length, 2, "a project revision refetches the same topic identity"); + assert.equal(states.activeTopicTurns, 5, "refetch keeps the resolved turns"); + + gates.set("beta", deferred()); + gates.set("gamma", deferred()); + await paint({ target: tab("beta"), revision: 2 }); + await paint({ target: tab("gamma"), revision: 3 }); + await act(async () => { gates.get("beta")!.resolve({ turns: 99 }); }); + assert.equal(states.activeTopicTurns, 5, "a superseded topic identity cannot overwrite the committed turns"); + await act(async () => { gates.get("gamma")!.resolve({ turns: 7 }); }); + assert.equal(states.activeTopicTurns, 7, "the newest topic identity owns the committed turns"); + assert.deepEqual(requests.map((r) => r.topicId), ["alpha", "alpha", "beta", "gamma"], "every identity change fetches exactly once"); + + failWith = new Error("summary offline"); + await paint({ target: tab("delta"), revision: 4 }); + assert.equal(states.activeTopicTurns, undefined, "a failed fetch clears the turns"); + failWith = null; + + await paint({ target: tab("global-1", "global") }); + assert.deepEqual(requests.at(-1), { scope: "global", workspaceRoot: "", topicId: "global-1" }, + "global topics fetch without a workspace root"); + + await act(async () => root.unmount()); + console.log("topic summary commands: identity memo, single-flight fetch, revision refetch and failure clearing passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/topicbar-actions-lifecycle.test.tsx b/desktop/frontend/src/__tests__/topicbar-actions-lifecycle.test.tsx new file mode 100644 index 0000000000..a1995a7c93 --- /dev/null +++ b/desktop/frontend/src/__tests__/topicbar-actions-lifecycle.test.tsx @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import React, { act, type ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { TopicbarActionsRegion } from "../app-shell/TopicbarActionsRegion"; +import { LocaleProvider } from "../lib/i18n"; +import { ToastProvider } from "../lib/toast"; + +const dom = new JSDOM("
", { url: "http://localhost", pretendToBeVisual: true }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, + Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +const warnings: unknown[][] = []; +const originalError = console.error; +console.error = (...args) => { warnings.push(args); }; +const opened: string[] = []; +const bridge = { + async ExternalOpenersForTab() { return { openers: [{ id: "editor", name: "Editor", kind: "editor" as const }], preferred: "editor", workspaceOpenable: true }; }, + async SetPreferredExternalOpener() {}, + async OpenWorkspaceInExternalOpenerForTab(tabId: string) { opened.push(tabId); }, +}; +const noop = () => {}; +const session: ComponentProps["session"] = { + sessionHasContent: true, getSessionMarkdown: () => "fixture", exportSession: noop, + toggleTerminal: noop, terminalOpen: false, openSessionSummary: noop, tasksOpen: false, +}; +try { + let baseline = 0; + for (let index = 0; index < 128; index++) { + const tabId = index % 2 ? "B" : "A"; + await act(async () => root.render( + + )); + assert.equal(document.querySelectorAll(".external-opener").length, 1, "one live external opener after every resource replacement"); + const count = document.querySelectorAll("*").length; + if (!index) baseline = count; + assert.equal(count, baseline, "reconciliation never leaves attached orphan controls"); + } + await act(async () => document.querySelector(".external-opener__primary")!.click()); + assert.deepEqual(opened, ["B"], "the surviving control routes only to the final session"); + assert.equal(warnings.length, 0, "resource replacement emits no duplicate-key or reconciliation warnings"); + await act(async () => root.unmount()); + assert.equal(document.querySelectorAll(".external-opener").length, 0); + console.log("topicbar lifecycle: 128 replacements retain one action group and no orphan DOM"); +} finally { console.error = originalError; dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/topicbar-controls.test.ts b/desktop/frontend/src/__tests__/topicbar-controls.test.ts index 53f2b34f49..de615cfd37 100644 --- a/desktop/frontend/src/__tests__/topicbar-controls.test.ts +++ b/desktop/frontend/src/__tests__/topicbar-controls.test.ts @@ -7,24 +7,17 @@ import { fileURLToPath } from "node:url"; const testDir = dirname(fileURLToPath(import.meta.url)); const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const dockToggleSource = readFileSync(resolve(testDir, "../app-shell/DockToggleButton.tsx"), "utf8"); const sessionActionsSource = readFileSync(resolve(testDir, "../components/TopicbarSessionActions.tsx"), "utf8"); assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/); const taskSummaryControlIndex = sessionActionsSource.indexOf('t("summary.session")'); -const workspaceToggleIndex = appSource.indexOf(''); +const workspaceToggleIndex = dockToggleSource.indexOf(''); assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control"); assert.ok(workspaceToggleIndex >= 0, "topic bar keeps the right-edge workspace toggle"); -assert.match( - appSource, - /const localWorkspaceDockBlocked = remoteSurfaceActive && \(rightDockMode === "files" \|\| rightDockMode === "changed"\);/, - "remote sessions block local Files and Changes surfaces", -); -assert.match( - appSource, - /const surfaceWorkspacePanelRenderable = workspacePanelRenderable && !localWorkspaceDockBlocked;/, - "the topic bar projects the workspace toggle through the active surface boundary", -); +// Remote/local surface policy is exercised by conversation-projection.test.ts +// against the production projection and mounted WorkspaceDockRegion. assert.ok(!sessionActionsSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label"); -process.stdout.write("topicbar controls: 4 contracts passed\n"); +process.stdout.write("topicbar static presentation contracts passed\n"); diff --git a/desktop/frontend/src/__tests__/topicbar-region.test.tsx b/desktop/frontend/src/__tests__/topicbar-region.test.tsx new file mode 100644 index 0000000000..dbaed99acb --- /dev/null +++ b/desktop/frontend/src/__tests__/topicbar-region.test.tsx @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import type { TopicbarView } from "../app-shell/TopicbarRegion"; + +const dom = new JSDOM("
", { pretendToBeVisual: true }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, + KeyboardEvent: dom.window.KeyboardEvent, IS_REACT_ACT_ENVIRONMENT: true }); +const { default: React, act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { TopicbarRegion } = await import("../app-shell/TopicbarRegion"); +const { LocaleProvider } = await import("../lib/i18n"); +const root = createRoot(document.getElementById("root")!); +const calls: string[] = []; +const commands = { + openAutomation: () => { calls.push("automation"); }, toggleSidebar: () => { calls.push("sidebar"); }, + setTitleDraft: (value: string) => { calls.push(`draft:${value}`); }, commitRename: () => { calls.push("commit"); }, + cancelRename: () => { calls.push("cancel"); }, startRename: () => { calls.push("rename"); }, + openWorktree: (id: string) => { calls.push(`worktree:${id}`); }, +}; +const view: TopicbarView = { + automationReturn: true, automationReturnLabel: "Back to automation", chromeHidden: true, + sidebar: { title: "Sidebar", blocked: false, pressed: false, collapsed: true }, + title: { text: "A", hover: "Full A", renameLabel: "Rename", editing: false, draft: "Draft A", editSize: 12, canRename: true, workspaceLabel: "Project" }, + subtitle: { visible: true, title: "Workspace", worktreeTabId: "A", mergeLabel: "Merge", mergeTooltip: "Merge back", sourcePlatform: "feishu", sourceLabel: "Channel" }, +}; +const paint = (next = view) => act(async () => root.render( +
+
)); +const click = (selector: string) => act(async () => document.querySelector(selector)!.click()); +try { + await paint(); + assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className), + ["btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"], "region extraction adds no DOM wrapper"); + const action = document.querySelector(".topicbar__actions button"); + assert.equal(document.querySelectorAll(".topicbar__subtitle .worktree-badge").length, 1); + assert.ok(document.querySelector(".worktree-badge")!.getAttribute("aria-label"), "isolated worktree identity is accessible"); + await click(".topicbar__title-button"); + await click(".topicbar__worktree-btn"); + await click(".topicbar__chrome-btn"); + await click(".btn"); + assert.deepEqual(calls, ["rename", "worktree:A", "sidebar", "automation"]); + assert.equal(document.activeElement, document.querySelector(".btn"), "return control establishes focus synchronously before navigating"); + await paint({ ...view, sidebar: { ...view.sidebar, blocked: true }, subtitle: { ...view.subtitle, worktreeTabId: "B" } }); + await click(".topicbar__chrome-btn"); + await click(".topicbar__worktree-btn"); + assert.equal(calls.filter(value => value === "sidebar").length, 1); + assert.equal(calls.at(-1), "worktree:B", "synchronous command receives the rendered source identity"); + await paint({ ...view, title: { ...view.title, editing: true } }); + const input = document.querySelector("input")!; + assert.equal(input.value, "Draft A"); assert.equal(input.size, 12); + assert.equal(input.selectionStart, 0); assert.equal(input.selectionEnd, input.value.length); + await act(async () => { + Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, "Renamed A"); + input.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + }); + assert.equal(calls.at(-1), "draft:Renamed A", "DOM event is converted synchronously to a draft value"); + const enter = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + const escape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + await act(async () => { input.dispatchEvent(enter); input.dispatchEvent(escape); }); + assert.equal(enter.defaultPrevented, true); assert.equal(escape.defaultPrevented, true); + assert.deepEqual(calls.slice(-2), ["commit", "cancel"]); + await act(async () => input.blur()); + assert.equal(calls.at(-1), "commit", "blur retains the existing rename commit contract"); + assert.equal(action, document.querySelector(".topicbar__actions button"), "title editing preserves action subtree identity"); + await paint({ ...view, subtitle: { ...view.subtitle, worktreeTabId: undefined } }); + assert.equal(document.querySelector(".worktree-badge"), null, "ordinary topics do not claim isolated worktree identity"); + assert.equal(document.querySelector(".topicbar__worktree-btn"), null, "ordinary topics expose no worktree merge action"); + await act(async () => root.unmount()); + console.log("topicbar region: DOM structure, source commands, focus, rename keys and action identity passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/turn-verification-commands.test.tsx b/desktop/frontend/src/__tests__/turn-verification-commands.test.tsx new file mode 100644 index 0000000000..74eb1a2c13 --- /dev/null +++ b/desktop/frontend/src/__tests__/turn-verification-commands.test.tsx @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useTurnVerificationCommands } from "../app-runtime/useTurnVerificationCommands"; +import type { WireCompletionSummary } from "../lib/types"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); + +function summary(mutations: number): WireCompletionSummary { + return { + preset: "balanced", + verdict: "partial", + mutations, + checks_passed: 2, + checks_failed: 1, + checks_suppressed: 0, + review: "passed", + gap_kinds: [], + constraint_degraded: false, + }; +} + +const dockCalls: string[] = []; +let states!: ReturnType; +function Probe(props: { activeTabId?: string; turnStartAt?: number; completionSummary?: WireCompletionSummary }) { + states = useTurnVerificationCommands({ + activeTabId: props.activeTabId, + turnStartAt: props.turnStartAt ?? 0, + completionSummary: props.completionSummary, + openChangedDock: () => { dockCalls.push("changed"); }, + }); + return null; +} +const paint = (props?: { activeTabId?: string; turnStartAt?: number; completionSummary?: WireCompletionSummary }) => + act(async () => root.render()); + +try { + await paint(); + assert.equal(states.verificationRevealRequest, null, "no reveal request exists before the first open"); + + const historical = summary(7); + await act(async () => { states.openTurnVerification(historical); }); + assert.deepEqual(dockCalls, ["changed"], "opening verification reveals the changed-files dock"); + assert.deepEqual(states.verificationRevealRequest, { + id: 1, summary: historical, tabId: "A", turnStartAt: 100, currentSummary: summary(1), + }, "the reveal request binds the clicked summary to the tab and turn that published it"); + + const second = summary(9); + await act(async () => { states.openTurnVerification(second); }); + assert.equal(states.verificationRevealRequest?.id, 2, "reveal request ids increase monotonically"); + assert.equal(states.verificationRevealRequest?.summary, second, "the newest open replaces the pending request"); + assert.equal(dockCalls.length, 2, "every open re-reveals the dock"); + + await paint({ turnStartAt: 200 }); + assert.equal(states.verificationRevealRequest, null, "a new turn clears the historical reveal"); + + await act(async () => { states.openTurnVerification(summary(3)); }); + assert.equal(states.verificationRevealRequest?.id, 3, "the reveal sequence survives resets"); + await paint({ activeTabId: "B" }); + assert.equal(states.verificationRevealRequest, null, "switching tabs clears the historical reveal"); + + await act(async () => { states.openTurnVerification(summary(4)); }); + await paint({ completionSummary: summary(2) }); + assert.equal(states.verificationRevealRequest, null, "a new completion summary clears the historical reveal"); + + await paint({ activeTabId: undefined }); + await act(async () => { states.openTurnVerification(summary(5)); }); + assert.equal(states.verificationRevealRequest?.tabId, "", "opening without an active tab records an empty tab binding"); + + console.log("turn verification commands: dock reveal, sequenced requests and reset lifecycle passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/windows-maximised-sync.test.tsx b/desktop/frontend/src/__tests__/windows-maximised-sync.test.tsx new file mode 100644 index 0000000000..8c0cde92c0 --- /dev/null +++ b/desktop/frontend/src/__tests__/windows-maximised-sync.test.tsx @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { syncMainWindowMaximised, useWindowsMaximisedSync } from "../app-runtime/useNativeWindowController"; +import { useWindowChromeStore } from "../store/windowChrome"; + +const dom = new JSDOM("
", { pretendToBeVisual: true }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +globalThis.Event = dom.window.Event; +const root = createRoot(document.getElementById("root")!); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((yes) => { resolve = yes; }); + return { promise, resolve }; +} + +const bridgeCalls: string[] = []; +let maximisedValue = false; +let maximisedGate: ReturnType> | null = null; +window.go = { + main: { + App: { + IsMainWindowMaximised: async () => { + bridgeCalls.push("query"); + if (maximisedGate) return maximisedGate.promise; + return maximisedValue; + }, + }, + }, +} as unknown as typeof window.go; + +function Probe({ enabled }: { enabled: boolean }) { + useWindowsMaximisedSync(enabled); + return null; +} + +const maximised = () => useWindowChromeStore.getState().mainWindowMaximised; + +try { + syncMainWindowMaximised(); + assert.equal(bridgeCalls.length, 0, "sync before any lifecycle is a no-op"); + + maximisedValue = true; + await act(async () => root.render()); + assert.equal(maximised(), true, "the enabling lifecycle syncs the native flag into the store"); + assert.deepEqual(bridgeCalls, ["query"], "the initial sync queries the bridge once"); + + maximisedValue = false; + await act(async () => { window.dispatchEvent(new window.Event("resize")); }); + assert.equal(maximised(), false, "a resize event re-syncs the flag"); + assert.equal(bridgeCalls.length, 2, "listener sync queries the bridge again"); + + maximisedValue = true; + await act(async () => { window.dispatchEvent(new window.Event("focus")); }); + assert.equal(maximised(), true, "a focus event re-syncs the flag"); + + const supersededGate = deferred(); + maximisedGate = supersededGate; + await act(async () => { syncMainWindowMaximised(); }); + maximisedGate = null; + maximisedValue = false; + await act(async () => { syncMainWindowMaximised(); }); + assert.equal(maximised(), false, "the newer sync lands first"); + await act(async () => { supersededGate.resolve(true); await supersededGate.promise; }); + assert.equal(maximised(), false, "an out-of-order resolution from a superseded sync is discarded"); + + await act(async () => root.render()); + assert.equal(maximised(), false, "disabling the lifecycle resets the flag"); + bridgeCalls.length = 0; + syncMainWindowMaximised(); + assert.equal(bridgeCalls.length, 0, "event-handler sync stays gated while disabled"); + + await act(async () => root.unmount()); + bridgeCalls.length = 0; + syncMainWindowMaximised(); + assert.equal(bridgeCalls.length, 0, "unmounting the host disables the sync gate"); + + console.log("windows maximised sync: lifecycle gating, listener sync, generation fence and store ownership passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/workspace-layout.test.ts b/desktop/frontend/src/__tests__/workspace-layout.test.ts index de8f6115d3..986391654a 100644 --- a/desktop/frontend/src/__tests__/workspace-layout.test.ts +++ b/desktop/frontend/src/__tests__/workspace-layout.test.ts @@ -17,7 +17,6 @@ import { let passed = 0; let failed = 0; const testDir = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8"); const sessionActionsSource = readFileSync(resolve(testDir, "../components/TopicbarSessionActions.tsx"), "utf8"); const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8"); @@ -163,45 +162,12 @@ eq(terminalMaxHeight(480), 240, "terminal maximum follows half of the current vi eq(terminalMaxHeight(180), 120, "terminal maximum never falls below the accessible minimum"); eq(clampTerminalHeight(680, 480), 240, "restored terminal height clamps after the window shrinks"); eq(clampTerminalHeight(80, 720), 120, "terminal height clamps to its minimum"); -eq( - /const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false, activeWorkspaceRoot\);/.test(appSource), - true, - "closing the dock clears the transient render width, hides the panel, and persists the collapsed preference", -); eq( /\.workspace-panel-resizer \{[\s\S]*?grid-column: 3;[\s\S]*?justify-self: start;[\s\S]*?width: 1px;/.test(stylesSource) && /\.workspace-panel-resizer::before \{[\s\S]*?left: 0;[\s\S]*?right: -7px;/.test(stylesSource), true, "workspace resize hit area starts at the dock boundary and never overlaps the chat scrollbar gutter", ); -eq( - /createPointerResizeLifecycle\(\{[\s\S]*?separator,[\s\S]*?pointerId,[\s\S]*?onMove,[\s\S]*?onFinish: \(\) => \{[\s\S]*?liveResize\.flush\(\);/.test(appSource) - && /workspacePanelResizeFinishRef\.current = lifecycle\.finish/.test(appSource), - true, - "workspace resize has one guarded finish path for capture loss, blur, cancellation, and unmount", -); -eq( - /setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true, activeWorkspaceRoot\);/.test(appSource), - true, - "opening the dock persists the expanded preference for the next launch", -); -eq( - /terminalPanelOpen[\s\S]*?terminal-drawer/.test(appSource), - true, - "terminal drawer is an independent panel, not a workspace dock mode", -); -eq( - /const addTerminalOutputToComposer = useCallback\(async \(sessionId: string\) => \{[\s\S]*?app\.TerminalOutputForTab\(activeTabId, sessionId\)[\s\S]*?addWorkspaceTextToComposer\(/.test(appSource), - true, - "terminal output reaches chat only through the explicit add-output action", -); -eq( - /const addSelectedTextToComposer = useCallback\(\(text: string, source\?: SelectedTextInsertRequest\["source"\]\)/.test(appSource) - && /addSelectedTextToComposer\(text, "terminal"\)/.test(appSource) - && /onAddToChat=\{addTerminalSelectionToComposer\}/.test(appSource), - true, - "terminal selections enter the composer as typed quoted context", -); eq( /@media \(max-width: 820px\) \{[\s\S]*?\.layout--terminal-drawer-open \.terminal-drawer[\s\S]*?display: flex !important/.test(stylesSource), true, @@ -217,31 +183,6 @@ eq( true, "narrow viewport keeps the resizer and drawer in the content column above the status bar", ); -eq( - /const terminalRenderHeight = clampTerminalHeight\(terminalHeight, viewportHeight\)/.test(appSource) - && /"--terminal-height": `\$\{terminalSurfaceOpen \? liveTerminalHeight \?\? terminalRenderHeight : 0\}px`/.test(appSource), - true, - "terminal render height re-clamps whenever the viewport changes", -); -eq( - /aria-hidden=\{!terminalSurfaceOpen\}/.test(appSource) - && /tabIndex=\{terminalSurfaceOpen \? 0 : -1\}/.test(appSource) - && /onKeyDown=\{resizeTerminalWithKeyboard\}/.test(appSource), - true, - "closed terminal resizer leaves the tab order and open resizer supports keyboard adjustment", -); -eq( - /terminalSurfaceOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource) - && !/\.layout\.layout--terminal-drawer-open \.footer/.test(stylesSource), - true, - "footer compaction applies only while the terminal is expanded outside Creation mode", -); -eq( - /sidebarImDetailConnection \? "layout--statusbar-hidden" : ""/.test(appSource) - && /\.layout\.layout--statusbar-hidden,[\s\S]*?--statusbar-height: 0px;/.test(stylesSource), - true, - "IM detail collapses the status bar row when the bar is not rendered", -); eq( /\.layout--terminal-drawer-expanded \.terminal-drawer \{[\s\S]*?border-top: 1px solid var\(--border-soft\)/.test(stylesSource), true, @@ -253,15 +194,6 @@ eq( true, "workbench sidebar does not reserve the docked status bar twice", ); -const workspaceDockTabsSource = appSource.match(/
/)?.[0] ?? ""; -eq( - workspaceDockTabsSource.length > 0 - && !/rightDock\.terminal|terminalPanelOpen|toggleTerminalPanel/.test(workspaceDockTabsSource) - && / import\("\.\/components\/TerminalPanel"\)/.test(appSource), - true, - "terminal and xterm remain in a lazy chunk", -); eq( /onPointerEnter=\{terminalEnabled \? prefetchTerminal : undefined\}/.test(sessionActionsSource) && /onFocus=\{terminalEnabled \? prefetchTerminal : undefined\}/.test(sessionActionsSource) @@ -335,45 +256,18 @@ eq( true, "pointer and keyboard intent prefetch the terminal chunk before opening from the topic bar", ); -eq( - /useWarmTerminalPanel\(terminalPanelOpen, terminalResizing, !managementActive\)/.test(appSource) - && /if \(open\) setMounted\(true\)/.test(terminalLifecycleSource) - && !/setMounted\(false\)/.test(terminalLifecycleSource), - true, - "the terminal stays mounted after first open to preserve the live xterm", -); eq( /registerTerminalSink\(session\.id, \(bytes\) => terminal\.write\(bytes\), openRef\.current\)/.test(terminalViewSource) && /terminalSinkRef\.current\?\.setActive\(open\)/.test(terminalViewSource), true, "the warm terminal pauses PTY output while collapsed and resumes from its output cursor", ); -eq( - /fitEnabled=\{terminalFitEnabled\}/.test(appSource) - && /setFitEnabled\(false\)/.test(terminalLifecycleSource) - && /TERMINAL_TRANSITION_MS/.test(terminalLifecycleSource) - && /fitEnabled=\{fitEnabled\}/.test(terminalPanelSource), - true, - "drawer transitions pause xterm fit and perform one fit after opening", -); eq( /useGlobalShortcut\(\s*"selection\.addToChat"/.test(terminalPanelSource) && /\{addShortcut\}<\/kbd>/.test(terminalPanelSource), true, "terminal selection-to-chat exposes the shared configurable shortcut", ); -eq( - /className="terminal-drawer"[\s\S]*?aria-hidden=\{!terminalSurfaceOpen\}[\s\S]*?inert=\{!terminalSurfaceOpen \? true : undefined\}/.test(appSource), - true, - "the warm collapsed terminal is hidden from accessibility and focus navigation", -); -eq( - /open=\{terminalSurfaceOpen\}/.test(appSource) - && /open && selectionAction &&/.test(terminalPanelSource) - && /if \(!open\) setSelectionAction\(null\)/.test(terminalPanelSource), - true, - "closing a warm terminal removes portaled selection controls", -); // C1: the chat pane keeps its 400px floor no matter how wide the dock is // dragged — the dock's available width is viewport minus sidebar minus the @@ -404,16 +298,6 @@ eq(wideDock > chatFloorDock, true, "C1: wider viewport gives the dock more room, // C3: switching dock tabs (context/files/changed) must never resize the dock — // the preferred width is a single source (rightDockTreeWidth), not a // detail-dependent ternary that would jump the sidebar per tab. -eq( - /const preferredWorkspacePanelWidth = rightDockTreeWidth;/.test(appSource), - true, - "C3: preferredWorkspacePanelWidth is the single tree width (no detail ternary)", -); -eq( - /const preferredWorkspacePanelWidth = rightDockDetailActive \? rightDockPreviewWidth : rightDockTreeWidth;/.test(appSource), - false, - "C3: no preview-width dual system that would resize the sidebar on tab switch", -); console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/workspace-panel-commands.test.tsx b/desktop/frontend/src/__tests__/workspace-panel-commands.test.tsx new file mode 100644 index 0000000000..d4db898dc9 --- /dev/null +++ b/desktop/frontend/src/__tests__/workspace-panel-commands.test.tsx @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useWorkspacePanelCommands } from "../app-runtime/useWorkspacePanelCommands"; +import { loadWorkspacePanelOpen, saveWorkspacePanelOpen, useLayoutStore } from "../store/layout"; +import { useRemoteStore } from "../store/remote"; +import type { RemoteHostView } from "../lib/types"; + +const dom = new JSDOM("
", { url: "http://localhost" }); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); +let commands!: ReturnType; +let closes = 0; let widthClears = 0; +const closeOverlays = () => { closes++; }; +const clearLiveWidth = () => { widthClears++; }; +let restoredWidth = 0; +const setTreeWidth = (width: number) => { restoredWidth = width; }; +function Probe({ workspace, creation, visible }: { workspace: string; creation: boolean; visible: boolean }) { + commands = useWorkspacePanelCommands({ workspaceRoot: workspace, creation, visible, closeOverlays, clearLiveWidth, + availableWidth: 800, clampTreeWidth: (width) => width, setTreeWidth }); + return null; +} +const paint = (workspace: string, creation = false, visible = false) => act(async () => root.render()); +try { + saveWorkspacePanelOpen(false, "A"); saveWorkspacePanelOpen(true, "B"); + await paint("A"); + const first = commands; + assert.equal(useLayoutStore.getState().workspacePanelOpen, false); + await act(async () => commands.openRightDockMode("changed")); + assert.equal(loadWorkspacePanelOpen("A"), true); + await paint("A", false, true); + await act(async () => { commands.toggleWorkspaceMaximized(); commands.handleWorkspacePreviewModeChange(true); }); + assert.equal(useLayoutStore.getState().workspacePanelMaximized, true); + await act(async () => commands.openRightDockMode("context")); + assert.equal(useLayoutStore.getState().workspacePanelMaximized, false); + assert.equal(useLayoutStore.getState().workspacePreviewActive, false); + await act(async () => commands.toggleWorkspacePanel()); + assert.equal(loadWorkspacePanelOpen("A"), false); + assert.equal(widthClears, 1); + await paint("B"); + assert.equal(useLayoutStore.getState().workspacePanelOpen, true, "different project restores its own preference"); + await paint("A", true); + assert.equal(useLayoutStore.getState().workspacePanelOpen, false); + assert.equal(useLayoutStore.getState().rightDockMode, "files", "Creation cannot leave a hidden overview selected"); + assert.equal(commands.closeWorkspacePanel, first.closeWorkspacePanel); + assert.equal(commands.openRightDockMode, first.openRightDockMode); + await act(async () => commands.toggleWorkspacePanel()); + assert.equal(useLayoutStore.getState().rightDockMode, "files"); + const hosts = [{ id: "offline" }, { id: "online" }] as RemoteHostView[]; + await act(async () => { + useRemoteStore.getState().setHosts(hosts); + useRemoteStore.getState().applyStatus({ hostId: "online", state: "connected" }); + commands.openRemoteDock(); + }); + assert.equal(useRemoteStore.getState().explorerHostId, "online"); + assert.equal(useRemoteStore.getState().explorerOpen, false, "request is consumed by the same dock owner"); + assert.equal(useLayoutStore.getState().rightDockMode, "remote"); + await act(async () => { commands.restoreWorkspaceDockWidths(640, 0); }); + assert.equal(restoredWidth, 640, "dock width restore clamps through the owner and writes the layout store port"); + await act(async () => useRemoteStore.getState().setHosts([])); + assert.equal(useLayoutStore.getState().rightDockMode, "files"); + await act(async () => root.unmount()); + const before = { closes, widthClears, layout: useLayoutStore.getState() }; + first.openRightDockMode("changed"); first.toggleWorkspaceMaximized(); first.closeWorkspacePanel(); + assert.deepEqual({ closes, widthClears, layout: useLayoutStore.getState() }, before, "disposed entries cannot change layout or project preferences"); + console.log("workspace commands: scoped restoration, Creation, preview/maximize, remote requests and synchronous disposal passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/__tests__/worktree-merge-commands.test.tsx b/desktop/frontend/src/__tests__/worktree-merge-commands.test.tsx new file mode 100644 index 0000000000..d06dffb7c8 --- /dev/null +++ b/desktop/frontend/src/__tests__/worktree-merge-commands.test.tsx @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { useWorktreeMergeCommands } from "../app-runtime/useWorktreeMergeCommands"; +import type { TabMeta, WorktreeMergeResult } from "../lib/types"; +import type { Translator } from "../lib/i18n"; + +const dom = new JSDOM("
"); +Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); +const root = createRoot(document.getElementById("root")!); + +const t = ((key: string) => key) as Translator; +const sourceTab = { id: "source-tab", workspaceRoot: "/source" } as TabMeta; +const worktreeTab = { id: "worktree-tab", workspaceRoot: "/worktree" } as TabMeta; +const receipt: WorktreeMergeResult = { + merged: true, + alreadyMerged: false, + recoveryRequired: false, + sourceRoot: "/source", + targetBranch: "main", + mergedCommit: "merge-head", + worktreeRoot: "/worktree", + worktreeBranch: "reasonix/delivery-test", + worktreeHead: "worktree-head", +}; + +const toasts: string[] = []; +const cleanups: unknown[] = []; +const lifecycleCalls: string[] = []; +let navigationToken: string | null = "nav-token"; +let navigationCurrent = true; +let staleAfterEnsure = false; + +let states!: ReturnType; +function Probe() { + states = useWorktreeMergeCommands({ + singleSurfaceLayout: false, + noteNavigationIntent: () => 42, + registeredNavigationIntent: async () => navigationToken, + isNavigationIntentCurrent: () => navigationCurrent, + ensureBlankSurface: async () => sourceTab, + ensureBlankTab: async () => { + lifecycleCalls.push("ensure"); + if (staleAfterEnsure) navigationCurrent = false; + return sourceTab; + }, + seedSource: () => { lifecycleCalls.push("seed"); }, + listTabs: async () => { lifecycleCalls.push("list"); return [sourceTab, worktreeTab]; }, + closeWorktree: async () => { lifecycleCalls.push("close"); return { closed: true, idempotent: false }; }, + finalize: async () => { + lifecycleCalls.push("finalize"); + return { completed: true, worktreeRemoved: true, branchDeleted: true, blockers: [] }; + }, + showToast: (message) => { toasts.push(message); }, + t, + showCleanup: (cleanup) => { cleanups.push(cleanup); }, + }); + return null; +} + +try { + await act(async () => root.render()); + assert.equal(states.worktreeMergeTabId, null, "the merge overlay starts closed"); + + await act(async () => { states.openWorktreeMerge("worktree-tab"); }); + assert.equal(states.worktreeMergeTabId, "worktree-tab", "the topicbar merge action opens the overlay for its tab"); + await act(async () => { states.closeWorktreeMerge(); }); + assert.equal(states.worktreeMergeTabId, null, "the overlay close command clears the tab"); + + await assert.rejects( + () => states.handleWorktreeMerged({ ...receipt, mergedCommit: "" }), + /worktree\.mergeReceiptInvalid/, + "an invalid receipt rejects without touching navigation", + ); + assert.equal(toasts.length, 0, "an invalid receipt surfaces through the throw, not a toast"); + + navigationToken = null; + await act(async () => { states.openWorktreeMerge("worktree-tab"); }); + await act(async () => { await states.handleWorktreeMerged(receipt); }); + assert.deepEqual(toasts, ["worktree.navigationChangedPreserved"], "a superseded navigation intent preserves the worktree with a toast"); + assert.deepEqual(lifecycleCalls, [], "a superseded intent runs no close or finalize"); + navigationToken = "nav-token"; + + toasts.length = 0; + await act(async () => { await states.handleWorktreeMerged(receipt); }); + assert.deepEqual(lifecycleCalls, ["ensure", "seed", "list", "close", "finalize"], "a stable intent runs the full close/finalize chain in order"); + assert.equal(cleanups.length, 1, "a finalized merge hands the cleanup receipt to the notice"); + + staleAfterEnsure = true; + toasts.length = 0; + lifecycleCalls.length = 0; + await act(async () => { await states.handleWorktreeMerged(receipt); }); + assert.deepEqual(lifecycleCalls, ["ensure"], "a mid-flight navigation change stops the lifecycle before closing anything"); + assert.deepEqual(toasts, ["worktree.navigationChangedPreserved"], "the preserved path reports through the lifecycle toast"); + staleAfterEnsure = false; + navigationCurrent = true; + + await act(async () => root.unmount()); + console.log("worktree merge commands: overlay state, receipt gate, intent fences and close/finalize chain passed"); +} finally { dom.window.close(); } diff --git a/desktop/frontend/src/app-runtime/AppRuntimeEffects.tsx b/desktop/frontend/src/app-runtime/AppRuntimeEffects.tsx new file mode 100644 index 0000000000..9d95405cc6 --- /dev/null +++ b/desktop/frontend/src/app-runtime/AppRuntimeEffects.tsx @@ -0,0 +1,71 @@ +import { useEffect } from "react"; +import { + app, + onEvent, + onReady, + onRemoteForwards, + onRemoteServer, + onRemoteStatus, + onRuntimeRebuilt, +} from "../lib/bridge"; +import { generativeMusic, isGenerativeMusicEnabled } from "../lib/generative-music"; +import { startTerminalEventBridge } from "../lib/terminalEvents"; +import { trackAppSubscription } from "./appLifecycleProbe"; +import { createSubscriptionScope } from "../lib/subscriptionScope"; + +export type RuntimeEventListener = Parameters[0]; +export type RuntimeReadyListener = Parameters[0]; +export type RuntimeRebuiltListener = Parameters[0]; +export type RemoteStatusListener = Parameters[0]; +export type RemoteForwardsListener = Parameters[0]; +export type RemoteServerListener = Parameters[0]; + +type AppRuntimeEffectsProps = { + running: boolean; + onEvent: RuntimeEventListener; + onReady: RuntimeReadyListener; + onRebuilt: RuntimeRebuiltListener; + onRemoteStatus: RemoteStatusListener; + onRemoteForwards: RemoteForwardsListener; + onRemoteServer: RemoteServerListener; + onInitialRemoteHosts: (hosts: Awaited>) => void; + onInitialRemoteStatuses: (statuses: Awaited>) => void; +}; + +/** Owns app-wide bridge subscriptions; App regions never subscribe directly. */ +export function AppRuntimeEffects(props: AppRuntimeEffectsProps) { + const { onEvent: eventListener, onReady: readyListener, onRebuilt, onRemoteStatus: statusListener, + onRemoteForwards: forwardsListener, onRemoteServer: serverListener, + onInitialRemoteHosts, onInitialRemoteStatuses, running } = props; + useEffect(startTerminalEventBridge, []); + useEffect(() => { + const scope = createSubscriptionScope(trackAppSubscription); + scope.listen(onEvent, (event) => { + eventListener(event); + if (event.kind === "text" || event.kind === "reasoning" || event.kind === "tool_dispatch") { + generativeMusic.playTokenNote(); + } + }); + scope.listen(onReady, readyListener); + scope.listen(onRuntimeRebuilt, onRebuilt); + scope.listen(onRemoteStatus, statusListener); + scope.listen(onRemoteForwards, forwardsListener); + scope.listen(onRemoteServer, serverListener); + return () => scope.dispose(); + }, [eventListener, readyListener, onRebuilt, forwardsListener, serverListener, statusListener]); + + useEffect(() => { + let disposed = false; + void app.RemoteHosts().then((hosts) => { if (!disposed) onInitialRemoteHosts(hosts); }).catch(() => {}); + void app.RemoteConnectionStatuses().then((statuses) => { if (!disposed) onInitialRemoteStatuses(statuses); }).catch(() => {}); + return () => { disposed = true; }; + }, [onInitialRemoteHosts, onInitialRemoteStatuses]); + + useEffect(() => { + if (running && isGenerativeMusicEnabled()) generativeMusic.start(); + else generativeMusic.stop(); + return () => generativeMusic.stop(); + }, [running]); + + return null; +} diff --git a/desktop/frontend/src/app-runtime/StartupGateLifecycle.tsx b/desktop/frontend/src/app-runtime/StartupGateLifecycle.tsx new file mode 100644 index 0000000000..cbc9101b82 --- /dev/null +++ b/desktop/frontend/src/app-runtime/StartupGateLifecycle.tsx @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +import { app } from "../lib/bridge"; +import { shouldOpenOnboarding } from "../lib/onboarding"; +import { useOverlayStore } from "../store/overlays"; + +export async function probeProviderSetupState(): Promise { + const needs = await app.NeedsOnboarding(); + useOverlayStore.getState().setProviderSetupNeeded(needs); + return needs; +} + +/** + * Startup onboarding gate: probes whether a provider must be configured and + * whether the first-run guide should open. Renders nothing; App composes it + * once beside the other lifecycle components. + */ +export function StartupGateLifecycle() { + const setNeedsOnboarding = useOverlayStore((state) => state.setNeedsOnboarding); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const needs = await probeProviderSetupState(); + if (cancelled) return; + setNeedsOnboarding(shouldOpenOnboarding(needs)); + } catch { + // Bridge unavailable (browser dev seam) — skip the gate; a real key + // failure still surfaces via the topbar startupError banner. + if (!cancelled) setNeedsOnboarding(false); + } + })(); + return () => { + cancelled = true; + }; + }, [setNeedsOnboarding]); + return null; +} diff --git a/desktop/frontend/src/app-runtime/WindowChromeLifecycle.tsx b/desktop/frontend/src/app-runtime/WindowChromeLifecycle.tsx new file mode 100644 index 0000000000..93a48eb7a1 --- /dev/null +++ b/desktop/frontend/src/app-runtime/WindowChromeLifecycle.tsx @@ -0,0 +1,82 @@ +import { useEffect } from "react"; +import { app } from "../lib/bridge"; +import { browserPlatformOverride, normalizeDesktopPlatform } from "../lib/desktopPlatform"; +import { useDesktopPreferences } from "./useDesktopPreferences"; +import { setDesktopPlatform, setViewportSize, useWindowChromeStore } from "../store/windowChrome"; +import { + CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, + RIGHT_DOCK_TREE_MIN_WIDTH, + SIDEBAR_MIN_WIDTH, + saveRightDockTreeWidth, + saveSidebarWidth, + useLayoutStore, +} from "../store/layout"; + +/** + * Owns the desktop chrome listeners that feed the windowChrome store: the + * native platform probe, viewport resize, the data-platform attribute and the + * layout minimum-width guards. Renders nothing; App composes it once beside + * AppRuntimeEffects so every chrome consumer reads one store. + */ +export function WindowChromeLifecycle() { + const platform = useWindowChromeStore((state) => state.platform); + const sidebarWidth = useLayoutStore((state) => state.sidebarWidth); + const setSidebarWidth = useLayoutStore((state) => state.setSidebarWidth); + const rightDockTreeWidth = useLayoutStore((state) => state.rightDockTreeWidth); + const setRightDockTreeWidth = useLayoutStore((state) => state.setRightDockTreeWidth); + const { desktopLayoutStyle } = useDesktopPreferences(); + + useEffect(() => { + document.documentElement.setAttribute("data-platform", platform); + }, [platform]); + + useEffect(() => { + let cancelled = false; + const override = browserPlatformOverride(); + if (override) { + setDesktopPlatform(override); + return () => { + cancelled = true; + }; + } + void app.Platform() + .then((value) => { + if (!cancelled) setDesktopPlatform(normalizeDesktopPlatform(value)); + }) + .catch((e) => { + console.warn("platform probe failed", e); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + const onResize = () => { + setViewportSize(window.innerWidth, window.innerHeight); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + useEffect(() => { + if (desktopLayoutStyle === "creation" || sidebarWidth >= SIDEBAR_MIN_WIDTH) return; + setSidebarWidth(SIDEBAR_MIN_WIDTH); + saveSidebarWidth(SIDEBAR_MIN_WIDTH); + }, [desktopLayoutStyle, setSidebarWidth, sidebarWidth]); + + useEffect(() => { + if (desktopLayoutStyle === "creation") { + if (rightDockTreeWidth >= CREATION_RIGHT_DOCK_TREE_MIN_WIDTH) return; + setRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH); + saveRightDockTreeWidth(CREATION_RIGHT_DOCK_TREE_MIN_WIDTH); + return; + } + if (rightDockTreeWidth >= RIGHT_DOCK_TREE_MIN_WIDTH) return; + setRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH); + saveRightDockTreeWidth(RIGHT_DOCK_TREE_MIN_WIDTH); + }, [desktopLayoutStyle, rightDockTreeWidth, setRightDockTreeWidth]); + + return null; +} diff --git a/desktop/frontend/src/app-runtime/activeTabMirror.ts b/desktop/frontend/src/app-runtime/activeTabMirror.ts new file mode 100644 index 0000000000..7659f58262 --- /dev/null +++ b/desktop/frontend/src/app-runtime/activeTabMirror.ts @@ -0,0 +1,25 @@ +import { useEffect } from "react"; + +export type ActiveTabMirror = Readonly<{ current: string | undefined }>; + +const mirror: { current: string | undefined } = { current: undefined }; + +/** + * Layout-committed active tab mirror. The single AppRuntime host writes it + * through useActiveTabMirrorCommit after each committed layout; readers are + * event handlers and async continuations in app-runtime owners that must + * never capture a stale render value. It is not a render input — presentation + * keeps reading the reactive activeTabId. + */ +export function activeTabMirror(): ActiveTabMirror { + return mirror; +} + +export function useActiveTabMirrorCommit(activeTabId: string | undefined): void { + useEffect(() => { + mirror.current = activeTabId; + }, [activeTabId]); + useEffect(() => () => { + mirror.current = undefined; + }, []); +} diff --git a/desktop/frontend/src/app-runtime/appLifecycleProbe.ts b/desktop/frontend/src/app-runtime/appLifecycleProbe.ts new file mode 100644 index 0000000000..df627eeaea --- /dev/null +++ b/desktop/frontend/src/app-runtime/appLifecycleProbe.ts @@ -0,0 +1,83 @@ +export type LifecycleProbeSnapshot = { + committedRenders: number; + liveRenderTokens: number; + liveRenderTokenIds: number[]; + activeOperations: number; + activeSubscriptions: number; + invariantViolations: number; + overflow: boolean; +}; + +type AppLifecycleProbeApi = { snapshot(): LifecycleProbeSnapshot }; + +declare global { + interface Window { __reasonixAppLifecycle?: AppLifecycleProbeApi } +} + +// Qualification is finite. Overflow invalidates the evidence; never evict live refs. +const MAX_RENDER_REFS = 65_536; +const renderRefs = new Map>(); +const renderIds = new WeakMap(); +let committedRenders = 0; +let activeOperations = 0; +let activeSubscriptions = 0; +let invariantViolations = 0; +let overflow = false; + +function enabled(): boolean { + if (typeof window === "undefined") return false; + const params = new URLSearchParams(window.location.search); + return params.get("app-lifecycle-probe") === "1" || params.get("bench") === "1"; +} + +function liveIds(): number[] { + const ids: number[] = []; + for (const [id, ref] of renderRefs) { + if (ref.deref()) ids.push(id); + else renderRefs.delete(id); + } + return ids; +} + +function publishApi(): void { + if (!enabled() || window.__reasonixAppLifecycle) return; + window.__reasonixAppLifecycle = { + snapshot: () => { + const liveRenderTokenIds = liveIds(); + return { + committedRenders, liveRenderTokens: liveRenderTokenIds.length, liveRenderTokenIds, + activeOperations, activeSubscriptions, invariantViolations, overflow, + }; + }, + }; +} + +export function createAppRenderToken(): object | null { + if (!enabled()) return null; + publishApi(); + return {}; +} + +export function commitAppRenderToken(token: object | null): void { + if (!token || renderIds.has(token)) return; + const id = ++committedRenders; + renderIds.set(token, id); + if (renderRefs.size >= MAX_RENDER_REFS) liveIds(); + if (renderRefs.size >= MAX_RENDER_REFS) { + overflow = true; + return; + } + renderRefs.set(id, new WeakRef(token)); +} + +export function trackAppOperation(delta: 1 | -1): void { + if (!enabled()) return; + activeOperations += delta; + if (activeOperations < 0) invariantViolations += 1; +} + +export function trackAppSubscription(delta: 1 | -1): void { + if (!enabled()) return; + activeSubscriptions += delta; + if (activeSubscriptions < 0) invariantViolations += 1; +} diff --git a/desktop/frontend/src/app-runtime/botRuntimeAdapter.ts b/desktop/frontend/src/app-runtime/botRuntimeAdapter.ts new file mode 100644 index 0000000000..e86bb80a5e --- /dev/null +++ b/desktop/frontend/src/app-runtime/botRuntimeAdapter.ts @@ -0,0 +1,12 @@ +import { app } from "../lib/bridge"; +import type { BotRuntimeStatusView } from "../lib/types"; + +export async function loadBotRuntimeStatus(): Promise { + if (typeof window !== "undefined" && !window.runtime) return null; + try { + return await app.BotRuntimeStatus(); + } catch (error) { + console.warn("bot runtime status failed", error); + return null; + } +} diff --git a/desktop/frontend/src/app-runtime/composerModeOwner.ts b/desktop/frontend/src/app-runtime/composerModeOwner.ts new file mode 100644 index 0000000000..61a2ec1be9 --- /dev/null +++ b/desktop/frontend/src/app-runtime/composerModeOwner.ts @@ -0,0 +1,76 @@ +import { composerProfileWithMode, type ComposerProfile, type ComposerProfileField } from "../lib/composerProfile"; +import { modeHasPlan, type CollaborationMode, type Mode, type ToolApprovalMode } from "../lib/types"; +import type { SessionOperationAuthority, SessionResource } from "./useSessionOperations"; + +export type ComposerModeRequest = + | { kind: "mode"; mode: Mode } + | { kind: "collaboration"; mode: CollaborationMode } + | { kind: "approval"; mode: ToolApprovalMode }; +export type ComposerModePorts = { + setMode: (tabId: string, mode: Mode) => Promise | void; + setCollaboration: (tabId: string, mode: CollaborationMode) => Promise; + setApproval: (tabId: string, mode: ToolApprovalMode) => Promise | void; + clearGoal: (tabId: string) => Promise; + setRemote: (tabId: string, collaboration: CollaborationMode, approval: ToolApprovalMode, goal: string) => Promise; + drainRemote: (tabId: string, ids: string[]) => void; + patch: (tabId: string, patch: Partial>, fields: ComposerProfileField[]) => void; + rememberPlan: (tabId: string, enabled: boolean) => void; + rememberApproval: (tabId: string, previous: ToolApprovalMode, next: ToolApprovalMode) => void; +}; +export type ComposerModeInput = { + target: SessionResource; + request: ComposerModeRequest; + remote: boolean; + collaborationMode: CollaborationMode; + toolApprovalMode: ToolApprovalMode; + goal: string; + ports: ComposerModePorts; +}; + +export async function executeComposerMode(input: ComposerModeInput, authority: SessionOperationAuthority): Promise { + const { target: { tabId }, request, ports } = input; + authority.checkpoint(); + let patch: Partial>; + let fields: ComposerProfileField[]; + if (request.kind === "mode") { + patch = composerProfileWithMode(request.mode); + fields = ["collaborationMode", "toolApprovalMode", "goal"]; + if (input.remote) { + const ids = await ports.setRemote(tabId, patch.collaborationMode ?? "normal", patch.toolApprovalMode ?? "ask", ""); + authority.checkpoint(); + if (authority.ownsUI()) ports.drainRemote(tabId, ids); + } else await ports.setMode(tabId, request.mode); + authority.checkpoint(); + ports.rememberPlan(tabId, modeHasPlan(request.mode)); + } else if (request.kind === "collaboration") { + const mode = request.mode === "goal" ? "normal" : request.mode; + patch = { collaborationMode: mode, goalDraftMode: request.mode === "goal", goal: "" }; + fields = ["collaborationMode", "goal"]; + if (input.remote) { + const ids = await ports.setRemote(tabId, mode, input.toolApprovalMode, ""); + authority.checkpoint(); + if (authority.ownsUI()) ports.drainRemote(tabId, ids); + } else { + if (input.goal.trim()) { + await ports.clearGoal(tabId); + authority.checkpoint(); + } + await ports.setCollaboration(tabId, mode); + } + authority.checkpoint(); + ports.rememberPlan(tabId, request.mode === "plan"); + } else { + patch = { toolApprovalMode: request.mode }; + fields = ["toolApprovalMode"]; + if (input.remote) { + const mode = input.goal.trim() ? "goal" : input.collaborationMode === "plan" ? "plan" : "normal"; + const ids = await ports.setRemote(tabId, mode, request.mode, input.goal); + authority.checkpoint(); + if (authority.ownsUI()) ports.drainRemote(tabId, ids); + } else await ports.setApproval(tabId, request.mode); + authority.checkpoint(); + ports.rememberApproval(tabId, input.toolApprovalMode, request.mode); + } + authority.checkpoint(); + ports.patch(tabId, patch, fields); +} diff --git a/desktop/frontend/src/app-runtime/controllerProfileOwner.ts b/desktop/frontend/src/app-runtime/controllerProfileOwner.ts new file mode 100644 index 0000000000..534c0f3cd2 --- /dev/null +++ b/desktop/frontend/src/app-runtime/controllerProfileOwner.ts @@ -0,0 +1,88 @@ +import { composerProfileFromTab, composerProfileMode, controllerComposerProfileCollaborationMode, displayedComposerProfileCollaborationMode, type ComposerProfile } from "../lib/composerProfile"; +import type { CollaborationMode, TabMeta, ToolApprovalMode } from "../lib/types"; +import { sessionIdentityKey } from "./sessionTarget"; +import type { SessionOperationAuthority, SessionResource } from "./useSessionOperations"; + +export type ControllerProfile = { collaboration: CollaborationMode; approval: ToolApprovalMode; goal: string }; +export type ControllerProfileResource = { target: SessionResource; profile: ControllerProfile; remote: boolean }; +export type ControllerProfilePorts = { + model(tabId: string, name: string): Promise; + profile(tabId: string, collaboration: CollaborationMode, approval: ToolApprovalMode, goal: string, options: { propagateError: boolean }): Promise; +}; +const runtimeProfile = (profile: ComposerProfile): ControllerProfile => ({ + collaboration: controllerComposerProfileCollaborationMode(profile), approval: profile.toolApprovalMode, goal: profile.goal, +}); + +/** A display-free read projection, not a second profile store. */ +export function projectControllerProfiles(tabs: readonly TabMeta[], profiles: Readonly>, + active: { target: SessionResource; profile: ComposerProfile; remote: boolean }): ControllerProfileResource[] { + return [{ target: active.target, profile: runtimeProfile(active.profile), remote: active.remote }, + ...tabs.filter(tab => tab.id !== active.target.tabId).map(tab => ({ + target: { tabId: tab.id, sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath, + sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }) }, + profile: runtimeProfile(profiles[tab.id] ?? composerProfileFromTab(tab)), remote: Boolean(tab.remote), + }))]; +} + +export type ControllerProfileInput = { + target: SessionResource; + read(target: SessionResource): ControllerProfileResource; + ports: ControllerProfilePorts; +}; + +/** Rebuild and ordinary readiness restoration use the same source-profile application. */ +export async function executeControllerProfile(input: ControllerProfileInput, authority: SessionOperationAuthority): Promise { + authority.checkpoint(); + const resource = input.read(input.target); + if (resource.remote) return false; + // Rebuilding may outlive a profile commit. Read only this resource's committed + // values, never the render captured before rebuilding or the active tab. + const { collaboration, approval, goal } = input.read(input.target).profile; + const applied = await input.ports.profile(input.target.tabId, collaboration, approval, goal, { propagateError: true }); + authority.checkpoint(); + return applied; +} + +export async function executeControllerModel(input: ControllerProfileInput & { + name: string; remote?: (name: string) => Promise; + restore(target: SessionResource): Promise; +}, authority: SessionOperationAuthority): Promise { + authority.checkpoint(); + if (input.read(input.target).remote) { + if (!input.remote) return false; + await input.remote(input.name); + } else { + if (!await input.ports.model(input.target.tabId, input.name)) return false; + authority.checkpoint(); + // Startup, explicit send readiness and post-model restoration share one + // request channel. A coalesced Controller failure has only one UI owner. + if (!await input.restore(input.target)) return false; + } + authority.checkpoint(); + return authority.ownsUI(); +} + +/** Visible tab strip projection: committed order plus profile display fields. */ +export function projectVisibleTabs(input: { + tabs: readonly TabMeta[]; + orderIds: readonly string[]; + profiles: Readonly>; + visibleTabId: string | undefined; + running: boolean; +}) { + const byId = new Map(input.tabs.map((tab) => [tab.id, tab])); + const ordered = input.orderIds.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab)); + const missing = input.tabs.filter((tab) => !input.orderIds.includes(tab.id)); + return [...ordered, ...missing].map((tab) => { + const profile = input.profiles[tab.id] ?? composerProfileFromTab(tab); + return { + ...tab, + running: tab.id === input.visibleTabId ? tab.running || input.running : tab.running, + mode: composerProfileMode(profile), + collaborationMode: displayedComposerProfileCollaborationMode(profile), + toolApprovalMode: profile.toolApprovalMode, + goal: profile.goal, + active: tab.id === input.visibleTabId, + }; + }); +} diff --git a/desktop/frontend/src/app-runtime/conversationProjection.ts b/desktop/frontend/src/app-runtime/conversationProjection.ts new file mode 100644 index 0000000000..7dec909b0d --- /dev/null +++ b/desktop/frontend/src/app-runtime/conversationProjection.ts @@ -0,0 +1,138 @@ +import type { State } from "../lib/useController"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; +import type { BackgroundRuntimeView, TabMeta } from "../lib/types"; + +type Input = { + local: State; + remote?: Pick; + tab?: Pick; + activeTabId?: string; + backgroundRuntimes: BackgroundRuntimeView[]; + connectingLabel: string; +}; + +/** Readiness and paint identity must come from the surface that will render. */ +export function projectNavigationSurfaceTarget(input: { + activeTabId?: string; sessionKey: string; + local: Pick; + remote?: Pick; +}) { + const { remote, local } = input; + const terminal = remote && ["error", "serve_down", "disconnected"].includes(remote.state); + return { + activeTabId: input.activeTabId, + sessionKey: remote ? JSON.stringify([input.sessionKey, remote.surfaceGeneration]) : input.sessionKey, + ready: remote ? remote.state === "ready" && remote.hydrated : local.meta?.ready === true, + backendActivationPending: remote ? false : Boolean(local.backendActivationPending), + hydrating: remote ? !remote.hydrated && !terminal : Boolean(local.hydrating), + hydrateError: remote ? terminal ? remote.error || remote.state : undefined : local.hydrateError, + }; +} + +/** Display-only projection. No local telemetry fallback is permitted on a remote surface. */ +export function projectConversation({ local, remote, tab, activeTabId, backgroundRuntimes, connectingLabel }: Input) { + const runtime = remote?.transcript ?? local; + const remoteActive = Boolean(remote); + const modelLabel = remote ? remote.modelLabel || tab?.label : local.meta?.label; + const timing = { + turnPhase: runtime.turnPhase, turnStartAt: runtime.turnStartAt, + turnWaitAccumMs: runtime.turnWaitAccumMs, promptWaitStartedAt: runtime.promptWaitStartedAt, + turnTokens: runtime.turnTokens, turnOutputTokens: runtime.turnOutputTokens, + turnOutputCharsAtUsage: runtime.turnOutputCharsAtUsage, + turnModelActiveAt: runtime.turnModelActiveAt, turnModelActiveMs: runtime.turnModelActiveMs, + turnArgChars: runtime.turnArgChars, retry: runtime.retry, + }; + return { + runtime, + localToolsEnabled: !remoteActive, + composer: { + ...timing, + running: remote ? remote.running : local.running, + goalStatus: remote ? remote.composerProfile?.goalStatus : local.meta?.goalStatus, + goalRuntime: remote ? remote.goalRuntime : local.meta?.goalRuntime, + cwd: remote ? tab?.remote?.workspace : local.meta?.cwd, + modelLabel: modelLabel || connectingLabel, + commandCatalog: remote?.commands, + imageInputEnabled: !remoteActive && local.meta?.imageInputEnabled !== false, + imageUnderstandingEnabled: !remoteActive && local.meta?.visionFallbackEnabled === true, + attachmentInputEnabled: !remoteActive, + pinnedFiles: remote ? undefined : local.meta?.pinnedFiles, + turnId: remote ? undefined : local.activeTurnId, + effort: remote ? remote.effort : local.effort, + localDurableGuidance: !remoteActive, + context: runtime.context, turnCost: runtime.turnCost, turnRateBand: runtime.turnRateBand, + currency: runtime.sessionCurrency, cacheHitTokens: runtime.usage?.cacheHitTokens, + cacheMissTokens: runtime.usage?.cacheMissTokens, balance: runtime.balance, + }, + context: { + tabId: remote ? undefined : activeTabId, + items: runtime.items, context: runtime.context, usage: runtime.usage, + sessionTokens: runtime.sessionTokens, sessionCost: runtime.sessionCost, + sessionCurrency: runtime.sessionCurrency, turnTokens: runtime.turnTotalTokens, + turnCost: runtime.turnCost, turnRateBand: runtime.turnRateBand, balance: runtime.balance, + sessionGen: runtime.sessionGen, usageSeq: runtime.usageSeq, + }, + status: { + context: runtime.context, usage: runtime.usage, balance: runtime.balance, + running: runtime.running, jobs: runtime.jobs, + backgroundRuntimes: remote ? [] : backgroundRuntimes, + sessionTokens: runtime.sessionTokens, turnTokens: runtime.turnTotalTokens, + lastTurnOutputTokens: runtime.lastTurnOutputTokens, lastTurnModelMs: runtime.lastTurnModelMs, + lastTurnOutputEstimated: runtime.lastTurnOutputEstimated, lastRequestTps: runtime.lastRequestTps, + turnCost: runtime.turnCost, turnRateBand: runtime.turnRateBand, cost: runtime.sessionCost, + currency: runtime.sessionCurrency, modelLabel, + workspacePath: remote ? tab?.remote?.workspace : local.meta?.workspacePath || local.meta?.workspaceRoot || local.meta?.cwd, + workspaceName: remote ? tab?.workspaceName : local.meta?.workspaceName, + gitBranch: remote ? undefined : local.meta?.gitBranch, + }, + }; +} + +export function projectConversationLayout(input: { + chatVisible: boolean; localToolsEnabled: boolean; dockMode: string; + dockRenderable: boolean; dockGridOpen: boolean; dockOverlay: boolean; + dockOpen: boolean; dockMaximized: boolean; terminalOpen: boolean; +}) { + const localDockBlocked = !input.localToolsEnabled && (input.dockMode === "files" || input.dockMode === "changed"); + const dockVisible = input.chatVisible && input.dockRenderable && !localDockBlocked; + return { + dockVisible, + dockGridOpen: input.chatVisible && input.dockGridOpen && !localDockBlocked, + dockOverlay: dockVisible && input.dockOverlay, + dockMaximized: input.chatVisible && input.dockOpen && input.dockMaximized, + terminalOpen: input.chatVisible && input.terminalOpen && input.localToolsEnabled, + }; +} + +/** Workspace controller scope key: any identity input change re-scopes the composer. */ +export function projectWorkspaceScopeKey(input: { + activeTabId: string | undefined; + tabSessionPath: string | undefined; + metaSessionPath: string | undefined; + cwd: string | undefined; + sessionGen: number; + workspaceControllerEpoch: number; +}): string { + return [ + input.activeTabId ?? "", + input.tabSessionPath ?? "", + input.metaSessionPath ?? "", + input.cwd ?? "", + input.sessionGen, + input.workspaceControllerEpoch, + ].join("\u0000"); +} + +// Workspace navigation belongs to the project, not to a single conversation. +// A session switch inside the same project must therefore retain the dock, +// tree and selection state. +export function projectWorkspaceTreeMemoryKey(input: { + scope: string | undefined; + workspaceRoot: string | undefined; + cwd: string | undefined; +}): string { + return [ + input.scope ?? "", + input.workspaceRoot ?? input.cwd ?? "", + ].join("\u0000"); +} diff --git a/desktop/frontend/src/app-runtime/decisionSurfaceProjection.ts b/desktop/frontend/src/app-runtime/decisionSurfaceProjection.ts new file mode 100644 index 0000000000..606f8b802a --- /dev/null +++ b/desktop/frontend/src/app-runtime/decisionSurfaceProjection.ts @@ -0,0 +1,33 @@ +import type { DecisionSurfaceKind as MockDecisionSurfaceKind } from "../lib/decisionSurfaceMock"; +import type { State } from "../lib/useController"; +import type { ActiveWorkView, WorkspaceConflictView } from "../lib/types"; + +export type AppDecisionSurfaceKind = MockDecisionSurfaceKind | "extension_form"; + +type PendingClose = { tabId: string; work: ActiveWorkView; stopping: boolean } | null; + +/** + * Single footer decision surface precedence. Composer stays mounted + * underneath and is only visually/a11y-hidden so per-session draft caches + * survive. + */ +export function projectDecisionSurface(input: { + approval: State["approval"]; + ask: State["ask"]; + mcpInteraction: State["mcpInteraction"]; + extensionForm: State["extensionForm"]; + workspaceConflict: WorkspaceConflictView | null; + pendingClose: PendingClose; + clearContextPending: boolean; +}): AppDecisionSurfaceKind | null { + if (input.approval) { + return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval"; + } + if (input.ask) return "ask"; + if (input.mcpInteraction) return "mcp_interaction"; + if (input.extensionForm) return "extension_form"; + if (input.workspaceConflict) return "workspace_conflict"; + if (input.pendingClose) return "close_active"; + if (input.clearContextPending) return "clear_context"; + return null; +} diff --git a/desktop/frontend/src/app-runtime/desktopBridgeAdapter.ts b/desktop/frontend/src/app-runtime/desktopBridgeAdapter.ts new file mode 100644 index 0000000000..51f4063ab3 --- /dev/null +++ b/desktop/frontend/src/app-runtime/desktopBridgeAdapter.ts @@ -0,0 +1,20 @@ +import { app } from "../lib/bridge"; + +/** Runtime-only bridge ports used by App owners; presentation never imports Wails directly. */ +export const desktopBridge = { + setRemoteTabComposerProfile: (tabId: string, mode: string, approvalMode: string, goal: string) => + app.SetRemoteTabComposerProfile(tabId, mode, approvalMode, goal), + getTopicSummary: (request: Parameters[0]) => app.GetTopicSummary(request), + cancelJobForTab: (tabId: string, jobId: string) => app.CancelJobForTab(tabId, jobId), + dismissTodoBatchForTab: (tabId: string, batchKey: string) => app.DismissTodoBatchForTab(tabId, batchKey), + clearRemoteTabSession: (tabId: string) => app.ClearRemoteTabSession(tabId), + terminalOutputForTab: (tabId: string, sessionId: string) => app.TerminalOutputForTab(tabId, sessionId), + acceptDeliveryToTab: (tabId: string) => app.AcceptDeliveryToTab(tabId), + disconnectRemoteHost: (hostId: string) => app.DisconnectRemoteHost(hostId), + openRemoteProjectTab: app.OpenRemoteProjectTab, + listTabs: app.ListTabs, + openTaskSessionForTab: app.OpenTaskSessionForTab, + listSessionsForTab: app.ListSessionsForTab, + closeMergedWorktreeTab: app.CloseMergedWorktreeTab, + finalizeWorktreeMerge: app.FinalizeWorktreeMerge, +}; diff --git a/desktop/frontend/src/app-runtime/desktopNavigationOwner.ts b/desktop/frontend/src/app-runtime/desktopNavigationOwner.ts new file mode 100644 index 0000000000..246b2db563 --- /dev/null +++ b/desktop/frontend/src/app-runtime/desktopNavigationOwner.ts @@ -0,0 +1,153 @@ +import { CommandCancelled } from "../lib/commandOutcome"; +import type { RemoteTabOpenOptions, RemoteTabRefView, SessionMeta, TabMeta } from "../lib/types"; +import type { useAppRuntimeAdapter } from "./useAppRuntimeAdapter"; +import { isChannelSession, sidebarImSessionTarget, type SidebarImConnection } from "./sidebarImProjection"; +import type { SessionOperationAuthority } from "./useResourceOperations"; + +export type DesktopNavigationIntent = + | { kind: "topic"; scope: string; workspaceRoot: string; topicId: string; sessionPath?: string } + | { kind: "blank"; scope: string; workspaceRoot: string } + | { kind: "isolated-worktree"; workspaceRoot: string } + | { kind: "sidebar-im"; connection: SidebarImConnection } + | { kind: "resume-session"; session: SessionMeta } + | { kind: "remote-project"; remote: RemoteTabRefView; options: RemoteTabOpenOptions }; +type Runtime = ReturnType; +export type DesktopNavigationPorts = Pick & + Pick & { + listTabs(): Promise; + openRemoteProject(hostId: string, workspace: string, options: RemoteTabOpenOptions): Promise; + applyTabs(tabs: TabMeta[]): void; + seedTab(tab: TabMeta): void; + topicAccepted?(intent: number): void; + reveal(): void; + projectChanged(): void; + closeHistory(): void; + listSessions(): Promise; + applyHistorySessions(sessions: SessionMeta[]): void; + notice(notice: NavigationNotice): void; + }; +export type NavigationNotice = { + key: "history.failedOpenSession" | "history.missingWorkspaceRoot" | "history.failedOpenProject" | "sidebar.imWaiting" | "sidebar.imOpenFailed" + | "projectTree.worktreeCreated" | "projectTree.worktreeCreatedDirty"; + params?: Record; + tone?: "error" | "warn" | "info"; + durationMs?: number; +} | { message: string; tone?: "error"; durationMs?: number }; +export type DesktopNavigationCapture = { + intent: DesktopNavigationIntent; + navigationIntentSeq: number; + singleSurface: boolean; + ports: DesktopNavigationPorts; +}; +class InvalidSessionTarget extends Error { + constructor(readonly key: "history.failedOpenSession" | "history.missingWorkspaceRoot") { super(key); } +} + +/** One executor for topic, blank, IM, worktree and history activation. */ +export async function executeDesktopNavigation(input: DesktopNavigationCapture, authority: SessionOperationAuthority) { + const { intent: request, navigationIntentSeq: seq, ports, singleSurface } = input; + const checkpoint = () => { + authority.checkpoint(); + if (!ports.isNavigationIntentCurrent(seq)) throw new CommandCancelled("superseded"); + }; + const refresh = async () => { + const tabs = await ports.listTabs().catch(() => []); + checkpoint(); + ports.applyTabs(tabs); + }; + const openTopic = (scope: string, workspace: string, topic: string, path?: string) => singleSurface + ? ports.activateTopic(scope, workspace, topic, path || "", seq) + : path ? ports.openTopicSession(scope, workspace, topic, path, seq) + : scope === "global" ? ports.openGlobalTab(topic, seq) : ports.openProjectTab(workspace, topic, seq); + const openBlank = (scope: string, workspace: string) => singleSurface + ? ports.ensureBlankSurface(scope, scope === "project" ? workspace : "", seq) + : ports.ensureBlankTab(scope, scope === "project" ? workspace : "", seq); + checkpoint(); + try { + if (request.kind === "remote-project") { + const token = await ports.registeredNavigationIntent(seq); + checkpoint(); + if (!token) throw new CommandCancelled("superseded"); + const tab = await ports.openRemoteProject(request.remote.hostId, request.remote.workspace, request.options); + checkpoint(); ports.seedTab(tab); + await ports.switchRemoteTab(tab, seq); + checkpoint(); ports.reveal(); + await refresh(); + return tab; + } + if (request.kind === "topic" || request.kind === "blank") { + const tab = request.kind === "topic" + ? await openTopic(request.scope, request.workspaceRoot, request.topicId, request.sessionPath) + : await openBlank(request.scope, request.workspaceRoot); + checkpoint(); ports.seedTab(tab); + if (request.kind === "topic") ports.topicAccepted?.(seq); + if (request.kind === "blank") ports.projectChanged(); + if (request.kind === "topic") { ports.reveal(); await refresh(); } + else { await refresh(); checkpoint(); ports.reveal(); } + return; + } + if (request.kind === "isolated-worktree") { + const result = await ports.createIsolatedWorktree(request.workspaceRoot, seq); + checkpoint(); ports.seedTab(result.tab); ports.projectChanged(); + await refresh(); checkpoint(); + ports.notice({ key: result.sourceDirty ? "projectTree.worktreeCreatedDirty" : "projectTree.worktreeCreated", + params: { branch: result.branch }, tone: result.sourceDirty ? "warn" : "info", durationMs: result.sourceDirty ? 7000 : 3500 }); + ports.reveal(); return; + } + if (request.kind === "sidebar-im") { + const { connection } = request; + const target = sidebarImSessionTarget(connection); + if (!target) { ports.notice({ key: "sidebar.imWaiting", params: { name: connection.title } }); return; } + let tab: TabMeta; + if (target.kind === "path") { + tab = await openBlank(connection.scope, connection.workspaceRoot); + checkpoint(); + if (connection.sessionSource === "auto") await ports.openChannelSession(target.value, tab.id, seq); + else await ports.resumeSession(target.value, tab.id, seq); + } else tab = await openTopic(connection.scope, connection.workspaceRoot, target.value); + checkpoint(); ports.seedTab(tab); + await refresh(); checkpoint(); ports.reveal(); ports.projectChanged(); return; + } + const { session } = request; + const scope = session.scope || (session.workspaceRoot ? "project" : "global"); + let tab: TabMeta; + if (isChannelSession(session)) { + tab = await openBlank(scope === "project" ? "project" : "global", session.workspaceRoot || ""); + checkpoint(); await ports.openChannelSession(session.path, tab.id, seq); + } else if (scope === "project" && session.workspaceRoot && session.topicId) { + tab = await openTopic("project", session.workspaceRoot, session.topicId, session.path); + } else if (scope === "global" && session.topicId) { + tab = await openTopic("global", "", session.topicId, session.path); + } else throw new InvalidSessionTarget(scope === "global" && !session.topicId + ? "history.failedOpenSession" : session.topicId ? "history.missingWorkspaceRoot" : "history.failedOpenSession"); + checkpoint(); ports.seedTab(tab); ports.closeHistory(); + ports.reveal(); await refresh(); + } catch (error) { + checkpoint(); + if (request.kind === "remote-project") throw error; + if (request.kind === "topic" || request.kind === "blank") { + ports.notice({ key: "history.failedOpenSession", tone: "error" }); + await refresh(); return; + } + if (request.kind === "isolated-worktree") { + ports.notice({ message: error instanceof Error ? error.message : String(error), tone: "error", durationMs: 6000 }); return; + } + if (request.kind === "sidebar-im") { ports.notice({ key: "sidebar.imOpenFailed", params: { name: request.connection.title } }); return; } + const history = await ports.listSessions().catch(() => null); + checkpoint(); + if (history) ports.applyHistorySessions(history); + const message = error instanceof Error ? error.message : String(error ?? ""); + if (/no such file|cannot find the file|file does not exist|session is pending cleanup|session .*not found/i.test(message)) return; + ports.closeHistory(); + const session = request.session; + const scope = session.scope || (session.workspaceRoot ? "project" : "global"); + if (scope === "project" && session.workspaceRoot) { + const parts = session.workspaceRoot.split(/[/\\]/).filter(Boolean); + ports.notice({ key: "history.failedOpenProject", params: { + name: parts[parts.length - 1] || session.workspaceRoot, path: session.workspaceRoot, + } }); + } else ports.notice(error instanceof InvalidSessionTarget ? { key: error.key } : { message }); + } +} diff --git a/desktop/frontend/src/app-runtime/desktopPreferencesAdapter.ts b/desktop/frontend/src/app-runtime/desktopPreferencesAdapter.ts new file mode 100644 index 0000000000..22b3c96415 --- /dev/null +++ b/desktop/frontend/src/app-runtime/desktopPreferencesAdapter.ts @@ -0,0 +1,69 @@ +import { app } from "../lib/bridge"; +import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref } from "../lib/i18n"; +import { clearLegacyThemePreference, normalizeThemePreference, normalizeThemeStyleForTheme, readLegacyThemePreference } from "../lib/theme"; +import { applyConfiguredBaseAppearance, applyThemePack, clearThemePack } from "../lib/themePack"; +import { applyTerminalThemePreference } from "../lib/terminalTheme"; +import { applyConversationWidth } from "../lib/conversationWidth"; +import { hydrateReasoningDisplayMode } from "../lib/reasoningDisplayPreference"; +import { hydrateSessionExperience } from "../lib/sessionExperience"; +import { applyLayoutStyleDefaults } from "../store/layout"; +import { loadBotRuntimeStatus } from "./botRuntimeAdapter"; +import type { CommandAuthority } from "../lib/commandOutcome"; +import type { BotRuntimeStatusView, DesktopStartupSettingsView, SettingsView } from "../lib/types"; + +export type DesktopPreferencesSnapshot = DesktopStartupSettingsView | SettingsView; +export function layoutStyleFromSnapshot(style?: string) { + return style === "creation" ? "creation" : style === "classic" ? "classic" : "workbench"; +} +export function applyPreferencesAppearance(settings: DesktopPreferencesSnapshot) { + const theme = normalizeThemePreference(settings.desktopTheme); + applyConfiguredBaseAppearance(theme, normalizeThemeStyleForTheme(settings.desktopThemeStyle, theme)); + applyTerminalThemePreference(settings.desktopTerminalTheme); + applyConversationWidth(settings.conversationWidth); + applyLayoutStyleDefaults(layoutStyleFromSnapshot(settings.desktopLayoutStyle)); + hydrateSessionExperience(settings.sessionExperience); + hydrateReasoningDisplayMode(settings.sessionExperience === "deep" ? "expanded" : "auto", settings.sessionExperience === "deep"); + return normalizeLangPref(settings.desktopLanguage); +} +type Input = { + provided?: DesktopPreferencesSnapshot | null; + loadTheme: boolean; + publish: (settings: DesktopPreferencesSnapshot, runtime: BotRuntimeStatusView | null) => void; +}; + +/** Every async boundary is fenced before publishing preferences or theme DOM. */ +export async function synchronizeDesktopPreferences(input: Input, authority: CommandAuthority) { + authority.checkpoint(); + const language = readLegacyLangPref(); + const theme = readLegacyThemePreference(); + if (language || theme.hasValue) { + await app.MigrateDesktopPreferences(language, theme.theme, theme.style); + authority.checkpoint(); + clearLegacyLangPref(); + clearLegacyThemePreference(); + } + const [settings, runtime] = await Promise.all([ + input.provided ?? app.DesktopStartupSettings(), loadBotRuntimeStatus(), + ]); + authority.checkpoint(); + input.publish(settings, runtime); + if (!input.loadTheme) return; + try { + const { loadThemeExperience, applyExperienceToDOM } = await import("../lib/themeExperience"); + authority.checkpoint(); + const experience = await loadThemeExperience(); + authority.checkpoint(); + applyExperienceToDOM(experience); + } catch { + authority.checkpoint(); + try { + const active = await app.GetActiveThemePack(); + authority.checkpoint(); + if (active?.pack) applyThemePack(active.pack); + else clearThemePack(); + } catch { + authority.checkpoint(); + clearThemePack(); + } + } +} diff --git a/desktop/frontend/src/app-runtime/desktopProjectAdapter.ts b/desktop/frontend/src/app-runtime/desktopProjectAdapter.ts new file mode 100644 index 0000000000..6e3a7ee320 --- /dev/null +++ b/desktop/frontend/src/app-runtime/desktopProjectAdapter.ts @@ -0,0 +1,7 @@ +import { app } from "../lib/bridge"; + +export const desktopProjectAdapter = { + renameLocal: (id: string, title: string) => app.RenameTopic(id, title), + listRemote: (host: string, workspace: string) => app.RemoteProjectSessions(host, workspace), + renameRemote: (host: string, workspace: string, name: string, title: string) => app.RenameRemoteProjectSession(host, workspace, name, title), +}; diff --git a/desktop/frontend/src/app-runtime/desktopSubmissionAdapter.ts b/desktop/frontend/src/app-runtime/desktopSubmissionAdapter.ts new file mode 100644 index 0000000000..3e017cca2f --- /dev/null +++ b/desktop/frontend/src/app-runtime/desktopSubmissionAdapter.ts @@ -0,0 +1,34 @@ +import { app } from "../lib/bridge"; +import { displayedComposerProfileCollaborationMode, type ComposerProfile } from "../lib/composerProfile"; +import type { TabMeta } from "../lib/types"; +import type { StructuredInvocationSubmit } from "../lib/invocationDisplay"; +import type { ControllerProfileResource } from "./controllerProfileOwner"; +import type { InitialGoal, SubmissionPorts, SubmissionResource } from "./sessionSubmissionOwner"; + +export function createSubmissionPorts(input: { + send(tab: string, display: string, submit?: string, original?: string, structured?: StructuredInvocationSubmit, initialGoal?: InitialGoal): Promise; + setGoal(tab: string, goal: string): Promise; clearGoal(tab: string): Promise; + clearUndo: SubmissionPorts["clearUndo"]; patchGoal: SubmissionPorts["patchGoal"]; profile: SubmissionPorts["profile"]; +}): SubmissionPorts { + return { clearUndo: input.clearUndo, patchGoal: input.patchGoal, profile: input.profile, + send: (tab, display, submit, structured, goal) => input.send(tab, display, submit, undefined, structured, goal), + setGoal: (tab, goal, remote) => remote ? app.SetRemoteTabGoal(tab, goal) : goal ? input.setGoal(tab, goal) : input.clearGoal(tab), + }; +} + +export function projectSubmissionResources(resources: readonly ControllerProfileResource[], tabs: readonly TabMeta[], + profiles: Readonly>, active: { tabId: string; profile: ComposerProfile; ready: boolean }, + messages: { starting: string; readOnly: string }): SubmissionResource[] { + return resources.map(resource => { + const tab = tabs.find(value => value.id === resource.target.tabId); + const profile = resource.target.tabId === active.tabId ? active.profile : profiles[resource.target.tabId]; + const ready = Boolean(tab?.ready && (!tab.runtime || tab.runtime.phase === "ready") && !tab.startupErr) + && (resource.target.tabId !== active.tabId || active.ready); + return { target: resource.target, remote: resource.remote, + ready, + unavailable: tab?.readOnly ? messages.readOnly : ready ? "" : tab?.runtime?.issue?.message || tab?.startupErr || messages.starting, + goalDraft: Boolean(profile && displayedComposerProfileCollaborationMode(profile) === "goal" && !profile.goal.trim()), + collaboration: resource.profile.collaboration, approval: resource.profile.approval, + }; + }); +} diff --git a/desktop/frontend/src/app-runtime/historyViewProjection.ts b/desktop/frontend/src/app-runtime/historyViewProjection.ts new file mode 100644 index 0000000000..3fb788432f --- /dev/null +++ b/desktop/frontend/src/app-runtime/historyViewProjection.ts @@ -0,0 +1,15 @@ +import type { SessionMeta } from "../lib/types"; + +export type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string }; +export type HistoryViewState = + | { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] } + | { kind: "history"; source: "all"; sessions: SessionMeta[] }; +export function sessionsForScope(sessions: SessionMeta[], filter: HistoryScopeFilter): SessionMeta[] { + return filter.scope === "project" + ? sessions.filter(session => session.scope === "project" && session.workspaceRoot === filter.workspaceRoot) + : sessions.filter(session => (session.scope || "global") === "global"); +} +export function refreshHistoryProjection(current: HistoryViewState | null, sessions: SessionMeta[]): HistoryViewState | null { + if (!current || current.kind !== "history") return current; + return { ...current, sessions: current.source === "scope" ? sessionsForScope(sessions, current.filter) : sessions }; +} diff --git a/desktop/frontend/src/app-runtime/navigationOwner.ts b/desktop/frontend/src/app-runtime/navigationOwner.ts new file mode 100644 index 0000000000..eb324274d7 --- /dev/null +++ b/desktop/frontend/src/app-runtime/navigationOwner.ts @@ -0,0 +1,34 @@ +export type WorkspaceNavigationPorts = { + claimIntent: () => number; + beginSurface: (intent: number) => void; + isIntentCurrent: (intent: number) => boolean; + pickWorkspace: (intent: number) => Promise; + switchWorkspace: (path: string, intent: number) => Promise; + markProjectChanged: (updater: (value: number) => number) => void; + refreshTabsAfterMutation: (latest: () => boolean) => Promise; + maskTarget: (intent: number) => void; +}; + +/** Source-bound workspace navigation executor with one terminal surface owner. */ +export async function navigateWorkspace( + path: string | undefined, + ports: WorkspaceNavigationPorts, +): Promise { + const intent = ports.claimIntent(); + ports.beginSurface(intent); + try { + const picked = path === undefined + ? await ports.pickWorkspace(intent) + : await ports.switchWorkspace(path, intent); + if (!ports.isIntentCurrent(intent)) return picked; + if (picked) { + ports.markProjectChanged((value) => value + 1); + await ports.refreshTabsAfterMutation(() => ports.isIntentCurrent(intent)); + } + return picked; + } finally { + // Masking is intent-matched by the surface owner, so an old finally cannot + // release or advance a replacement request. + ports.maskTarget(intent); + } +} diff --git a/desktop/frontend/src/app-runtime/operationOwner.ts b/desktop/frontend/src/app-runtime/operationOwner.ts new file mode 100644 index 0000000000..e3b70c58ac --- /dev/null +++ b/desktop/frontend/src/app-runtime/operationOwner.ts @@ -0,0 +1,115 @@ +export type OperationTarget = + | { kind: "session"; tabId: string; sessionKey: string } + | { kind: "workspace"; workspaceKey: string } + | { kind: "application" }; + +export type OperationIdentity = { + ownerEpoch: number; + requestId: number; + target: OperationTarget; + navigationIntent?: number; + channel: string; +}; + +export type OperationTerminalStatus = "completed" | "failed" | "cancelled"; + +export function operationTargetsEqual(left: OperationTarget, right: OperationTarget): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "application") return true; + if (left.kind === "workspace" && right.kind === "workspace") { + return left.workspaceKey === right.workspaceKey; + } + return left.kind === "session" + && right.kind === "session" + && left.tabId === right.tabId + && left.sessionKey === right.sessionKey; +} + +function freezeIdentity(identity: OperationIdentity): OperationIdentity { + return Object.freeze({ ...identity, target: Object.freeze({ ...identity.target }) }); +} + +export type OperationOwner = ReturnType; + +/** + * Owns a last-request-wins interaction without retaining request payloads. + * Resource data may complete independently; `owns` governs current UI rights. + */ +export function createOperationOwner(trackOperation: (delta: 1 | -1) => void = () => {}) { + let ownerEpoch = 0; + let requestId = 0; + let mounted = false; + const active = new Map(); + const terminalCounts: Record = { + completed: 0, + failed: 0, + cancelled: 0, + }; + + return { + mount(): number { + if (mounted) return ownerEpoch; + ownerEpoch += 1; + mounted = true; + active.clear(); + return ownerEpoch; + }, + + unmount(epoch: number): void { + if (!mounted || epoch !== ownerEpoch) return; + for (const _identity of active.values()) { + terminalCounts.cancelled += 1; + trackOperation(-1); + } + active.clear(); + mounted = false; + }, + + begin(target: OperationTarget, navigationIntent?: number, channel = "navigation"): OperationIdentity { + if (!mounted) throw new Error("operation owner is not mounted"); + if (active.has(channel)) terminalCounts.cancelled += 1; + else trackOperation(1); + const identity = freezeIdentity({ + ownerEpoch, + requestId: ++requestId, + target, + channel, + ...(navigationIntent === undefined ? {} : { navigationIntent }), + }); + active.set(channel, identity); + return identity; + }, + + owns(identity: OperationIdentity): boolean { + const current = active.get(identity.channel); + return Boolean( + mounted + && current + && identity.ownerEpoch === ownerEpoch + && identity === current + && operationTargetsEqual(identity.target, current.target) + && identity.navigationIntent === current.navigationIntent, + ); + }, + + finish(identity: OperationIdentity, status: OperationTerminalStatus = "completed"): boolean { + if (!this.owns(identity)) return false; + active.delete(identity.channel); + trackOperation(-1); + terminalCounts[status] += 1; + return true; + }, + + cancel(identity: OperationIdentity): boolean { + return this.finish(identity, "cancelled"); + }, + + get activeCount(): number { + return active.size; + }, + + get diagnostics(): Readonly> { + return { ...terminalCounts }; + }, + }; +} diff --git a/desktop/frontend/src/app-runtime/pendingRevisionOwner.ts b/desktop/frontend/src/app-runtime/pendingRevisionOwner.ts new file mode 100644 index 0000000000..472ac29bf7 --- /dev/null +++ b/desktop/frontend/src/app-runtime/pendingRevisionOwner.ts @@ -0,0 +1,68 @@ +import type { SessionOperationAuthority, SessionResource, useSessionOperations } from "./useSessionOperations"; + +export type PendingRevisionInput = { + visible: SessionResource; resources: readonly SessionResource[]; running: boolean; ready: boolean; + operations: ReturnType; + send(target: SessionResource, text: string, authority: SessionOperationAuthority): Promise; report(error: unknown): void; +}; +type Committed = { epoch: number; input: PendingRevisionInput }; +type Entry = { target: SessionResource; text: string; failedAt?: number; failed?: boolean }; +const key = (target: SessionResource) => JSON.stringify([target.tabId, target.sessionKey]); + +async function deliver(entry: Entry, committed: Committed) { + return committed.input.operations(entry.target, "plan-revision", { entry, send: committed.input.send }, async ({ entry, send }, authority) => { + authority.checkpoint(); + try { await send(entry.target, entry.text, authority); } catch (error) { + authority.checkpoint(); + // Resource failure retention is independent of permission to show error UI. + entry.failed = true; + throw error; + } + authority.checkpoint(); + }); +} + +/** Latest revision per source; only an identical active request can release its slot. */ +export function createPendingRevisionOwner(read: () => Committed | undefined) { + const queued = new Map(); + const active = new Map(); + let eligibility = "", eligibilityRevision = 0; + const pump = () => { + const committed = read(); + if (!committed) return; + const nextEligibility = JSON.stringify([key(committed.input.visible), committed.input.running, committed.input.ready]); + if (eligibility !== nextEligibility) { eligibility = nextEligibility; eligibilityRevision++; } + const valid = new Set(committed.input.resources.map(key)); + for (const id of queued.keys()) if (!valid.has(id)) queued.delete(id); + for (const id of active.keys()) if (!valid.has(id)) active.delete(id); + const id = key(committed.input.visible), entry = queued.get(id); + if (!committed.input.ready || committed.input.running || !entry || entry.failedAt === eligibilityRevision || active.has(id)) return; + entry.failed = false; + active.set(id, entry); + void deliver(entry, committed).then(outcome => { + if (read()?.epoch !== committed.epoch || active.get(id) !== entry) return; + if (entry.failed) { + // Keep the user's revision, but only a later source activation/idle + // transition (or a new revision) may retry it, never unrelated renders. + entry.failedAt = eligibilityRevision; + if (outcome.status === "failed") read()?.input.report(outcome.error); + } else if (queued.get(id) === entry) queued.delete(id); + }).finally(() => { + if (read()?.epoch !== committed.epoch || active.get(id) !== entry) return; + active.delete(id); + // A replacement revision is a new request, not a retry of the old one. + pump(); + }); + }; + return { + remember(tabId: string, text: string) { + const committed = read(); + const target = committed?.input.resources.find(resource => resource.tabId === tabId); + if (!target || !text) return; + queued.set(key(target), { target, text }); + pump(); + }, + pump, + dispose() { queued.clear(); active.clear(); }, + }; +} diff --git a/desktop/frontend/src/app-runtime/pollingOwner.ts b/desktop/frontend/src/app-runtime/pollingOwner.ts new file mode 100644 index 0000000000..4a6f89a4a4 --- /dev/null +++ b/desktop/frontend/src/app-runtime/pollingOwner.ts @@ -0,0 +1,59 @@ +import { createOperationOwner, type OperationTarget } from "./operationOwner"; + +export type PollClock = { setTimeout(callback: () => void, delay: number): unknown; clearTimeout(handle: unknown): void }; +type PollInput = { + target: OperationTarget; periodMs: number; clock: PollClock; + read(): Promise; publish(value: T): void; failed(error: unknown): void; +}; +type PollState = { + input?: PollInput; owner: ReturnType; + epoch: number; timer?: unknown; pending?: Promise; +}; + +async function sample(state: PollState): Promise { + if (!state.input) return; + const identity = state.owner.begin(state.input.target, undefined, "poll"); + const read = state.input.read; + let status: "completed" | "failed" = "completed"; + try { + const value = await read(); + if (!state.owner.owns(identity)) return; + state.input?.publish(value); + } catch (error) { + status = "failed"; + if (!state.owner.owns(identity)) return; + state.input?.failed(error); + } finally { state.owner.finish(identity, status); } +} +function bindRefresh(state: PollState): () => Promise { + const refresh = (): Promise => { + if (!state.input) return Promise.resolve(); + if (state.pending) return state.pending; + if (state.timer !== undefined) { state.input.clock.clearTimeout(state.timer); state.timer = undefined; } + const pending = sample(state).finally(() => { + if (state.pending !== pending) return; + state.pending = undefined; + if (state.input) state.timer = state.input.clock.setTimeout(() => { state.timer = undefined; void refresh(); }, state.input.periodMs); + }); + state.pending = pending; + return pending; + }; + return refresh; +} + +/** Single-flight polling. Disposal releases sinks and cancels queued delivery synchronously. */ +export function createPollingOwner(input: PollInput, track?: (delta: 1 | -1) => void) { + const owner = createOperationOwner(track); + const state: PollState = { input, owner, epoch: owner.mount() }; + const refresh = bindRefresh(state); + return { + refresh, + dispose() { + if (!state.input) return; + if (state.timer !== undefined) state.input.clock.clearTimeout(state.timer); + state.timer = undefined; + state.input = undefined; + state.owner.unmount(state.epoch); + }, + }; +} diff --git a/desktop/frontend/src/app-runtime/projectTopicOwner.ts b/desktop/frontend/src/app-runtime/projectTopicOwner.ts new file mode 100644 index 0000000000..f87b38163d --- /dev/null +++ b/desktop/frontend/src/app-runtime/projectTopicOwner.ts @@ -0,0 +1,44 @@ +import { CommandCancelled } from "../lib/commandOutcome"; +import type { RemoteSessionView } from "../lib/remoteTypes"; +import type { SessionOperationAuthority } from "./useResourceOperations"; + +export type TopicRenameTarget = + | { kind: "local"; topicId: string } + | { kind: "remote"; hostId: string; workspace: string; sessionPath: string }; +export type ProjectTopicPorts = { + renameLocal: (id: string, title: string) => Promise; + listRemote: (host: string, workspace: string) => Promise; + renameRemote: (host: string, workspace: string, name: string, title: string) => Promise; + markChanged: (update: (value: number) => number) => void; + refreshTabs: (apply?: () => boolean, options?: { afterMutation?: boolean }) => Promise; + syncActive: (rebuild: boolean) => Promise; +}; +export type ProjectRefreshInput = { activeTabId?: string; ports: ProjectTopicPorts }; + +export async function refreshProjectTopics(input: ProjectRefreshInput, authority: SessionOperationAuthority) { + authority.checkpoint(); + input.ports.markChanged(value => value + 1); + const tabs = await input.ports.refreshTabs(() => { + try { authority.checkpoint(); return true; } catch { return false; } + }, { afterMutation: true }); + authority.checkpoint(); + if (authority.ownsUI() && input.activeTabId && !tabs.some(tab => tab.id === input.activeTabId)) await input.ports.syncActive(false); +} + +export async function renameProjectTopic(input: ProjectRefreshInput & { target: TopicRenameTarget; title: string }, authority: SessionOperationAuthority) { + const { target, title, ports } = input; + authority.checkpoint(); + if (target.kind === "local") await ports.renameLocal(target.topicId, title); + else { + const sessions = await ports.listRemote(target.hostId, target.workspace); + authority.checkpoint(); + // `current` is a navigation snapshot, not the identity of the rename target. + const source = sessions.find(session => target.sessionPath + ? session.path === target.sessionPath + : !session.path && !session.name); + if (!source) throw new CommandCancelled("superseded"); + await ports.renameRemote(target.hostId, target.workspace, source.name, title); + } + authority.checkpoint(); + await refreshProjectTopics(input, authority); +} diff --git a/desktop/frontend/src/app-runtime/remoteComposerOwner.ts b/desktop/frontend/src/app-runtime/remoteComposerOwner.ts new file mode 100644 index 0000000000..e16f8e3e19 --- /dev/null +++ b/desktop/frontend/src/app-runtime/remoteComposerOwner.ts @@ -0,0 +1,63 @@ +import type { SessionOperationAuthority } from "./useResourceOperations"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; +import type { RemoteTabRefView } from "../lib/types"; +import type { RemoteNavigationCommand } from "../lib/remoteNavigationCommands"; +import { CommandCancelled } from "../lib/commandOutcome"; + +type RemoteSendPorts = Pick & { + send: (display: string, submit: string) => Promise; + applyGoal: (tab: string, goal: string) => Promise; + requestClear: () => void; + newSession: RemoteNavigationCommand; +}; +export type RemoteSendInput = { + tabId: string; + remote?: RemoteTabRefView; + activateGoal: boolean; + display: string; + submit: string; + commandText: string; + command: ReturnType; + ports: RemoteSendPorts; +}; + +export async function executeRemoteSend(input: RemoteSendInput, authority: SessionOperationAuthority): Promise { + const { command, ports } = input; + authority.checkpoint(); + if (command?.method === "clearSession") { if (authority.ownsUI()) ports.requestClear(); return; } + if (command?.method === "newSession") { + if (input.remote && authority.ownsUI()) { + const outcome = await ports.newSession(input.remote, { newSession: true }); + if (outcome.status === "failed") throw outcome.error; + if (outcome.status === "cancelled") throw new CommandCancelled(outcome.reason); + } + return; + } + if (command?.method === "compact") return ports.compact(command.value); + if (command?.method === "runManagementCommand") return ports.runManagementCommand(input.commandText, command.rehydrate); + if (command?.method === "setModel" || command?.method === "setEffort") return ports[command.method](command.value); + if (input.activateGoal) { + await ports.applyGoal(input.tabId, input.commandText); + authority.checkpoint(); + } + await ports.send(input.display, input.submit); +} + +export type ComposerRuntimeInput = { + tabId: string; + remote: boolean; + action: "pause" | "resume" | "effort"; + level?: string; + ports: Pick & { + pauseLocal: (tab: string) => Promise; + resumeLocal: (tab: string) => Promise; + effortLocal: (tab: string, level: string) => Promise; + }; +}; +export async function executeComposerRuntime(input: ComposerRuntimeInput, authority: SessionOperationAuthority) { + authority.checkpoint(); + const { ports, tabId, remote } = input; + if (input.action === "pause") await (remote ? ports.pauseGoal() : ports.pauseLocal(tabId)); + else if (input.action === "resume") await (remote ? ports.resumeGoal() : ports.resumeLocal(tabId)); + else await (remote ? ports.setEffort(input.level ?? "") : ports.effortLocal(tabId, input.level ?? "")); +} diff --git a/desktop/frontend/src/app-runtime/sessionActionOwner.ts b/desktop/frontend/src/app-runtime/sessionActionOwner.ts new file mode 100644 index 0000000000..3f75b8ad9f --- /dev/null +++ b/desktop/frontend/src/app-runtime/sessionActionOwner.ts @@ -0,0 +1,99 @@ +import type { CollaborationMode, QuestionAnswer, ToolApprovalMode } from "../lib/types"; +import type { SessionOperationAuthority } from "./useSessionOperations"; + +export type SessionPromptTarget = Readonly<{ + tabId: string; + sessionKey: string; + promptId: string; +}>; + +export type PlanDecisionAction = "start_execution" | "revise_plan" | "exit_plan"; +export type RecoveryAction = "continue" | "continue_task" | "revise" | "stop"; +export type MCPInteractionAction = "accept" | "decline" | "cancel"; + + +export type SessionActionPorts = { + approveForTab: (tabId: string, id: string, allow: boolean, session: boolean, persist: boolean) => void; + resolvePlanForTab: (tabId: string, id: string, action: PlanDecisionAction) => void; + resolveRecoveryForTab: (tabId: string, id: string, action: RecoveryAction, feedback: string) => void; + answerQuestionForTab: (tabId: string, id: string, answers: QuestionAnswer[]) => Promise; + answerMCPForTab: (tabId: string, id: string, action: MCPInteractionAction, content?: Record) => void; + setCollaborationModeForTab: (tabId: string, mode: CollaborationMode) => Promise; + clearGoalForTab: (tabId: string) => Promise; + setRemoteComposerProfile: ( + tabId: string, + mode: CollaborationMode, + approvalMode: ToolApprovalMode, + goal: string, + ) => Promise; + patchComposerProfile: (tabId: string, mode: CollaborationMode) => void; + notePlanMode: (tabId: string, enabled: boolean) => void; + drainRemoteApprovals: (tabId: string, ids: string[]) => void; +}; + +export function submitApproval( + target: SessionPromptTarget, + input: { allow: boolean; session: boolean; persist: boolean }, + ports: Pick, +): void { + ports.approveForTab(target.tabId, target.promptId, input.allow, input.session, input.persist); +} + +export async function submitPlanDecision( + target: SessionPromptTarget, + input: { + action: PlanDecisionAction; + leavePlanMode: boolean; + remote: boolean; + goal: string; + toolApprovalMode: ToolApprovalMode; + }, + ports: SessionActionPorts, + authority: SessionOperationAuthority, +): Promise { + authority.checkpoint(); + if (input.leavePlanMode) { + if (input.remote) { + const drained = await ports.setRemoteComposerProfile(target.tabId, "normal", input.toolApprovalMode, ""); + authority.checkpoint(); + if (authority.ownsUI()) ports.drainRemoteApprovals(target.tabId, drained); + } else { + if (input.goal.trim()) { + await ports.clearGoalForTab(target.tabId); + authority.checkpoint(); + } + await ports.setCollaborationModeForTab(target.tabId, "normal"); + authority.checkpoint(); + } + ports.notePlanMode(target.tabId, false); + ports.patchComposerProfile(target.tabId, "normal"); + } + authority.checkpoint(); + ports.resolvePlanForTab(target.tabId, target.promptId, input.action); +} + +export function submitRecovery( + target: SessionPromptTarget, + action: RecoveryAction, + feedback: string, + ports: Pick, +): void { + ports.resolveRecoveryForTab(target.tabId, target.promptId, action, feedback); +} + +export function submitQuestion( + target: SessionPromptTarget, + answers: QuestionAnswer[], + ports: Pick, +): Promise { + return ports.answerQuestionForTab(target.tabId, target.promptId, answers); +} + +export function submitMCPInteraction( + target: SessionPromptTarget, + action: MCPInteractionAction, + content: Record | undefined, + ports: Pick, +): void { + ports.answerMCPForTab(target.tabId, target.promptId, action, content); +} diff --git a/desktop/frontend/src/app-runtime/sessionPromptExecutor.ts b/desktop/frontend/src/app-runtime/sessionPromptExecutor.ts new file mode 100644 index 0000000000..3f4d9a08f6 --- /dev/null +++ b/desktop/frontend/src/app-runtime/sessionPromptExecutor.ts @@ -0,0 +1,41 @@ +import { CommandCancelled } from "../lib/commandOutcome"; +import type { QuestionAnswer, ToolApprovalMode } from "../lib/types"; +import type { MCPInteractionAction, PlanDecisionAction, RecoveryAction, SessionActionPorts, SessionPromptTarget } from "./sessionActionOwner"; +import type { SessionOperationAuthority } from "./useSessionOperations"; + +export type SessionPromptKind = "approval" | "ask" | "mcpInteraction"; +export type PromptRequest = + | { kind: "approval"; allow: boolean; session: boolean; persist: boolean } + | { kind: "plan"; action: PlanDecisionAction; leavePlanMode: boolean; remote: boolean; goal: string; toolApprovalMode: ToolApprovalMode; revision?: string } + | { kind: "recovery"; action: RecoveryAction; feedback: string } + | { kind: "question"; answers: QuestionAnswer[] } + | { kind: "mcp"; action: MCPInteractionAction; content?: Record }; +export type PromptPorts = SessionActionPorts & { + isPromptCurrentForTab: (tabId: string, kind: SessionPromptKind, promptId: string) => boolean; + rememberRevision: (tabId: string, revision: string) => void; +}; +export type PromptInput = { target: SessionPromptTarget; promptKind: SessionPromptKind; request: PromptRequest; ports: PromptPorts }; + +/** Lazy loading and every business continuation share the same source receipt. */ +export async function executeSessionPrompt(input: PromptInput, source: SessionOperationAuthority) { + const { target, request, ports } = input; + const authority: SessionOperationAuthority = { + checkpoint() { + source.checkpoint(); + if (!ports.isPromptCurrentForTab(target.tabId, input.promptKind, target.promptId)) throw new CommandCancelled("superseded"); + }, + ownsUI: () => source.ownsUI(), + }; + authority.checkpoint(); + const owner = await import("./sessionActionOwner"); + authority.checkpoint(); + switch (request.kind) { + case "approval": return owner.submitApproval(target, request, ports); + case "plan": + if (request.revision !== undefined) ports.rememberRevision(target.tabId, request.revision); + return owner.submitPlanDecision(target, request, ports, authority); + case "recovery": return owner.submitRecovery(target, request.action, request.feedback, ports); + case "question": return owner.submitQuestion(target, request.answers, ports); + case "mcp": return owner.submitMCPInteraction(target, request.action, request.content, ports); + } +} diff --git a/desktop/frontend/src/app-runtime/sessionRuntimeOwner.ts b/desktop/frontend/src/app-runtime/sessionRuntimeOwner.ts new file mode 100644 index 0000000000..e7d23d3b07 --- /dev/null +++ b/desktop/frontend/src/app-runtime/sessionRuntimeOwner.ts @@ -0,0 +1,64 @@ +import type { SessionOperationAuthority, SessionResource } from "./useResourceOperations"; + +export async function executeCancelRuntimeJob( + target: SessionResource, + jobId: string, + ports: { cancelForTab: (tabId: string, jobId: string) => Promise; refresh: () => Promise }, + authority: SessionOperationAuthority, +): Promise { + authority.checkpoint(); + const cancelled = await ports.cancelForTab(target.tabId, jobId); + authority.checkpoint(); + if (authority.ownsUI()) await ports.refresh(); + authority.checkpoint(); + return cancelled; +} + +export async function executeTerminalOutputInsertion( + target: SessionResource, + sessionId: string, + ports: { read: (tabId: string, sessionId: string) => Promise; apply: (text: string) => void }, + format: (output: string) => string, + authority: SessionOperationAuthority, +): Promise { + authority.checkpoint(); + const text = format(await ports.read(target.tabId, sessionId)); + authority.checkpoint(); + if (!text) return false; + if (authority.ownsUI()) ports.apply(text); + return true; +} + +export async function executeTodoDismissal( + target: SessionResource, + batchKey: string, + port: (tabId: string, batchKey: string) => Promise, + authority: SessionOperationAuthority, +): Promise { + authority.checkpoint(); + await port(target.tabId, batchKey); + authority.checkpoint(); +} + +export type ClearSessionPorts = { + clearSession: () => Promise; + clearRemoteSession: (tabId: string) => Promise; + retryRemoteHydration: () => Promise; +}; + +export async function executeClearSession( + target: SessionResource, + input: { remote: boolean }, + ports: ClearSessionPorts, + authority: SessionOperationAuthority, +): Promise { + authority.checkpoint(); + if (input.remote) { + await ports.clearRemoteSession(target.tabId); + authority.checkpoint(); + await ports.retryRemoteHydration(); + } else { + await ports.clearSession(); + } + authority.checkpoint(); +} diff --git a/desktop/frontend/src/app-runtime/sessionSubmissionOwner.ts b/desktop/frontend/src/app-runtime/sessionSubmissionOwner.ts new file mode 100644 index 0000000000..b24638a0a8 --- /dev/null +++ b/desktop/frontend/src/app-runtime/sessionSubmissionOwner.ts @@ -0,0 +1,81 @@ +import type { CollaborationMode, ToolApprovalMode } from "../lib/types"; +import type { StructuredInvocationSubmit } from "../lib/invocationDisplay"; +import type { SessionOperationAuthority, SessionResource } from "./useSessionOperations"; + +export type InitialGoal = { goal: string; collaborationMode: CollaborationMode; toolApprovalMode: ToolApprovalMode }; +export type SubmissionResource = { + target: SessionResource; remote: boolean; ready: boolean; unavailable: string; goalDraft: boolean; + collaboration: CollaborationMode; approval: ToolApprovalMode; +}; +export type Submission = { display: string; submit?: string; structured?: StructuredInvocationSubmit; initialGoal?: InitialGoal }; +export type SubmissionPorts = { + send(tab: string, display: string, submit?: string, structured?: StructuredInvocationSubmit, goal?: InitialGoal): Promise; + clearUndo(tab: string): void; + setGoal(tab: string, goal: string, remote: boolean): Promise; + patchGoal(tab: string, goal: string): void; + profile(tab: string, propagateError: boolean): Promise; +}; +export type SubmissionInput = { + target: SessionResource; read(target: SessionResource): SubmissionResource; ports: SubmissionPorts; + request: { kind: "direct" | "composer"; content: Submission } | { kind: "goal"; goal: string }; +}; +const legacyFlags = new Set(["--research", "--auto-research", "--deep", "--simple", "--no-research"]); +export function goalCommand(input: string) { + const match = /^\/goal(?:\s+(.*))?$/.exec(input); + if (!match) return undefined; + const parts = (match[1] ?? "").trim().split(/\s+/).filter(Boolean); + const legacy = legacyFlags.has(parts[0]?.toLowerCase()); + while (legacyFlags.has(parts[0]?.toLowerCase())) parts.shift(); + const value = parts.join(" "); + const action = value.toLowerCase(); + return { value, legacy, activate: Boolean(value) && !["status", "clear", "off", "stop", "done", "pause", "resume"].includes(action), + clear: ["clear", "off", "stop", "done"].includes(action) }; +} + +async function applyGoal(input: SubmissionInput, goal: string, authority: SessionOperationAuthority) { + authority.checkpoint(); + await input.ports.setGoal(input.target.tabId, goal, input.read(input.target).remote); + authority.checkpoint(); + input.ports.patchGoal(input.target.tabId, goal); +} + +async function send(input: SubmissionInput, content: Submission, authority: SessionOperationAuthority) { + authority.checkpoint(); + const source = input.read(input.target); + if (!source.ready || source.unavailable) throw Error(source.unavailable); + input.ports.clearUndo(input.target.tabId); + await input.ports.send(input.target.tabId, content.display, content.submit, content.structured, content.initialGoal); + authority.checkpoint(); +} + +/** Only minimal source data survives awaits. No active-tab reads or render refs. */ +export async function executeSubmission(input: SubmissionInput, authority: SessionOperationAuthority): Promise { + authority.checkpoint(); + if (input.request.kind === "goal") return applyGoal(input, input.request.goal.trim(), authority); + const { content } = input.request; + if (input.request.kind === "direct") return send(input, content, authority); + const source = input.read(input.target); + const display = content.display.trim(); + const submit = content.submit ?? content.display; + const command = goalCommand(display); + if (command) { + if (command.activate) { + if (command.legacy) input.ports.patchGoal(input.target.tabId, command.value); + else await applyGoal(input, command.value, authority); + } else if (command.clear) await applyGoal(input, "", authority); + authority.checkpoint(); + if (input.read(input.target).ready) await send(input, { display, submit: submit.trim() }, authority); + return; + } + if (!source.ready) return; + if (source.goalDraft) { + await send(input, { display, submit: content.structured ? submit.trim() : `/goal ${submit.trim()}`, + structured: content.structured, initialGoal: { goal: display, collaborationMode: source.collaboration, toolApprovalMode: source.approval } }, authority); + authority.checkpoint(); + input.ports.patchGoal(input.target.tabId, display); + return; + } + if (!await input.ports.profile(input.target.tabId, false)) return; + authority.checkpoint(); + await send(input, { display, submit: submit.trim(), structured: content.structured }, authority); +} diff --git a/desktop/frontend/src/app-runtime/sessionTarget.ts b/desktop/frontend/src/app-runtime/sessionTarget.ts new file mode 100644 index 0000000000..4b13c3b44a --- /dev/null +++ b/desktop/frontend/src/app-runtime/sessionTarget.ts @@ -0,0 +1,65 @@ +export type SessionIdentityInput = { + tabId?: string; + sessionPath?: string; + sessionGeneration?: number; + scope?: string; + workspaceRoot?: string; + topicId?: string; +}; + +/** Runtime session identity; intentionally distinct from draft/workspace keys. */ +export function sessionIdentityKey(input: SessionIdentityInput): string { + const sessionPath = (input.sessionPath ?? "").trim(); + if (sessionPath) { + return ["session", sessionPath, String(input.sessionGeneration ?? 0)].join("\u0000"); + } + return [ + "topic", + input.scope ?? "", + input.workspaceRoot ?? "", + input.topicId ?? "", + input.tabId ?? "", + ].join("\u0000"); +} + +export type SessionSurfaceOwnership = Readonly<{ + revision: number; + tabId: string; + sessionKey: string; +}>; + +/** Commit-owned UI fence; A → B → A advances revision and never revives A. */ +export function createSessionSurfaceFence() { + let revision = 0; + let current: SessionSurfaceOwnership | undefined; + const owns = (ownership: SessionSurfaceOwnership): boolean => Boolean( + current + && current.revision === ownership.revision + && current.tabId === ownership.tabId + && current.sessionKey === ownership.sessionKey, + ); + return { + commit(tabId: string | undefined, sessionKey: string): SessionSurfaceOwnership | undefined { + if (!tabId) { + if (current) revision += 1; + current = undefined; + return undefined; + } + if (!current || current.tabId !== tabId || current.sessionKey !== sessionKey) revision += 1; + current = Object.freeze({ revision, tabId, sessionKey }); + return current; + }, + capture(): SessionSurfaceOwnership | undefined { + return current; + }, + owns, + ownsUnknown(ownership: unknown): boolean { + if (!ownership || typeof ownership !== "object") return false; + return owns(ownership as SessionSurfaceOwnership); + }, + dispose(): void { + revision += 1; + current = undefined; + }, + }; +} diff --git a/desktop/frontend/src/app-runtime/sidebarImProjection.ts b/desktop/frontend/src/app-runtime/sidebarImProjection.ts new file mode 100644 index 0000000000..c7cfb2a030 --- /dev/null +++ b/desktop/frontend/src/app-runtime/sidebarImProjection.ts @@ -0,0 +1,280 @@ +import { asArray } from "../lib/array"; +import type { Translator } from "../lib/i18n"; +import type { BotConnectionView, BotRuntimeStatusView, BotSettingsView, SessionMeta } from "../lib/types"; + +export type SidebarImPlatform = "qq" | "feishu" | "lark" | "weixin"; +type SidebarImStatus = "connected" | "disabled" | "pending" | "error" | "disconnected"; +export type SidebarImConnection = { + id: string; + connectionId: string; + platform: SidebarImPlatform; + title: string; + platformLabel: string; + subtitle: string; + status: SidebarImStatus; + statusLabel: string; + remoteId: string; + sessionId: string; + sessionSource: string; + scope: "global" | "project"; + workspaceRoot: string; + allowAll: boolean; + allowlistEnabled: boolean; + allowlistUsers: string[]; + allowlistMatched: boolean; +}; +export type SidebarImTopicSource = { + platform: SidebarImPlatform; + label: string; + title: string; + remoteId: string; + connectionId: string; +}; +function isSidebarImConnection(connection: BotConnectionView): boolean { + return connection.provider === "feishu" || connection.provider === "weixin"; +} + +function sidebarImPlatform(connection: BotConnectionView): SidebarImPlatform { + if (connection.provider === "weixin") return "weixin"; + return connection.domain === "lark" ? "lark" : "feishu"; +} + +function sidebarImPlatformLabel(platform: SidebarImPlatform, translate: Translator): string { + if (platform === "qq") return "QQ"; + if (platform === "lark") return "Lark"; + if (platform === "weixin") return translate("settings.botWeixin"); + return translate("settings.botFeishu"); +} + +function botMappingScope(mapping: BotConnectionView["sessionMappings"][number] | null | undefined, connectionWorkspaceRoot: string): "global" | "project" { + if (mapping?.scope === "project") return "project"; + if ((mapping?.workspaceRoot ?? "").trim()) return "project"; + return connectionWorkspaceRoot.trim() ? "project" : "global"; +} + +function botMappingWorkspaceRoot( + mapping: BotConnectionView["sessionMappings"][number] | null | undefined, + connectionWorkspaceRoot: string, +): string { + const workspaceRoot = (mapping?.workspaceRoot ?? "").trim() || connectionWorkspaceRoot.trim(); + return botMappingScope(mapping, connectionWorkspaceRoot) === "project" ? workspaceRoot : ""; +} + +function compactRemoteId(value: string): string { + const trimmed = value.trim(); + if (trimmed.length <= 28) return trimmed; + return `${trimmed.slice(0, 12)}…${trimmed.slice(-8)}`; +} + +function botMappingIdentityLabel(mapping: BotConnectionView["sessionMappings"][number] | null | undefined): string { + const chatType = (mapping?.chatType ?? "").trim(); + const userId = (mapping?.userId ?? "").trim(); + const threadId = (mapping?.threadId ?? "").trim(); + if (threadId) return compactRemoteId(threadId); + if ((chatType === "group" || chatType === "guild") && userId) return compactRemoteId(userId); + return ""; +} + +function sidebarImStatus(connection: BotConnectionView, botEnabled: boolean): SidebarImStatus { + if (!botEnabled || !connection.enabled) return "disabled"; + if (connection.status === "connected") return "connected"; + if (connection.status === "pending") return "pending"; + if (connection.status === "error") return "error"; + return "disconnected"; +} + +function sidebarImStatusLabel(status: SidebarImStatus, translate: Translator): string { + switch (status) { + case "connected": + return translate("sidebar.imConnected"); + case "disabled": + return translate("sidebar.imDisabled"); + case "pending": + return translate("sidebar.imPending"); + case "error": + return translate("sidebar.imError"); + default: + return translate("sidebar.imDisconnected"); + } +} + +function uniqueTrimmedValues(values: string[]): string[] { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function sidebarImAllowlistUsers(bot: BotSettingsView, platform: SidebarImPlatform): string[] { + if (platform === "qq") return uniqueTrimmedValues(asArray(bot.allowlist.qqUsers)); + if (platform === "weixin") return uniqueTrimmedValues(asArray(bot.allowlist.weixinUsers)); + return uniqueTrimmedValues(asArray(bot.allowlist.feishuUsers)); +} + +function sidebarImQQAdded(qq: BotSettingsView["qq"]): boolean { + return Boolean(qq.enabled || qq.secretSet || qq.appId.trim()); +} + +function sidebarImQQStatus(bot: BotSettingsView, runtimeStatus: BotRuntimeStatusView | null | undefined, nativeRuntime: boolean): SidebarImStatus { + const appId = bot.qq.appId.trim(); + if (!bot.enabled || !bot.qq.enabled) return "disabled"; + if (!appId || !bot.qq.secretSet) return "disconnected"; + if (!nativeRuntime) return "pending"; + if (!runtimeStatus) return "pending"; + const status = runtimeStatus.status.trim().toLowerCase(); + if (runtimeStatus.running && runtimeStatus.connections > 0 && status === "running") { + return "connected"; + } + if (status === "error" || status === "blocked" || status === "degraded") return "error"; + if (status === "stopped") return "disconnected"; + return "pending"; +} + +function sidebarImQQConnection(bot: BotSettingsView, translate: Translator, runtimeStatus: BotRuntimeStatusView | null | undefined, nativeRuntime: boolean): SidebarImConnection | null { + if (!sidebarImQQAdded(bot.qq)) return null; + const remoteId = bot.qq.appId.trim(); + const status = sidebarImQQStatus(bot, runtimeStatus, nativeRuntime); + const statusLabel = sidebarImStatusLabel(status, translate); + const allowlistUsers = sidebarImAllowlistUsers(bot, "qq"); + const subtitleParts = [ + remoteId ? compactRemoteId(remoteId) : "QQ", + statusLabel, + ].filter(Boolean); + return { + id: "__qq_bot__", + connectionId: "__qq_bot__", + platform: "qq", + title: "QQ Bot", + platformLabel: "QQ", + subtitle: subtitleParts.join(" · "), + status, + statusLabel, + remoteId, + sessionId: "", + sessionSource: "", + scope: "global", + workspaceRoot: "", + allowAll: bot.allowlist.allowAll, + allowlistEnabled: bot.allowlist.enabled, + allowlistUsers, + allowlistMatched: remoteId ? allowlistUsers.includes(remoteId) : false, + }; +} + +export function sidebarImConnectionsFromBot( + bot: BotSettingsView | null | undefined, + translate: Translator, + runtimeStatus: BotRuntimeStatusView | null | undefined, + nativeRuntime: boolean, +): SidebarImConnection[] { + if (!bot) return []; + const qqConnection = sidebarImQQConnection(bot, translate, runtimeStatus, nativeRuntime); + const connectionItems: SidebarImConnection[] = []; + for (const connection of asArray(bot.connections)) { + if (!isSidebarImConnection(connection)) continue; + const mappings = connection.sessionMappings.filter((mapping) => mapping.sessionId.trim() || mapping.remoteId.trim()); + const rowMappings = mappings.length > 0 ? mappings : [null]; + rowMappings.forEach((mapping, index) => { + const platform = sidebarImPlatform(connection); + const platformLabel = sidebarImPlatformLabel(platform, translate); + const remoteId = mapping?.remoteId.trim() ?? ""; + const sessionId = mapping?.sessionId.trim() ?? ""; + const sessionSource = mapping?.sessionSource.trim() ?? ""; + const scope = botMappingScope(mapping, connection.workspaceRoot); + const workspaceRoot = botMappingWorkspaceRoot(mapping, connection.workspaceRoot); + const status = sidebarImStatus(connection, bot.enabled); + const title = connection.label.trim() || platformLabel; + const allowlistUsers = sidebarImAllowlistUsers(bot, platform); + const identityLabel = botMappingIdentityLabel(mapping); + const mappedUserId = mapping?.userId.trim() ?? ""; + const subtitleParts = [ + remoteId ? compactRemoteId(remoteId) : platformLabel, + identityLabel, + connection.model.trim() || "", + sidebarImStatusLabel(status, translate), + ].filter(Boolean); + connectionItems.push({ + id: mapping ? `${connection.id}:mapping:${index}` : connection.id, + connectionId: connection.id, + platform, + title, + platformLabel, + subtitle: subtitleParts.join(" · "), + status, + statusLabel: sidebarImStatusLabel(status, translate), + remoteId, + sessionId, + sessionSource, + scope, + workspaceRoot, + allowAll: bot.allowlist.allowAll, + allowlistEnabled: bot.allowlist.enabled, + allowlistUsers, + allowlistMatched: remoteId + ? allowlistUsers.includes(remoteId) || (mappedUserId ? allowlistUsers.includes(mappedUserId) : false) + : false, + }); + }); + } + return qqConnection ? [qqConnection, ...connectionItems] : connectionItems; +} + +function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null { + const trimmed = sessionId.trim(); + if (!trimmed) return null; + const lower = trimmed.toLowerCase(); + if (lower.startsWith("path:")) { + const value = trimmed.slice(5).trim(); + return value ? { kind: "path", value } : null; + } + if (lower.startsWith("topic:")) { + const value = trimmed.slice(6).trim(); + return value ? { kind: "topic", value } : null; + } + if (trimmed.endsWith(".jsonl") || trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith("~")) { + return { kind: "path", value: trimmed }; + } + return { kind: "topic", value: trimmed }; +} + +export function taskSessionIDFromPath(path: string): string { + const base = path.replace(/\\/g, "/").split("/").pop() || ""; + const extension = base.lastIndexOf("."); + return extension > 0 ? base.slice(0, extension) : base; +} + +export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null { + return mappedSessionTarget(connection.sessionId); +} + +export function isChannelSession(session: SessionMeta): boolean { + return session.kind === "channel" || session.sessionSource === "auto"; +} + +export function sidebarImTopicSourcesFromBot(bot: BotSettingsView | null | undefined, translate: Translator): Record { + if (!bot?.connections?.length) return {}; + const sources: Record = {}; + for (const connection of bot.connections) { + if (!isSidebarImConnection(connection)) continue; + const platform = sidebarImPlatform(connection); + const label = sidebarImPlatformLabel(platform, translate); + const title = connection.label.trim() || label; + for (const mapping of asArray(connection.sessionMappings)) { + const scope = botMappingScope(mapping, connection.workspaceRoot); + if (scope !== "global") continue; + const target = mappedSessionTarget(mapping.sessionId); + if (!target || target.kind !== "topic") continue; + if (sources[target.value]) continue; + sources[target.value] = { + platform, + label, + title, + remoteId: mapping.remoteId.trim(), + connectionId: connection.id, + }; + } + } + return sources; +} + +export function sidebarImScopeLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.scope === "project") return translate("botDetail.scopeProject", { name: connection.workspaceRoot || "Project" }); + return translate("botDetail.scopeGlobal"); +} diff --git a/desktop/frontend/src/app-runtime/useAppChromeCommands.ts b/desktop/frontend/src/app-runtime/useAppChromeCommands.ts new file mode 100644 index 0000000000..4da1af1113 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useAppChromeCommands.ts @@ -0,0 +1,93 @@ +import type { MouseEvent as ReactMouseEvent, Dispatch, SetStateAction } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { isMacOSWorkbenchSidebarTitlebar, type DesktopPlatform } from "../lib/desktopPlatform"; +import { nativeWindowCommands, syncMainWindowMaximised } from "./useNativeWindowController"; +import type { SettingsTab, SettingsView } from "../lib/types"; +import type { SettingsInitialFocus } from "../components/SettingsPanel"; + +export type AppChromeCommandsInput = { + platform: DesktopPlatform; + windowsFrameless: boolean; + closeTransientOverlays: () => void; + clearImDetail: () => void; + setSettingsFocus: Dispatch>; + setSettingsTarget: Dispatch>; + setSidebarSearchOpen: Dispatch>; + setSidebarSearchFocusSignal: Dispatch>; + refreshMeta: () => Promise; + refreshProviderSetupState: () => Promise; + reloadDesktopPreferences: (settings?: SettingsView | null) => Promise; +}; + +/** + * Owns the window-chrome and settings-surface commands: native window + * minimize/toggle/close with the maximised re-sync, the frameless titlebar + * double-click zoom, settings open/close/changed, bot settings entries and + * the sidebar search toggle. All are stable committed commands; consumers in + * the chrome, sidebar, IM detail and overlay regions only wire them. + */ +export function useAppChromeCommands(input: AppChromeCommandsInput) { + const openBotSettings = useCommittedCommand(() => { + input.closeTransientOverlays(); + input.clearImDetail(); + input.setSettingsFocus(null); + input.setSettingsTarget("bots"); + }); + + const openBotAllowlistSettings = useCommittedCommand((connectionId: string) => { + input.closeTransientOverlays(); + input.clearImDetail(); + input.setSettingsFocus({ target: "bot-allowlist", connectionId }); + input.setSettingsTarget("bots"); + }); + + // The Wails drag runtime ignores anything with detail !== 1, so a double click + // on a --wails-draggable region never reaches the OS. Both platforms that hide + // their native title bar need this handled here. + const chromeDoubleClickZooms = input.windowsFrameless || input.platform === "darwin"; + const handleChromeTitlebarDoubleClick = useCommittedCommand((event: ReactMouseEvent) => { + if (!chromeDoubleClickZooms) return; + const target = event.target as HTMLElement | null; + const onChromeSurface = target?.closest(".app-chrome, .topicbar, .workbench-dock__tools, .management-screen__chrome"); + const onMacOSWorkbenchSidebarTitlebar = isMacOSWorkbenchSidebarTitlebar(target, event.clientY, input.platform); + if (!onChromeSurface && !onMacOSWorkbenchSidebarTitlebar) return; + if (target?.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return; + event.preventDefault(); + void nativeWindowCommands.toggleMaximize().then(syncMainWindowMaximised).catch(() => undefined); + }); + const minimizeMainWindow = useCommittedCommand(() => { void nativeWindowCommands.minimize(); }); + const toggleMainWindowMaximized = useCommittedCommand(() => { + void nativeWindowCommands.toggleMaximize().then(syncMainWindowMaximised).catch(() => undefined); + }); + const closeMainWindow = useCommittedCommand(() => { void nativeWindowCommands.close(); }); + const closeSettings = useCommittedCommand(() => { + input.setSettingsFocus(null); + input.setSettingsTarget(null); + }); + const handleSettingsChanged = useCommittedCommand((settings?: SettingsView | null) => { + void input.refreshMeta(); + void input.refreshProviderSetupState().catch(() => {}); + void input.reloadDesktopPreferences(settings); + }); + const openSidebarSettings = useCommittedCommand((tab: SettingsTab) => { + input.closeTransientOverlays(); + input.setSettingsTarget(tab); + }); + const toggleSidebarSearch = useCommittedCommand(() => { + input.setSidebarSearchOpen((open) => !open); + input.setSidebarSearchFocusSignal((signal) => signal + 1); + }); + + return { + openBotSettings, + openBotAllowlistSettings, + handleChromeTitlebarDoubleClick, + minimizeMainWindow, + toggleMainWindowMaximized, + closeMainWindow, + closeSettings, + handleSettingsChanged, + openSidebarSettings, + toggleSidebarSearch, + }; +} diff --git a/desktop/frontend/src/app-runtime/useAppEffectHosts.ts b/desktop/frontend/src/app-runtime/useAppEffectHosts.ts new file mode 100644 index 0000000000..b394709837 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useAppEffectHosts.ts @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { recordFrontendDiagnostic } from "../lib/frontendDiagnosticBridge"; + +export function useAppDiagnostics(input: { + activeTabId?: string | null; + tabCount: number; + ready: boolean; + running: boolean; + hydrating: boolean; + runtimeTransitioning: boolean; + contentRevision?: number; +}) { + useEffect(() => { + recordFrontendDiagnostic("app", "app.surface", { hasActiveTab: Boolean(input.activeTabId), tabCount: input.tabCount }); + }, [input.activeTabId, input.tabCount]); + useEffect(() => { + recordFrontendDiagnostic("app", "app.runtime-state", { + ready: input.ready, running: input.running, hydrating: input.hydrating, + runtimeTransitioning: input.runtimeTransitioning, contentRevision: input.contentRevision, + }); + }, [input.contentRevision, input.hydrating, input.ready, input.running, input.runtimeTransitioning]); +} + +export function useSidebarConnectionValidity(input: { + connections: readonly T[]; + setConnectionId: (update: (current: string) => string) => void; +}) { + const { connections, setConnectionId } = input; + useEffect(() => { + setConnectionId((current) => !current || connections.some((connection) => connection.id === current) ? current : ""); + }, [connections, setConnectionId]); +} diff --git a/desktop/frontend/src/app-runtime/useAppNavigationComposition.ts b/desktop/frontend/src/app-runtime/useAppNavigationComposition.ts new file mode 100644 index 0000000000..27951ddc3b --- /dev/null +++ b/desktop/frontend/src/app-runtime/useAppNavigationComposition.ts @@ -0,0 +1,221 @@ +import { browserMockScenarioParam, GUIDANCE_QUEUE_MOCK_ITEMS, isGuidanceMockScenario } from "../lib/mockScenarios"; +import { formatShortcutCombo, resolvedShortcutCombo } from "../lib/keyboardShortcuts"; +import { showWorktreeCleanupNotice } from "../lib/worktreeCleanupNotice"; +import { desktopBridge } from "./desktopBridgeAdapter"; +import { desktopProjectAdapter } from "./desktopProjectAdapter"; +import { useHistoryCommands } from "./useHistoryCommands"; +import { useSessionNavigationCommands } from "./useSessionNavigationCommands"; +import { usePaletteCommands } from "./usePaletteCommands"; +import { useTopicNavigationShortcuts } from "./useTopicNavigationShortcuts"; +import { useProjectTopicCommands } from "./useProjectTopicCommands"; +import { useAppChromeCommands } from "./useAppChromeCommands"; +import { useOnboardingCommands } from "./useOnboardingCommands"; +import { useWorktreeMergeCommands } from "./useWorktreeMergeCommands"; +import type { HistoryViewState } from "./historyViewProjection"; +import type { State } from "../lib/useController"; +import type { TabMeta } from "../lib/types"; +import type { Translator } from "../lib/i18n"; +import type { useAppRuntimeAdapter } from "./useAppRuntimeAdapter"; +import type { useAppShellStores } from "./useAppShellStores"; +import type { useNavigationSurface } from "../lib/useNavigationSurface"; +import type { useAppSessionComposition } from "./useAppSessionComposition"; + +type Runtime = ReturnType; +type Shell = ReturnType; +type SessionComposition = ReturnType; + +export type AppNavigationCompositionInput = { + runtime: Runtime; + t: Translator; + notice: Runtime["snapshot"]["notice"]; + showToast: (message: string, level?: "info" | "warn" | "error", options?: { durationMs?: number }) => void; + shell: Shell; + state: State; + activeTab: TabMeta | undefined; + activeTabId: string | undefined; + activeSessionIdentity: string; + remoteSurfaceActive: boolean; + surface: Pick, "begin" | "maskTarget">; + local: { + setHistView: React.Dispatch>; + setProjectRevision: React.Dispatch>; + setSidebarImDetailConnectionId: React.Dispatch>; + setTasksOpen: React.Dispatch>; + }; + session: SessionComposition; +}; + +/** + * Navigation/chrome composition: history, automation, desktop and session + * navigation, command palette, topic shortcuts, project/topic commands, + * window chrome and worktree merge. Runs after the session composition in + * the App body's original order; pure relocation. + */ +export function useAppNavigationComposition(input: AppNavigationCompositionInput) { + const { runtime, t, notice, showToast, shell, state, activeTab, activeTabId, activeSessionIdentity, session } = input; + const { remoteSurfaceActive } = input; + const { begin: beginNavigationSurface, maskTarget: settleNavigationSurface } = input.surface; + const { + setHistView, setProjectRevision, + setSidebarImDetailConnectionId, setTasksOpen, + } = input.local; + const { + noteNavigationIntent, registeredNavigationIntent, + isNavigationIntentCurrent, syncActiveTab, ensureBlankTab, ensureBlankSurface, + } = runtime.navigation; + const { listSessions, deleteSession, renameSession } = runtime.sessionActions; + const { refreshMeta, pickWorkspace, switchWorkspace } = runtime.workspace; + const { + managementActive, desktopPlatform, windowsFramelessChrome, singleSurfaceLayout, sidebarCollapsed, + openPage, returnToWorkspace, enterConversation, + setSettingsTarget, setSettingsFocus, setSidebarSearchOpen, setSidebarSearchFocusSignal, setProviderSetupNeeded, + } = shell; + const { reload: reloadDesktopPreferences } = shell.preferences; + const { + closeTransientOverlays, refreshProviderSetupState, + tabBarCommands, terminalPanelCommands, remoteWorkspaceCommands, runtimeEventCommands, + desktopNavigation: desktopNavigationBag, + } = session; + const { refreshTabMetas, seedActiveTabMeta } = runtimeEventCommands; + const { handleTabClose } = tabBarCommands; + const { toggleTerminalPanel } = terminalPanelCommands; + const { openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace } = remoteWorkspaceCommands; + const { enqueueNavigation, enqueueNavigationWithIntent, openRemoteProject } = desktopNavigationBag; + const { toggleSidebar } = session.shellGeometry; + + const historyCommands = useHistoryCommands({ + running: state.running, + setHistView, + ports: { + listSessions, + deleteSession, + renameSession, + openPage: (page) => openPage(page), + }, + }); + const { openTrash, refreshHistoryView } = historyCommands; + + + const navigationCommands = useSessionNavigationCommands({ + activeTab, + running: state.running, + singleSurface: singleSurfaceLayout, + t, + showToast, + closeTransientOverlays, + clearImDetail: () => setSidebarImDetailConnectionId(""), + navigation: { enqueueNavigation, enqueueNavigationWithIntent, openRemoteProject }, + noteNavigationIntent, + beginNavigationSurface, + settleNavigationSurface, + isNavigationIntentCurrent, + markProjectChanged: setProjectRevision, + refreshTabMetas, + refreshHistoryView, + enterConversation, + pickWorkspace, + switchWorkspace, + ports: { + openTaskSessionForTab: (tabId, taskId) => desktopBridge.openTaskSessionForTab(tabId, taskId), + listSessionsForTab: (tabId) => desktopBridge.listSessionsForTab(tabId), + }, + }); + const { openBlankSession, handleNewTab, onResumeSession, switchFolder, handleNavigateTopic } = navigationCommands; + + // Command palette: ⌘K / Ctrl+K opens a fuzzy navigator over commands and + // recent sessions. Sessions are snapshotted on open so the list is stable + // while the palette is up; extension actions follow the same snapshot rule. + const { openPalette, paletteItems } = usePaletteCommands({ + managementActive, + activeTabId, + remoteSurfaceActive, + t, + notice, + showToast, + ports: { + handleNewTab: () => void handleNewTab(), + listSessions, + openTrash: () => void openTrash(), + onResumeSession: (session) => onResumeSession(session), + openRemoteWorkspaceFromStatus: (host) => openRemoteWorkspaceFromStatus(host), + connectAndOpenRemoteWorkspace: (host) => connectAndOpenRemoteWorkspace(host), + toggleTerminalPanel, + setTasksOpen: (open) => setTasksOpen(open), + handleTabClose: (id) => void handleTabClose(id), + toggleSidebar, + returnToWorkspace, + }, + }); + + // --- Topic shortcut navigation (Cmd/Ctrl+1-9) --- + const { showBadges: showTopicBadges, setVisibleTopics: handleVisibleTopicsChange } = useTopicNavigationShortcuts({ + enabled: !sidebarCollapsed && !managementActive, + platform: desktopPlatform, + onNavigate: handleNavigateTopic, + }); + + // Delete / rename act on disk, then re-fetch so the panel reflects the change. + // Workspace: open the folder chooser and switch projects. The hook resets the + // transcript and refreshes meta on a pick. A cancel is a no-op. + const projectTopicCommands = useProjectTopicCommands({ + visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + topic: activeTab?.remote ? { + id: activeTab.id, title: activeTab.topicTitle || "", + target: { kind: "remote", ...activeTab.remote, sessionPath: activeTab.sessionPath || "" }, + } : activeTab?.topicId ? { + id: activeTab.topicId, title: activeTab.topicTitle || "", target: { kind: "local", topicId: activeTab.topicId }, + } : undefined, + ports: { ...desktopProjectAdapter, markChanged: setProjectRevision, refreshTabs: refreshTabMetas, syncActive: syncActiveTab }, + navigation: { openBlank: openBlankSession, enqueue: enqueueNavigation, switchFolder }, + reportError: error => showToast(error instanceof Error ? error.message : String(error), "error"), + }); + + const sidebarExpandBlocked = false; + const sidebarToggleTitle = sidebarCollapsed + ? t("sidebar.expand") + : t("sidebar.collapse"); + const browserPreviewChrome = typeof window !== "undefined" && !window.runtime; + const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : ""; + const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined; + // Command palette shortcut label (⌘K / Ctrl+K), platform-aware. + const commandPaletteShortcut = formatShortcutCombo( + resolvedShortcutCombo("commandPalette.open", desktopPlatform), + desktopPlatform, + ); + const chromeCommands = useAppChromeCommands({ + platform: desktopPlatform, + windowsFrameless: windowsFramelessChrome, + closeTransientOverlays, + clearImDetail: () => setSidebarImDetailConnectionId(""), + setSettingsFocus, + setSettingsTarget, + setSidebarSearchOpen, + setSidebarSearchFocusSignal, + refreshMeta, + refreshProviderSetupState, + reloadDesktopPreferences: (settings) => reloadDesktopPreferences(settings), + }); + const onboardingCommands = useOnboardingCommands(() => setProviderSetupNeeded(false)); + const worktreeMergeCommands = useWorktreeMergeCommands({ + singleSurfaceLayout, noteNavigationIntent, + registeredNavigationIntent, isNavigationIntentCurrent, ensureBlankSurface, ensureBlankTab, + seedSource: seedActiveTabMeta, listTabs: desktopBridge.listTabs, + closeWorktree: desktopBridge.closeMergedWorktreeTab, finalize: desktopBridge.finalizeWorktreeMerge, + showToast, t, showCleanup: (cleanup, translate) => showWorktreeCleanupNotice(cleanup, translate, showToast), + }); + return { + historyCommands, + navigationCommands, + paletteCommands: { openPalette, paletteItems }, + topicShortcuts: { showTopicBadges, handleVisibleTopicsChange }, + projectTopicCommands, + chromeCommands, + onboardingCommands, + worktreeMergeCommands, + sidebarExpandBlocked, + sidebarToggleTitle, + browserPreviewChrome, + commandPaletteShortcut, + guidanceQueueMockItems, + }; +} diff --git a/desktop/frontend/src/app-runtime/useAppRuntimeAdapter.ts b/desktop/frontend/src/app-runtime/useAppRuntimeAdapter.ts new file mode 100644 index 0000000000..65eea2a7ba --- /dev/null +++ b/desktop/frontend/src/app-runtime/useAppRuntimeAdapter.ts @@ -0,0 +1,94 @@ +import { useController } from "../lib/useController"; + +/** + * Narrows the legacy controller into explicit App-facing runtime ports. + * It does not own state: Controller stores remain authoritative and every + * source-bound operation still executes through the existing controller. + */ +export function useAppRuntimeAdapter() { + const controller = useController(); + return { + snapshot: { + state: controller.state, + liveStore: controller.liveStore, + activeTabId: controller.activeTabId, + notice: controller.notice, + }, + composer: { + sendToTab: controller.sendToTab, + runShellForTab: controller.runShellForTab, + steerForTab: controller.steerForTab, + cancel: controller.cancel, + cancelForTab: controller.cancelForTab, + setControllerMode: controller.setControllerMode, + setControllerModeForTab: controller.setControllerModeForTab, + setCollaborationMode: controller.setCollaborationMode, + setCollaborationModeForTab: controller.setCollaborationModeForTab, + setToolApprovalMode: controller.setToolApprovalMode, + setToolApprovalModeForTab: controller.setToolApprovalModeForTab, + setQualityFloor: controller.setQualityFloor, + setComposerProfileForTab: controller.setComposerProfileForTab, + setGoalForTab: controller.setGoalForTab, + resumeGoalForTab: controller.resumeGoalForTab, + pauseGoalForTab: controller.pauseGoalForTab, + clearGoal: controller.clearGoal, + clearGoalForTab: controller.clearGoalForTab, + setModel: controller.setModel, + setModelForTab: controller.setModelForTab, + setEffort: controller.setEffort, + setEffortForTab: controller.setEffortForTab, + cancelJob: controller.cancelJob, + }, + sessionActions: { + isPromptCurrentForTab: controller.isPromptCurrentForTab, + recoverDeliveryToTab: controller.recoverDeliveryToTab, + approveForTab: controller.approveForTab, + resolvePlanDecisionForTab: controller.resolvePlanDecisionForTab, + resolveRecoveryForTab: controller.resolveRecoveryForTab, + answerQuestionForTab: controller.answerQuestionForTab, + answerMCPInteractionForTab: controller.answerMCPInteractionForTab, + dismissExtensionForm: controller.dismissExtensionForm, + drainExtensionNotifications: controller.drainExtensionNotifications, + clearSession: controller.clearSession, + newSession: controller.newSession, + listSessions: controller.listSessions, + listTrashedSessions: controller.listTrashedSessions, + resumeSession: controller.resumeSession, + openChannelSession: controller.openChannelSession, + previewSession: controller.previewSession, + deleteSession: controller.deleteSession, + restoreSession: controller.restoreSession, + purgeTrashedSession: controller.purgeTrashedSession, + renameSession: controller.renameSession, + loadOlderHistory: controller.loadOlderHistory, + retrySessionHistory: controller.retrySessionHistory, + rewindForTab: controller.rewindForTab, + rewindForTabDetailed: controller.rewindForTabDetailed, + undoRewindForTab: controller.undoRewindForTab, + }, + workspace: { + refreshMeta: controller.refreshMeta, + pickWorkspace: controller.pickWorkspace, + switchWorkspace: controller.switchWorkspace, + }, + navigation: { + switchTab: controller.switchTab, + switchRemoteTab: controller.switchRemoteTab, + openProjectTab: controller.openProjectTab, + createIsolatedWorktree: controller.createIsolatedWorktree, + openGlobalTab: controller.openGlobalTab, + closeTab: controller.closeTab, + reorderTabs: controller.reorderTabs, + openTopicSession: controller.openTopicSession, + activateTopic: controller.activateTopic, + noteNavigationIntent: controller.noteNavigationIntent, + registeredNavigationIntent: controller.registeredNavigationIntent, + isNavigationIntentCurrent: controller.isNavigationIntentCurrent, + reassertVisibleTabAfterStaleNavigation: controller.reassertVisibleTabAfterStaleNavigation, + syncActiveTab: controller.syncActiveTab, + ensureBlankTab: controller.ensureBlankTab, + ensureBlankSurface: controller.ensureBlankSurface, + commitSingleSurfaceNavigation: controller.commitSingleSurfaceNavigation, + }, + }; +} diff --git a/desktop/frontend/src/app-runtime/useAppSessionComposition.ts b/desktop/frontend/src/app-runtime/useAppSessionComposition.ts new file mode 100644 index 0000000000..6b5c983f46 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useAppSessionComposition.ts @@ -0,0 +1,755 @@ +import { useMemo, useRef, useState, type CSSProperties } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useWailsResizeFix } from "../lib/useWailsResizeFix"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; +import { activeLeaseBlockedTab } from "../lib/tabMetaRefresh"; +import { topicTitle } from "../lib/sessionTitles"; +import { composerDraftKeyForTab } from "../lib/composerDraftKey"; +import { useWindowStatePersistence, useViewportHeightVar } from "../lib/windowState"; +import { useManagementWorkspace } from "../lib/useManagementWorkspace"; +import { reportPendingRevisionFailure, usePendingPlanRevisions } from "../lib/usePendingPlanRevisions"; +import { useComposerModeActions } from "../lib/useComposerModeActions"; +import { useRemoteComposerRuntimeActions, useRemoteComposerSend } from "../lib/useRemoteComposerIntegration"; +import type { RemoteNavigationCommand } from "../lib/remoteNavigationCommands"; +import type { CollaborationMode, TabMeta } from "../lib/types"; +import type { RestorableToolApprovalMode } from "../lib/toolApprovalMode"; +import type { ComposerProfile, UserPlanModeIntents } from "../lib/composerProfile"; +import type { State } from "../lib/useController"; +import type { Translator } from "../lib/i18n"; +import type { useAppRuntimeAdapter } from "./useAppRuntimeAdapter"; +import type { useNavigationSurface } from "../lib/useNavigationSurface"; +import { desktopBridge } from "./desktopBridgeAdapter"; +import { useSessionOperations } from "./useSessionOperations"; +import { useComposerInsertCommands } from "./useComposerInsertCommands"; +import { useSessionClearCommands } from "./useSessionClearCommands"; +import { useRuntimeStatus } from "./useRuntimeStatus"; +import { useAppDiagnostics, useSidebarConnectionValidity } from "./useAppEffectHosts"; +import { useActiveTabUiReset, useDecisionSurfaceFocus } from "./useLocalUiLifecycles"; +import { useActiveTabMirrorCommit } from "./activeTabMirror"; +import { useInvocationMetadata } from "./useInvocationMetadata"; +import { useFooterHeightLifecycle } from "./useFooterHeightLifecycle"; +import { useNativeSettingsEvent } from "./useNativeSettingsEvent"; +import { useWindowsMaximisedSync } from "./useNativeWindowController"; +import { useShellGeometry } from "./useShellGeometry"; +import { useTopicSummary } from "./useTopicSummary"; +import { useComposerProfileProjection } from "./useComposerProfileProjection"; +import { useTabBarCommands } from "./useTabBarCommands"; +import { useExtensionSurface } from "./useExtensionSurface"; +import { useTabProjectionLifecycle } from "./useTabProjectionLifecycle"; +import { useSessionUndo } from "./useSessionUndo"; +import { useSessionSubmission } from "../lib/useSessionSubmission"; +import { useControllerProfileCommands } from "../lib/useControllerProfileCommands"; +import { useSessionPromptCommands } from "./useSessionPromptCommands"; +import { useSessionControlCommands } from "./useSessionControlCommands"; +import { useTodoPanelCommands } from "./useTodoPanelCommands"; +import { useSessionExportCommands } from "./useSessionExportCommands"; +import { useComposerRouter } from "./useComposerRouter"; +import { useComposerGoalCommands } from "./useComposerGoalCommands"; +import { useRuntimeEventHandlers } from "./useRuntimeEventHandlers"; +import { probeProviderSetupState } from "./StartupGateLifecycle"; +import { useSessionBannerCommands } from "./useSessionBannerCommands"; +import { useWorkspacePanelCommands } from "./useWorkspacePanelCommands"; +import { useTurnVerificationCommands } from "./useTurnVerificationCommands"; +import { useTerminalPanelCommands } from "./useTerminalPanelCommands"; +import { useRemoteWorkspaceCommands } from "./useRemoteWorkspaceCommands"; +import { useAutomationNavigation } from "./useAutomationNavigation"; +import { useDesktopNavigation } from "./useDesktopNavigation"; +import { useTranscriptSurfaceProjection } from "./useTranscriptSurfaceProjection"; +import { useDeliveryContinueCommands } from "./useDeliveryContinueCommands"; +import { projectConversation, projectConversationLayout, projectWorkspaceScopeKey, projectWorkspaceTreeMemoryKey } from "./conversationProjection"; +import { projectControllerProfiles, projectVisibleTabs } from "./controllerProfileOwner"; +import { projectDecisionSurface, type AppDecisionSurfaceKind } from "./decisionSurfaceProjection"; +import { createSubmissionPorts, projectSubmissionResources } from "./desktopSubmissionAdapter"; +import type { useAppShellStores } from "./useAppShellStores"; + +function setRemoteComposerProfileForSessionAction( + tabId: string, + mode: CollaborationMode, + approvalMode: import("../lib/types").ToolApprovalMode, + goal: string, +) { + return desktopBridge.setRemoteTabComposerProfile(tabId, mode, approvalMode, goal); +} + +const WORKSPACE_RESIZER_WIDTH = 8; + +type Runtime = ReturnType; +type Shell = ReturnType; +type Surface = ReturnType; +type LiveStore = Runtime["snapshot"]["liveStore"]; + +export type AppSessionCompositionInput = { + runtime: Runtime; + t: Translator; + showToast: (message: string, level?: "info" | "warn" | "error", options?: { durationMs?: number }) => void; + shell: Shell; + core: { + state: State; + liveStore: LiveStore; + activeTabId: string | undefined; + notice: Runtime["snapshot"]["notice"]; + activeTab: TabMeta | undefined; + remoteSurfaceActive: boolean; + remoteSession: RemoteSessionApi; + remoteComposerReady: boolean; + remoteSend: (text: string) => Promise; + remoteCancel: (queuedItemIDs?: string[]) => Promise; + activeSessionIdentity: string; + sessionSurfaceFence: ReturnType; + sessionOperations: ReturnType; + }; + surface: Surface; + stores: { + composerProfilesByTab: Record; + setComposerProfilesByTab: React.Dispatch>>; + tabMetas: TabMeta[]; + setTabMetas: React.Dispatch>; + tabOrderIds: string[]; + setTabOrderIds: React.Dispatch>; + yoloRestoreToolApprovalModesRef: { current: Record }; + userPlanModeByTabRef: { current: UserPlanModeIntents }; + }; + local: { + setHistView: React.Dispatch>; + setTabRevealSignal: React.Dispatch>; + setTranscriptRevealSignal: React.Dispatch>; + sidebarImDetailConnectionId: string; + setSidebarImDetailConnectionId: React.Dispatch>; + workspaceScopeActiveTabRef: { current: string | undefined }; + workspaceControllerEpoch: number; + setWorkspaceControllerEpoch: React.Dispatch>; + dockRefreshKey: number; + setDockRefreshKey: React.Dispatch>; + fileRefRefreshKey: number; + setFileRefRefreshKey: React.Dispatch>; + projectRevision: number; + setProjectRevision: React.Dispatch>; + }; + goal: { + runGoalAction: ReturnType["runGoalAction"]; + handleGoalActionError: ReturnType["handleGoalActionError"]; + }; +}; + +/** + * Session/composer composition: runs every session-domain owner hook in the + * App body's original order and returns the bags the navigation composition + * and the shell view consume. Pure relocation — hook order within the + * segment is unchanged. + */ +export function useAppSessionComposition(input: AppSessionCompositionInput) { + const { t, showToast, shell, runtime } = input; + const { + state, liveStore, activeTabId, notice, activeTab, remoteSurfaceActive, remoteSession, remoteComposerReady, + remoteSend, activeSessionIdentity, sessionSurfaceFence, sessionOperations, + } = input.core; + // remoteCancel is consumed by the shell view through core. + const { + transitioning: runtimeTransitioning, dataReady: navigationTargetDataReady, + preserved: preservedTranscriptSurface, commitRendered: commitRenderedTranscriptSurface, + begin: beginNavigationSurface, maskTarget: settleNavigationSurface, commitPaint: commitNavigationSurfacePaint, + } = input.surface; + const { + sendToTab, runShellForTab, steerForTab, cancel, cancelForTab, + setControllerModeForTab, setCollaborationMode: setControllerCollaborationMode, + setCollaborationModeForTab: setControllerCollaborationModeForTab, + setToolApprovalModeForTab, setQualityFloor: setControllerQualityFloor, + setComposerProfileForTab: setControllerComposerProfileForTab, setGoalForTab: setControllerGoalForTab, + resumeGoalForTab: resumeControllerGoalForTab, pauseGoalForTab: pauseControllerGoalForTab, + clearGoalForTab: clearControllerGoalForTab, + setModelForTab, setEffortForTab, + } = runtime.composer; + const { + recoverDeliveryToTab, approveForTab, isPromptCurrentForTab, resolvePlanDecisionForTab, resolveRecoveryForTab, + answerQuestionForTab, answerMCPInteractionForTab, dismissExtensionForm, drainExtensionNotifications, + clearSession, newSession, loadOlderHistory, rewindForTab, rewindForTabDetailed, undoRewindForTab, + listSessions, openChannelSession, resumeSession, + } = runtime.sessionActions; + const { + switchTab, switchRemoteTab, closeTab, reorderTabs, createIsolatedWorktree, + noteNavigationIntent, registeredNavigationIntent, isNavigationIntentCurrent, reassertVisibleTabAfterStaleNavigation, + commitSingleSurfaceNavigation, openTopicSession, openGlobalTab, openProjectTab, activateTopic, + ensureBlankSurface, ensureBlankTab, + } = runtime.navigation; + const { + setTransientOverlayDismissSignal, managementActive, desktopLayoutStyle, + singleSurfaceLayout, windowsFramelessChrome, mainWindowMaximised, rightDockMode, + workspacePanelOpen, workspacePanelMaximized, liveTerminalHeight, setLiveWorkspacePanelRenderWidth, + setRightDockTreeWidth, terminalPanelOpen, setSettingsTarget, enterConversation, + } = shell; + const { sidebarImConnections, reloadConfigWarnings } = shell.preferences; + const { + composerProfilesByTab, setComposerProfilesByTab, tabMetas, setTabMetas, tabOrderIds, setTabOrderIds, + yoloRestoreToolApprovalModesRef, userPlanModeByTabRef, + } = input.stores; + const { + setHistView, setTabRevealSignal, setTranscriptRevealSignal, + sidebarImDetailConnectionId, setSidebarImDetailConnectionId, + workspaceScopeActiveTabRef, workspaceControllerEpoch, setWorkspaceControllerEpoch, + setDockRefreshKey, projectRevision, setProjectRevision, + } = input.local; + const { runGoalAction, handleGoalActionError } = input.goal; + const insertCommands = useComposerInsertCommands({ + activeTabId, + sessionKey: activeSessionIdentity, + approval: state.approval, + operations: sessionOperations, + t, + showToast, + ports: { terminalOutput: (tabId, terminalSessionId) => desktopBridge.terminalOutputForTab(tabId, terminalSessionId) }, + }); + const { + setInsertTarget: setWorkspaceInsertTarget, replaceComposerInsert, + } = insertCommands; + useWindowsMaximisedSync(windowsFramelessChrome); + useWailsResizeFix(windowsFramelessChrome, mainWindowMaximised); + const clearCommands = useSessionClearCommands({ + activeTabId, + activeSessionIdentity, + remote: remoteSurfaceActive, + t, + notice, + operations: sessionOperations, + refreshDock: () => setDockRefreshKey((value) => value + 1), + ports: { + clearSession, + clearRemoteSession: (tabId) => desktopBridge.clearRemoteTabSession(tabId), + retryRemoteHydration: () => remoteSession.retryHydration(), + }, + }); + const { clearContextPending, setClearContextPending } = clearCommands; + const appRef = useRef(null); + const layoutRef = useRef(null); + useManagementWorkspace(layoutRef, managementActive); + + // Persist window geometry across launches. + useWindowStatePersistence(); + useViewportHeightVar(); + + const { backgroundRuntimes, workspaceConflict, setWorkspaceConflict, refreshBackgroundRuntimes } = useRuntimeStatus({ + tabId: activeTabId, sessionKey: activeSessionIdentity, running: state.running, + }); + + const closeTransientOverlays = useCommittedCommand(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }); + + useSidebarConnectionValidity({ connections: sidebarImConnections, setConnectionId: setSidebarImDetailConnectionId }); + + useNativeSettingsEvent({ closeTransientOverlays, setSettingsTarget }); + + const [footerHeight, setFooterHeight] = useState(0); + const footerRef = useRef(null); + const commitFooterHeight = useCommittedCommand((height: number) => setFooterHeight(height)); + useFooterHeightLifecycle(footerRef, commitFooterHeight); + useActiveTabMirrorCommit(activeTabId); + const { invocationMetadataByTab, handleInvocationMetadataChange } = useInvocationMetadata(); + const shellGeometry = useShellGeometry({ appRef, layoutRef }); + const { + rightDockTreeWidthClamp, chatReservedWidth, + workspacePanelAvailableWidth, workspacePanelRenderWidth, workspacePanelOverlay, workspacePanelRenderable, + workspacePanelGridOpen, sidebarRenderWidth, terminalRenderHeight, + } = shellGeometry; + + // Remote tab became ready: refresh the tab list so the spectator banner + // (takenOver) renders. The agent:ready event only fires for local tabs; + // remote tabs publish readiness via remote-tab::state, which + const conversationView = projectConversation({ local: state, remote: remoteSurfaceActive ? remoteSession : undefined, + tab: activeTab, activeTabId, backgroundRuntimes, connectingLabel: t("status.connecting") }); + const visibleRuntimeState = conversationView.runtime; + const sidebarImDetailConnection = useMemo( + () => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null, + [sidebarImConnections, sidebarImDetailConnectionId], + ); + const chatSurfaceVisible = true; + const { dockVisible: surfaceWorkspacePanelRenderable, dockGridOpen: surfaceWorkspacePanelGridOpen, + dockOverlay: surfaceWorkspacePanelOverlay, + terminalOpen: terminalSurfaceOpen } = projectConversationLayout({ + chatVisible: chatSurfaceVisible, localToolsEnabled: conversationView.localToolsEnabled, dockMode: rightDockMode, + dockRenderable: workspacePanelRenderable, dockGridOpen: workspacePanelGridOpen, dockOverlay: workspacePanelOverlay, + dockOpen: workspacePanelOpen, dockMaximized: workspacePanelMaximized, terminalOpen: terminalPanelOpen, + }); + const statusBarVisible = chatSurfaceVisible && !sidebarImDetailConnection; + const composerSessionKey = useMemo(() => { + return composerDraftKeyForTab(activeTab, activeTabId); + }, [activeTab, activeTabId]); + const transcriptGeometrySessionKey = activeSessionIdentity; + const workspaceScopeKey = projectWorkspaceScopeKey({ + activeTabId, tabSessionPath: activeTab?.sessionPath, metaSessionPath: state.meta?.sessionPath, + cwd: state.meta?.cwd, sessionGen: state.sessionGen, workspaceControllerEpoch, + }); + const workspaceTreeMemoryKey = projectWorkspaceTreeMemoryKey({ + scope: activeTab?.scope, workspaceRoot: activeTab?.workspaceRoot, cwd: state.meta?.cwd, + }); + const { activeTopicTurns } = useTopicSummary({ activeTab, revision: projectRevision }); + const visibleUserTurns = visibleRuntimeState.items.reduce((count, item) => (item.kind === "user" ? count + 1 : count), 0); + const currentTabTurns = Math.max(visibleRuntimeState.checkpoints.length, visibleUserTurns); + const sessionTurns = currentTabTurns > 0 ? currentTabTurns : remoteSurfaceActive ? 0 : activeTopicTurns ?? 0; + const startupSplashHold = !activeTabId && state.meta?.ready !== true && !state.meta?.startupErr; + const profileProjection = useComposerProfileProjection({ + activeTabId, + activeTab, + meta: state.meta, + profilesByTab: composerProfilesByTab, + setProfilesByTab: setComposerProfilesByTab, + tabMetas, + remote: remoteSurfaceActive, + remoteSession, + planIntentsRef: userPlanModeByTabRef, + setControllerQualityFloor, + showToast, + }); + const { + composerProfile, goal, collaborationMode, toolApprovalMode, + patchComposerProfileForTab, patchActivatedGoalForTab, + } = profileProjection; + const controllerReady = + state.meta?.ready === true && + (!state.meta.runtime || state.meta.runtime.phase === "ready") && + !state.meta.startupErr && + !state.backendActivationPending && + !runtimeTransitioning; + useAppDiagnostics({ activeTabId, tabCount: tabMetas.length, ready: controllerReady, running: state.running, + hydrating: state.hydrating, runtimeTransitioning, contentRevision: state.historyLayoutRevision }); + + const tabBarCommands = useTabBarCommands({ + activeTabId, + tabMetas, + deliveryWorktreeRoot: state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd, + t, + showToast, + setTabMetas, + setTabOrderIds, + setComposerProfilesByTab, + setTabRevealSignal, + clearWorkspaceConflict: () => setWorkspaceConflict(null), + ports: { + closeTab, + reorderTabs, + switchTab, + switchRemoteTab, + refreshTabMetas: (apply, options) => refreshTabMetas(apply, options), + refreshBackgroundRuntimes, + cancelActive: () => void handleCancelActive(), + noteNavigationIntent, + beginNavigationSurface, + settleNavigationSurface, + isNavigationIntentCurrent, + reassertVisibleTabAfterStaleNavigation, + enterChatView: enterConversation, + createIsolatedWorktree, + }, + }); + const { pendingClose, setPendingClose } = tabBarCommands; + + const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({ + approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm, + workspaceConflict, pendingClose, clearContextPending, + }), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]); + const visibleDecisionSurface = decisionSurface; + const composerSurfaceHidden = runtimeTransitioning || Boolean(decisionSurface); + useDecisionSurfaceFocus({ surface: decisionSurface, activeTabId, closeOverlays: closeTransientOverlays }); + + // Extension form surface (stage 8b2): submit delivers the structured values + // to the owning sidecar; cancel reports values{"cancelled": true} over the + // same channel. A failed cancel still dismisses — the sidecar that could not + // be reached is gone either way. + const extensionSurface = useExtensionSurface({ + activeTabId, + form: state.extensionForm, + notifications: state.extensionNotifications, + dismissForm: dismissExtensionForm, + drainNotifications: drainExtensionNotifications, + showToast, + }); + const extensionStatusList = useMemo(() => Object.values(state.extensionStatuses ?? {}), [state.extensionStatuses]); + const visibleTabId = activeTabId; + const visibleTabs = useMemo(() => projectVisibleTabs({ + tabs: tabMetas, orderIds: tabOrderIds, profiles: composerProfilesByTab, visibleTabId, running: state.running, + }), [composerProfilesByTab, state.running, tabMetas, tabOrderIds, visibleTabId]); + + useTabProjectionLifecycle({ + tabs: tabMetas, activeTabId, activeMeta: activeTab, meta: state.meta, + yoloRestoreRef: yoloRestoreToolApprovalModesRef, planIntentsRef: userPlanModeByTabRef, + setOrder: setTabOrderIds, setProfiles: setComposerProfilesByTab, + }); + + + const controllerProfiles = projectControllerProfiles(tabMetas, composerProfilesByTab, { + target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, profile: composerProfile, remote: remoteSurfaceActive, + }); + const controllerProfileCommands = useControllerProfileCommands({ + target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, profiles: controllerProfiles, + ready: controllerReady, remote: remoteSurfaceActive, runtimeEpoch: state.meta?.runtime?.epoch, operations: sessionOperations, + ports: { model: setModelForTab, profile: setControllerComposerProfileForTab }, + remoteModel: remoteSession.setModel, report: handleGoalActionError, + }); + const { switchModel, applyProfile: applyControllerProfile } = controllerProfileCommands; + const hydratePlaceholderActive = Boolean( + state.hydrating && + state.items.length === 0 && + state.hydratePlaceholderItems?.length, + ); + const sessionUndoCommands = useSessionUndo({ + activeTabId, + activeTabReadOnly: Boolean(activeTab?.readOnly), + items: state.items, + hydratePlaceholderActive, + controllerReady, running: state.running, messageActionOpen: state.messageAction != null, + approvalOpen: state.approval != null, askOpen: state.ask != null, clearContextPending, + ports: { + rewindForTab, rewindForTabDetailed, + refreshTabMetas: () => void refreshTabMetas(undefined, { afterMutation: true }), + undoRewindForTab, sendToTab, + composeInsert: replaceComposerInsert, + refreshDock: () => setDockRefreshKey((value) => value + 1), + refreshProject: () => setProjectRevision((value) => value + 1), + }, + }); + const { + rewindState, rewindCommitting, rewindSignal, setRewindStateForTab, + handleSessionRevertCommitted, handleMessageAction, handleUndoRewind, handleEditPrompt, + } = sessionUndoCommands; + const clearSubmissionUndo = useCommittedCommand((tab: string) => setRewindStateForTab(tab, null)); + const { commitThenSend, submit: submitComposerTurn, applyGoalForTab, applyGoal, sendRevision } = useSessionSubmission({ + target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, operations: sessionOperations, + resources: projectSubmissionResources(controllerProfiles, tabMetas, composerProfilesByTab, + { tabId: activeTabId ?? "", profile: composerProfile, ready: controllerReady }, + { starting: t("composer.workspaceStarting"), readOnly: t("composer.readOnlyChannel") }), + missingSource: t("composer.workspaceStarting"), + ports: createSubmissionPorts({ send: sendToTab, setGoal: setControllerGoalForTab, clearGoal: clearControllerGoalForTab, + clearUndo: clearSubmissionUndo, patchGoal: patchActivatedGoalForTab, profile: applyControllerProfile }), + }); + const patchPlanExitProfileForTab = useCommittedCommand((tabId: string, mode: CollaborationMode) => { + patchComposerProfileForTab(tabId, { + collaborationMode: mode, + goalDraftMode: false, + goal: "", + }, ["collaborationMode", "goal"]); + }); + const drainRemoteApprovalsForTab = useCommittedCommand((tabId: string, ids: string[]) => { + if (activeTabId === tabId) remoteSession.drainApprovals(ids); + }); + const modeActions = useComposerModeActions({ + target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + remote: remoteSurfaceActive, collaborationMode, toolApprovalMode, goal, + operations: sessionOperations, + planIntentsRef: userPlanModeByTabRef, + yoloRestoreRef: yoloRestoreToolApprovalModesRef, + ports: { + setMode: setControllerModeForTab, setCollaboration: setControllerCollaborationModeForTab, + setApproval: setToolApprovalModeForTab, clearGoal: clearControllerGoalForTab, + setRemote: setRemoteComposerProfileForSessionAction, drainRemote: drainRemoteApprovalsForTab, + patch: patchComposerProfileForTab, + }, + showError: (message) => showToast(message, "error"), + }); + const { applyCollaborationMode, notePlanModeForTab } = modeActions; + const rememberPlanRevisionForTab = usePendingPlanRevisions({ + visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + resources: controllerProfiles.map(resource => resource.target), running: state.running, + ready: controllerReady && !state.approval && !state.ask && !state.mcpInteraction, + operations: sessionOperations, send: sendRevision, report: reportPendingRevisionFailure, + }); + const promptCommands = useSessionPromptCommands({ + target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + approval: state.approval ? { id: state.approval.id, tool: state.approval.tool } : undefined, + questionId: state.ask?.id, remote: Boolean(activeTab?.remote), goal, toolApprovalMode, + operations: sessionOperations, + ports: { + approveForTab, isPromptCurrentForTab, resolvePlanForTab: resolvePlanDecisionForTab, + resolveRecoveryForTab, answerQuestionForTab, answerMCPForTab: answerMCPInteractionForTab, + setCollaborationModeForTab: setControllerCollaborationModeForTab, + clearGoalForTab: clearControllerGoalForTab, setRemoteComposerProfile: setRemoteComposerProfileForSessionAction, + patchComposerProfile: patchPlanExitProfileForTab, notePlanMode: notePlanModeForTab, + drainRemoteApprovals: drainRemoteApprovalsForTab, rememberRevision: rememberPlanRevisionForTab, + }, + reportError: error => showToast(error instanceof Error ? error.message : String(error), "error"), + }); + const remoteComposerSend = useRemoteComposerSend(activeTab?.remote, activeTabId, collaborationMode, goal, + remoteSession, remoteSend, applyGoalForTab, useCommittedCommand(() => setClearContextPending(true)), + { target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, operations: sessionOperations, + navigateRemote: useCommittedCommand((remote, options) => openRemoteProject(remote, options)) }); + const controlCommands = useSessionControlCommands({ + activeTabId, + resources: controllerProfiles.map(resource => resource.target), + operations: sessionOperations, + showToast, + clearWorkspaceConflict: () => setWorkspaceConflict(null), + ports: { + cancel, + cancelForTab, + acceptDelivery: (tabId) => desktopBridge.acceptDeliveryToTab(tabId), + disconnectRemote: (hostId) => desktopBridge.disconnectRemoteHost(hostId), + cancelJobForTab: (tabId, jobId) => desktopBridge.cancelJobForTab(tabId, jobId), + refreshBackgroundRuntimes, + }, + }); + const { handleCancelActive } = controlCommands; + // Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the + // tool-permission axis while preserving the Ask/Auto base mode. + const cycleMode = useCommittedCommand(() => { + runGoalAction(() => applyCollaborationMode(collaborationMode === "plan" ? "normal" : "plan")); + }); + + const todoPanelCommands = useTodoPanelCommands({ + items: visibleRuntimeState.items, + running: visibleRuntimeState.running, + pendingPrompt: visibleRuntimeState.pendingPrompt, + meta: state.meta, + activeTab, + activeTabId, + remote: remoteSurfaceActive, + remoteReady: remoteComposerReady, + controllerReady, + sessionKey: activeSessionIdentity, + operations: sessionOperations, + t, + ports: { + remoteSend: (text) => remoteSend(text), + sendToTab: (tabId, text) => sendToTab(tabId, text), + dismissTodoBatch: (tabId, batchKey) => desktopBridge.dismissTodoBatchForTab(tabId, batchKey), + }, + }); + const { showTodos, scopedTodoBatch, todos, dismissTodos, handleTodoContinue } = todoPanelCommands; + + const sessionTitle = topicTitle(activeTab); + const exportItems = remoteSurfaceActive ? remoteSession.transcript.items : state.items; + const exportLive = remoteSurfaceActive + ? remoteSession.transcript.live + : liveStore.getSnapshot(activeTabId) ?? state.live; + const sessionHasContent = exportItems.length > 0 || Boolean(exportLive?.text || exportLive?.reasoning); + + const sessionExportCommands = useSessionExportCommands({ + sessionTitle, + items: exportItems, + live: exportLive, + hasContent: sessionHasContent, + t, + showToast, + }); + + useActiveTabUiReset({ activeTabId, setClearPending: setClearContextPending, setInsertTarget: setWorkspaceInsertTarget }); + + const routerCommands = useComposerRouter({ + activeTabId, + goalDraftActive: collaborationMode === "goal" && !goal.trim(), + t, + notice, + showToast, + ports: { + runShellForTab, + switchModel: (name, tabId) => switchModel(name, tabId), + newSession: () => newSession(), + setSettingsTarget: (tab) => setSettingsTarget(tab), + setClearContextPending, + clearWorkspaceConflict: () => setWorkspaceConflict(null), + setWorkspaceConflict: (value) => setWorkspaceConflict(value), + setPendingClose: (value) => setPendingClose(value), + submitComposerTurn: (tab, display, submit, structured) => submitComposerTurn(tab, display, submit, structured), + steerForTab, + isRemoteTab: (tabId) => tabMetas.some((tab) => tab.id === tabId && tab.remote), + }, + }); + + const goalCommands = useComposerGoalCommands({ applyCollaborationMode, applyGoal }); + const remoteGoalActions = useRemoteComposerRuntimeActions({ + target: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, operations: sessionOperations, + remote: remoteSurfaceActive, session: remoteSession, runGoalAction, + pauseLocal: pauseControllerGoalForTab, resumeLocal: resumeControllerGoalForTab, + setLocalEffort: setEffortForTab, showError: (message) => showToast(message, "error"), + }); + + const { + refreshTabMetas, seedActiveTabMeta, + handleRuntimeEvent, handleRuntimeReady, handleRuntimeRebuilt, + handleRemoteStatus, handleRemoteForwards, handleRemoteServer, + handleInitialRemoteHosts, handleInitialRemoteStatuses, + } = useRuntimeEventHandlers({ + activeTabId, + workspaceScopeKey, + workspaceScopeActiveTabRef, + userPlanModeByTabRef, + setTabMetas, + setTabOrderIds, + setComposerProfilesByTab, + setDockRefreshKey, + setProjectRevision, + setWorkspaceControllerEpoch, + setControllerCollaborationMode, + }); + + const refreshProviderSetupState = useCommittedCommand(() => probeProviderSetupState()); + + const leaseBlockedTab = activeLeaseBlockedTab(tabMetas, activeTab?.id ?? activeTabId); + const bannerCommands = useSessionBannerCommands({ + remote: Boolean(activeTab?.remote), + reloadConfigWarnings, + }); + + const workspacePanelCommands = useWorkspacePanelCommands({ + workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd ?? "", + creation: desktopLayoutStyle === "creation", visible: surfaceWorkspacePanelRenderable, + closeOverlays: closeTransientOverlays, clearLiveWidth: setLiveWorkspacePanelRenderWidth, + availableWidth: workspacePanelAvailableWidth, clampTreeWidth: rightDockTreeWidthClamp, setTreeWidth: setRightDockTreeWidth, + }); + const { openRightDockMode } = workspacePanelCommands; + + const turnVerificationCommands = useTurnVerificationCommands({ + activeTabId, + turnStartAt: state.turnStartAt, + completionSummary: state.completionSummary, + openChangedDock: () => openRightDockMode("changed"), + }); + + const terminalPanelCommands = useTerminalPanelCommands({ + tabId: activeTabId, enabled: conversationView.localToolsEnabled, shortcutsEnabled: !managementActive, + }); + + const remoteWorkspaceCommands = useRemoteWorkspaceCommands({ t, showToast }); + + const layoutStyle = useMemo( + () => + ({ + "--sidebar-expanded-width": `${sidebarRenderWidth}px`, + "--chat-min-width": `${chatReservedWidth}px`, + "--workspace-width": `${workspacePanelRenderWidth}px`, + "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, + "--terminal-height": `${terminalSurfaceOpen ? liveTerminalHeight ?? terminalRenderHeight : 0}px`, + }) as CSSProperties, + [chatReservedWidth, liveTerminalHeight, sidebarRenderWidth, terminalRenderHeight, terminalSurfaceOpen, workspacePanelRenderWidth], + ); + + // Coalesce tab-bar switches through the same last-click-wins scheduler that + // openTopic/blank/resume navigation uses, so rapidly clicking between two + // running sessions can't run two switchTab() calls concurrently. Concurrent + // switches race on the backend SetActiveTab/confirmBackendActiveTab ordering, + const { + transcriptHydrating, creationEmptyHero, + visibleTranscriptItems, visibleTranscriptTabId, visibleTranscriptGeometryKey, + handleLoadOlderHistory, handleSurfacePaintReady, latestGuidanceConsumed, handleTranscriptPrompt, + } = useTranscriptSurfaceProjection({ + hydrating: state.hydrating, + hydrateHistoryLoaded: state.hydrateHistoryLoaded, + hydratePlaceholderItems: state.hydratePlaceholderItems, + hydratePlaceholderActive, + items: state.items, + remote: remoteSurfaceActive, + remoteItems: remoteSession.transcript.items, + activeTabId, + geometrySessionKey: transcriptGeometrySessionKey, + transitioning: runtimeTransitioning, + navigationDataReady: navigationTargetDataReady, + preserved: preservedTranscriptSurface, + singleSurface: singleSurfaceLayout, + controllerReady, + creationLayout: desktopLayoutStyle === "creation", + imDetailActive: Boolean(sidebarImDetailConnection), + sessionHasContent, + commitRendered: commitRenderedTranscriptSurface, + commitPaint: commitNavigationSurfacePaint, + commitSingleSurface: commitSingleSurfaceNavigation, + ports: { + loadOlderHistory: (tabId, targetTurn, trigger) => loadOlderHistory(tabId, targetTurn, trigger), + commitThenSend: (tabId, text) => commitThenSend(tabId, text), + }, + }); + + const { handleDeliveryContinue } = useDeliveryContinueCommands({ + surfaceFence: sessionSurfaceFence, + ready: controllerReady, + goal: state.meta?.goal, + t, + ports: { + resumeGoal: resumeControllerGoalForTab, + recoverDelivery: recoverDeliveryToTab, + }, + }); + + const { openAutomationTopic, topicAccepted } = useAutomationNavigation({ noteIntent: noteNavigationIntent, + enqueue: useCommittedCommand((intent, seq) => enqueueNavigationWithIntent(intent, seq)) }); const { enqueueNavigation, enqueueNavigationWithIntent, openRemoteProject } = useDesktopNavigation({ + visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, singleSurface: singleSurfaceLayout, + ports: { isNavigationIntentCurrent, activateTopic, openTopicSession, openGlobalTab, openProjectTab, + ensureBlankSurface, ensureBlankTab, createIsolatedWorktree, openChannelSession, resumeSession, + registeredNavigationIntent, switchRemoteTab, openRemoteProject: desktopBridge.openRemoteProjectTab, + listTabs: desktopBridge.listTabs, applyTabs: setTabMetas, seedTab: seedActiveTabMeta, listSessions, topicAccepted }, + setTabRevealSignal, setTranscriptRevealSignal, setProjectRevision, setHistory: setHistView, t, showToast, + noteIntent: noteNavigationIntent, beginSurface: beginNavigationSurface, settleSurface: settleNavigationSurface, + showChat: enterConversation, + }); + return { + insertCommands, + clearCommands, + tabBarCommands, + extensionSurface, + promptCommands, + controlCommands, + routerCommands, + goalCommands, + remoteGoalActions, + modeActions, + controllerProfileCommands, + profileProjection, + sessionExportCommands, + workspacePanelCommands, + turnVerificationCommands, + terminalPanelCommands, + remoteWorkspaceCommands, + bannerCommands, + runtimeEventCommands: { + refreshTabMetas, seedActiveTabMeta, + handleRuntimeEvent, handleRuntimeReady, handleRuntimeRebuilt, + handleRemoteStatus, handleRemoteForwards, handleRemoteServer, + handleInitialRemoteHosts, handleInitialRemoteStatuses, + }, + sessionUndo: { + rewindState, rewindCommitting, rewindSignal, handleSessionRevertCommitted, handleMessageAction, handleUndoRewind, handleEditPrompt, + }, + todoPanel: { showTodos, scopedTodoBatch, todos, dismissTodos, handleTodoContinue }, + delivery: { handleDeliveryContinue }, + transcript: { + transcriptHydrating, creationEmptyHero, + visibleTranscriptItems, visibleTranscriptTabId, visibleTranscriptGeometryKey, + handleLoadOlderHistory, handleSurfacePaintReady, latestGuidanceConsumed, handleTranscriptPrompt, + }, + automation: { openAutomationTopic }, + desktopNavigation: { enqueueNavigation, enqueueNavigationWithIntent, openRemoteProject }, + invocation: { invocationMetadataByTab, handleInvocationMetadataChange }, + sessionHasContent, + conversationView, + visibleRuntimeState, + sidebarImDetailConnection, + surfaceWorkspacePanelRenderable, + surfaceWorkspacePanelGridOpen, + surfaceWorkspacePanelOverlay, + terminalSurfaceOpen, + statusBarVisible, + chatSurfaceVisible, + composerSessionKey, + workspaceScopeKey, + workspaceTreeMemoryKey, + sessionTurns, + startupSplashHold, + controllerReady, + decisionSurface, + visibleDecisionSurface, + composerSurfaceHidden, + extensionStatusList, + visibleTabs, + visibleTabId, + hydratePlaceholderActive, + leaseBlockedTab, + layoutStyle, + cycleMode, + remoteComposerSend, + closeTransientOverlays, + refreshProviderSetupState, + shellGeometry, + appRef, + layoutRef, + footerHeight, + footerRef, + backgroundRuntimes, + workspaceConflict, + }; +} diff --git a/desktop/frontend/src/app-runtime/useAppShellStores.ts b/desktop/frontend/src/app-runtime/useAppShellStores.ts new file mode 100644 index 0000000000..349025e6ea --- /dev/null +++ b/desktop/frontend/src/app-runtime/useAppShellStores.ts @@ -0,0 +1,97 @@ +import { useLayoutStore } from "../store/layout"; +import { useOverlayStore } from "../store/overlays"; +import { useAppNavigationStore } from "../store/appNavigation"; +import { useRemoteStore } from "../store/remote"; +import { useWindowChromeStore } from "../store/windowChrome"; +import { useDesktopPreferences } from "./useDesktopPreferences"; + +/** + * Single subscription surface for the store-backed shell state AppRuntime + * wires into regions: overlay visibility, navigation page, layout geometry + * flags, remote catalogs, window chrome and desktop preferences. Controller + * state never flows through here — this hook only reads stores. + */ +export function useAppShellStores() { + const startupSplashVisible = useOverlayStore((s) => s.startupSplashVisible); + const setStartupSplashVisible = useOverlayStore((s) => s.setStartupSplashVisible); + // null until the mount probe resolves; true shows the first-run guide. + const needsOnboarding = useOverlayStore((s) => s.needsOnboarding); + const providerSetupNeeded = useOverlayStore((s) => s.providerSetupNeeded); + const setProviderSetupNeeded = useOverlayStore((s) => s.setProviderSetupNeeded); + const paletteOpen = useOverlayStore((s) => s.paletteOpen); + const setPaletteOpen = useOverlayStore((s) => s.setPaletteOpen); + const shortcutsOpen = useOverlayStore((s) => s.shortcutsOpen); + const setShortcutsOpen = useOverlayStore((s) => s.setShortcutsOpen); + const takeoverDialogTab = useOverlayStore((s) => s.takeoverDialogTab); + const reclaimBusyTab = useOverlayStore((s) => s.reclaimBusyTab); + const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal); + const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal); + const sidebarSearchOpen = useOverlayStore((s) => s.sidebarSearchOpen); + const setSidebarSearchOpen = useOverlayStore((s) => s.setSidebarSearchOpen); + const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal); + const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal); + + const page = useAppNavigationStore((s) => s.page); + const openPage = useAppNavigationStore((s) => s.openPage); + const returnToWorkspace = useAppNavigationStore((s) => s.returnToWorkspace); + const enterConversation = useAppNavigationStore((s) => s.enterConversation); + const visitedTrash = useAppNavigationStore((s) => s.visitedTrash); + const visitedAutomation = useAppNavigationStore((s) => s.visitedAutomation); + const automationReturn = useAppNavigationStore((s) => s.automationReturn); + const setSettingsTarget = useAppNavigationStore((s) => s.setSettingsTarget); + const settingsFocus = useAppNavigationStore((s) => s.settingsFocus); + const setSettingsFocus = useAppNavigationStore((s) => s.setSettingsFocus); + + const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed); + const sidebarResizing = useLayoutStore((state) => state.sidebarResizing); + const sidebarTogglePressed = useLayoutStore((state) => state.sidebarTogglePressed); + const workspacePanelOpen = useLayoutStore((s) => s.workspacePanelOpen); + const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth); + const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth); + const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth); + const workspacePanelResizing = useLayoutStore((state) => state.workspacePanelResizing); + const liveTerminalHeight = useLayoutStore((state) => state.liveTerminalHeight); + const setLiveWorkspacePanelRenderWidth = useLayoutStore((state) => state.setLiveWorkspacePanelRenderWidth); + const workspacePanelMaximized = useLayoutStore((s) => s.workspacePanelMaximized); + const rightDockMode = useLayoutStore((s) => s.rightDockMode); + const terminalPanelOpen = useLayoutStore((s) => s.terminalPanelOpen); + + const remoteHosts = useRemoteStore((s) => s.hosts); + const remoteStatuses = useRemoteStore((s) => s.statuses); + const requestRemoteExplorer = useRemoteStore((s) => s.openExplorer); + + const desktopPlatform = useWindowChromeStore((state) => state.platform); + const mainWindowMaximised = useWindowChromeStore((state) => state.mainWindowMaximised); + + const preferences = useDesktopPreferences(); + + const managementActive = page.kind !== "workspace"; + const settingsTarget = page.kind === "settings" ? page.tab : null; + const desktopLayoutStyle = preferences.desktopLayoutStyle; + const singleSurfaceLayout = desktopLayoutStyle === "workbench" || desktopLayoutStyle === "creation"; + const sidebarWorkbench = desktopLayoutStyle === "workbench"; + const sidebarCreation = desktopLayoutStyle === "creation"; + const windowsFramelessChrome = desktopPlatform === "windows"; + const terminalResizing = liveTerminalHeight !== null; + + return { + startupSplashVisible, setStartupSplashVisible, + needsOnboarding, providerSetupNeeded, setProviderSetupNeeded, + paletteOpen, setPaletteOpen, shortcutsOpen, setShortcutsOpen, + takeoverDialogTab, reclaimBusyTab, + transientOverlayDismissSignal, setTransientOverlayDismissSignal, + sidebarSearchOpen, setSidebarSearchOpen, sidebarSearchFocusSignal, setSidebarSearchFocusSignal, + page, openPage, returnToWorkspace, enterConversation, + visitedTrash, visitedAutomation, automationReturn, + settingsTarget, settingsFocus, setSettingsTarget, setSettingsFocus, + sidebarCollapsed, sidebarResizing, sidebarTogglePressed, + workspacePanelOpen, rightDockTreeWidth, setRightDockTreeWidth, rightDockPreviewWidth, + workspacePanelResizing, liveTerminalHeight, setLiveWorkspacePanelRenderWidth, + workspacePanelMaximized, rightDockMode, terminalPanelOpen, + remoteHosts, remoteStatuses, requestRemoteExplorer, + desktopPlatform, mainWindowMaximised, + preferences, + managementActive, desktopLayoutStyle, singleSurfaceLayout, sidebarWorkbench, sidebarCreation, + windowsFramelessChrome, terminalResizing, + }; +} diff --git a/desktop/frontend/src/app-runtime/useAutomationNavigation.ts b/desktop/frontend/src/app-runtime/useAutomationNavigation.ts new file mode 100644 index 0000000000..da278ac73e --- /dev/null +++ b/desktop/frontend/src/app-runtime/useAutomationNavigation.ts @@ -0,0 +1,49 @@ +import { useLayoutEffect, useRef } from "react"; +import { useAppNavigationStore } from "../store/appNavigation"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { createSubscriptionScope } from "../lib/subscriptionScope"; +import type { DesktopNavigationIntent } from "./desktopNavigationOwner"; + +async function finishAutomationNavigation(input: { + intent: number; request: DesktopNavigationIntent; + enqueue(request: DesktopNavigationIntent, intent: number): Promise; + finish(intent: number): void; +}) { + try { await input.enqueue(input.request, input.intent); } + finally { input.finish(input.intent); } +} + +/** The management page owns its link until the navigation owner accepts it. */ +export function useAutomationNavigation(input: { + noteIntent(): number; + enqueue(intent: DesktopNavigationIntent, seq: number): Promise; +}) { + const pending = useRef<{ intent: number; generation: number } | null>(null); + const invalidate = useCommittedCommand(() => { + if (!pending.current) return; + pending.current = null; + input.noteIntent(); + }); + useLayoutEffect(() => { + const scope = createSubscriptionScope(); + scope.listen(listener => useAppNavigationStore.subscribe((next, previous) => { + if (next.generation !== previous.generation) listener(); + }), invalidate); + return () => { pending.current = null; scope.dispose(); }; + }, [invalidate]); + const finish = useCommittedCommand((intent: number) => { + if (pending.current?.intent === intent) pending.current = null; + }); + const openAutomationTopic = useCommittedCommand((scope: string, workspaceRoot: string, topicId: string) => { + const intent = input.noteIntent(); + pending.current = { intent, generation: useAppNavigationStore.getState().generation }; + return finishAutomationNavigation({ intent, request: { kind: "topic", scope, workspaceRoot, topicId }, enqueue: input.enqueue, finish }); + }); + const topicAccepted = useCommittedCommand((intent: number) => { + const link = pending.current; + if (!link || link.intent !== intent) return; + pending.current = null; + useAppNavigationStore.getState().returnFromAutomationLink(link.generation); + }); + return { openAutomationTopic, topicAccepted }; +} diff --git a/desktop/frontend/src/app-runtime/useComposerGoalCommands.ts b/desktop/frontend/src/app-runtime/useComposerGoalCommands.ts new file mode 100644 index 0000000000..7145f68421 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useComposerGoalCommands.ts @@ -0,0 +1,14 @@ +import type { CollaborationMode } from "../lib/types"; +import { useGoalActionHandler } from "../lib/goalAction"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; + +/** Void Composer events share one error boundary; awaited send paths still reject. */ +export function useComposerGoalCommands(input: { + applyGoal: (goal: string) => Promise; + applyCollaborationMode: (mode: CollaborationMode) => Promise; +}) { + const { runGoalAction } = useGoalActionHandler(); + const clearGoalFromUi = useCommittedCommand(() => runGoalAction(() => input.applyGoal(""))); + const setCollaborationModeFromUi = useCommittedCommand((mode: CollaborationMode) => runGoalAction(() => input.applyCollaborationMode(mode))); + return { clearGoalFromUi, setCollaborationModeFromUi }; +} diff --git a/desktop/frontend/src/app-runtime/useComposerInsertCommands.ts b/desktop/frontend/src/app-runtime/useComposerInsertCommands.ts new file mode 100644 index 0000000000..66838f431a --- /dev/null +++ b/desktop/frontend/src/app-runtime/useComposerInsertCommands.ts @@ -0,0 +1,140 @@ +import { useRef, useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { formatTerminalOutputForComposer } from "../lib/terminalOutput"; +import { formatSelectionReference, type SelectedTextInsertRequest } from "../lib/selectedTextContext"; +import type { ComposerInsertRequest } from "../lib/types"; +import type { Translator } from "../lib/i18n"; +import type { useSessionOperations } from "./useSessionOperations"; + +export type WorkspaceInsertTarget = "composer" | "planRevision"; + +export type ComposerInsertCommandsInput = { + activeTabId: string | undefined; + sessionKey: string; + approval: { id: string; tool: string } | undefined | null; + operations: ReturnType; + t: Translator; + showToast: (message: string, kind: "info" | "warn" | "error") => void; + ports: { + terminalOutput(tabId: string, sessionId: string): Promise; + }; +}; + +/** + * Owns every composer-bound insertion channel: per-tab composer insert + * requests, selected-text/code requests, the plan-revision insert and the + * workspace insert target that routes between them, plus terminal-output + * insertion through the session operations authority. The plan-revision + * input is plain text and only consumes request.text, so structured + * references land there in their fenced rendering. + */ +export function useComposerInsertCommands(input: ComposerInsertCommandsInput) { + const { activeTabId, approval, t, showToast, ports } = input; + const [composerInsertRequestsByTab, setComposerInsertRequestsByTab] = useState>({}); + const [selectedTextRequestsByTab, setSelectedTextRequestsByTab] = useState>({}); + const selectedTextRequestIdRef = useRef(0); + const [planRevisionInsertRequest, setPlanRevisionInsertRequest] = useState<{ + tabId: string; + approvalId: string; + request: ComposerInsertRequest; + } | null>(null); + const [workspaceInsertTarget, setWorkspaceInsertTarget] = useState("composer"); + + const activePlanRevisionInsertRequest = + planRevisionInsertRequest && + planRevisionInsertRequest.tabId === activeTabId && + planRevisionInsertRequest.approvalId === approval?.id + ? planRevisionInsertRequest.request + : null; + const composerInsertRequest = activeTabId ? composerInsertRequestsByTab[activeTabId] ?? null : null; + const selectedTextRequest = activeTabId ? selectedTextRequestsByTab[activeTabId] ?? null : null; + + const setInsertTarget = useCommittedCommand((target: WorkspaceInsertTarget) => setWorkspaceInsertTarget(target)); + const handleRevisionActiveChange = useCommittedCommand((active: boolean) => { + setWorkspaceInsertTarget(active ? "planRevision" : "composer"); + }); + + const replaceComposerInsert = useCommittedCommand((tabId: string, text: string) => { + setComposerInsertRequestsByTab((current) => ({ ...current, [tabId]: { id: Date.now(), text, mode: "replace" } })); + }); + const prefillSubagentCommand = useCommittedCommand((command: string) => { + if (!activeTabId) return; + setComposerInsertRequestsByTab((current) => ({ + ...current, + [activeTabId]: { id: Date.now(), text: command, mode: "prefix" }, + })); + }); + + const addWorkspaceTextToComposer = useCommittedCommand((text: string) => { + if (activeTabId && workspaceInsertTarget === "planRevision" && approval?.tool === "exit_plan_mode") { + setPlanRevisionInsertRequest({ + tabId: activeTabId, + approvalId: approval.id, + request: { id: Date.now(), text }, + }); + return; + } + if (activeTabId) { + setComposerInsertRequestsByTab((current) => ({ + ...current, + [activeTabId]: { id: Date.now(), text }, + })); + } + }); + + const addTerminalOutputToComposer = useCommittedCommand(async (sessionId: string) => { + if (!activeTabId) return; + const target = { tabId: activeTabId, sessionKey: input.sessionKey }; + const outcome = await input.operations(target, `terminal-output:${sessionId}`, {}, async (_operationInput, authority) => + (await import("./sessionRuntimeOwner")).executeTerminalOutputInsertion(target, sessionId, { + read: (tabId, terminalSessionId) => ports.terminalOutput(tabId, terminalSessionId), + apply: addWorkspaceTextToComposer, + }, formatTerminalOutputForComposer, authority), + ); + if (outcome.status === "completed" && !outcome.value) showToast(t("terminal.noOutput"), "info"); + if (outcome.status === "failed") showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error"); + }); + + const addSelectedTextToComposer = useCommittedCommand((text: string, source?: SelectedTextInsertRequest["source"]) => { + const selected = text.trim(); + if (!activeTabId || !selected) return; + selectedTextRequestIdRef.current += 1; + setSelectedTextRequestsByTab((current) => ({ + ...current, + [activeTabId]: { id: selectedTextRequestIdRef.current, text: selected, ...(source ? { source } : {}) }, + })); + }); + + const addTerminalSelectionToComposer = useCommittedCommand((text: string) => addSelectedTextToComposer(text, "terminal")); + const addWorkspaceCodeToComposer = useCommittedCommand((path: string, code: string) => { + if (!activeTabId || !code.trim()) return; + if (workspaceInsertTarget === "planRevision" && approval?.tool === "exit_plan_mode") { + setPlanRevisionInsertRequest({ + tabId: activeTabId, + approvalId: approval.id, + request: { id: Date.now(), text: formatSelectionReference(path, code) }, + }); + return; + } + selectedTextRequestIdRef.current += 1; + setSelectedTextRequestsByTab((current) => ({ + ...current, + [activeTabId]: { id: selectedTextRequestIdRef.current, text: code, path }, + })); + }); + + return { + composerInsertRequest, + selectedTextRequest, + activePlanRevisionInsertRequest, + setInsertTarget, + handleRevisionActiveChange, + replaceComposerInsert, + prefillSubagentCommand, + addWorkspaceTextToComposer, + addTerminalOutputToComposer, + addSelectedTextToComposer, + addTerminalSelectionToComposer, + addWorkspaceCodeToComposer, + }; +} diff --git a/desktop/frontend/src/app-runtime/useComposerProfileProjection.ts b/desktop/frontend/src/app-runtime/useComposerProfileProjection.ts new file mode 100644 index 0000000000..8316acb957 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useComposerProfileProjection.ts @@ -0,0 +1,105 @@ +import { useMemo, type Dispatch, type SetStateAction } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useRemoteComposerProfileSync } from "../lib/useRemoteComposerIntegration"; +import { + composerProfileFromMeta, + composerProfileFromTab, + composerProfileMode, + defaultComposerProfile, + displayedComposerProfileCollaborationMode, + patchComposerProfile, + updateUserPlanModeIntent, + type ComposerProfile, + type ComposerProfileField, + type UserPlanModeIntents, +} from "../lib/composerProfile"; +import type { Meta, QualityFloor, TabMeta } from "../lib/types"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; + +export type ComposerProfileProjectionInput = { + activeTabId: string | undefined; + activeTab: TabMeta | undefined; + meta: Meta | null | undefined; + profilesByTab: Record; + setProfilesByTab: Dispatch>>; + tabMetas: readonly TabMeta[]; + remote: boolean; + remoteSession: RemoteSessionApi; + planIntentsRef: { current: UserPlanModeIntents }; + setControllerQualityFloor: (floor: QualityFloor) => Promise; + showToast: (message: string, level: "error") => void; +}; + +/** + * Owns the active composer profile projection (UI override over the backend + * profile, remote sync) and the profile patch commands: generic per-tab + * patches, quality-floor application and goal activation patches. Mode axis + * changes stay in useComposerModeActions; this hook owns the profile record. + */ +export function useComposerProfileProjection(input: ComposerProfileProjectionInput) { + const { activeTabId, activeTab, meta, profilesByTab, setProfilesByTab, tabMetas, remote, remoteSession } = input; + const activeComposerProfile = activeTabId ? profilesByTab[activeTabId] : undefined; + const backendActiveComposerProfile = useMemo(() => { + if (meta) { + return composerProfileFromMeta( + meta, + activeTab ? composerProfileMode(composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode)) : undefined, + activeComposerProfile?.toolApprovalMode, + ); + } + return composerProfileFromTab(activeTab, activeComposerProfile?.toolApprovalMode); + }, [activeComposerProfile?.toolApprovalMode, activeTab, meta]); + const composerProfile = activeTabId + ? activeComposerProfile ?? backendActiveComposerProfile + : defaultComposerProfile; + const goal = composerProfile.goal; + const collaborationMode = displayedComposerProfileCollaborationMode(composerProfile); + const toolApprovalMode = composerProfile.toolApprovalMode; + const remoteComposerProfileReady = useRemoteComposerProfileSync({ activeTabId, remote, + remoteProfile: remoteSession.composerProfile, collaborationMode, toolApprovalMode, goal, + qualityFloor: composerProfile.qualityFloor, pending: composerProfile.pending, setProfiles: setProfilesByTab }); + + const patchActiveComposerProfile = useCommittedCommand((patch: Partial>, pendingFields: ComposerProfileField[]) => { + if (!activeTabId) return; + setProfilesByTab((current) => patchComposerProfile(current, activeTabId, composerProfile, patch, pendingFields)); + }); + const patchComposerProfileForTab = useCommittedCommand((tabId: string, patch: Partial>, pendingFields: ComposerProfileField[]) => { + if (!tabId) return; + setProfilesByTab((current) => { + const base = current[tabId] ?? composerProfileFromTab(tabMetas.find((tab) => tab.id === tabId)); + return patchComposerProfile(current, tabId, base, patch, pendingFields); + }); + }); + + const applyQualityFloor = useCommittedCommand((floor: QualityFloor) => { + if (!activeTabId) return; + if (remote) { + void remoteSession.setQualityFloor(floor).catch((error) => input.showToast(error instanceof Error ? error.message : String(error), "error")); + return; + } + patchActiveComposerProfile({ qualityFloor: floor }, ["qualityFloor"]); + void input.setControllerQualityFloor(floor); + }); + + const patchActivatedGoalForTab = useCommittedCommand((tabId: string, nextGoal: string): void => { + const trimmed = nextGoal.trim(); + patchComposerProfileForTab(tabId, { + collaborationMode: trimmed ? "goal" : "normal", + goalDraftMode: false, + goal: trimmed, + }, ["collaborationMode", "goal"]); + input.planIntentsRef.current = updateUserPlanModeIntent(input.planIntentsRef.current, tabId, false); + }); + + return { + composerProfile, + goal, + collaborationMode, + toolApprovalMode, + remoteComposerProfileReady, + patchActiveComposerProfile, + patchComposerProfileForTab, + applyQualityFloor, + patchActivatedGoalForTab, + }; +} diff --git a/desktop/frontend/src/app-runtime/useComposerRouter.ts b/desktop/frontend/src/app-runtime/useComposerRouter.ts new file mode 100644 index 0000000000..51df1b5fcc --- /dev/null +++ b/desktop/frontend/src/app-runtime/useComposerRouter.ts @@ -0,0 +1,181 @@ +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { clearThemePack } from "../lib/themePack"; +import { applyTheme, getTheme, getThemeStyle, isThemeStyle } from "../lib/theme"; +import { decisionSurfaceMockFromInput } from "../lib/decisionSurfaceMock"; +import { activeTabMirror } from "./activeTabMirror"; +import type { SettingsTab } from "../lib/types"; +import type { StructuredInvocationSubmit } from "../lib/invocationDisplay"; +import type { Translator } from "../lib/i18n"; + +type MockWorkView = { + running: true; + pendingPrompt: false; + cancellable: true; + jobs: { id: string; kind: string; label: string; status: string; startedAt: number }[]; +}; + +export type ComposerRouterInput = { + activeTabId: string | undefined; + goalDraftActive: boolean; + t: Translator; + notice(message: string, kind?: "info" | "warn" | "error"): void; + showToast(message: string, level: "info" | "warn" | "error", options?: { durationMs?: number }): void; + ports: { + runShellForTab(tabId: string, cmd: string): Promise; + switchModel(name: string, tabId: string): Promise; + newSession(): Promise; + setSettingsTarget(tab: SettingsTab): void; + setClearContextPending(pending: boolean): void; + clearWorkspaceConflict(): void; + setWorkspaceConflict(value: { state: "local"; ownerTabId: string; ownerTitle: string; ownerWork: MockWorkView; canReveal: true; canCreateWorktree: true } | null): void; + setPendingClose(value: { tabId: string; work: MockWorkView; stopping: boolean } | null): void; + submitComposerTurn(tabId: string, display: string, submit?: string, structured?: StructuredInvocationSubmit): Promise; + steerForTab(tabId: string, text: string): Promise; + isRemoteTab(tabId: string): boolean; + }; +}; + +function isThemeMode(value: string): value is "auto" | "light" | "dark" { + return value === "auto" || value === "light" || value === "dark"; +} + +/** + * Routes a composer submit to its desktop-native action: shell commands, + * model/memory/clear/new commands, the browser decision-surface mock seeds, + * Goal activation or ordinary submission, theme commands and remote steer. + * Only the routes that need a desktop-native UI action are reserved here. + */ +export function useComposerRouter(input: ComposerRouterInput) { + const { activeTabId, goalDraftActive, t, notice, showToast, ports } = input; + + const handleSend = useCommittedCommand(async (displayText: string, submitText = displayText, requestedTabId = activeTabId, structured?: StructuredInvocationSubmit) => { + const sourceTabId = requestedTabId || activeTabId; + if (!sourceTabId) throw new Error(t("composer.workspaceStarting")); + const trimmed = displayText.trim(); + // "!" runs a shell command directly, bypassing the model. + if (trimmed.startsWith("!")) { + const cmd = trimmed.slice(1).trim(); + if (!cmd) { + notice("usage: ! (e.g. !ls -la)"); + return; + } + await ports.runShellForTab(sourceTabId, cmd); + return; + } + const model = /^\/model\s+(\S+)$/.exec(trimmed); + if (model) { + await ports.switchModel(model[1], sourceTabId); + return; + } + if (trimmed === "/memory") { + if (activeTabMirror().current !== sourceTabId) return; + ports.setSettingsTarget("memory"); + return; + } + if (trimmed === "/clear") { + if (activeTabMirror().current !== sourceTabId) return; + ports.setClearContextPending(true); + return; + } + if (trimmed === "/new") { + if (activeTabMirror().current !== sourceTabId) return; + await ports.newSession(); + return; + } + const decisionMock = typeof window !== "undefined" && !window.runtime + ? decisionSurfaceMockFromInput(trimmed) + : null; + if (decisionMock === "workspace_conflict" || decisionMock === "mode_jobs" || decisionMock === "close_active" || decisionMock === "clear_context") { + if (activeTabMirror().current !== sourceTabId) return; + ports.clearWorkspaceConflict(); + ports.setPendingClose(null); + ports.setClearContextPending(false); + const mockWork: MockWorkView = { + running: true, + pendingPrompt: false, + cancellable: true, + jobs: [ + { id: "mock-decision-build", kind: "bash", label: "pnpm build", status: "running", startedAt: Date.now() - 42_000 }, + { id: "mock-decision-test", kind: "bash", label: "go test ./...", status: "running", startedAt: Date.now() - 18_000 }, + ], + }; + if (decisionMock === "workspace_conflict") { + ports.setWorkspaceConflict({ + state: "local", + ownerTabId: "mock-workspace-writer", + ownerTitle: t("mock.topicDevStandard"), + ownerWork: mockWork, + canReveal: true, + canCreateWorktree: true, + }); + } else if (decisionMock === "close_active") { + ports.setPendingClose({ tabId: sourceTabId, work: mockWork, stopping: false }); + } else { + ports.setClearContextPending(true); + } + return; + } + if (goalDraftActive) { + await ports.submitComposerTurn(sourceTabId, displayText, submitText, structured); + return; + } + const theme = /^\/theme(?:\s+(\S+))?$/.exec(trimmed); + if (theme) { + const arg = theme[1]?.toLowerCase(); + if (!arg) { + const cur = getTheme(); + notice(t("settings.themeCurrent", { theme: cur, style: getThemeStyle(cur) })); + return; + } + if (arg === "reset" || arg === "default" || arg === "clear") { + try { + await app.ResetThemePack(); + clearThemePack(); + notice(t("settings.themeReset")); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + if (isThemeMode(arg)) { + const next = arg; + const style = getThemeStyle(next); + try { + await app.SetDesktopAppearance(next, style); + applyTheme(next, style); + notice(t("settings.themeChanged", { theme: next, style })); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + if (isThemeStyle(arg)) { + const cur = getTheme(); + try { + await app.SetDesktopAppearance(cur, arg); + applyTheme(cur, arg); + notice(t("settings.themeChanged", { theme: cur, style: arg })); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error"); + } + return; + } + notice(t("settings.themeUnknown", { name: arg }), "warn"); + return; + } + await ports.submitComposerTurn(sourceTabId, displayText, submitText, structured); + }); + + const handleSteer = useCommittedCommand(async (text: string, requestedTabId = activeTabId) => { + const sourceTabId = requestedTabId || activeTabId; + if (!sourceTabId) throw new Error(t("composer.workspaceStarting")); + if (ports.isRemoteTab(sourceTabId)) { + await app.SteerRemoteTab(sourceTabId, text.trim()); + return; + } + await ports.steerForTab(sourceTabId, text.trim()); + }); + + return { handleSend, handleSteer }; +} diff --git a/desktop/frontend/src/app-runtime/useDeliveryContinueCommands.ts b/desktop/frontend/src/app-runtime/useDeliveryContinueCommands.ts new file mode 100644 index 0000000000..27c2bf7268 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useDeliveryContinueCommands.ts @@ -0,0 +1,45 @@ +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { Translator } from "../lib/i18n"; +import type { createSessionSurfaceFence } from "./sessionTarget"; + +const loadDeliveryContinue = () => import("../lib/deliveryContinue"); + +export type DeliveryContinueCommandsInput = { + surfaceFence: ReturnType; + ready: boolean; + goal: string | undefined; + t: Translator; + ports: { + resumeGoal(tabId: string): Promise; + recoverDelivery(tabId: string, prompt: string): Promise; + }; +}; + +/** + * Owns the delivery "continue checks" chain: the recovery-prompt send and the + * continue command that captures the committed surface ownership at click + * time, so a mid-flight tab switch or session replacement can never deliver + * the continuation into a session that no longer owns the UI. The delivery + * owner chunk stays lazy behind the command. + */ +export function useDeliveryContinueCommands(input: DeliveryContinueCommandsInput) { + const { surfaceFence, t, ports } = input; + + const sendDeliveryRecovery = useCommittedCommand((tabId: string) => + ports.recoverDelivery(tabId, t("notice.deliveryIncompleteContinuePrompt"))); + + const handleDeliveryContinue = useCommittedCommand(() => { + const ownership = surfaceFence.capture(); + return loadDeliveryContinue().then(({ continueDelivery }) => continueDelivery({ + tabId: ownership?.tabId, + ready: input.ready, + goal: input.goal, + uiOwnership: ownership, + ownsUI: surfaceFence.ownsUnknown, + resumeGoal: ports.resumeGoal, + send: sendDeliveryRecovery, + })); + }); + + return { handleDeliveryContinue }; +} diff --git a/desktop/frontend/src/app-runtime/useDesktopNavigation.ts b/desktop/frontend/src/app-runtime/useDesktopNavigation.ts new file mode 100644 index 0000000000..2a296df1b6 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useDesktopNavigation.ts @@ -0,0 +1,91 @@ +import { useLayoutEffect, useRef, type Dispatch, type SetStateAction } from "react"; +import type { Translator } from "../lib/i18n"; +import type { useToast } from "../lib/toast"; +import type { SessionMeta, TabMeta } from "../lib/types"; +import type { RemoteNavigationCommand } from "../lib/remoteNavigationCommands"; +import { CommandCancelled, type CommandAuthority, type CommandOutcome } from "../lib/commandOutcome"; +import { useCommittedAsyncCommand } from "../lib/useCommittedAsyncCommand"; +import { refreshHistoryProjection, type HistoryViewState } from "./historyViewProjection"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { enqueueNavigationRequest, type NavigationCoalescingRefs } from "../lib/openTopicCoalescing"; +import { useResourceOperations, type SessionResource, type SessionOperationAuthority } from "./useResourceOperations"; +import { executeDesktopNavigation, type DesktopNavigationCapture, type DesktopNavigationIntent, type DesktopNavigationPorts, type NavigationNotice } from "./desktopNavigationOwner"; + +type QueueInput = { capture: DesktopNavigationCapture; authority: SessionOperationAuthority; result: { error?: unknown; tab?: TabMeta } }; +async function runQueuedRequest(request: QueueInput) { + try { request.result.tab = await executeDesktopNavigation(request.capture, request.authority); } + catch (error) { request.result.error = error; } +} +async function executeQueuedNavigation(input: { capture: DesktopNavigationCapture; queue: NavigationCoalescingRefs }, authority: SessionOperationAuthority) { + const result: QueueInput["result"] = {}; + await enqueueNavigationRequest(input.queue, { capture: input.capture, authority, result }, runQueuedRequest); + if (result.error) throw result.error; + return result.tab; +} +async function startRemoteNavigation(input: { + intent: DesktopNavigationIntent; + noteIntent(): number; showChat(): void; + execute(intent: DesktopNavigationIntent, seq: number): Promise>; +}, authority: CommandAuthority) { + authority.checkpoint(); + input.showChat(); + const outcome = await input.execute(input.intent, input.noteIntent()); + if (outcome.status === "failed") throw outcome.error; + if (outcome.status === "cancelled") throw new CommandCancelled(outcome.reason); + return outcome.value; +} + +/** Owns the existing last-click-wins queue; no App render or view model is queued. */ +export function useDesktopNavigation(input: { + visible: SessionResource; + singleSurface: boolean; + ports: Omit; + setTabRevealSignal: Dispatch>; + setTranscriptRevealSignal: Dispatch>; + setProjectRevision: Dispatch>; + setHistory: Dispatch>; + t: Translator; + showToast: ReturnType["showToast"]; + noteIntent(): number; + beginSurface(seq: number): void; + settleSurface(seq: number): void; + showChat(): void; +}) { + const operations = useResourceOperations({ visible: input.visible }); + const reveal = useCommittedCommand(() => { input.setTabRevealSignal(value => value + 1); input.setTranscriptRevealSignal(value => value + 1); }); + const projectChanged = useCommittedCommand(() => input.setProjectRevision(value => value + 1)); + const closeHistory = useCommittedCommand(() => input.setHistory(null)); + const applyHistorySessions = useCommittedCommand((sessions: SessionMeta[]) => input.setHistory(current => refreshHistoryProjection(current, sessions))); + const notice = useCommittedCommand((notice: NavigationNotice) => { + input.showToast("key" in notice ? input.t(notice.key, notice.params) : notice.message, notice.tone, { durationMs: notice.durationMs }); + }); + const queueRef = useRef | null>(null); + if (!queueRef.current) queueRef.current = { seqRef: { current: 0 }, runningRef: { current: false }, pendingRef: { current: null } }; + const queue = queueRef.current; + const settle = useCommittedCommand(input.settleSurface); + const executeWithIntent = useCommittedCommand(async (intent: DesktopNavigationIntent, navigationIntentSeq: number) => { + input.beginSurface(navigationIntentSeq); + try { + return await operations({ kind: "application" }, "navigation", { + queue, capture: { intent, navigationIntentSeq, singleSurface: input.singleSurface, + ports: { ...input.ports, reveal, projectChanged, closeHistory, notice, applyHistorySessions } }, + }, executeQueuedNavigation); + } finally { settle(navigationIntentSeq); } + }); + const enqueueNavigationWithIntent = useCommittedCommand(async (intent: DesktopNavigationIntent, seq: number): Promise => { await executeWithIntent(intent, seq); }); + const enqueueNavigation = useCommittedCommand((intent: DesktopNavigationIntent) => { + input.showChat(); + return enqueueNavigationWithIntent(intent, input.noteIntent()); + }); + const openRemoteProject: RemoteNavigationCommand = useCommittedAsyncCommand( + (...[remote, options]: Parameters) => ({ + intent: { kind: "remote-project", remote: { ...remote }, options: { ...options } } as DesktopNavigationIntent, + showChat: input.showChat, noteIntent: input.noteIntent, execute: executeWithIntent, + }), startRemoteNavigation); + useLayoutEffect(() => () => { + queue.seqRef.current++; + queue.pendingRef.current?.resolve(); + queue.pendingRef.current = null; + }, [queue]); + return { enqueueNavigation, enqueueNavigationWithIntent, openRemoteProject }; +} diff --git a/desktop/frontend/src/app-runtime/useDesktopPreferences.ts b/desktop/frontend/src/app-runtime/useDesktopPreferences.ts new file mode 100644 index 0000000000..0b593fbfbf --- /dev/null +++ b/desktop/frontend/src/app-runtime/useDesktopPreferences.ts @@ -0,0 +1,58 @@ +import { useEffect, useMemo, useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useCommittedAsyncCommand } from "../lib/useCommittedAsyncCommand"; +import { useConfigLoadWarnings } from "../lib/useConfigLoadWarnings"; +import { useI18n, useT } from "../lib/i18n"; +import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "../lib/statusBarItems"; +import { hydrateReasoningDisplayMode, setReasoningDisplayPending } from "../lib/reasoningDisplayPreference"; +import { hydrateSessionExperience } from "../lib/sessionExperience"; +import type { BotRuntimeStatusView } from "../lib/types"; +import { app } from "../lib/bridge"; +import { applyPreferencesAppearance, layoutStyleFromSnapshot, synchronizeDesktopPreferences, type DesktopPreferencesSnapshot } from "./desktopPreferencesAdapter"; +import { sidebarImConnectionsFromBot, sidebarImTopicSourcesFromBot } from "./sidebarImProjection"; + +export function useDesktopPreferences() { + const { locale, setPref } = useI18n(); + const t = useT(); + const warnings = useConfigLoadWarnings(); + const [snapshot, setSnapshot] = useState(null); + const [botRuntime, setBotRuntime] = useState(null); + const [startupFailed, setStartupFailed] = useState(false); + const publish = useCommittedCommand((settings: DesktopPreferencesSnapshot, runtime: BotRuntimeStatusView | null) => { + setPref(applyPreferencesAppearance(settings)); + if ("configWarnings" in settings) warnings.applySnapshot(settings.configWarnings, settings.configWarningsRevision); + setSnapshot(settings); + setBotRuntime(runtime); + setStartupFailed(false); + }); + const synchronize = useCommittedAsyncCommand((provided?: DesktopPreferencesSnapshot | null, loadTheme: boolean = false) => ({ provided, publish, loadTheme }), synchronizeDesktopPreferences); + const failed = useCommittedCommand((error: unknown) => { + setStartupFailed(true); + if (!snapshot) { + hydrateSessionExperience("standard"); + hydrateReasoningDisplayMode("auto", false); + } + console.warn("desktop preferences sync failed", error); + }); + const reload = useCommittedCommand(async (provided?: DesktopPreferencesSnapshot | null, loadTheme = false) => { + const result = await synchronize(provided, loadTheme); + if (result.status === "failed") failed(result.error); + }); + useEffect(() => { + setReasoningDisplayPending(); + void reload(undefined, true); + }, [reload]); + useEffect(() => { void app.SetTrayLocale(locale).catch(() => {}); }, [locale]); + const nativeRuntime = typeof window === "undefined" || Boolean(window.runtime); + const sidebarImConnections = useMemo(() => snapshot ? sidebarImConnectionsFromBot(snapshot.bot, t, botRuntime, nativeRuntime) : [], [snapshot, t, botRuntime, nativeRuntime]); + const imTopicSources = useMemo(() => snapshot ? sidebarImTopicSourcesFromBot(snapshot.bot, t) : {}, [snapshot, t]); + return { + desktopLayoutStyle: layoutStyleFromSnapshot(snapshot?.desktopLayoutStyle), + startupUpdateChecksEnabled: snapshot ? snapshot.checkUpdates !== false : startupFailed ? true : null, + statusBarStyle: snapshot ? snapshot.statusBarStyle === "text" ? "text" as const : "icon" as const : "text" as const, + statusBarItems: snapshot ? normalizeStatusBarItems(snapshot.statusBarItems) : DEFAULT_STATUS_BAR_ITEMS, + sidebarImConnections, imTopicSources, + configLoadWarnings: warnings.configLoadWarnings, reloadConfigWarnings: warnings.reload, dismissConfigWarnings: warnings.dismiss, + reload, + }; +} diff --git a/desktop/frontend/src/app-runtime/useExtensionSurface.ts b/desktop/frontend/src/app-runtime/useExtensionSurface.ts new file mode 100644 index 0000000000..1bfd563f98 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useExtensionSurface.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; + +export type ExtensionSurfaceView = { pluginId: string; surfaceId: string }; +export type ExtensionNotificationView = { severity?: string; title: string; body?: string }; + +/** + * Owns the extension form surface: submitting delivers the structured values + * to the owning sidecar, cancel reports values{"cancelled": true} over the + * same channel (a failed cancel still dismisses), and queued notifications + * drain into toasts from per-tab reducer state the toast context cannot read. + */ +export function useExtensionSurface(input: { + activeTabId: string | undefined; + form: ExtensionSurfaceView | undefined; + notifications: readonly ExtensionNotificationView[] | undefined; + dismissForm(): void; + drainNotifications(): void; + showToast(message: string, level: "info" | "warn" | "error"): void; +}) { + const { activeTabId, form, notifications, dismissForm, drainNotifications, showToast } = input; + const [extensionFormBusy, setExtensionFormBusy] = useState(false); + + useEffect(() => { + const pending = notifications; + if (!pending || pending.length === 0) return; + for (const notification of pending) { + const level = notification.severity === "error" ? "error" : notification.severity === "warn" ? "warn" : "info"; + showToast(notification.body ? `${notification.title} — ${notification.body}` : notification.title, level); + } + drainNotifications(); + }, [drainNotifications, notifications, showToast]); + + const submitExtensionForm = useCommittedCommand(async (values: Record) => { + const pending = form; + if (!pending || !activeTabId || extensionFormBusy) return; + setExtensionFormBusy(true); + try { + await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, values); + dismissForm(); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error"); + } finally { + setExtensionFormBusy(false); + } + }); + + const cancelExtensionForm = useCommittedCommand(async () => { + const pending = form; + if (!pending || extensionFormBusy) return; + setExtensionFormBusy(true); + try { + if (activeTabId) { + await app.SubmitExtensionForm(activeTabId, pending.pluginId, pending.surfaceId, { cancelled: true }).catch(() => {}); + } + dismissForm(); + } finally { + setExtensionFormBusy(false); + } + }); + + return { extensionFormBusy, submitExtensionForm, cancelExtensionForm }; +} diff --git a/desktop/frontend/src/app-runtime/useFooterHeightLifecycle.ts b/desktop/frontend/src/app-runtime/useFooterHeightLifecycle.ts new file mode 100644 index 0000000000..8b33d056e0 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useFooterHeightLifecycle.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, type RefObject } from "react"; + +export function useFooterHeightLifecycle( + footerRef: RefObject, + onHeight: (height: number) => void, +) { + const lastHeight = useRef(0); + useEffect(() => { + const element = footerRef.current; + if (!element || typeof ResizeObserver === "undefined") return; + let frame = 0; + const update = () => { + if (frame) window.cancelAnimationFrame(frame); + frame = window.requestAnimationFrame(() => { + frame = 0; + const next = Math.round(element.getBoundingClientRect().height); + if (Math.abs(lastHeight.current - next) < 2) return; + lastHeight.current = next; + onHeight(next); + }); + }; + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => { + if (frame) window.cancelAnimationFrame(frame); + observer.disconnect(); + }; + }, [footerRef, onHeight]); +} diff --git a/desktop/frontend/src/app-runtime/useHistoryCommands.ts b/desktop/frontend/src/app-runtime/useHistoryCommands.ts new file mode 100644 index 0000000000..21236b4f0c --- /dev/null +++ b/desktop/frontend/src/app-runtime/useHistoryCommands.ts @@ -0,0 +1,85 @@ +import type { Dispatch, SetStateAction } from "react"; +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { sessionsForScope, type HistoryViewState } from "./historyViewProjection"; +import { useOverlayStore } from "../store/overlays"; +import type { SessionMeta } from "../lib/types"; + +export type HistoryCommandsInput = { + running: boolean; + setHistView: Dispatch>; + ports: { + listSessions(): Promise; + deleteSession(path: string): Promise; + renameSession(path: string, title: string): Promise; + openPage(page: { kind: "trash" }): void; + }; +}; + +/** + * Owns the trash/history commands: opening the trash page, closing and + * refreshing the history view, deleting a history session (local filtering + * after the backend succeeds) and renaming one (topic or session path by + * availability). Deletes/renames are gated on a stopped runtime. + */ +export function useHistoryCommands(input: HistoryCommandsInput) { + const { running, setHistView, ports } = input; + const setTransientOverlayDismissSignal = useOverlayStore((state) => state.setTransientOverlayDismissSignal); + + const closeTransientOverlays = useCommittedCommand(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }); + + const openTrash = useCommittedCommand(async () => { + closeTransientOverlays(); + setHistView(null); + ports.openPage({ kind: "trash" }); + }); + const closeHistory = useCommittedCommand(() => { + closeTransientOverlays(); + setHistView(null); + }); + const refreshHistoryView = useCommittedCommand(async () => { + const sessions = await ports.listSessions().catch(() => null); + if (!sessions) return; + setHistView((cur) => + cur === null || cur.kind !== "history" + ? cur + : cur.source === "scope" + ? { ...cur, sessions: sessionsForScope(sessions, cur.filter) } + : { ...cur, sessions }, + ); + }); + + const onDeleteSession = useCommittedCommand(async (path: string) => { + if (running) return; + try { + await ports.deleteSession(path); + } catch { + await refreshHistoryView(); + return; + } + // Local state removal: filter the deleted session out of the current + // history view instead of re-fetching the full list from the backend. + setHistView((cur) => + cur === null || cur.kind !== "history" + ? cur + : { ...cur, sessions: cur.sessions.filter((s) => s.path !== path) }, + ); + }); + const onRenameHistorySession = useCommittedCommand(async (session: SessionMeta, title: string) => { + if (running) return; + if (session.topicId) await app.RenameTopic(session.topicId, title); + else await ports.renameSession(session.path, title); + const sessions = await ports.listSessions(); + setHistView((cur) => + cur === null + ? null + : cur.kind === "history" + ? { ...cur, sessions: cur.source === "scope" ? sessionsForScope(sessions, cur.filter) : sessions } + : cur, + ); + }); + + return { openTrash, closeHistory, refreshHistoryView, onDeleteSession, onRenameHistorySession }; +} diff --git a/desktop/frontend/src/app-runtime/useInvocationMetadata.ts b/desktop/frontend/src/app-runtime/useInvocationMetadata.ts new file mode 100644 index 0000000000..db9a3d1413 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useInvocationMetadata.ts @@ -0,0 +1,26 @@ +import { useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { activeTabMirror } from "./activeTabMirror"; +import type { InvocationMetadataMap } from "../lib/invocationDisplay"; + +/** + * Owns the per-tab invocation metadata ledger: commits are bound to the + * layout-committed active tab through the mirror, never a stale render + * capture, and identical kind/color maps commit as no-ops. + */ +export function useInvocationMetadata() { + const [invocationMetadataByTab, setInvocationMetadataByTab] = useState>({}); + const handleInvocationMetadataChange = useCommittedCommand((metadata: InvocationMetadataMap) => { + const sourceTabId = activeTabMirror().current; + if (!sourceTabId) return; + setInvocationMetadataByTab((current) => { + const previous = current[sourceTabId] ?? {}; + const names = Object.keys(metadata); + if (names.length === Object.keys(previous).length && names.every((name) => ( + previous[name]?.kind === metadata[name]?.kind && previous[name]?.color === metadata[name]?.color + ))) return current; + return { ...current, [sourceTabId]: metadata }; + }); + }); + return { invocationMetadataByTab, handleInvocationMetadataChange }; +} diff --git a/desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts b/desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts new file mode 100644 index 0000000000..c4de9e0741 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useLocalUiLifecycles.ts @@ -0,0 +1,66 @@ +import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { activeTabMirror } from "./activeTabMirror"; +export type TopicTimeFilter = "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d"; + +export function useTopicTimeFilter(): [TopicTimeFilter, Dispatch>] { + const [value, setValue] = useState(() => { + try { + const saved = localStorage.getItem("projectTree:timeFilter"); + if (saved === "all" || saved === "10" || saved === "20" || saved === "1h" || saved === "3h" || saved === "5h" || saved === "1d") return saved; + } catch { /* localStorage unavailable */ } + return "all"; + }); + useEffect(() => { + try { localStorage.setItem("projectTree:timeFilter", value); } catch { /* ignore */ } + }, [value]); + return [value, setValue]; +} + +export function useDecisionSurfaceFocus(input: { + surface: string | null; + activeTabId?: string | null; + closeOverlays: () => void; +}) { + const { surface, activeTabId, closeOverlays } = input; + const previous = useRef(null); + const surfaceRef = useRef(surface); + surfaceRef.current = surface; + useEffect(() => { + if (surface) { + closeOverlays(); + previous.current = surface; + return; + } + const hadSurface = previous.current !== null; + previous.current = null; + if (!hadSurface) return; + const tabAtRelease = activeTabId; + const frame = requestAnimationFrame(() => { + if (surfaceRef.current !== null || activeTabMirror().current !== tabAtRelease) return; + (document.getElementById("composer-input") as HTMLTextAreaElement | null)?.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + }, [activeTabId, closeOverlays, surface]); +} + +export function useActiveTabUiReset(input: { + activeTabId?: string | null; + setClearPending: (value: boolean) => void; + setInsertTarget: (value: "composer") => void; +}) { + const { activeTabId, setClearPending, setInsertTarget } = input; + useEffect(() => { + setClearPending(false); + setInsertTarget("composer"); + }, [activeTabId, setClearPending, setInsertTarget]); +} + +export function useVerificationRevealReset(input: { + activeTabId?: string | null; + completionSummary: unknown; + turnStartAt?: number | null; + reset: (value: null) => void; +}) { + const { activeTabId, completionSummary, turnStartAt, reset } = input; + useEffect(() => { reset(null); }, [activeTabId, completionSummary, reset, turnStartAt]); +} diff --git a/desktop/frontend/src/app-runtime/useNativeSettingsEvent.ts b/desktop/frontend/src/app-runtime/useNativeSettingsEvent.ts new file mode 100644 index 0000000000..35acb78f46 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useNativeSettingsEvent.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { useAppNavigationStore } from "../store/appNavigation"; + +export function useNativeSettingsEvent(input: { + closeTransientOverlays: () => void; + setSettingsTarget: (target: ReturnType["lastSettingsTarget"]) => void; +}) { + const { closeTransientOverlays, setSettingsTarget } = input; + useEffect(() => { + if (typeof window === "undefined" || !window.runtime) return; + return window.runtime.EventsOn("app:open-settings", () => { + closeTransientOverlays(); + setSettingsTarget(useAppNavigationStore.getState().lastSettingsTarget); + }); + }, [closeTransientOverlays, setSettingsTarget]); +} diff --git a/desktop/frontend/src/app-runtime/useNativeWindowController.ts b/desktop/frontend/src/app-runtime/useNativeWindowController.ts new file mode 100644 index 0000000000..cc6473f344 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useNativeWindowController.ts @@ -0,0 +1,55 @@ +import { useEffect } from "react"; + +import { app } from "../lib/bridge"; +import { setMainWindowMaximised } from "../store/windowChrome"; + +// Module-owned sync state for the single AppRuntime host: the enabled gate +// mirrors the active lifecycle, and the generation ticket discards +// out-of-order IsMainWindowMaximised resolutions. +let syncEnabled = false; +let syncGeneration = 0; + +/** + * Re-reads the native maximised flag into the windowChrome store. Event + * handlers call this after a toggle/zoom; a no-op while the lifecycle is + * disabled so a non-frameless platform never issues the bridge call. + */ +export function syncMainWindowMaximised(): void { + if (!syncEnabled) return; + const generation = ++syncGeneration; + void app.IsMainWindowMaximised() + .then((value) => { if (generation === syncGeneration) setMainWindowMaximised(value); }) + .catch(() => { if (generation === syncGeneration) setMainWindowMaximised(false); }); +} + +/** + * Owns the maximised-sync lifecycle: initial sync, resize/focus listeners and + * the disabled/unmount reset. The flag itself lives in the windowChrome store; + * consumers select `mainWindowMaximised` from there. + */ +export function useWindowsMaximisedSync(enabled: boolean): void { + useEffect(() => { + if (!enabled) { + syncEnabled = false; + syncGeneration += 1; + setMainWindowMaximised(false); + return; + } + syncEnabled = true; + syncMainWindowMaximised(); + window.addEventListener("resize", syncMainWindowMaximised); + window.addEventListener("focus", syncMainWindowMaximised); + return () => { + syncEnabled = false; + syncGeneration += 1; + window.removeEventListener("resize", syncMainWindowMaximised); + window.removeEventListener("focus", syncMainWindowMaximised); + }; + }, [enabled]); +} + +export const nativeWindowCommands = { + minimize: () => app.MinimiseMainWindow(), + toggleMaximize: () => app.ToggleMaximiseMainWindow(), + close: () => app.CloseMainWindow(), +}; diff --git a/desktop/frontend/src/app-runtime/useOnboardingCommands.ts b/desktop/frontend/src/app-runtime/useOnboardingCommands.ts new file mode 100644 index 0000000000..a6a3e25e25 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useOnboardingCommands.ts @@ -0,0 +1,23 @@ +import { dismissOnboarding } from "../lib/onboarding"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useOverlayStore } from "../store/overlays"; +import { useAppNavigationStore } from "../store/appNavigation"; + +export function useOnboardingCommands(providerConfigured: () => void) { + const completeOnboarding = useCommittedCommand(() => { + providerConfigured(); + useOverlayStore.getState().setNeedsOnboarding(false); + }); + const chooseOnboardingProvider = useCommittedCommand(() => { + const overlays = useOverlayStore.getState(); + overlays.setNeedsOnboarding(false); + const navigation = useAppNavigationStore.getState(); + navigation.setSettingsFocus({ target: "model-access" }); + navigation.setSettingsTarget("models"); + }); + const skipOnboarding = useCommittedCommand(() => { + dismissOnboarding(); + useOverlayStore.getState().setNeedsOnboarding(false); + }); + return { completeOnboarding, chooseOnboardingProvider, skipOnboarding }; +} diff --git a/desktop/frontend/src/app-runtime/usePaletteCommands.tsx b/desktop/frontend/src/app-runtime/usePaletteCommands.tsx new file mode 100644 index 0000000000..8adb6bb97d --- /dev/null +++ b/desktop/frontend/src/app-runtime/usePaletteCommands.tsx @@ -0,0 +1,202 @@ +import { useMemo } from "react"; +import { AlarmClock, Activity, BarChart3, Brain, Cpu, Palette, Puzzle, RotateCw, Server, Settings as SettingsIcon, SquarePen, TerminalSquare, Trash2 } from "lucide-react"; +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useGlobalShortcut } from "../lib/keyboardShortcuts"; +import { clearThemePack } from "../lib/themePack"; +import { paletteSessionDisplayTitle, paletteSessionHint, paletteSessionKeywords, sessionActivityTime } from "../lib/session"; +import { useOverlayStore } from "../store/overlays"; +import { useAppNavigationStore } from "../store/appNavigation"; +import { useRemoteStore } from "../store/remote"; +import { activeTabMirror } from "./activeTabMirror"; +import type { RemoteHostView, SessionMeta } from "../lib/types"; +import type { Translator } from "../lib/i18n"; +import type { PaletteItem } from "../components/CommandPalette"; + +export type PaletteCommandsInput = { + managementActive: boolean; + activeTabId: string | undefined; + remoteSurfaceActive: boolean; + t: Translator; + notice(message: string, kind?: "info" | "warn" | "error"): void; + showToast(message: string, level: "info" | "warn" | "error", options?: { durationMs?: number }): void; + ports: { + handleNewTab(): void; + listSessions(): Promise; + openTrash(): void; + onResumeSession(session: SessionMeta): Promise; + openRemoteWorkspaceFromStatus(host: RemoteHostView): void; + connectAndOpenRemoteWorkspace(host: RemoteHostView): void; + toggleTerminalPanel(): void; + setTasksOpen(open: false | "session" | "all"): void; + handleTabClose(id: string): void; + toggleSidebar(): void; + returnToWorkspace(): void; + }; +}; + +/** + * Owns the command palette: its open action (snapshotting sessions and + * extension actions), its items and the global command shortcuts that open it + * or the new-session/settings/tab-close/shortcuts/sidebar surfaces. Session, + * extension, remote-host and navigation targets come from their stores. + */ +export function usePaletteCommands(input: PaletteCommandsInput) { + const { managementActive, activeTabId, remoteSurfaceActive, t, notice, showToast, ports } = input; + const setPaletteOpen = useOverlayStore((state) => state.setPaletteOpen); + const paletteSessions = useOverlayStore((state) => state.paletteSessions); + const setPaletteSessions = useOverlayStore((state) => state.setPaletteSessions); + const paletteExtensionActions = useOverlayStore((state) => state.paletteExtensionActions); + const setPaletteExtensionActions = useOverlayStore((state) => state.setPaletteExtensionActions); + const setShortcutsOpen = useOverlayStore((state) => state.setShortcutsOpen); + const setTransientOverlayDismissSignal = useOverlayStore((state) => state.setTransientOverlayDismissSignal); + const remoteHosts = useRemoteStore((state) => state.hosts); + const remoteStatuses = useRemoteStore((state) => state.statuses); + + const closeTransientOverlays = useCommittedCommand(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }); + + const openPalette = useCommittedCommand(async () => { + closeTransientOverlays(); + setPaletteOpen(true); + setPaletteSessions(await ports.listSessions().catch(() => [])); + setPaletteExtensionActions(await app.ExtensionActions(activeTabMirror().current ?? "").catch(() => [])); + }); + + useGlobalShortcut("commandPalette.open", () => { + setPaletteOpen((current) => { + if (!current) void openPalette(); + return !current; // toggle the state so the palette actually opens/closes + }); + }, [openPalette]); + useGlobalShortcut("app.newSession", () => void ports.handleNewTab(), [ports.handleNewTab]); + useGlobalShortcut("settings.open", () => { + closeTransientOverlays(); + useAppNavigationStore.getState().setSettingsTarget(useAppNavigationStore.getState().lastSettingsTarget); + }, [closeTransientOverlays]); + useGlobalShortcut("tab.close", () => { + if (managementActive) ports.returnToWorkspace(); + else if (activeTabId) void ports.handleTabClose(activeTabId); + }, [activeTabId, managementActive, ports.handleTabClose, ports.returnToWorkspace], managementActive || Boolean(activeTabId)); + useGlobalShortcut("shortcuts.show", () => setShortcutsOpen(true)); + useGlobalShortcut("sidebar.toggle", ports.toggleSidebar, [ports.toggleSidebar], !managementActive); + + const paletteItems = useMemo(() => { + const navigation = useAppNavigationStore.getState(); + const cmds: PaletteItem[] = [ + { id: "cmd-new", group: t("palette.group.commands"), title: t("palette.cmd.newSession"), icon: , compact: true, keywords: ["new", "新建"], run: () => void ports.handleNewTab() }, + { id: "cmd-automation", group: t("palette.group.commands"), title: t("sidebar.automation"), icon: , compact: true, keywords: ["automation", "自动化"], run: () => navigation.openPage({ kind: "automation" }) }, + { id: "cmd-trash", group: t("palette.group.commands"), title: t("palette.cmd.trash"), icon: , compact: true, keywords: ["trash", "回收站"], run: () => void ports.openTrash() }, + { id: "cmd-settings", group: t("palette.group.commands"), title: t("palette.cmd.settings"), icon: , compact: true, keywords: ["settings", "设置"], run: () => navigation.setSettingsTarget(navigation.lastSettingsTarget) }, + { id: "cmd-appearance", group: t("palette.group.commands"), title: t("palette.cmd.appearance"), icon: , compact: true, keywords: ["theme", "appearance", "外观", "主题"], run: () => navigation.setSettingsTarget("appearance") }, + { + id: "cmd-theme-reset", + group: t("palette.group.commands"), + title: t("settings.themeLibrary.reset"), + icon: , + compact: true, + keywords: ["theme", "reset", "default", "恢复默认", "主题"], + run: () => { + void app.ResetThemePack() + .then(() => { + clearThemePack(); + notice(t("settings.themeReset")); + }) + .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); + }, + }, + { id: "cmd-memory", group: t("palette.group.commands"), title: t("palette.cmd.memory"), icon: , compact: true, keywords: ["memory", "记忆"], run: () => navigation.setSettingsTarget("memory") }, + { id: "cmd-models", group: t("palette.group.commands"), title: t("palette.cmd.models"), icon: , compact: true, keywords: ["model", "模型"], run: () => navigation.setSettingsTarget("models") }, + { + id: "cmd-usage-stats", + group: t("palette.group.commands"), + title: t("palette.cmd.usageStats"), + icon: , + compact: true, + keywords: ["usage", "stats", "statistics", "用量", "统计"], + run: () => { + navigation.setSettingsFocus((current) => ({ + target: "model-stats", + requestId: (current?.requestId ?? 0) + 1, + })); + navigation.setSettingsTarget("models"); + }, + }, + { id: "cmd-task-center", group: t("palette.group.commands"), title: t("palette.cmd.taskCenter"), icon: , compact: true, keywords: ["task", "tasks", "center", "任务", "任务中心"], run: () => ports.setTasksOpen("all") }, + { id: "cmd-terminal", group: t("palette.group.commands"), title: t("rightDock.terminal"), icon: , compact: true, keywords: ["terminal", "shell", "终端"], run: () => ports.toggleTerminalPanel() }, + { + id: "cmd-reload-runtime", + group: t("palette.group.commands"), + title: t("palette.cmd.reloadRuntime"), + icon: , + compact: true, + keywords: ["reload", "runtime", "重载", "运行时"], + run: () => { + const tabID = activeTabId; + if (!tabID) return; + // Success/queued feedback arrives as a tab notice; only hard failures need a toast. + void app.ReloadRuntime(tabID).catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); + }, + }, + ]; + const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + const dayLabel = (ms: number) => { + const days = Math.round((startOfDay(new Date()) - startOfDay(new Date(ms))) / 86_400_000); + if (days <= 0) return t("history.today"); + if (days === 1) return t("history.yesterday"); + return new Date(ms).toLocaleDateString(); + }; + const sessionItems: PaletteItem[] = paletteSessions.slice(0, 12).map((s) => ({ + id: `sess-${s.path}`, + group: t("palette.group.sessions"), + title: paletteSessionDisplayTitle(s, t("history.emptySession")), + hint: paletteSessionHint(s), + keywords: paletteSessionKeywords(s), + meta: dayLabel(sessionActivityTime(s)), + badge: t(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns }), + run: () => void ports.onResumeSession(s), + })); + const remoteItems: PaletteItem[] = remoteHosts.map((host) => { + const status = remoteStatuses[host.id]; + const connected = status?.state === "connected" || status?.state === "degraded"; + const target = `${host.user ? `${host.user}@` : ""}${host.host}${host.port && host.port !== 22 ? `:${host.port}` : ""}`; + return { + id: `remote-${host.id}`, + group: t("palette.group.remote"), + title: connected + ? t("palette.remote.open", { host: host.label }) + : t("palette.remote.connect", { host: host.label }), + hint: host.defaultWorkspace || target, + icon: , + keywords: ["ssh", "remote", "远程", "连接", host.label, host.host], + run: () => { + if (connected) ports.openRemoteWorkspaceFromStatus(host); + else ports.connectAndOpenRemoteWorkspace(host); + }, + }; + }); + const extensionItems: PaletteItem[] = paletteExtensionActions.map((action) => ({ + id: `ext-${action.slash}`, + group: t("palette.group.extensions"), + title: action.description || action.slash, + hint: action.slash, + icon: , + keywords: ["extension", "扩展", action.plugin, action.action, action.slash], + run: () => { + const tabID = activeTabId; + if (!tabID) return; + // The extension's result message is user-facing feedback; only hard + // failures need an error toast. + void app.InvokeExtensionAction(tabID, action.slash, {}) + .then((message) => { + if (message) showToast(message, "info"); + }) + .catch((err) => showToast(err instanceof Error ? err.message : String(err), "error")); + }, + })); + return [...(remoteSurfaceActive ? cmds.filter((item) => item.id !== "cmd-terminal" && item.id !== "cmd-reload-runtime") : cmds), ...extensionItems, ...remoteItems, ...sessionItems]; + }, [t, paletteSessions, paletteExtensionActions, remoteHosts, remoteStatuses, activeTabId, remoteSurfaceActive, ports, showToast, notice]); + + return { openPalette, paletteItems }; +} diff --git a/desktop/frontend/src/app-runtime/useProjectTopicCommands.ts b/desktop/frontend/src/app-runtime/useProjectTopicCommands.ts new file mode 100644 index 0000000000..c9a445fd4e --- /dev/null +++ b/desktop/frontend/src/app-runtime/useProjectTopicCommands.ts @@ -0,0 +1,62 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useResourceOperations, type SessionResource } from "./useResourceOperations"; +import { refreshProjectTopics, renameProjectTopic, type ProjectTopicPorts, type TopicRenameTarget } from "./projectTopicOwner"; + +type Input = { + visible: SessionResource; + topic?: { id: string; title: string; target: TopicRenameTarget }; + ports: ProjectTopicPorts; + navigation: { + openBlank: (scope: string, workspace: string) => Promise; + enqueue: (request: { kind: "isolated-worktree"; workspaceRoot: string }) => Promise; + switchFolder: (path?: string) => Promise; + }; + reportError: (error: unknown) => void; +}; +type RenameDraft = { id: string; target: TopicRenameTarget; title: string }; + +/** Project commands retain only committed ports and one explicitly targeted draft. */ +export function useProjectTopicCommands(input: Input) { + const operations = useResourceOperations({ visible: { tabId: input.visible.tabId || "application", sessionKey: input.visible.sessionKey } }); + const [draft, setDraft] = useState(null); + const handled = useRef(false); + const activeIdentity = input.topic ? JSON.stringify([input.topic.id, input.topic.target]) : ""; + const draftIdentity = draft ? JSON.stringify([draft.id, draft.target]) : ""; + useLayoutEffect(() => { + if (draftIdentity && draftIdentity !== activeIdentity) { handled.current = true; setDraft(null); } + }, [activeIdentity, draftIdentity]); + const report = useCommittedCommand(input.reportError); + const rename = useCommittedCommand(async (target: TopicRenameTarget, title: string) => { + if (!title.trim()) return; + const outcome = await operations({ kind: "workspace", workspaceKey: JSON.stringify(target) }, "topic-rename", + { target, title: title.trim(), activeTabId: input.visible.tabId, ports: input.ports }, renameProjectTopic); + if (outcome.status === "failed") report(outcome.error); + }); + const renameTopic = useCommittedCommand((topicId: string, title: string) => topicId ? rename({ kind: "local", topicId }, title) : Promise.resolve()); + const refreshProjectsAndTabs = useCommittedCommand(async () => { + const outcome = await operations({ kind: "application" }, "project-refresh", { activeTabId: input.visible.tabId, ports: input.ports }, refreshProjectTopics); + if (outcome.status === "failed") report(outcome.error); + }); + const startActiveTopicRename = useCommittedCommand(() => { + if (!input.topic) return; + handled.current = false; + setDraft({ ...input.topic }); + }); + const cancelActiveTopicRename = useCommittedCommand(() => { handled.current = true; setDraft(null); }); + const commitActiveTopicRename = useCommittedCommand(async () => { + if (!draft || handled.current) return; + handled.current = true; + setDraft(null); + await rename(draft.target, draft.title); + }); + const setTopicTitleDraft = useCommittedCommand((title: string) => setDraft(current => current ? { ...current, title } : current)); + const onCreateTopic = useCommittedCommand((scope: string, workspace: string) => input.navigation.openBlank(scope, scope === "project" ? workspace : "")); + const onCreateIsolatedWorktree = useCommittedCommand((workspaceRoot: string) => input.navigation.enqueue({ kind: "isolated-worktree", workspaceRoot })); + const onAddProject = useCommittedCommand(async (path?: string) => { await input.navigation.switchFolder(path); }); + return { + topicTitleDraft: draft?.title ?? "", topicbarEditing: Boolean(draft && draftIdentity === activeIdentity), + setTopicTitleDraft, startActiveTopicRename, cancelActiveTopicRename, commitActiveTopicRename, + renameTopic, refreshProjectsAndTabs, onCreateTopic, onCreateIsolatedWorktree, onAddProject, + }; +} diff --git a/desktop/frontend/src/app-runtime/useRemoteWorkspaceCommands.ts b/desktop/frontend/src/app-runtime/useRemoteWorkspaceCommands.ts new file mode 100644 index 0000000000..041d9b917e --- /dev/null +++ b/desktop/frontend/src/app-runtime/useRemoteWorkspaceCommands.ts @@ -0,0 +1,83 @@ +import { useRef } from "react"; +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { RemoteConnectionTimeoutError, useRemoteStore, waitForRemoteConnection } from "../store/remote"; +import { RemoteWorkspaceLaunchGate, resolveRemoteWorkspace } from "../lib/remoteWorkspace"; +import { publishNavigationIntent } from "../lib/useNavigationIntentFence"; +import type { RemoteHostView } from "../lib/types"; +import type { Translator } from "../lib/i18n"; + +export type RemoteWorkspaceCommandsInput = { + t: Translator; + showToast(message: string, level: "error", options?: { durationMs?: number; actionLabel?: string; onAction?: () => void }): void; +}; + +/** + * Owns remote workspace launches and host connections. Each host gets one + * launch generation; a status popover entry may open the workspace, and a + * connect request first drives the host to connected (clearing stale failure + * state, then waiting on the connection waiter) before launching. A timeout + * offers stop-and-retry; other failures stay host-scoped on the status entry. + */ +export function useRemoteWorkspaceCommands(input: RemoteWorkspaceCommandsInput) { + const { t, showToast } = input; + const remoteWorkspaceLaunchGate = useRef(new RemoteWorkspaceLaunchGate()); + + const launchRemoteWorkspace = useCommittedCommand(async (host: RemoteHostView, requestSeq: number) => { + const lastWorkspace = await app.RemoteLastWorkspace(host.id).catch(() => ""); + const workspace = resolveRemoteWorkspace(lastWorkspace, host.defaultWorkspace); + if (!remoteWorkspaceLaunchGate.current.isCurrent(host.id, requestSeq)) return; + await publishNavigationIntent("remote-workspace"); + await app.OpenRemoteWorkspace(host.id, workspace); + }); + + const openRemoteWorkspaceFromStatus = useCommittedCommand((host: RemoteHostView) => { + const requestSeq = remoteWorkspaceLaunchGate.current.begin(host.id); + void launchRemoteWorkspace(host, requestSeq).catch((err) => { + showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 }); + }); + }); + + const connectAndOpenRemoteWorkspace = useCommittedCommand(function connectRemoteWorkspace(host: RemoteHostView) { + const requestSeq = remoteWorkspaceLaunchGate.current.begin(host.id); + void (async () => { + try { + const status = useRemoteStore.getState().statuses[host.id]?.state; + if (status !== "connected" && status !== "degraded") { + // Clear any stale failure before the new generation starts; otherwise a + // previous stopped+error snapshot could make the waiter reject before + // the kernel's fresh connecting event reaches the frontend. + useRemoteStore.getState().applyStatus({ hostId: host.id, state: "connecting" }); + await app.ConnectRemoteHost(host.id); + await waitForRemoteConnection(host.id); + } + } catch (err) { + if (err instanceof RemoteConnectionTimeoutError) { + showToast(t("remote.error.timeout", { host: host.label }), "error", { + actionLabel: t("remote.error.stopAndRetry"), + durationMs: 10_000, + onAction: () => { + void app.DisconnectRemoteHost(host.id) + .catch(() => undefined) + .then(() => connectRemoteWorkspace(host)); + }, + }); + return; + } + // Connection failures are host-scoped. Keep the persistent error and its + // recovery actions beside the Remote SSH status entry instead of + // stretching a raw backend error across the native titlebar. + useRemoteStore.getState().requestStatusPopover(host.id); + return; + } + + try { + await launchRemoteWorkspace(host, requestSeq); + } catch (err) { + showToast(err instanceof Error ? err.message : String(err), "error", { durationMs: 6000 }); + } + })(); + }); + + return { openRemoteWorkspaceFromStatus, connectAndOpenRemoteWorkspace }; +} diff --git a/desktop/frontend/src/app-runtime/useResourceOperations.ts b/desktop/frontend/src/app-runtime/useResourceOperations.ts new file mode 100644 index 0000000000..eec3bcd88f --- /dev/null +++ b/desktop/frontend/src/app-runtime/useResourceOperations.ts @@ -0,0 +1,67 @@ +import { useLayoutEffect, useMemo, useRef } from "react"; +import { useCommittedSlot, type CommittedSlot } from "../lib/useCommittedSlot"; +import { CommandCancelled, executeCapturedCommand, type CommandOutcome } from "../lib/commandOutcome"; +import { createOperationOwner, operationTargetsEqual, type OperationTarget } from "./operationOwner"; +import { createSessionSurfaceFence, type SessionSurfaceOwnership } from "./sessionTarget"; +import { trackAppOperation } from "./appLifecycleProbe"; + +export type SessionResource = Readonly<{ tabId: string; sessionKey: string }>; +export type SessionOperationAuthority = { + checkpoint(): void; + ownsUI(): boolean; +}; +type Input = { visible: SessionResource; resources?: readonly OperationTarget[] }; +type State = { + owner: ReturnType; + surface: ReturnType; + epoch: number; +}; + +function authorityFor(state: State, slot: CommittedSlot, target: OperationTarget, channel: string) { + const epoch = slot.epoch; + state.epoch = state.owner.mount(); + const identity = state.owner.begin(target, undefined, JSON.stringify([target, channel])); + const surface: SessionSurfaceOwnership | undefined = state.surface.capture(); + const authority: SessionOperationAuthority = { + checkpoint() { + if (slot.phase !== "ready" || epoch !== slot.epoch) throw new CommandCancelled("disposed"); + if (!state.owner.owns(identity) || (slot.value?.resources && !slot.value.resources.some(resource => operationTargetsEqual(resource, target)))) { + throw new CommandCancelled("superseded"); + } + }, + ownsUI() { + try { this.checkpoint(); } catch { return false; } + return Boolean(surface && state.surface.owns(surface) && (target.kind !== "session" || (surface.tabId === target.tabId && surface.sessionKey === target.sessionKey))); + }, + }; + return { identity, authority }; +} + +// Stable entry is created outside render. The executor receives no capture callback. +function bindOperations(state: State, slot: CommittedSlot) { + return async ( + target: OperationTarget, channel: string, input: Input, + execute: (input: Input, authority: SessionOperationAuthority) => Result, + ): Promise>> => { + if (slot.phase !== "ready" || (target.kind === "session" && !target.tabId)) return { status: "cancelled", reason: slot.phase === "disposed" ? "disposed" : "not-ready" }; + const { identity, authority } = authorityFor(state, slot, Object.freeze({ ...target }), channel); + const result = await executeCapturedCommand(input, execute, authority); + const uiOwned = authority.ownsUI(); + state.owner.finish(identity, result.status); + return result.status === "failed" && !uiOwned ? { status: "cancelled", reason: "superseded" } : result; + }; +} + +/** A source request may finish on A while B is visible; only its UI rights expire. */ +export function useResourceOperations(input: Input) { + const slot = useCommittedSlot(input); + const stateRef = useRef(null); + if (!stateRef.current) stateRef.current = { owner: createOperationOwner(trackAppOperation), surface: createSessionSurfaceFence(), epoch: 0 }; + const state = stateRef.current; + useLayoutEffect(() => { state.surface.commit(input.visible.tabId, input.visible.sessionKey); }, [input.visible.tabId, input.visible.sessionKey, state]); + useLayoutEffect(() => () => { + state.surface.dispose(); + state.owner.unmount(state.epoch); + }, [state]); + return useMemo(() => bindOperations(state, slot), [state, slot]); +} diff --git a/desktop/frontend/src/app-runtime/useRuntimeEventHandlers.ts b/desktop/frontend/src/app-runtime/useRuntimeEventHandlers.ts new file mode 100644 index 0000000000..dca84c9a7c --- /dev/null +++ b/desktop/frontend/src/app-runtime/useRuntimeEventHandlers.ts @@ -0,0 +1,166 @@ +import { useEffect, useRef, type Dispatch, type RefObject, type SetStateAction } from "react"; +import { app, onProjectTreeChanged } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { activeTabMirror } from "./activeTabMirror"; +import { asArray } from "../lib/array"; +import { createBoundedRefreshCoordinator, sameTabMetaLists, seedActiveTabMetaList, shouldRefreshTabMetaForEvent, TAB_META_MAX_IN_FLIGHT } from "../lib/tabMetaRefresh"; +import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "../lib/sound"; +import { composerProfileFromTab, defaultComposerProfile, patchComposerProfile, resolvePlanRestoreTabId, shouldRestoreUserPlanModeForProfile, updateUserPlanModeIntent, type ComposerProfile, type UserPlanModeIntents } from "../lib/composerProfile"; +import { useRemoteTabOpened } from "../lib/useRemoteTabOpened"; +import { recordFrontendDiagnostic } from "../lib/frontendDiagnosticBridge"; +import { useRemoteStore } from "../store/remote"; +import type { TabMeta } from "../lib/types"; +import type { + RemoteForwardsListener, + RemoteServerListener, + RemoteStatusListener, + RuntimeEventListener, + RuntimeReadyListener, + RuntimeRebuiltListener, +} from "./AppRuntimeEffects"; + +export type RuntimeEventHandlersInput = { + activeTabId: string | undefined; + workspaceScopeKey: string; + workspaceScopeActiveTabRef: RefObject; + userPlanModeByTabRef: RefObject; + setTabMetas: Dispatch>; + setTabOrderIds: Dispatch>; + setComposerProfilesByTab: Dispatch>>; + setDockRefreshKey: Dispatch>; + setProjectRevision: Dispatch>; + setWorkspaceControllerEpoch: Dispatch>; + setControllerCollaborationMode(mode: string): Promise; +}; + +/** + * Owns the runtime event surface: tab-meta registry refresh/seed/remote + * registration with its single-flight coordinator, the runtime + * event/ready/rebuilt listeners (chimes, plan-mode restore, workspace-scope + * epochs), the remote status/forwards/server listeners, and the workspace + * focus reconciliation that refreshes tab metas when the project tree changes. + */ +export function useRuntimeEventHandlers(input: RuntimeEventHandlersInput) { + const { activeTabId, workspaceScopeKey, setProjectRevision } = input; + const attentionChimeEvents = useRef(new Set()); + const tabMetaRefreshCoordinatorRef = useRef> | null>(null); + if (!tabMetaRefreshCoordinatorRef.current) { + tabMetaRefreshCoordinatorRef.current = createBoundedRefreshCoordinator(TAB_META_MAX_IN_FLIGHT); + } + + const refreshTabMetas = useCommittedCommand(async ( + apply?: () => boolean, + options?: { afterMutation?: boolean }, + ): Promise => { + const result = await tabMetaRefreshCoordinatorRef.current!.run( + async () => asArray(await app.ListTabs().catch(() => [] as TabMeta[])), + options?.afterMutation ? { invalidate: true } : undefined, + ); + const tabs = result.value; + if (result.latest && (!apply || apply())) { + input.setTabMetas((current) => sameTabMetaLists(current, tabs) ? current : tabs); + } + return tabs; + }); + const seedActiveTabMeta = useCommittedCommand((tab: TabMeta): void => { + input.setTabMetas((current) => seedActiveTabMetaList(current, tab)); + input.setTabOrderIds((current) => current.includes(tab.id) ? current : [...current, tab.id]); + }); + const updateRemoteTabMeta = useCommittedCommand((tab: TabMeta): void => { + input.setTabMetas((current) => current.map((existing) => existing.id === tab.id + ? { ...existing, ...tab, active: existing.active } + : existing)); + }); + + const registerRemoteTabMeta = useCommittedCommand((tab: TabMeta) => { + input.setTabMetas(current => current.some(existing => existing.id === tab.id) ? current : [...current, { ...tab, active: false }]); + }); + useRemoteTabOpened(registerRemoteTabMeta, updateRemoteTabMeta); + + const handleRuntimeEvent = useCommittedCommand((event) => { + recordFrontendDiagnostic("runtime", "runtime.event", { action: event.kind, status: event.err ? "error" : "ok" }); + if (event.kind === "turn_done") { + input.setDockRefreshKey((value) => value + 1); + input.setProjectRevision((value) => value + 1); + if (!event.err) playSuccessChime(); + } + if (shouldPlayAttentionChimeForEvent(event, attentionChimeEvents.current)) playAttentionChime(); + if (shouldRefreshTabMetaForEvent(event.kind)) void refreshTabMetas(undefined, { afterMutation: true }); + if (event.kind !== "turn_done") return; + const turnTabId = resolvePlanRestoreTabId(event.tabId, activeTabMirror().current); + void refreshTabMetas(undefined, { afterMutation: true }).then((tabs) => { + if (!turnTabId) return; + const tab = tabs.find((item) => item.id === turnTabId); + const baseProfile = tab ? composerProfileFromTab(tab) : defaultComposerProfile; + if (!shouldRestoreUserPlanModeForProfile(input.userPlanModeByTabRef.current, turnTabId, baseProfile)) { + if (baseProfile.goal.trim()) { + input.userPlanModeByTabRef.current = updateUserPlanModeIntent(input.userPlanModeByTabRef.current, turnTabId, false); + } + return; + } + input.setComposerProfilesByTab((current) => patchComposerProfile( + current, turnTabId, current[turnTabId] ?? baseProfile, + { collaborationMode: "plan", goalDraftMode: false, goal: "" }, + ["collaborationMode", "goal"], + )); + if (activeTabMirror().current === turnTabId) void input.setControllerCollaborationMode("plan"); + }); + }); + + const handleRuntimeReady = useCommittedCommand((readyTabId) => { + recordFrontendDiagnostic("runtime", "runtime.ready", { ready: true, hasActiveTab: Boolean(readyTabId) }); + clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId); + void refreshTabMetas(); + if (!readyTabId || readyTabId === input.workspaceScopeActiveTabRef.current) { + input.setWorkspaceControllerEpoch((value) => value + 1); + } + }); + + const handleRuntimeRebuilt = useCommittedCommand((rebuiltTabId) => { + recordFrontendDiagnostic("runtime", "runtime.rebuilt", { ready: true, hasActiveTab: Boolean(rebuiltTabId) }); + clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId); + if (!rebuiltTabId || rebuiltTabId === input.workspaceScopeActiveTabRef.current) { + input.setWorkspaceControllerEpoch((value) => value + 1); + } + }); + + useEffect(() => { + let live = true; + const ready = import("../lib/workspaceRefreshStore") + .then(({ default: startWorkspaceFocusReconciliation }) => live ? startWorkspaceFocusReconciliation(activeTabId, workspaceScopeKey, refreshTabMetas) : undefined) + .catch(() => undefined); + const stopProjectTree = onProjectTreeChanged(() => { + setProjectRevision((value) => value + 1); + void refreshTabMetas(undefined, { afterMutation: true }); + }); + return () => { + live = false; + stopProjectTree(); + void ready.then((stop) => stop?.()); + }; + }, [activeTabId, refreshTabMetas, setProjectRevision, workspaceScopeKey]); + + const handleRemoteStatus = useCommittedCommand((status) => { + useRemoteStore.getState().applyStatus(status); + if (status.state === "stopped" && status.error) useRemoteStore.getState().requestStatusPopover(status.hostId); + }); + const handleRemoteForwards = useCommittedCommand((event) => useRemoteStore.getState().setForwards(event.hostId, event.forwards)); + const handleRemoteServer = useCommittedCommand((server) => useRemoteStore.getState().setServer(server)); + const handleInitialRemoteHosts = useCommittedCommand((hosts: Awaited>) => useRemoteStore.getState().setHosts(hosts)); + const handleInitialRemoteStatuses = useCommittedCommand((statuses: Awaited>) => useRemoteStore.getState().hydrateStatuses(statuses)); + + return { + refreshTabMetas, + seedActiveTabMeta, + registerRemoteTabMeta, + updateRemoteTabMeta, + handleRuntimeEvent, + handleRuntimeReady, + handleRuntimeRebuilt, + handleRemoteStatus, + handleRemoteForwards, + handleRemoteServer, + handleInitialRemoteHosts, + handleInitialRemoteStatuses, + }; +} diff --git a/desktop/frontend/src/app-runtime/useRuntimeStatus.ts b/desktop/frontend/src/app-runtime/useRuntimeStatus.ts new file mode 100644 index 0000000000..358b1b7e46 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useRuntimeStatus.ts @@ -0,0 +1,40 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { BackgroundRuntimeView, WorkspaceConflictView } from "../lib/types"; +import { createPollingOwner, type PollClock } from "./pollingOwner"; +import { trackAppOperation } from "./appLifecycleProbe"; + +const browserClock: PollClock = { + setTimeout: (callback, delay) => window.setTimeout(callback, delay), + clearTimeout: handle => window.clearTimeout(handle as number), +}; +export function useRuntimeStatus(input: { tabId?: string; sessionKey: string; running: boolean }, clock: PollClock = browserClock) { + const [backgroundRuntimes, setBackgroundRuntimes] = useState([]); + const [conflict, setConflict] = useState<{ key: string; value: WorkspaceConflictView | null } | null>(null); + const background = useRef> | null>(null); + const refreshBackgroundRuntimes = useCommittedCommand(() => background.current?.refresh() ?? Promise.resolve()); + useLayoutEffect(() => { + const owner = createPollingOwner({ target: { kind: "application" }, periodMs: 1000, clock, + read: app.BackgroundRuntimes, publish: setBackgroundRuntimes, failed: () => {}, + }, trackAppOperation); + background.current = owner; + void owner.refresh(); + return () => { owner.dispose(); if (background.current === owner) background.current = null; }; + }, [clock]); + const { tabId, sessionKey, running } = input; + const key = JSON.stringify([tabId, sessionKey]); + const setWorkspaceConflict = useCommittedCommand((value: WorkspaceConflictView | null) => setConflict(value ? { key, value } : null)); + useLayoutEffect(() => { + setConflict(null); + if (!tabId || !running) return; + const owner = createPollingOwner({ target: { kind: "session", tabId, sessionKey }, periodMs: 500, clock, + read: () => app.WorkspaceConflictForTab(tabId), + publish: value => setConflict({ key, value: value.state === "none" ? null : value }), + failed: () => setConflict({ key, value: null }), + }, trackAppOperation); + void owner.refresh(); + return () => owner.dispose(); + }, [clock, key, running, sessionKey, tabId]); + return { backgroundRuntimes, refreshBackgroundRuntimes, setWorkspaceConflict, workspaceConflict: conflict?.key === key ? conflict.value : null }; +} diff --git a/desktop/frontend/src/app-runtime/useSessionBannerCommands.ts b/desktop/frontend/src/app-runtime/useSessionBannerCommands.ts new file mode 100644 index 0000000000..b730212089 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionBannerCommands.ts @@ -0,0 +1,49 @@ +import { app, openExternal } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useOverlayStore } from "../store/overlays"; +export type ConfigWarningsReload = (warnings: string[], revision: number) => void; + +/** + * Owns the startup/session banner commands: session reclaim or takeover, the + * takeover dialog, config-file open/reload, provider setup navigation and the + * release-notes link. Banner state (busy tab, dialog, provider gate) lives on + * the overlay store. + */ +export function useSessionBannerCommands(options: { remote: boolean; reloadConfigWarnings: ConfigWarningsReload }) { + const reclaimBusyTab = useOverlayStore((state) => state.reclaimBusyTab); + const setReclaimBusyTab = useOverlayStore((state) => state.setReclaimBusyTab); + const setTakeoverDialogTab = useOverlayStore((state) => state.setTakeoverDialogTab); + + const reclaimSession = useCommittedCommand((tabId: string) => { + if (reclaimBusyTab) return; + setReclaimBusyTab(tabId); + (options.remote ? app.ReclaimRemoteTabSession(tabId) : app.TakeoverSession(tabId, "wait")) + .catch((error) => console.warn("[takeover] reclaim failed", error)) + .finally(() => setReclaimBusyTab(null)); + }); + + const openTakeoverDialog = useCommittedCommand((tabId: string) => setTakeoverDialogTab(tabId)); + const closeTakeoverDialog = useCommittedCommand(() => setTakeoverDialogTab(null)); + + const openConfigFile = useCommittedCommand(() => { + void app.OpenUserConfigPath?.().catch(() => {}); + }); + + const reloadConfigFile = useCommittedCommand(() => { + void (async () => { + try { + const view = await app.ReloadUserConfig?.(); + options.reloadConfigWarnings(view?.configWarnings ?? [], view?.configWarningsRevision ?? 0); + } catch { + /* keep banner */ + } + })(); + }); + + const showReleaseNotes = useCommittedCommand((latest: string) => { + const version = latest.replace(/^(?:desktop-)?v/, ""); + void openExternal(`https://reasonix.io/changelog/v${version}/`); + }); + + return { reclaimSession, openTakeoverDialog, closeTakeoverDialog, openConfigFile, reloadConfigFile, showReleaseNotes }; +} diff --git a/desktop/frontend/src/app-runtime/useSessionClearCommands.ts b/desktop/frontend/src/app-runtime/useSessionClearCommands.ts new file mode 100644 index 0000000000..0e395000c2 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionClearCommands.ts @@ -0,0 +1,54 @@ +import { useState } from "react"; +import type { Translator } from "../lib/i18n"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { useSessionOperations } from "./useSessionOperations"; + +export type SessionClearCommandsInput = { + activeTabId: string | undefined; + activeSessionIdentity: string; + remote: boolean; + t: Translator; + notice: (text: string, level?: "info" | "warn") => void; + operations: ReturnType; + refreshDock(): void; + ports: { + clearSession(): Promise; + clearRemoteSession(tabId: string): Promise; + retryRemoteHydration(): Promise; + }; +}; + +/** + * Owns the clear-context decision surface: the pending flag, its cancel and + * the confirm chain — target capture at click time, sessionRuntimeOwner + * execution under the session operations authority, dock refresh plus notice + * on commit, and a warning notice on failure. Tab switches and session + * replacement still reset the flag through the returned setter. The runtime + * owner chunk stays lazy behind the confirm. + */ +export function useSessionClearCommands(input: SessionClearCommandsInput) { + const { activeTabId, activeSessionIdentity, t, notice, operations, ports } = input; + const [clearContextPending, setClearContextPending] = useState(false); + + const cancelClearContext = useCommittedCommand(() => { + setClearContextPending(false); + }); + + const confirmClearContext = useCommittedCommand(async () => { + const target = activeTabId ? { tabId: activeTabId, sessionKey: activeSessionIdentity } : null; + if (!target) return; + setClearContextPending(false); + const outcome = await operations(target, "clear-context", { remote: input.remote }, async (operationInput, authority) => + (await import("./sessionRuntimeOwner")).executeClearSession(target, operationInput, ports, authority), + ); + if (outcome.status === "completed") { + input.refreshDock(); + notice(t("clearContext.done")); + } else if (outcome.status === "failed") { + const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error); + notice(message || t("clearContext.failed"), "warn"); + } + }); + + return { clearContextPending, setClearContextPending, cancelClearContext, confirmClearContext }; +} diff --git a/desktop/frontend/src/app-runtime/useSessionControlCommands.ts b/desktop/frontend/src/app-runtime/useSessionControlCommands.ts new file mode 100644 index 0000000000..b286ce0493 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionControlCommands.ts @@ -0,0 +1,80 @@ +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { CancelOutcome } from "../lib/inboxCancel"; +import type { SessionResource, useSessionOperations } from "./useSessionOperations"; + +export type SessionControlCommandsInput = { + activeTabId: string | undefined; + resources: readonly SessionResource[]; + operations: ReturnType; + showToast: (message: string, level: "error") => void; + clearWorkspaceConflict: () => void; + ports: { + cancel(queuedItemIDs: string[]): Promise; + cancelForTab(tabId: string, queuedItemIDs: string[]): Promise; + acceptDelivery(tabId: string): Promise; + disconnectRemote(hostId: string): Promise; + cancelJobForTab(tabId: string, jobId: string): Promise; + refreshBackgroundRuntimes(): Promise; + }; +}; + +/** + * Owns the session control commands: active-turn cancel (capturing the + * committed source tab at the event boundary so presentation never reads the + * active-tab mirror mid-flight), delivery accept, remote host disconnect, + * workspace-conflict cancel and per-job runtime cancel through the session + * operations authority. + */ +export function useSessionControlCommands(input: SessionControlCommandsInput) { + const { activeTabId, resources, operations, showToast, ports } = input; + + const cancelRuntimeJob = useCommittedCommand(async (tabId: string, jobId: string): Promise => { + const target = resources.find(resource => resource.tabId === tabId); + if (!target) return false; + const outcome = await operations(target, `runtime-cancel:${jobId}`, {}, async (_operationInput, authority) => + (await import("./sessionRuntimeOwner")).executeCancelRuntimeJob(target, jobId, { + cancelForTab: (sourceTabId, sourceJobId) => ports.cancelJobForTab(sourceTabId, sourceJobId), + refresh: () => ports.refreshBackgroundRuntimes(), + }, authority), + ); + if (outcome.status === "failed") { + showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error"); + return false; + } + return outcome.status === "completed" ? outcome.value : false; + }); + + const handleCancelActive = useCommittedCommand((queuedItemIDs: string[] = []) => { + const sourceTabId = activeTabId; + return sourceTabId ? ports.cancelForTab(sourceTabId, queuedItemIDs) : ports.cancel(queuedItemIDs); + }); + + // Capture the committed source tab at the event boundary. Presentation must + // never read the active-tab mirror while an async delivery operation is in flight. + const handleAcceptDelivery = useCommittedCommand(() => { + const sourceTabId = activeTabId; + if (!sourceTabId) return; + void ports.acceptDelivery(sourceTabId).catch((error) => { + console.warn("Failed to accept delivery", error); + }); + }); + + const handleDisconnectRemote = useCommittedCommand((hostId: string) => { + void ports.disconnectRemote(hostId).catch((error) => { + console.warn("Failed to disconnect remote host", error); + }); + }); + + const cancelWorkspaceConflict = useCommittedCommand(() => { + void handleCancelActive(); + input.clearWorkspaceConflict(); + }); + + return { + cancelRuntimeJob, + handleCancelActive, + handleAcceptDelivery, + handleDisconnectRemote, + cancelWorkspaceConflict, + }; +} diff --git a/desktop/frontend/src/app-runtime/useSessionExportCommands.ts b/desktop/frontend/src/app-runtime/useSessionExportCommands.ts new file mode 100644 index 0000000000..1bc79e2699 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionExportCommands.ts @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { safeFilename } from "../lib/sessionTitles"; +import { applyThemeScene } from "../lib/themePack"; +import { useOverlayStore } from "../store/overlays"; +import type { Translator } from "../lib/i18n"; +import type { Item, LiveStream } from "../lib/useController"; + +export type SessionExportFormat = "markdown" | "json" | "pdf" | "image"; + +/** + * Owns the session export commands (markdown/json/pdf/image file pickers and + * writers), the export popover outside-click close and the theme scene that + * switches between the empty home and the content task scene. Each command + * captures the session title/items/live snapshot of the render that published + * it; the renderer chunks stay lazy behind the file dialog. + */ +export function useSessionExportCommands(input: { + sessionTitle: string; + items: readonly Item[]; + live: LiveStream | undefined; + hasContent: boolean; + t: Translator; + showToast: (message: string, kind: "info" | "warn" | "error", options?: { durationMs?: number }) => void; +}) { + const { sessionTitle, items, live, hasContent, t, showToast } = input; + const topicExportOpen = useOverlayStore((state) => state.topicExportOpen); + const setTopicExportOpen = useOverlayStore((state) => state.setTopicExportOpen); + + // Theme pack scene: home when the session is empty, task once content exists. + useEffect(() => { + applyThemeScene(hasContent ? "task" : "home"); + }, [hasContent]); + + useEffect(() => { + if (!topicExportOpen) return; + const onDown = (event: MouseEvent) => { + const target = event.target as Element | null; + if (!target?.closest(".topicbar__export")) setTopicExportOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [setTopicExportOpen, topicExportOpen]); + + const getSessionMarkdown = useCommittedCommand(async () => (await import("../lib/sessionExportData")).sessionItemsToMarkdown(sessionTitle, Array.from(items), live)); + const getSessionJson = useCommittedCommand(async () => (await import("../lib/sessionExportData")).sessionItemsToJson(sessionTitle, Array.from(items), live)); + + const exportSession = useCommittedCommand(async (format: SessionExportFormat) => { + const base = safeFilename(sessionTitle); + setTopicExportOpen(false); + try { + if (format === "json") { + const path = await app.PickExportFile(`${base}.json`, "application/json"); + if (path) { + await app.SaveExportFile(path, await getSessionJson(), false); + showToast(t("topicBar.exportSuccess", { count: 1 }), "info"); + } + } else if (format === "pdf") { + const path = await app.PickExportFile(`${base}.pdf`, "application/pdf"); + if (!path) return; + const { blobToBase64, renderSessionPdfBlob } = await import("../lib/sessionExport"); + const blob = await renderSessionPdfBlob(await getSessionMarkdown(), sessionTitle); + await app.SaveExportFile(path, await blobToBase64(blob), true); + showToast(t("topicBar.exportSuccess", { count: 1 }), "info"); + } else if (format === "image") { + const path = await app.PickExportFile(`${base}.png`, "image/png"); + if (!path) return; + const { renderSessionImageBase64Payloads } = await import("../lib/sessionExport"); + const payloads = await renderSessionImageBase64Payloads(await getSessionMarkdown()); + await app.SaveExportImageFiles(path, payloads); + showToast( + payloads.length > 1 + ? t("topicBar.exportImageParts", { count: payloads.length }) + : t("topicBar.exportSuccess", { count: 1 }), + "info", + ); + } else { + const path = await app.PickExportFile(`${base}.md`, "text/markdown"); + if (path) { + await app.SaveExportFile(path, await getSessionMarkdown(), false); + showToast(t("topicBar.exportSuccess", { count: 1 }), "info"); + } + } + } catch (err) { + console.error("Failed to export session", err); + showToast( + t("topicBar.exportFailed", { error: err instanceof Error ? err.message : String(err) }), + "error", + { durationMs: 8000 }, + ); + } + }); + + return { getSessionMarkdown, getSessionJson, exportSession }; +} diff --git a/desktop/frontend/src/app-runtime/useSessionNavigationCommands.ts b/desktop/frontend/src/app-runtime/useSessionNavigationCommands.ts new file mode 100644 index 0000000000..770a31c79b --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionNavigationCommands.ts @@ -0,0 +1,161 @@ +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { asArray } from "../lib/array"; +import { resolveTaskMonitorSession } from "../lib/taskMonitorNavigation"; +import { taskSessionIDFromPath, type SidebarImConnection } from "./sidebarImProjection"; +import type { useDesktopNavigation } from "./useDesktopNavigation"; +import type { WorkspaceNavigationPorts } from "./navigationOwner"; +import type { ControlResult, SessionMeta, TabMeta } from "../lib/types"; +import type { TopicShortcutEntry } from "../lib/topicShortcuts"; +import type { Translator } from "../lib/i18n"; +import type { Dispatch, SetStateAction } from "react"; + +const loadNavigationOwner = () => import("./navigationOwner"); + +export type SessionNavigationCommandsInput = { + activeTab: TabMeta | undefined; + running: boolean; + singleSurface: boolean; + t: Translator; + showToast: (message: string, level: "error") => void; + closeTransientOverlays: () => void; + clearImDetail: () => void; + navigation: Pick, "enqueueNavigation" | "enqueueNavigationWithIntent" | "openRemoteProject">; + noteNavigationIntent: () => number; + beginNavigationSurface: (seq: number) => void; + settleNavigationSurface: (seq: number) => void; + isNavigationIntentCurrent: (seq: number) => boolean; + markProjectChanged: Dispatch>; + refreshTabMetas: (apply?: () => boolean, options?: { afterMutation?: boolean }) => Promise; + refreshHistoryView: () => void; + enterConversation: () => void; + pickWorkspace: WorkspaceNavigationPorts["pickWorkspace"]; + switchWorkspace: WorkspaceNavigationPorts["switchWorkspace"]; + ports: { + openTaskSessionForTab(tabId: string, taskId: string): Promise; + listSessionsForTab(tabId: string): Promise; + }; +}; + +/** + * Owns the session-level navigation commands: blank/topic/resume/sidebar-IM + * enqueues, new-tab routing (remote hosts reopen remotely), recovery refresh + * pairs, folder switching through the lazy navigation owner and the + * task-monitor session lookup with its navigation-intent fence. All commands + * coalesce through the shared navigation epoch from useDesktopNavigation. + */ +export function useSessionNavigationCommands(input: SessionNavigationCommandsInput) { + const { activeTab, running, singleSurface, t, showToast, navigation, ports } = input; + + const blankSessionTarget = useCommittedCommand(() => { + const activeWorkspaceRoot = activeTab?.scope === "project" ? activeTab.workspaceRoot || "" : ""; + const scope = activeWorkspaceRoot ? "project" : "global"; + return { scope, workspaceRoot: activeWorkspaceRoot }; + }); + + const openBlankSession = useCommittedCommand((scope: string, workspaceRoot: string): Promise => + navigation.enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" })); + + const handleNewTab = useCommittedCommand(async () => { + input.closeTransientOverlays(); + input.clearImDetail(); + if (activeTab?.remote) { + const outcome = await navigation.openRemoteProject(activeTab.remote, { newSession: true }); + if (outcome.status === "failed") showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error"); + return; + } + const target = blankSessionTarget(); + await openBlankSession(target.scope, target.workspaceRoot); + }); + + const handleOpenTopic = useCommittedCommand((scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise => { + input.closeTransientOverlays(); + input.clearImDetail(); + return navigation.enqueueNavigation({ kind: "topic", scope, workspaceRoot, topicId, sessionPath }); + }); + + const openSidebarImConnectionSession = useCommittedCommand((connection: SidebarImConnection): Promise => { + input.clearImDetail(); + return navigation.enqueueNavigation({ kind: "sidebar-im", connection }); + }); + + const onResumeSession = useCommittedCommand((session: SessionMeta): Promise => { + if (running && !singleSurface) return Promise.resolve(); + return navigation.enqueueNavigation({ kind: "resume-session", session }); + }); + + const onRecoveryCreated = useCommittedCommand(() => { + input.markProjectChanged((value) => value + 1); + void input.refreshTabMetas(undefined, { afterMutation: true }); + }); + const onRecoveryLineageChanged = useCommittedCommand(() => { + input.markProjectChanged((value) => value + 1); + input.refreshHistoryView(); + }); + + const openTaskMonitorSession = useCommittedCommand(async (tabID: string, taskID: string): Promise => { + if (running && !singleSurface) { + throw new Error(t("history.failedOpenSession")); + } + // Claim the navigation epoch before the first Wails await. If the user + // switches tabs while the task/session lookup is pending, its completion is + // stale and must not enqueue a newer navigation request. + const navigationIntentSeq = input.noteNavigationIntent(); + input.beginNavigationSurface(navigationIntentSeq); + let session: SessionMeta | null; + try { + session = await resolveTaskMonitorSession({ + tabID, + taskID, + intentSeq: navigationIntentSeq, + isIntentCurrent: input.isNavigationIntentCurrent, + openTaskSessionForTab: (sourceTabID, sourceTaskID) => ports.openTaskSessionForTab(sourceTabID, sourceTaskID), + listSessionsForTab: async (sourceTabID) => asArray(await ports.listSessionsForTab(sourceTabID)), + sessionIDFromPath: taskSessionIDFromPath, + }); + } catch (error) { + input.settleNavigationSurface(navigationIntentSeq); + throw error; + } + if (!session) { + input.settleNavigationSurface(navigationIntentSeq); + return false; + } + await navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq); + return input.isNavigationIntentCurrent(navigationIntentSeq); + }); + + const refreshTabsAfterMutation = useCommittedCommand((latest: () => boolean) => ( + input.refreshTabMetas(latest, { afterMutation: true }) + )); + const switchFolder = useCommittedCommand(async (path?: string) => { + input.enterConversation(); + return loadNavigationOwner().then(({ navigateWorkspace }) => navigateWorkspace(path, { + claimIntent: input.noteNavigationIntent, + beginSurface: input.beginNavigationSurface, + isIntentCurrent: input.isNavigationIntentCurrent, + pickWorkspace: input.pickWorkspace, + switchWorkspace: input.switchWorkspace, + markProjectChanged: input.markProjectChanged, + refreshTabsAfterMutation, + maskTarget: input.settleNavigationSurface, + })); + }); + + const handleNavigateTopic = useCommittedCommand((entry: TopicShortcutEntry) => { + void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath); + }); + + return { + openBlankSession, + handleNewTab, + handleOpenTopic, + openSidebarImConnectionSession, + onResumeSession, + onRecoveryCreated, + onRecoveryLineageChanged, + openTaskMonitorSession, + refreshTabsAfterMutation, + switchFolder, + handleNavigateTopic, + }; +} diff --git a/desktop/frontend/src/app-runtime/useSessionOperations.ts b/desktop/frontend/src/app-runtime/useSessionOperations.ts new file mode 100644 index 0000000000..a8a038d353 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionOperations.ts @@ -0,0 +1,15 @@ +import { useMemo } from "react"; +import { useResourceOperations, type SessionResource } from "./useResourceOperations"; +export type { SessionResource, SessionOperationAuthority } from "./useResourceOperations"; + +function bindSessions(operations: ReturnType) { + return (target: SessionResource, channel: string, input: Input, + execute: (input: Input, authority: import("./useResourceOperations").SessionOperationAuthority) => Result) => ( + operations({ kind: "session", ...target }, channel, input, execute) + ); +} + +export function useSessionOperations(input: { visible: SessionResource; resources: readonly SessionResource[] }) { + const operations = useResourceOperations({ visible: input.visible, resources: input.resources.map(resource => ({ kind: "session", ...resource })) }); + return useMemo(() => bindSessions(operations), [operations]); +} diff --git a/desktop/frontend/src/app-runtime/useSessionPromptCommands.ts b/desktop/frontend/src/app-runtime/useSessionPromptCommands.ts new file mode 100644 index 0000000000..f083431848 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionPromptCommands.ts @@ -0,0 +1,47 @@ +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { QuestionAnswer, ToolApprovalMode } from "../lib/types"; +import { executeSessionPrompt, type PromptPorts, type PromptRequest, type SessionPromptKind } from "./sessionPromptExecutor"; +import type { MCPInteractionAction, RecoveryAction } from "./sessionActionOwner"; +import type { SessionResource, useSessionOperations } from "./useSessionOperations"; + +type Input = { + target: SessionResource; + approval?: { id: string; tool: string }; + questionId?: string; + remote: boolean; + goal: string; + toolApprovalMode: ToolApprovalMode; + ports: PromptPorts; + operations: ReturnType; + reportError: (error: unknown) => void; +}; + +export function useSessionPromptCommands(input: Input) { + const run = useCommittedCommand(async (promptId: string | undefined, promptKind: SessionPromptKind, request: PromptRequest) => { + if (!promptId || !input.target.tabId) return; + const target = { ...input.target, promptId }; + const result = await input.operations(target, `prompt:${promptKind}`, { target, promptKind, request, ports: input.ports }, executeSessionPrompt); + if (result.status === "failed") throw result.error; + }); + const plan = useCommittedCommand((action: "start_execution" | "revise_plan" | "exit_plan", revision?: string) => run(input.approval?.id, "approval", { + kind: "plan", action, leavePlanMode: action !== "revise_plan", remote: input.remote, + goal: input.goal, toolApprovalMode: input.toolApprovalMode, revision, + })); + const report = useCommittedCommand(input.reportError); + const handleApprovalAnswer = useCommittedCommand((allow: boolean, session: boolean, persist: boolean) => ( + input.approval?.tool === "exit_plan_mode" + ? plan(allow ? "start_execution" : "revise_plan") + : run(input.approval?.id, "approval", { kind: "approval", allow, session, persist }) + )); + const handleRecoveryAnswer = useCommittedCommand((action: RecoveryAction, feedback = "") => { + void run(input.approval?.id, "approval", { kind: "recovery", action, feedback }).catch(report); + }); + const handleRevisePlan = useCommittedCommand((revision: string) => { void plan("revise_plan", revision).catch(report); }); + const handleExitPlan = useCommittedCommand(() => plan("exit_plan")); + const handleQuestionAnswer = useCommittedCommand((id: string, answers: QuestionAnswer[]) => run(id, "ask", { kind: "question", answers })); + const handleQuestionDismiss = useCommittedCommand(() => run(input.questionId, "ask", { kind: "question", answers: [] })); + const handleMCPAnswer = useCommittedCommand((id: string, action: MCPInteractionAction, content?: Record) => { + void run(id, "mcpInteraction", { kind: "mcp", action, content }).catch(report); + }); + return { handleApprovalAnswer, handleRecoveryAnswer, handleRevisePlan, handleExitPlan, handleQuestionAnswer, handleQuestionDismiss, handleMCPAnswer }; +} diff --git a/desktop/frontend/src/app-runtime/useSessionUndo.ts b/desktop/frontend/src/app-runtime/useSessionUndo.ts new file mode 100644 index 0000000000..f8daa59477 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useSessionUndo.ts @@ -0,0 +1,245 @@ +import { useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { Item } from "../lib/useController"; +import type { RewindUndoState } from "../lib/rewindTypes"; +import type { RewindResultView } from "../lib/types"; + +export type SessionUndoInput = { + activeTabId: string | undefined; + activeTabReadOnly: boolean; + items: readonly Item[]; + hydratePlaceholderActive: boolean; + controllerReady: boolean; + running: boolean; + messageActionOpen: boolean; + approvalOpen: boolean; + askOpen: boolean; + clearContextPending: boolean; + ports: { + rewindForTab(tabId: string, turn: number, scope: string): Promise; + rewindForTabDetailed(tabId: string, turn: number, scope: string): Promise; + refreshTabMetas(): void; + undoRewindForTab(tabId: string, transactionId: string): Promise; + sendToTab(tabId: string, display: string, submit: string, original: string): Promise; + composeInsert(tabId: string, text: string): void; + refreshDock(): void; + refreshProject(): void; + }; +}; + +/** + * Owns the undo/rewind lifecycle: per-tab rewind state and committing flags, + * message-action rewinds (fork/code/summarize/full), edit-prompt rewinds and + * the committed-session revert handler. The undo banner still reads + * `rewindState`/`setRewindStateForTab` through this hook's return; only the + * banner identity and its DOM live in the footer region. + */ +export function useSessionUndo(input: SessionUndoInput) { + const { activeTabId, items, ports } = input; + const [rewindStatesByTab, setRewindStatesByTab] = useState>({}); + const [rewindCommittingByTab, setRewindCommittingByTab] = useState>({}); + const [rewindSignal, setRewindSignal] = useState(0); + + const setRewindStateForTab = useCommittedCommand((tabId: string, nextState: RewindUndoState | null) => { + if (!tabId) return; + setRewindStatesByTab(current => { + if (!nextState && !current[tabId]) return current; + const next = { ...current }; + if (nextState) next[tabId] = nextState; + else delete next[tabId]; + return next; + }); + }); + + const setRewindCommittingForTab = useCommittedCommand((tabId: string, committing: boolean) => { + setRewindCommittingByTab((current) => { + const next = { ...current }; + if (committing) next[tabId] = true; + else delete next[tabId]; + return next; + }); + }); + + const bumpRewindSignal = useCommittedCommand(() => setRewindSignal((value) => value + 1)); + + const handleSessionRevertCommitted = useCommittedCommand((sourceTabId: string, outcome: RewindResultView) => { + if (!sourceTabId || !outcome.ok) return; + setRewindStateForTab(sourceTabId, { + turnDiff: 0, + transactionId: outcome.transactionId, + undoAvailable: outcome.undoAvailable, + filesRestored: outcome.written ?? [], + filesRemoved: outcome.deleted ?? [], + }); + ports.refreshDock(); + ports.refreshProject(); + }); + + const rewindState = activeTabId ? rewindStatesByTab[activeTabId] ?? null : null; + const rewindCommitting = Boolean(activeTabId && rewindCommittingByTab[activeTabId]); + + const handleMessageAction = useCommittedCommand((turn: number, scope: string) => { + const sourceTabId = activeTabId; + if (!sourceTabId || input.activeTabReadOnly) return; + if (input.hydratePlaceholderActive) return; + if (scope === "fork") { + // Fork still goes through the controller (not optimistic). + ports.rewindForTab(sourceTabId, turn, scope).then((ok) => { + if (!ok) return; + ports.refreshTabMetas(); + ports.refreshProject(); + }); + return; + } + + // Code-only rewind only affects files — no message truncation, + // no optimistic UI needed. Execute immediately. + if (scope === "code") { + setRewindCommittingForTab(sourceTabId, true); + void ports.rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => { + setRewindCommittingForTab(sourceTabId, false); + if (!outcome.ok) return; + setRewindStateForTab(sourceTabId, { + turnDiff: 0, + transactionId: outcome.transactionId, + undoAvailable: outcome.undoAvailable, + filesRestored: outcome.written ?? [], + filesRemoved: outcome.deleted ?? [], + }); + ports.refreshDock(); + ports.refreshProject(); + }); + return; + } + + // Summarize only compresses the conversation log — no files touched, + // no optimistic UI needed. Execute immediately like code-only rewind. + if (scope === "summ-from" || scope === "summ-upto") { + ports.rewindForTab(sourceTabId, turn, scope).then((ok) => { + if (!ok) return; + ports.refreshDock(); + ports.refreshProject(); + }); + return; + } + + const hasCheckpointTurns = items.some((it) => it.kind === "user" && it.checkpointTurn != null); + let boundaryIdx = -1; + let userCount = 0; + let targetUserCount = -1; + for (let i = 0; i < items.length; i++) { + if (items[i].kind === "user") { + const item = items[i] as Extract; + const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn; + if (matches) { + boundaryIdx = i; + targetUserCount = userCount; + break; + } + userCount++; + } + } + if (boundaryIdx < 0) { + ports.rewindForTab(sourceTabId, turn, scope).then((ok) => { + if (!ok) return; + if (scope === "both") { + ports.refreshDock(); + ports.refreshProject(); + } + }); + return; + } + + const prevUserCount = items.filter((it) => it.kind === "user").length; + const turnDiff = prevUserCount - targetUserCount; + const userItem = items[boundaryIdx]?.kind === "user" ? items[boundaryIdx] as Extract : undefined; + const prompt = userItem?.text ?? ""; + + // Immediate backend commit — only update UI after success. + setRewindCommittingForTab(sourceTabId, true); + void ports.rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => { + setRewindCommittingForTab(sourceTabId, false); + if (!outcome.ok) { + // Keep conversation/files as-is; notices already carry the reason. + return; + } + const targetTabId = outcome.tabId || sourceTabId; + setRewindStateForTab(targetTabId, { + turnDiff: outcome.tabId ? 0 : turnDiff, + transactionId: outcome.transactionId, + undoAvailable: outcome.undoAvailable, + undoTabId: sourceTabId, + filesRestored: outcome.written ?? [], + filesRemoved: outcome.deleted ?? [], + }); + ports.composeInsert(targetTabId, prompt); + bumpRewindSignal(); + if (scope === "both" || scope === "code") { + ports.refreshDock(); + ports.refreshProject(); + } + }); + }); + + const handleUndoRewind = useCommittedCommand(() => { + const tabId = activeTabId; + const state = rewindState; + if (!tabId || !state) return; + const tx = state.transactionId; + const undoTabId = state.undoTabId || tabId; + const undo = tx && state.undoAvailable ? ports.undoRewindForTab(undoTabId, tx) : Promise.resolve(true); + void undo.then((ok) => { + if (!ok) return; + setRewindStateForTab(tabId, null); + ports.composeInsert(tabId, ""); + bumpRewindSignal(); + ports.refreshDock(); + ports.refreshProject(); + }); + }); + + const handleEditPrompt = useCommittedCommand(async (turn: number, displayText: string, submitText?: string): Promise => { + const sourceTabId = activeTabId; + if (!sourceTabId || input.activeTabReadOnly || !input.controllerReady || input.hydratePlaceholderActive + || rewindStatesByTab[sourceTabId] || input.running || input.messageActionOpen + || input.approvalOpen || input.askOpen || input.clearContextPending) return false; + const next = displayText.trim(); + if (!next) return false; + const submit = (submitText ?? displayText).trim(); + const hasCheckpointTurns = items.some((it) => it.kind === "user" && it.checkpointTurn != null); + let original = ""; + let userCount = 0; + for (const item of items) { + if (item.kind !== "user") continue; + const matches = hasCheckpointTurns ? item.checkpointTurn === turn : userCount === turn; + if (matches) { + original = (item.submitText ?? item.text).trim(); + break; + } + userCount++; + } + const outcome = await ports.rewindForTabDetailed(sourceTabId, turn, "conversation"); + if (!outcome.ok) return false; + bumpRewindSignal(); + const targetTabId = outcome.tabId || sourceTabId; + try { + await ports.sendToTab(targetTabId, next, submit, original); + return true; + } catch { + return false; + } + }); + + return { + rewindState, + rewindCommitting, + rewindSignal, + setRewindStateForTab, + setRewindCommittingForTab, + bumpRewindSignal, + handleSessionRevertCommitted, + handleMessageAction, + handleUndoRewind, + handleEditPrompt, + }; +} diff --git a/desktop/frontend/src/app-runtime/useShellGeometry.ts b/desktop/frontend/src/app-runtime/useShellGeometry.ts new file mode 100644 index 0000000000..e6c2944359 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useShellGeometry.ts @@ -0,0 +1,390 @@ +import { useEffect, useRef, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { createPointerResizeLifecycle, createRafResizeUpdater } from "../lib/resizeDrag"; +import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement } from "../lib/workspaceLayout"; +import { useDesktopPreferences } from "./useDesktopPreferences"; +import { useOverlayStore } from "../store/overlays"; +import { useWindowChromeStore } from "../store/windowChrome"; +import { + clampCreationRightDockTreeWidth, + clampCreationSidebarWidth, + clampRightDockTreeWidth, + clampSidebarWidth, + clampTerminalHeight, + CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH, + CREATION_RIGHT_DOCK_TREE_MIN_WIDTH, + CREATION_SIDEBAR_MIN_WIDTH, + RIGHT_DOCK_MIN_RENDER_WIDTH, + RIGHT_DOCK_TREE_MIN_WIDTH, + saveRightDockTreeWidth, + saveSidebarCollapsed, + saveSidebarWidth, + saveTerminalHeight, + SIDEBAR_MAX_WIDTH, + SIDEBAR_MIN_WIDTH, + terminalMaxHeight, + TERMINAL_MIN_HEIGHT, + useLayoutStore, +} from "../store/layout"; + +const CHAT_MIN_WIDTH = 400; +const WORKSPACE_RESIZER_WIDTH = 8; + +/** + * Owns the shell geometry commands and their read projections: sidebar and + * right-dock/terminal pointer and keyboard resizing, the sidebar toggle, and + * the derived widths consumed by App JSX and the region prop builders. All + * transient geometry lives on the layout store; the refs are injected because + * the resizers drive CSS variables on the root layout element. + */ +export function useShellGeometry(input: { appRef: RefObject; layoutRef: RefObject }) { + const { appRef, layoutRef } = input; + const { desktopLayoutStyle } = useDesktopPreferences(); + const viewportWidth = useWindowChromeStore((state) => state.viewportWidth); + const viewportHeight = useWindowChromeStore((state) => state.viewportHeight); + const sidebarCollapsed = useLayoutStore((state) => state.sidebarCollapsed); + const sidebarWidth = useLayoutStore((state) => state.sidebarWidth); + const liveSidebarWidth = useLayoutStore((state) => state.liveSidebarWidth); + const rightDockTreeWidth = useLayoutStore((state) => state.rightDockTreeWidth); + const liveWorkspacePanelRenderWidth = useLayoutStore((state) => state.liveWorkspacePanelRenderWidth); + const workspacePanelOpen = useLayoutStore((state) => state.workspacePanelOpen); + const workspacePanelMaximized = useLayoutStore((state) => state.workspacePanelMaximized); + const workspacePreviewActive = useLayoutStore((state) => state.workspacePreviewActive); + const rightDockMode = useLayoutStore((state) => state.rightDockMode); + const terminalPanelOpen = useLayoutStore((state) => state.terminalPanelOpen); + const terminalHeight = useLayoutStore((state) => state.terminalHeight); + const setSidebarCollapsed = useLayoutStore((state) => state.setSidebarCollapsed); + const setSidebarWidth = useLayoutStore((state) => state.setSidebarWidth); + const setRightDockTreeWidth = useLayoutStore((state) => state.setRightDockTreeWidth); + const setTerminalHeight = useLayoutStore((state) => state.setTerminalHeight); + const setSidebarTogglePressed = useLayoutStore((state) => state.setSidebarTogglePressed); + const setSidebarResizing = useLayoutStore((state) => state.setSidebarResizing); + const setLiveSidebarWidth = useLayoutStore((state) => state.setLiveSidebarWidth); + const setWorkspacePanelResizing = useLayoutStore((state) => state.setWorkspacePanelResizing); + const setLiveWorkspacePanelRenderWidth = useLayoutStore((state) => state.setLiveWorkspacePanelRenderWidth); + const setLiveTerminalHeight = useLayoutStore((state) => state.setLiveTerminalHeight); + const setSidebarSearchOpen = useOverlayStore((state) => state.setSidebarSearchOpen); + const setTransientOverlayDismissSignal = useOverlayStore((state) => state.setTransientOverlayDismissSignal); + + const closeTransientOverlays = useCommittedCommand(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }); + + const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive; + // The dock keeps one width across tab switches (context/files/changed): + // the tree width is the single source so toggling tabs never resizes the + // sidebar. Preview detail stays inside the dock without widening it. + const preferredWorkspacePanelWidth = rightDockTreeWidth; + const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH; + const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth; + const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive + ? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH + : RIGHT_DOCK_MIN_RENDER_WIDTH; + const workspacePanelMinWidth = rightDockTreeMinWidth; + const chatReservedWidth = CHAT_MIN_WIDTH; + const workspacePanelAvailableWidth = availableWorkspacePanelWidth({ + viewportWidth, + sidebarCollapsed, + sidebarWidth, + chatMinWidth: chatReservedWidth, + resizerWidth: WORKSPACE_RESIZER_WIDTH, + }); + const { + renderWidth: workspacePanelRenderWidth, + overlay: workspacePanelOverlay, + renderable: workspacePanelRenderable, + gridOpen: workspacePanelGridOpen, + } = resolveWorkspacePanelPlacement({ + viewportWidth, sidebarCollapsed, sidebarWidth, chatMinWidth: chatReservedWidth, + resizerWidth: WORKSPACE_RESIZER_WIDTH, open: workspacePanelOpen, + maximized: workspacePanelMaximized, preferredWidth: preferredWorkspacePanelWidth, + minWidth: workspacePanelMinWidth, minRenderWidth: rightDockMinRenderWidth, + liveWidth: liveWorkspacePanelRenderWidth, + }); + const resolveLiveWorkspacePanelRenderWidth = useCommittedCommand((preferredWidth: number, nextSidebarWidth = sidebarWidth) => + resolveLiveWorkspacePanelWidth({ + viewportWidth, + sidebarCollapsed, + sidebarWidth: nextSidebarWidth, + chatMinWidth: chatReservedWidth, + resizerWidth: WORKSPACE_RESIZER_WIDTH, + open: workspacePanelOpen, + maximized: workspacePanelMaximized, + preferredWidth, + minWidth: workspacePanelMinWidth, + })); + + const sidebarWidthClamp = desktopLayoutStyle === "creation" ? clampCreationSidebarWidth : clampSidebarWidth; + const sidebarRenderWidth = liveSidebarWidth ?? sidebarWidth; + const sidebarResizeMinWidth = desktopLayoutStyle === "creation" ? CREATION_SIDEBAR_MIN_WIDTH : SIDEBAR_MIN_WIDTH; + const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight); + const terminalResizeMaxHeight = terminalMaxHeight(viewportHeight); + + const sidebarTogglePressTimerRef = useRef(null); + const workspacePanelResizeFinishRef = useRef<(() => void) | null>(null); + const anchorPinTimerRef = useRef(null); + const anchorPinFrameRef = useRef(null); + useEffect(() => () => { + if (sidebarTogglePressTimerRef.current !== null) window.clearTimeout(sidebarTogglePressTimerRef.current); + if (anchorPinTimerRef.current !== null) window.clearTimeout(anchorPinTimerRef.current); + if (anchorPinFrameRef.current !== null) window.cancelAnimationFrame(anchorPinFrameRef.current); + workspacePanelResizeFinishRef.current?.(); + }, []); + + const pulseSidebarToggle = useCommittedCommand(() => { + if (typeof window === "undefined") return; + if (sidebarTogglePressTimerRef.current !== null) { + window.clearTimeout(sidebarTogglePressTimerRef.current); + } + setSidebarTogglePressed(true); + sidebarTogglePressTimerRef.current = window.setTimeout(() => { + sidebarTogglePressTimerRef.current = null; + setSidebarTogglePressed(false); + }, 260); + }); + + const anchorAppScrollToChat = useCommittedCommand(() => { + if (typeof window === "undefined") return; + const el = appRef.current; + if (!el) return; + const pin = () => { + el.scrollLeft = 0; + }; + pin(); + anchorPinFrameRef.current = window.requestAnimationFrame(pin); + anchorPinTimerRef.current = window.setTimeout(pin, 300); + }); + + const toggleSidebar = useCommittedCommand(() => { + closeTransientOverlays(); + pulseSidebarToggle(); + anchorAppScrollToChat(); + const nextCollapsed = !sidebarCollapsed; + if (nextCollapsed) setSidebarSearchOpen(false); + setSidebarCollapsed(nextCollapsed); + saveSidebarCollapsed(nextCollapsed); + }); + + const setExpandedSidebarWidth = useCommittedCommand((width: number) => { + closeTransientOverlays(); + const next = sidebarWidthClamp(width); + setSidebarWidth(next); + saveSidebarWidth(next); + }); + + const startSidebarResize = useCommittedCommand((event: ReactPointerEvent) => { + if (sidebarCollapsed) return; + const layout = layoutRef.current; + if (!layout) return; + event.preventDefault(); + closeTransientOverlays(); + setSidebarResizing(true); + let nextWidth = sidebarWidth; + const liveResize = createRafResizeUpdater({ + target: layout, + separator: event.currentTarget, + cssVar: "--sidebar-expanded-width", + onApply: setLiveSidebarWidth, + }); + const dockLiveResize = createRafResizeUpdater({ + target: layout, + cssVar: "--workspace-width", + onApply: setLiveWorkspacePanelRenderWidth, + }); + const onMove = (moveEvent: PointerEvent) => { + nextWidth = sidebarWidthClamp(moveEvent.clientX); + liveResize.schedule(nextWidth); + dockLiveResize.schedule(resolveLiveWorkspacePanelRenderWidth(preferredWorkspacePanelWidth, nextWidth)); + }; + const onDone = () => { + liveResize.flush(); + dockLiveResize.flush(); + setSidebarWidth(nextWidth); + saveSidebarWidth(nextWidth); + setLiveSidebarWidth(null); + setLiveWorkspacePanelRenderWidth(null); + setSidebarResizing(false); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onDone); + window.removeEventListener("pointercancel", onDone); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onDone); + window.addEventListener("pointercancel", onDone); + }); + + const resizeSidebarWithKeyboard = useCommittedCommand((event: KeyboardEvent) => { + if (sidebarCollapsed) return; + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + setExpandedSidebarWidth(sidebarWidth + (event.key === "ArrowRight" ? 16 : -16)); + } else if (event.key === "Home") { + event.preventDefault(); + setExpandedSidebarWidth(sidebarResizeMinWidth); + } else if (event.key === "End") { + event.preventDefault(); + setExpandedSidebarWidth(SIDEBAR_MAX_WIDTH); + } + }); + + const setSavedWorkspacePanelWidth = useCommittedCommand((width: number) => { + closeTransientOverlays(); + const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth); + setRightDockTreeWidth(next); + saveRightDockTreeWidth(next); + }); + + const ensureWorkspacePanelWidth = useCommittedCommand((width: number) => { + closeTransientOverlays(); + if (rightDockMode === "context") return; + const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth); + setRightDockTreeWidth(next); + saveRightDockTreeWidth(next); + }); + + const startWorkspacePanelResize = useCommittedCommand((event: ReactPointerEvent) => { + if (event.button !== 0 || !workspacePanelOpen) return; + const layout = layoutRef.current; + if (!layout) return; + event.preventDefault(); + workspacePanelResizeFinishRef.current?.(); + closeTransientOverlays(); + setWorkspacePanelResizing(true); + const separator = event.currentTarget; + const pointerId = event.pointerId; + const startX = event.clientX; + const startDockWidth = workspacePanelRenderWidth; + let nextDockWidth = startDockWidth; + const liveResize = createRafResizeUpdater({ + target: layout, + separator, + cssVar: "--workspace-width", + onApply: setLiveWorkspacePanelRenderWidth, + }); + const onMove = (moveEvent: PointerEvent) => { + const delta = moveEvent.clientX - startX; + nextDockWidth = startDockWidth - delta; + nextDockWidth = rightDockTreeWidthClamp(nextDockWidth, workspacePanelAvailableWidth); + liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth)); + }; + const lifecycle = createPointerResizeLifecycle({ + separator, + pointerId, + onMove, + onFinish: () => { + liveResize.flush(); + setSavedWorkspacePanelWidth(nextDockWidth); + setLiveWorkspacePanelRenderWidth(null); + setWorkspacePanelResizing(false); + workspacePanelResizeFinishRef.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }, + }); + workspacePanelResizeFinishRef.current = lifecycle.finish; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }); + + const resizeWorkspacePanelWithKeyboard = useCommittedCommand((event: KeyboardEvent) => { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16)); + } else if (event.key === "Home") { + event.preventDefault(); + setSavedWorkspacePanelWidth(rightDockTreeMinWidth); + } else if (event.key === "End") { + event.preventDefault(); + setSavedWorkspacePanelWidth(workspacePanelAvailableWidth); + } + }); + + const setSavedTerminalHeight = useCommittedCommand((height: number) => { + const next = clampTerminalHeight(height, viewportHeight); + setTerminalHeight(next); + saveTerminalHeight(next); + }); + + const startTerminalResize = useCommittedCommand((event: ReactPointerEvent) => { + if (!terminalPanelOpen) return; + const layout = layoutRef.current; + if (!layout) return; + event.preventDefault(); + closeTransientOverlays(); + const startY = event.clientY; + const startHeight = terminalRenderHeight; + let nextHeight = startHeight; + const liveResize = createRafResizeUpdater({ + target: layout, + separator: event.currentTarget, + cssVar: "--terminal-height", + onApply: setLiveTerminalHeight, + }); + const onMove = (moveEvent: PointerEvent) => { + const delta = startY - moveEvent.clientY; + nextHeight = clampTerminalHeight(startHeight + delta, viewportHeight); + liveResize.schedule(nextHeight); + }; + const onDone = () => { + liveResize.flush(); + setLiveTerminalHeight(null); + setSavedTerminalHeight(nextHeight); + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onDone); + window.removeEventListener("pointercancel", onDone); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onDone); + window.addEventListener("pointercancel", onDone); + }); + + const resizeTerminalWithKeyboard = useCommittedCommand((event: KeyboardEvent) => { + if (!terminalPanelOpen) return; + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + setSavedTerminalHeight(terminalRenderHeight + (event.key === "ArrowUp" ? 16 : -16)); + } else if (event.key === "Home") { + event.preventDefault(); + setSavedTerminalHeight(TERMINAL_MIN_HEIGHT); + } else if (event.key === "End") { + event.preventDefault(); + setSavedTerminalHeight(terminalResizeMaxHeight); + } + }); + + return { + toggleSidebar, + setExpandedSidebarWidth, + startSidebarResize, + resizeSidebarWithKeyboard, + setSavedWorkspacePanelWidth, + ensureWorkspacePanelWidth, + startWorkspacePanelResize, + resizeWorkspacePanelWithKeyboard, + setSavedTerminalHeight, + startTerminalResize, + resizeTerminalWithKeyboard, + rightDockTreeMinWidth, + rightDockTreeWidthClamp, + workspacePanelMinWidth, + chatReservedWidth, + workspacePanelAvailableWidth, + workspacePanelRenderWidth, + workspacePanelOverlay, + workspacePanelRenderable, + workspacePanelGridOpen, + sidebarRenderWidth, + sidebarResizeMinWidth, + sidebarWidthClamp, + terminalRenderHeight, + terminalResizeMaxHeight, + }; +} diff --git a/desktop/frontend/src/app-runtime/useTabBarCommands.ts b/desktop/frontend/src/app-runtime/useTabBarCommands.ts new file mode 100644 index 0000000000..38ef34e1c5 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTabBarCommands.ts @@ -0,0 +1,280 @@ +import { useRef, useState, type Dispatch, type SetStateAction } from "react"; +import { app } from "../lib/bridge"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { guardBackendNavigationResult } from "../lib/navigationSurfaceTransition"; +import { enqueueNavigationRequest, type PendingNavigationRequest } from "../lib/openTopicCoalescing"; +import { useOverlayStore } from "../store/overlays"; +import type { ActiveWorkView, TabMeta } from "../lib/types"; +import type { ComposerProfile } from "../lib/composerProfile"; +import type { Translator } from "../lib/i18n"; + +export type TabClosePolicy = "keep_running" | "stop_and_close"; + +export type TabBarCommandsInput = { + activeTabId: string | undefined; + tabMetas: readonly TabMeta[]; + deliveryWorktreeRoot: string | undefined; + t: Translator; + showToast(message: string, level: "error", options?: { durationMs?: number }): void; + setTabMetas: Dispatch>; + setTabOrderIds: Dispatch>; + setComposerProfilesByTab: Dispatch>>; + setTabRevealSignal: Dispatch>; + clearWorkspaceConflict(): void; + ports: { + closeTab(id: string, policy: TabClosePolicy): Promise; + reorderTabs(ids: string[]): Promise; + switchTab(id: string, tab?: TabMeta, seq?: number): Promise; + switchRemoteTab(tab: TabMeta, seq?: number): Promise; + refreshTabMetas(apply?: () => boolean, options?: { afterMutation?: boolean }): Promise; + refreshBackgroundRuntimes(): Promise; + cancelActive(): void; + noteNavigationIntent(): number; + beginNavigationSurface(seq: number): void; + settleNavigationSurface(seq: number): void; + isNavigationIntentCurrent(seq: number): boolean; + reassertVisibleTabAfterStaleNavigation(kind: string, staleTabId: string): Promise; + enterChatView(): void; + createIsolatedWorktree(root: string, seq: number): Promise; + }; +}; + +/** + * Owns the tab-bar commands (change/close/bulk-close/reorder with active-work + * gates), the single-flight tab switch queue, the background-runtime reveals + * and the delivery-worktree continuation. Tab close prompts and reveal + * navigation share one navigation-intent/surface lifecycle; only the visible + * tab list, reveal signal and close prompt stay on the caller's stores. + */ +export function useTabBarCommands(input: TabBarCommandsInput) { + const { activeTabId, t, showToast, ports } = input; + const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null); + // Tab switches serialize through one queue so a slow switch cannot land + // events/hydration on the wrong session; switchTab's own load is already + // seq-guarded, this serializes the backend activation around it. + const tabSwitchSeqRef = useRef(0); + const tabSwitchRunningRef = useRef(false); + const tabSwitchPendingRef = useRef | null>(null); + const setTransientOverlayDismissSignal = useOverlayStore((state) => state.setTransientOverlayDismissSignal); + + const closeTransientOverlays = useCommittedCommand(() => { + setTransientOverlayDismissSignal((signal) => signal + 1); + }); + + const enterChatViewForTabNavigation = useCommittedCommand(() => { + ports.enterChatView(); + }); + + const enqueueTabSwitch = useCommittedCommand((tabId: string, optimisticTab?: TabMeta): Promise => { + enterChatViewForTabNavigation(); + // Claim the shared navigation epoch at click time, before this request + // can wait behind an older tab switch. That immediately invalidates any + // in-flight blank/topic completion from a previous user intent. + const navigationIntentSeq = ports.noteNavigationIntent(); + ports.beginNavigationSurface(navigationIntentSeq); + return enqueueNavigationRequest( + { seqRef: tabSwitchSeqRef, runningRef: tabSwitchRunningRef, pendingRef: tabSwitchPendingRef }, + { tabId, optimisticTab, navigationIntentSeq }, + async (request) => { + try { + if (!ports.isNavigationIntentCurrent(request.navigationIntentSeq)) return; + if (request.optimisticTab?.remote) await ports.switchRemoteTab(request.optimisticTab, request.navigationIntentSeq); + else await ports.switchTab(request.tabId, request.optimisticTab, request.navigationIntentSeq); + if (!ports.isNavigationIntentCurrent(request.navigationIntentSeq)) return; + await ports.refreshTabMetas( + () => ports.isNavigationIntentCurrent(request.navigationIntentSeq), + { afterMutation: true }, + ); + } finally { + ports.settleNavigationSurface(request.navigationIntentSeq); + } + }, + ); + }); + + const revealBackgroundRuntime = useCommittedCommand(async (tabId: string): Promise => { + enterChatViewForTabNavigation(); + const navigationIntentSeq = ports.noteNavigationIntent(); + ports.beginNavigationSurface(navigationIntentSeq); + try { + const meta = await app.RevealBackgroundRuntime(tabId); + if (!await guardBackendNavigationResult({ + intent: navigationIntentSeq, + targetTabId: meta.id, + kind: "tab.reveal-background", + isIntentCurrent: ports.isNavigationIntentCurrent, + reassert: ports.reassertVisibleTabAfterStaleNavigation, + })) return; + await ports.switchTab(meta.id, meta, navigationIntentSeq); + if (!ports.isNavigationIntentCurrent(navigationIntentSeq)) return; + await ports.refreshTabMetas( + () => ports.isNavigationIntentCurrent(navigationIntentSeq), + { afterMutation: true }, + ); + } catch (err) { + if (ports.isNavigationIntentCurrent(navigationIntentSeq)) showToast(err instanceof Error ? err.message : String(err), "error"); + } finally { + ports.settleNavigationSurface(navigationIntentSeq); + } + }); + + const handleTabChange = useCommittedCommand((id: string) => { + closeTransientOverlays(); + const selected = input.tabMetas.find((tab) => tab.id === id); + input.setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === id }))); + void enqueueTabSwitch(id, selected); + input.setTabRevealSignal((signal) => signal + 1); + }); + + const finishTabClose = useCommittedCommand(async ( + id: string, + policy: TabClosePolicy, + ): Promise => { + closeTransientOverlays(); + const closed = await ports.closeTab(id, policy); + if (!closed) { + showToast(t("runtime.closeFailed"), "error"); + return false; + } + input.setComposerProfilesByTab((current) => { + if (!(id in current)) return current; + const next = { ...current }; + delete next[id]; + return next; + }); + input.setTabMetas((current) => { + if (current.length <= 1) return current; + const closingIndex = current.findIndex((tab) => tab.id === id); + if (closingIndex < 0) return current; + const closingTab = current[closingIndex]; + const remaining = current.filter((tab) => tab.id !== id); + if (!closingTab.active && closingTab.id !== activeTabId) return remaining; + const nextIndex = Math.min(closingIndex, remaining.length - 1); + const nextActiveId = remaining[nextIndex]?.id; + return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId })); + }); + await ports.refreshTabMetas(undefined, { afterMutation: true }); + await ports.refreshBackgroundRuntimes(); + input.setTabRevealSignal((signal) => signal + 1); + return true; + }); + + const handleTabClose = useCommittedCommand(async (id: string) => { + try { + const work = await app.ActiveWorkForTab(id); + if (work.running || work.pendingPrompt || work.jobs.length > 0) { + setPendingClose({ tabId: id, work, stopping: false }); + return; + } + } catch { + // CloseTabWithPolicy re-checks the controller state atomically. + } + await finishTabClose(id, "stop_and_close"); + }); + + const resolvePendingClose = useCommittedCommand(async (policy: TabClosePolicy) => { + const request = pendingClose; + if (!request || request.stopping) return; + if (policy === "stop_and_close") setPendingClose({ ...request, stopping: true }); + const closed = await finishTabClose(request.tabId, policy); + if (closed) setPendingClose(null); + else setPendingClose((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current); + }); + + const revealWorkspaceWriter = useCommittedCommand(async () => { + if (!activeTabId) return; + enterChatViewForTabNavigation(); + const navigationIntentSeq = ports.noteNavigationIntent(); + ports.beginNavigationSurface(navigationIntentSeq); + try { + const meta = await app.RevealWorkspaceWriterForTab(activeTabId); + if (!await guardBackendNavigationResult({ + intent: navigationIntentSeq, + targetTabId: meta.id, + kind: "tab.reveal-workspace-writer", + isIntentCurrent: ports.isNavigationIntentCurrent, + reassert: ports.reassertVisibleTabAfterStaleNavigation, + })) return; + input.clearWorkspaceConflict(); + await ports.switchTab(meta.id, meta, navigationIntentSeq); + if (!ports.isNavigationIntentCurrent(navigationIntentSeq)) return; + await ports.refreshTabMetas( + () => ports.isNavigationIntentCurrent(navigationIntentSeq), + { afterMutation: true }, + ); + } catch (err) { + if (ports.isNavigationIntentCurrent(navigationIntentSeq)) showToast(err instanceof Error ? err.message : String(err), "error"); + } finally { + ports.settleNavigationSurface(navigationIntentSeq); + } + }); + + const continueInDeliveryWorktree = useCommittedCommand(async () => { + const root = input.deliveryWorktreeRoot; + if (!root) return; + ports.cancelActive(); + input.clearWorkspaceConflict(); + const navigationIntentSeq = ports.noteNavigationIntent(); + ports.beginNavigationSurface(navigationIntentSeq); + try { + await ports.createIsolatedWorktree(root, navigationIntentSeq); + await ports.refreshTabMetas(undefined, { afterMutation: true }); + } catch (err) { + if (ports.isNavigationIntentCurrent(navigationIntentSeq)) showToast(err instanceof Error ? err.message : String(err), "error"); + } finally { + ports.settleNavigationSurface(navigationIntentSeq); + } + }); + + const handleTabsClose = useCommittedCommand(async (ids: string[], nextActiveTabId?: string) => { + closeTransientOverlays(); + const currentIds = input.tabMetas.map((tab) => tab.id); + const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index); + if (targets.length === 0) return; + for (const id of targets) { + let work: ActiveWorkView | null = null; + try { + work = await app.ActiveWorkForTab(id); + } catch { /* the close path remains authoritative */ } + if (work && (work.running || work.pendingPrompt || work.jobs.length > 0)) { + setPendingClose({ tabId: id, work, stopping: false }); + return; + } + await finishTabClose(id, "stop_and_close"); + } + if (nextActiveTabId && currentIds.includes(nextActiveTabId)) { + const selected = input.tabMetas.find((tab) => tab.id === nextActiveTabId); + input.setTabMetas((current) => current.map((tab) => ({ ...tab, active: tab.id === nextActiveTabId }))); + void enqueueTabSwitch(nextActiveTabId, selected); + } + await ports.refreshTabMetas(undefined, { afterMutation: true }); + input.setTabRevealSignal((signal) => signal + 1); + }); + + const handleTabsReorder = useCommittedCommand(async (ids: string[]) => { + input.setTabOrderIds(ids); + input.setTabMetas((current) => { + const byId = new Map(current.map((tab) => [tab.id, tab])); + const ordered = ids.map((id) => byId.get(id)).filter((tab): tab is TabMeta => Boolean(tab)); + return ordered.length === current.length ? ordered : current; + }); + await ports.reorderTabs(ids); + await ports.refreshTabMetas(undefined, { afterMutation: true }); + input.setTabRevealSignal((signal) => signal + 1); + }); + + return { + pendingClose, + setPendingClose, + enqueueTabSwitch, + revealBackgroundRuntime, + handleTabChange, + finishTabClose, + handleTabClose, + resolvePendingClose, + revealWorkspaceWriter, + continueInDeliveryWorktree, + handleTabsClose, + handleTabsReorder, + }; +} diff --git a/desktop/frontend/src/app-runtime/useTabProjectionLifecycle.ts b/desktop/frontend/src/app-runtime/useTabProjectionLifecycle.ts new file mode 100644 index 0000000000..41b38a8891 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTabProjectionLifecycle.ts @@ -0,0 +1,36 @@ +import { useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; +import type { TabMeta } from "../lib/types"; +import { hydrateComposerProfileFromMeta, hydrateComposerProfilesFromTabs, pruneUserPlanModeIntents, type ComposerProfile, type UserPlanModeIntents } from "../lib/composerProfile"; +import type { RestorableToolApprovalMode } from "../lib/toolApprovalMode"; + +export function useTabProjectionLifecycle(input: { + tabs: readonly TabMeta[]; + activeTabId?: string | null; + activeMeta: TabMeta | null | undefined; + meta: Parameters[2] | null | undefined; + yoloRestoreRef: MutableRefObject>; + planIntentsRef: MutableRefObject; + setOrder: Dispatch>; + setProfiles: Dispatch>>; +}) { + const { tabs, activeTabId, meta, yoloRestoreRef, planIntentsRef, setOrder, setProfiles } = input; + useEffect(() => { + const ids = tabs.map((tab) => tab.id); + setOrder((current) => { + const next = current.filter((id) => ids.includes(id)); + for (const id of ids) if (!next.includes(id)) next.push(id); + return next.join("\u0000") === current.join("\u0000") ? current : next; + }); + const present = new Set(ids); + for (const id of Object.keys(yoloRestoreRef.current)) { + if (!present.has(id)) delete yoloRestoreRef.current[id]; + } + planIntentsRef.current = pruneUserPlanModeIntents(planIntentsRef.current, present); + setProfiles((current) => hydrateComposerProfilesFromTabs(current, [...tabs])); + }, [planIntentsRef, setOrder, setProfiles, tabs, yoloRestoreRef]); + + useEffect(() => { + if (!activeTabId || !meta) return; + setProfiles((current) => hydrateComposerProfileFromMeta(current, activeTabId, meta)); + }, [activeTabId, meta, setProfiles]); +} diff --git a/desktop/frontend/src/app-runtime/useTerminalPanelCommands.ts b/desktop/frontend/src/app-runtime/useTerminalPanelCommands.ts new file mode 100644 index 0000000000..35e8ffbeee --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTerminalPanelCommands.ts @@ -0,0 +1,34 @@ +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { useGlobalShortcut } from "../lib/keyboardShortcuts"; +import { useLayoutStore, saveTerminalPanelOpen } from "../store/layout"; +import { useTerminalStore } from "../store/terminal"; + +function showTerminal() { + useLayoutStore.getState().setTerminalPanelOpen(true); + saveTerminalPanelOpen(true); +} + +/** Commands and shortcuts share the same committed capability boundary. */ +export function useTerminalPanelCommands(input: { tabId?: string; enabled: boolean; shortcutsEnabled?: boolean }) { + const toggleTerminalPanel = useCommittedCommand(() => { + if (!input.enabled) return; + const next = !useLayoutStore.getState().terminalPanelOpen; + useLayoutStore.getState().setTerminalPanelOpen(next); + saveTerminalPanelOpen(next); + }); + const openTerminalForPath = useCommittedCommand((path = ".") => { + if (!input.enabled) return; + showTerminal(); + if (input.tabId) void useTerminalStore.getState().createSession(input.tabId, path || ".", "default").catch(() => {}); + }); + const newTerminalSession = useCommittedCommand(() => { + if (input.tabId) openTerminalForPath(); + }); + const closeTerminalPanel = useCommittedCommand(() => { + useLayoutStore.getState().setTerminalPanelOpen(false); + saveTerminalPanelOpen(false); + }); + useGlobalShortcut("terminal.toggle", toggleTerminalPanel, [toggleTerminalPanel], input.shortcutsEnabled !== false); + useGlobalShortcut("terminal.newSession", newTerminalSession, [newTerminalSession], input.shortcutsEnabled !== false); + return { toggleTerminalPanel, openTerminalForPath, closeTerminalPanel }; +} diff --git a/desktop/frontend/src/app-runtime/useTodoPanelCommands.ts b/desktop/frontend/src/app-runtime/useTodoPanelCommands.ts new file mode 100644 index 0000000000..baf5df17b6 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTodoPanelCommands.ts @@ -0,0 +1,125 @@ +import { useMemo, useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { loadDismissedTodoKeys, saveDismissedTodoKeys } from "../lib/todoDismissalStorage"; +import { parseTodos, type Todo } from "../lib/tools"; +import { + dismissedTodoKeyForScope, + resolveTodoPanelTodos, + scopedTodoBatchKey, + scopedTodoDismissalKey, + shouldShowTodoPanel, + todoBatchKey, + todoContinueTarget, + todoDismissalKey, + todoPanelScope, +} from "../lib/todoVisibility"; +import type { Translator } from "../lib/i18n"; +import type { Item } from "../lib/useController"; +import type { TabMeta } from "../lib/types"; +import type { useSessionOperations } from "./useSessionOperations"; + +export type TodoPanelCommandsInput = { + items: readonly Item[]; + running: boolean; + pendingPrompt: boolean; + meta: { + canonicalTodos?: Todo[] | null; + sessionPath?: string; + eventChannel?: string; + dismissedTodoBatches?: string[]; + } | undefined | null; + activeTab: TabMeta | undefined; + activeTabId: string | undefined; + remote: boolean; + remoteReady: boolean; + controllerReady: boolean; + sessionKey: string; + operations: ReturnType; + t: Translator; + ports: { + remoteSend(text: string): Promise; + sendToTab(tabId: string, text: string): Promise; + dismissTodoBatch(tabId: string, batchKey: string): Promise; + }; +}; + +/** + * Owns the pinned task list above the composer: the canonical todo_write + * projection, session-scoped dismissal persistence and the dismiss/continue + * commands. The live task list comes from the most recent successful + * top-level todo_write result; failed or still-running attempts do not + * advance the canonical panel state. Incomplete lists are always shown so a + * stale local dismissal cannot hide work that still blocks final readiness; + * every new list starts collapsed while its header keeps showing live + * progress and the current task. Live completion briefly shows 3/3 before + * retirement; restored completed lists stay in transcript only. The + * dismissal key is still based on stable todo content/state so history + * reloads do not resurrect the same finished list. The status-agnostic batch + * key prevents false new batches; dismissal remains session-scoped and + * sidecar-persisted. + */ +export function useTodoPanelCommands(input: TodoPanelCommandsInput) { + const { items, activeTab, activeTabId, remote, t, ports } = input; + const todoEntry = useMemo(() => { + for (let i = items.length - 1; i >= 0; i--) { + const it = items[i]; + if (it.kind === "tool" && it.name === "todo_write" && !it.parentId && it.status === "done" && !it.error) { + return { item: it, index: i }; + } + } + return null; + }, [items]); + const todoItem = todoEntry?.item ?? null; + const metaTodos = remote ? undefined : input.meta?.canonicalTodos; + const todos = useMemo( + () => resolveTodoPanelTodos(metaTodos, todoItem ? parseTodos(todoItem.args) : undefined), + [metaTodos, todoItem], + ); + const [dismissedTodoKeys, setDismissedTodoKeys] = useState>(loadDismissedTodoKeys); + const todoKey = useMemo(() => todoDismissalKey(todos), [todos]); + const todoBatch = useMemo(() => todoBatchKey(todos), [todos]); + const todoScope = useMemo( + () => todoPanelScope({ activeTab, activeTabId, eventChannel: remote ? undefined : input.meta?.eventChannel }), + [activeTab, activeTabId, remote, input.meta?.eventChannel], + ); + const dismissedTodo = useMemo( + () => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey), + [dismissedTodoKeys, todoKey, todoScope], + ); + const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]); + const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]); + const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos, { batchKey: todoBatch, batches: !remote && input.meta?.sessionPath === activeTab?.sessionPath ? input.meta?.dismissedTodoBatches : undefined }); + const dismissTodos = useCommittedCommand(() => { + if (!scopedTodoKey) return; + setDismissedTodoKeys((current) => { + if (current.has(scopedTodoKey)) return current; + const next = new Set(current); + next.add(scopedTodoKey); + saveDismissedTodoKeys(next); + return next; + }); + if (!remote && activeTabId && todoBatch) { + const target = { tabId: activeTabId, sessionKey: input.sessionKey }; + void input.operations(target, "todo-dismiss", {}, async (_input, authority) => (await import("./sessionRuntimeOwner")).executeTodoDismissal( + target, todoBatch, (tabId, batchKey) => ports.dismissTodoBatch(tabId, batchKey), authority, + )).catch(() => undefined); + } + }); + const handleTodoContinue = useCommittedCommand(() => { + const targetTabId = todoContinueTarget(activeTabId, activeTabId, { + ready: remote ? input.remoteReady : input.controllerReady, + readOnly: Boolean(activeTab?.readOnly), + running: input.running, + pendingPrompt: input.pendingPrompt, + }); + if (!targetTabId) return; + const prompt = t("todo.continue"); + if (remote) { + void ports.remoteSend(prompt); + return; + } + void ports.sendToTab(targetTabId, prompt); + }); + + return { showTodos, scopedTodoBatch, todos, dismissTodos, handleTodoContinue }; +} diff --git a/desktop/frontend/src/app-runtime/useTopicNavigationShortcuts.ts b/desktop/frontend/src/app-runtime/useTopicNavigationShortcuts.ts new file mode 100644 index 0000000000..f824ce024a --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTopicNavigationShortcuts.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; +import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "../lib/topicShortcuts"; +import type { ShortcutPlatform } from "../lib/keyboardShortcuts"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; + +export function useTopicNavigationShortcuts(input: { + enabled: boolean; + platform: ShortcutPlatform; + onNavigate: (entry: TopicShortcutEntry) => void; +}) { + const topicsRef = useRef([]); + const onNavigate = useCommittedCommand(input.onNavigate); + const { showBadges } = useTopicShortcuts(input.enabled, input.platform); + useEffect(() => { + if (!input.enabled) return; + const onKeydown = (event: globalThis.KeyboardEvent) => { + const index = topicShortcutIndexFromEvent(event, input.platform); + if (index === null || index >= topicsRef.current.length) return; + event.preventDefault(); + onNavigate(topicsRef.current[index]); + }; + document.addEventListener("keydown", onKeydown); + return () => document.removeEventListener("keydown", onKeydown); + }, [input.enabled, input.platform, onNavigate]); + return { + showBadges, + setVisibleTopics: useCommittedCommand((topics: TopicShortcutEntry[]) => { topicsRef.current = topics; }), + }; +} diff --git a/desktop/frontend/src/app-runtime/useTopicSummary.ts b/desktop/frontend/src/app-runtime/useTopicSummary.ts new file mode 100644 index 0000000000..fc335ca8ae --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTopicSummary.ts @@ -0,0 +1,52 @@ +import { useEffect, useMemo, useState } from "react"; + +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { desktopBridge } from "./desktopBridgeAdapter"; +import type { TabMeta } from "../lib/types"; + +type TopicSummary = Readonly<{ turns?: number }>; + +/** + * Owns the topic summary chain: the target memo keyed by topic identity, the + * GetTopicSummary bridge command, the single-flight fetch (an identity or + * revision change cancels the superseded request) and the resulting + * activeTopicTurns state. Presentation reads only the returned turns. + */ +export function useTopicSummary(input: { + activeTab: TabMeta | undefined; + revision: number; +}): { activeTopicTurns: number | undefined } { + const { activeTab, revision } = input; + const [activeTopicTurns, setActiveTopicTurns] = useState(undefined); + + const scope = activeTab?.scope; + const workspaceRoot = activeTab?.workspaceRoot; + const topicId = activeTab?.topicId; + const target = useMemo(() => (topicId === undefined ? null : { scope, workspaceRoot, topicId }), + [scope, workspaceRoot, topicId]); + + const getSummary = useCommittedCommand((request: { scope: "global" | "project"; workspaceRoot: string; topicId: string }) => desktopBridge.getTopicSummary(request)); + const commitTurns = useCommittedCommand((turns: number | undefined) => setActiveTopicTurns(turns)); + + useEffect(() => { + const currentTarget = target; + const topicId = currentTarget?.topicId?.trim(); + if (!topicId) { + commitTurns(undefined); + return; + } + let current = true; + void getSummary({ + scope: currentTarget?.scope === "global" ? "global" : "project", + workspaceRoot: currentTarget?.scope === "global" ? "" : currentTarget?.workspaceRoot ?? "", + topicId, + }).then((summary: TopicSummary) => { + if (current) commitTurns(summary.turns); + }).catch(() => { + if (current) commitTurns(undefined); + }); + return () => { current = false; }; + }, [getSummary, commitTurns, revision, target]); + + return { activeTopicTurns }; +} diff --git a/desktop/frontend/src/app-runtime/useTranscriptSurfaceProjection.ts b/desktop/frontend/src/app-runtime/useTranscriptSurfaceProjection.ts new file mode 100644 index 0000000000..0a4e4ccf6b --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTranscriptSurfaceProjection.ts @@ -0,0 +1,114 @@ +import { useLayoutEffect, useMemo } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { useNavigationSurface } from "../lib/useNavigationSurface"; +import type { HistoryLoadTrigger, Item } from "../lib/useController"; + +type NavigationSurfaceApi = ReturnType; + +export type TranscriptSurfaceProjectionInput = { + hydrating: boolean; + hydrateHistoryLoaded: boolean | undefined; + hydratePlaceholderItems: Item[] | undefined; + hydratePlaceholderActive: boolean; + items: Item[]; + remote: boolean; + remoteItems: Item[]; + activeTabId: string | undefined; + geometrySessionKey: string; + transitioning: boolean; + navigationDataReady: boolean; + preserved: NavigationSurfaceApi["preserved"]; + singleSurface: boolean; + controllerReady: boolean; + creationLayout: boolean; + imDetailActive: boolean; + sessionHasContent: boolean; + commitRendered: NavigationSurfaceApi["commitRendered"]; + commitPaint: NavigationSurfaceApi["commitPaint"]; + commitSingleSurface: (tabId: string) => void; + ports: { + loadOlderHistory(tabId: string, targetTurn: number | undefined, trigger: HistoryLoadTrigger): Promise; + commitThenSend(tabId: string, text: string): Promise; + }; +}; + +/** + * Owns the transcript surface projection: hydration placeholders, the + * creation empty hero gate, the committed-surface commit effect (only + * committed presentation may become a retained source), the visible + * source-retained surface selection, surface paint receipts, the latest + * consumed guidance entry and the transcript prompt/load-older commands. + */ +export function useTranscriptSurfaceProjection(input: TranscriptSurfaceProjectionInput) { + const { activeTabId, transitioning, ports } = input; + const transcriptHydrating = input.hydrating && !input.hydrateHistoryLoaded; + // Creation hero only after history hydration settles on a truly empty session. + // Avoid flash while switching tabs: items may be empty while placeholders show. + // Exclude IM/Bot detail: hero CSS collapses .main, which also hosts that panel. + const creationEmptyHero = + input.creationLayout && + !transitioning && + !input.imDetailActive && + !input.sessionHasContent && + !transcriptHydrating && + !input.hydratePlaceholderActive; + const transcriptItems = input.hydratePlaceholderActive ? input.hydratePlaceholderItems! : input.items; + const handleLoadOlderHistory = useCommittedCommand((targetTurn?: number, trigger: HistoryLoadTrigger = "retry") => { + return activeTabId ? ports.loadOlderHistory(activeTabId, targetTurn, trigger) : Promise.resolve(false); + }); + + // Display items: backend history is authoritative after immediate commit. + // rewindState only drives the undo banner, not optimistic truncation. + const displayItems = transcriptItems; + const committedSurfaceItems = input.remote ? input.remoteItems : displayItems; + const committedGeometryKey = input.remote ? `tab:${activeTabId ?? "preview"}` : input.geometrySessionKey; + // Only committed presentation can become a future retained source surface. + // A suspended or abandoned render must never become navigation authority. + const commitRendered = input.commitRendered; + useLayoutEffect(() => { + if (transitioning) return; + commitRendered({ + tabId: activeTabId, + items: committedSurfaceItems, + geometrySessionKey: committedGeometryKey, + }); + }, [activeTabId, commitRendered, committedSurfaceItems, transitioning, committedGeometryKey]); + const visibleTranscriptSurface = transitioning && !input.navigationDataReady && input.preserved + ? input.preserved + : null; + const visibleTranscriptItems = visibleTranscriptSurface?.items ?? displayItems; + const visibleTranscriptTabId = visibleTranscriptSurface?.tabId ?? activeTabId; + const visibleTranscriptGeometryKey = visibleTranscriptSurface?.geometrySessionKey ?? input.geometrySessionKey; + const handleSurfacePaintReady = useCommittedCommand((token: string, outcome: "ready" | "degraded") => { + const receipt = input.commitPaint(token, outcome); + if (input.singleSurface && receipt) input.commitSingleSurface(receipt.targetTabId); + }); + const latestGuidanceConsumed = useMemo(() => { + for (let i = input.items.length - 1; i >= 0; i--) { + const item = input.items[i]; + if (item.kind === "notice" && item.text.startsWith("↪ ")) { + return { key: item.id, itemId: item.inboxItemId, text: item.text.slice(2) }; + } + } + return null; + }, [input.items]); + + const handleTranscriptPrompt = useCommittedCommand((text: string) => { + if (!activeTabId || !input.controllerReady) return; + void ports.commitThenSend(activeTabId, text).catch((err) => { + console.warn("Failed to submit transcript prompt", err); + }); + }); + + return { + transcriptHydrating, + creationEmptyHero, + visibleTranscriptItems, + visibleTranscriptTabId, + visibleTranscriptGeometryKey, + handleLoadOlderHistory, + handleSurfacePaintReady, + latestGuidanceConsumed, + handleTranscriptPrompt, + }; +} diff --git a/desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts b/desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts new file mode 100644 index 0000000000..cd1f8c4735 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useTurnVerificationCommands.ts @@ -0,0 +1,45 @@ +import { useRef, useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import type { WireCompletionSummary } from "../lib/types"; +import type { WorkspaceVerificationRevealRequest } from "../components/WorkspacePanel"; +import { useVerificationRevealReset } from "./useLocalUiLifecycles"; + +export type TurnVerificationCommandsInput = { + activeTabId: string | undefined; + turnStartAt: number; + completionSummary: WireCompletionSummary | undefined; + openChangedDock(): void; +}; + +/** + * Owns the turn-verification reveal chain: opening the changed-files dock, + * issuing a monotonically sequenced reveal request bound to the tab and turn + * that published it, and resetting the request whenever the tab, turn or + * current completion summary changes. WorkspacePanel consumes the request; + * only the reveal lifecycle lives here. + */ +export function useTurnVerificationCommands(input: TurnVerificationCommandsInput) { + const revealSequenceRef = useRef(0); + const [verificationRevealRequest, setVerificationRevealRequest] = useState(null); + + const openTurnVerification = useCommittedCommand((summary: WireCompletionSummary) => { + input.openChangedDock(); + revealSequenceRef.current += 1; + setVerificationRevealRequest({ + id: revealSequenceRef.current, + summary, + tabId: input.activeTabId ?? "", + turnStartAt: input.turnStartAt, + currentSummary: input.completionSummary, + }); + }); + + useVerificationRevealReset({ + activeTabId: input.activeTabId, + completionSummary: input.completionSummary, + turnStartAt: input.turnStartAt, + reset: setVerificationRevealRequest, + }); + + return { verificationRevealRequest, openTurnVerification }; +} diff --git a/desktop/frontend/src/app-runtime/useWorkspacePanelCommands.ts b/desktop/frontend/src/app-runtime/useWorkspacePanelCommands.ts new file mode 100644 index 0000000000..5d516afa18 --- /dev/null +++ b/desktop/frontend/src/app-runtime/useWorkspacePanelCommands.ts @@ -0,0 +1,85 @@ +import { useEffect, useLayoutEffect } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { loadWorkspacePanelOpen, saveWorkspacePanelOpen, useLayoutStore, type RightDockMode } from "../store/layout"; +import { useRemoteStore } from "../store/remote"; + +type Input = { + workspaceRoot: string; + creation: boolean; + visible: boolean; + closeOverlays: () => void; + clearLiveWidth: (width: null) => void; + availableWidth: number; + clampTreeWidth: (width: number, availableWidth: number) => number; + setTreeWidth: (width: number) => void; +}; + +/** One project-scoped preference owner, with no mirrored layout state. */ +export function useWorkspacePanelCommands(input: Input) { + const mode = useLayoutStore(state => state.rightDockMode); + const explorerOpen = useRemoteStore(state => state.explorerOpen); + const hostCount = useRemoteStore(state => state.hosts.length); + const openRightDockMode = useCommittedCommand((requestedMode?: RightDockMode) => { + input.closeOverlays(); + const layout = useLayoutStore.getState(); + const next = requestedMode ?? layout.rightDockMode; + if (next === "context" || next !== layout.rightDockMode) layout.setWorkspacePreviewActive(false); + layout.setRightDockMode(next); + layout.setWorkspacePanelMaximized(false); + if (layout.workspacePanelOpen && !layout.workspacePanelMaximized) return; + layout.setWorkspacePanelOpen(true); + saveWorkspacePanelOpen(true, input.workspaceRoot); + }); + const closeWorkspacePanel = useCommittedCommand(() => { + input.closeOverlays(); + const layout = useLayoutStore.getState(); + if (!layout.workspacePanelOpen) return; + input.clearLiveWidth(null); + layout.setWorkspacePanelMaximized(false); + layout.setWorkspacePanelOpen(false); + saveWorkspacePanelOpen(false, input.workspaceRoot); + }); + const toggleWorkspacePanel = useCommittedCommand(() => { + if (input.visible) { closeWorkspacePanel(); return; } + const current = useLayoutStore.getState().rightDockMode; + openRightDockMode(input.creation ? current === "changed" ? "changed" : "files" : current); + }); + const toggleWorkspaceMaximized = useCommittedCommand(() => { + input.closeOverlays(); + const layout = useLayoutStore.getState(); + layout.setWorkspacePanelMaximized(!layout.workspacePanelMaximized); + }); + const handleWorkspacePreviewModeChange = useCommittedCommand((active: boolean) => { + const layout = useLayoutStore.getState(); + if (layout.workspacePreviewActive === active) return; + input.closeOverlays(); + layout.setWorkspacePreviewActive(active); + }); + const openRemoteDock = useCommittedCommand(() => { + const remote = useRemoteStore.getState(); + const fallback = remote.hosts.find(host => ["connected", "degraded"].includes(remote.statuses[host.id]?.state)) ?? remote.hosts[0]; + const hostId = remote.hosts.some(host => host.id === remote.explorerHostId) ? remote.explorerHostId : fallback?.id; + if (hostId) remote.openExplorer(hostId); + }); + const restoreWorkspaceDockWidths = useCommittedCommand((treeWidth: number, _previewWidth: number) => { + // Single-width dock: only the tree width is meaningful; clamp it to the + // dynamic available width (chat keeps its 400px floor), never a fixed + // 560 ceiling, so the user's remembered width is preserved when reopened. + input.setTreeWidth(input.clampTreeWidth(treeWidth, input.availableWidth)); + }); + useLayoutEffect(() => { + useLayoutStore.getState().setWorkspacePanelOpen(loadWorkspacePanelOpen(input.workspaceRoot)); + }, [input.workspaceRoot]); + useLayoutEffect(() => { + if (input.creation && mode === "context") useLayoutStore.getState().setRightDockMode("files"); + }, [input.creation, mode]); + useEffect(() => { + if (!explorerOpen) return; + openRightDockMode("remote"); + useRemoteStore.getState().closeExplorer(); + }, [explorerOpen, openRightDockMode]); + useEffect(() => { + if (hostCount === 0 && mode === "remote") useLayoutStore.getState().setRightDockMode("files"); + }, [hostCount, mode]); + return { openRightDockMode, closeWorkspacePanel, toggleWorkspacePanel, toggleWorkspaceMaximized, handleWorkspacePreviewModeChange, openRemoteDock, restoreWorkspaceDockWidths }; +} diff --git a/desktop/frontend/src/app-runtime/useWorktreeMergeCommands.ts b/desktop/frontend/src/app-runtime/useWorktreeMergeCommands.ts new file mode 100644 index 0000000000..43765da52b --- /dev/null +++ b/desktop/frontend/src/app-runtime/useWorktreeMergeCommands.ts @@ -0,0 +1,63 @@ +import { useState } from "react"; +import { useCommittedCommand } from "../lib/useCommittedCommand"; +import { runWorktreeMergeLifecycle } from "../lib/worktreeMergeLifecycle"; +import type { WorktreeMergeResult } from "../lib/types"; +import type { Translator } from "../lib/i18n"; + +/** + * Owns the worktree merge coordination: the worktreeMergeTabId overlay state + * with its open/close commands and the merged-receipt handler that runs the + * navigation-intent-gated close/finalize lifecycle. Callers only wire the + * returned state and commands into the topicbar and overlay regions. + */ +export function useWorktreeMergeCommands(input: { + singleSurfaceLayout: boolean; + noteNavigationIntent: () => number; + registeredNavigationIntent: (seq: number) => Promise; + isNavigationIntentCurrent: (seq: number) => boolean; + ensureBlankSurface: (scope: string, workspace: string, seq: number) => Promise; + ensureBlankTab: (scope: string, workspace: string, seq: number) => Promise; + seedSource: (tab: any) => void; + listTabs: () => Promise; + closeWorktree: (request: any) => Promise; + finalize: (request: any) => Promise; + showToast: (message: string, level: "error", options?: { durationMs?: number }) => void; + t: Translator; + showCleanup: (cleanup: any, t: Translator) => void; +}) { + const [worktreeMergeTabId, setWorktreeMergeTabId] = useState(null); + + const openWorktreeMerge = useCommittedCommand((tabId: string) => setWorktreeMergeTabId(tabId)); + const closeWorktreeMerge = useCommittedCommand(() => setWorktreeMergeTabId(null)); + + const handleWorktreeMerged = useCommittedCommand(async (result: WorktreeMergeResult) => { + const tabToClose = worktreeMergeTabId; + if (!tabToClose || !result.sourceRoot || !result.worktreeRoot || !result.targetBranch || !result.mergedCommit || !result.worktreeBranch || !result.worktreeHead) { + throw new Error(result.error || input.t("worktree.mergeReceiptInvalid")); + } + const seq = input.noteNavigationIntent(); + try { + const token = await input.registeredNavigationIntent(seq); + if (!token || !input.isNavigationIntentCurrent(seq)) { + input.showToast(input.t("worktree.navigationChangedPreserved"), "error", { durationMs: 9000 }); + return; + } + const lifecycle = await runWorktreeMergeLifecycle(result, tabToClose, token, { + ensureSource: (root) => input.singleSurfaceLayout + ? input.ensureBlankSurface("project", root, seq) + : input.ensureBlankTab("project", root, seq), + isNavigationCurrent: () => input.isNavigationIntentCurrent(seq), + seedSource: input.seedSource, + listTabs: input.listTabs, + closeWorktree: input.closeWorktree, + finalize: input.finalize, + onNavigationPreserved: () => input.showToast(input.t("worktree.navigationChangedPreserved"), "error", { durationMs: 9000 }), + onCloseBlocked: () => input.showToast(input.t("worktree.cleanupViewBlocked"), "error", { durationMs: 8000 }), + }); + if (lifecycle.phase === "finalized") input.showCleanup(lifecycle.cleanup, input.t); + } catch (error) { + input.showToast(`${input.t("worktree.mergeDoneCleanupFailed")} ${error instanceof Error ? error.message : String(error)}`, "error", { durationMs: 9000 }); + } + }); + return { worktreeMergeTabId, openWorktreeMerge, closeWorktreeMerge, handleWorktreeMerged }; +} diff --git a/desktop/frontend/src/app-shell/AppBottomRegions.tsx b/desktop/frontend/src/app-shell/AppBottomRegions.tsx new file mode 100644 index 0000000000..fe82b1dbf0 --- /dev/null +++ b/desktop/frontend/src/app-shell/AppBottomRegions.tsx @@ -0,0 +1,49 @@ +import { lazy, Suspense, type ComponentProps, type KeyboardEvent, type PointerEvent } from "react"; +import { StatusBar } from "../components/StatusBar"; +import type { Translator } from "../lib/i18n"; + +const TerminalPanel = lazy(() => import("../components/TerminalPanel").then((module) => ({ default: module.TerminalPanel }))); + +export type AppBottomRegionsProps = { + terminal: { + surfaceVisible?: boolean; + open: boolean; + contentVisible: boolean; + remoteSurface: boolean; + t: Translator; + panel: ComponentProps; + resizer: { + min: number; + max: number; + value: number; + onPointerDown: (event: PointerEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; + onReset: () => void; + }; + }; + status?: ComponentProps; +}; + +/** Bottom shell surfaces remain mounted according to their original lifecycle. */ +export function AppBottomRegions({ terminal, status }: AppBottomRegionsProps) { + return ( + <> +
}> + + + )} + + {terminal.surfaceVisible !== false && : null} + + )} + + ); +} diff --git a/desktop/frontend/src/app-shell/DecisionFooterRegion.tsx b/desktop/frontend/src/app-shell/DecisionFooterRegion.tsx new file mode 100644 index 0000000000..d94077d151 --- /dev/null +++ b/desktop/frontend/src/app-shell/DecisionFooterRegion.tsx @@ -0,0 +1,110 @@ +import { lazy, Suspense, type ComponentProps, type CSSProperties, type ReactNode } from "react"; + +import { Composer } from "../components/Composer"; + +const TodoPanel = lazy(() => import("../components/TodoPanel").then((module) => ({ default: module.TodoPanel }))); +const UndoRewindBanner = lazy(() => import("../components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner }))); +const ApprovalModal = lazy(() => import("../components/ApprovalModal").then((module) => ({ default: module.ApprovalModal }))); +const AskCard = lazy(() => import("../components/AskCard").then((module) => ({ default: module.AskCard }))); +const MCPInteractionCard = lazy(() => import("../components/MCPInteractionCard").then((module) => ({ default: module.MCPInteractionCard }))); +const ExtensionFormDialog = lazy(() => import("../components/ExtensionFormDialog").then((module) => ({ default: module.ExtensionFormDialog }))); +const RuntimeDecisionCard = lazy(() => import("../components/RuntimeDecisionCard").then((module) => ({ default: module.RuntimeDecisionCard }))); +const ClearContextCard = lazy(() => import("../components/ClearContextCard").then((module) => ({ default: module.ClearContextCard }))); + +export type ComposerProps = ComponentProps<(typeof import("../components/Composer"))["Composer"]>; +export type TodoProps = ComponentProps<(typeof import("../components/TodoPanel"))["TodoPanel"]>; +export type UndoProps = ComponentProps<(typeof import("../components/UndoRewindBanner"))["UndoRewindBanner"]>; +export type ApprovalProps = ComponentProps<(typeof import("../components/ApprovalModal"))["ApprovalModal"]>; +export type AskProps = ComponentProps<(typeof import("../components/AskCard"))["AskCard"]>; +export type McpProps = ComponentProps<(typeof import("../components/MCPInteractionCard"))["MCPInteractionCard"]>; +export type ExtensionProps = ComponentProps<(typeof import("../components/ExtensionFormDialog"))["ExtensionFormDialog"]>; +export type RuntimeDecisionProps = ComponentProps<(typeof import("../components/RuntimeDecisionCard"))["RuntimeDecisionCard"]>; +export type ClearContextProps = ComponentProps<(typeof import("../components/ClearContextCard"))["ClearContextCard"]>; + +export type DecisionFooterSurface = + | { kind: "approval"; identity: string; props: ApprovalProps } + | { kind: "ask"; identity: string; props: AskProps } + | { kind: "mcp"; identity: string; props: McpProps } + | { kind: "extension"; identity: string; props: ExtensionProps } + | { kind: "runtime"; identity: string; props: RuntimeDecisionProps } + | { kind: "clear-context"; identity: string; props: ClearContextProps }; + +/** Loading one decision cannot hide an already available sibling or its focus. */ +export function DecisionFooterSlots({ todo, undo, decision }: { todo: ReactNode; undo: ReactNode; decision: ReactNode }) { + return <> + {todo} + {undo} + {decision} + ; +} + +export type DecisionFooterRegionProps = { + hidden: boolean; + className: string; + style?: CSSProperties; + footerRef: ComponentProps<"footer">["ref"]; + todo?: { identity: string; props: TodoProps }; + undo?: { identity: string; props: UndoProps }; + decision?: DecisionFooterSurface; + composer: { + hidden: boolean; + inert: boolean; + hero: boolean; + headline?: string; + props: ComposerProps; + }; +}; + +function DecisionSurface({ surface }: { surface: DecisionFooterSurface }) { + switch (surface.kind) { + case "approval": + return ; + case "ask": + return ; + case "mcp": + return ; + case "extension": + return ; + case "runtime": + return ; + case "clear-context": + return ; + } +} + +export function DecisionFooterRegion({ + hidden, + className, + style, + footerRef, + todo, + undo, + decision, + composer, +}: DecisionFooterRegionProps) { + if (hidden) return null; + + return ( +
+ : null} + undo={undo ? : null} + decision={decision ? : null} + /> + {/* Composer remains mounted while decisions are visible so session-scoped drafts survive. */} + +
+ ); +} diff --git a/desktop/frontend/src/app-shell/DockToggleButton.tsx b/desktop/frontend/src/app-shell/DockToggleButton.tsx new file mode 100644 index 0000000000..39e2174619 --- /dev/null +++ b/desktop/frontend/src/app-shell/DockToggleButton.tsx @@ -0,0 +1,25 @@ +import { PanelRight } from "lucide-react"; +import { Tooltip } from "../components/Tooltip"; +import type { Translator } from "../lib/i18n"; + +// Dock collapse/expand toggle. Rendered in the dock's own tools row when the +// dock is open (its top-right corner), and in the topic bar when closed. +export function DockToggleButton({ renderable, t, onToggle }: { renderable: boolean; t: Translator; onToggle: () => void }) { + return ( + + + + ); +} diff --git a/desktop/frontend/src/app-shell/HotkeyRegistrations.tsx b/desktop/frontend/src/app-shell/HotkeyRegistrations.tsx new file mode 100644 index 0000000000..ec3b6d7b93 --- /dev/null +++ b/desktop/frontend/src/app-shell/HotkeyRegistrations.tsx @@ -0,0 +1,18 @@ +import { useShellExpand } from "../lib/shellExpand"; +import { useGlobalShortcut } from "../lib/keyboardShortcuts"; +import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "../lib/textSize"; + +/** Global hotkey handler for shell-expand toggle (Ctrl/Cmd+B). */ +export function ShellHotkeys() { + const shellExpand = useShellExpand(); + useGlobalShortcut("shell.toggle", () => shellExpand?.toggleLast(), [shellExpand], Boolean(shellExpand)); + return null; +} + +/** Global hotkey handler for text-size shortcuts (Ctrl/Cmd + Plus/Minus/0). */ +export function TextSizeHotkeys() { + useGlobalShortcut("textSize.increase", () => applyTextSize(nextTextSize(getTextSize(), 1))); + useGlobalShortcut("textSize.decrease", () => applyTextSize(nextTextSize(getTextSize(), -1))); + useGlobalShortcut("textSize.reset", () => applyTextSize(DEFAULT_TEXT_SIZE)); + return null; +} diff --git a/desktop/frontend/src/app-shell/NoticePreviewPanel.tsx b/desktop/frontend/src/app-shell/NoticePreviewPanel.tsx new file mode 100644 index 0000000000..4328aeee93 --- /dev/null +++ b/desktop/frontend/src/app-shell/NoticePreviewPanel.tsx @@ -0,0 +1,79 @@ +import { NoticeCard } from "../components/Transcript"; +import { t } from "../lib/i18n"; +import { localizedNoticeText, type Item } from "../lib/useController"; +import { browserMockScenarioParam } from "../lib/mockScenarios"; + +export function noticePreviewMockEnabled(): boolean { + const value = browserMockScenarioParam(); + return value === "notice" || value === "notices" || value === "notice-preview"; +} + +function noticePreviewItems(): Item[] { + const notice = (index: number, level: "info" | "warn", text: string, detail: string, code?: string): Item => ({ + kind: "notice", + id: `notice-preview-${index}`, + level, + text: localizedNoticeText(text, code), + detail, + }); + return [ + { + kind: "notice", + id: "notice-preview-delivery", + level: "info", + variant: "delivery", + title: t("notice.deliveryIncompleteTitle"), + text: t("notice.deliveryIncompleteBody"), + detail: "final-answer readiness failed 3 times: missing verification, review_report, and complete_step receipts", + action: "continue_delivery", + }, + notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying", "empty_final"), + notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute", "executor_handoff"), + notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing", "tool_budget"), + notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts", "loop_guard"), + notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"), + notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"), + notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"), + notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"), + notice(9, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"), + notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"), + notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"), + notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"), + notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"), + notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""), + notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""), + notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""), + notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"), + notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"), + notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"), + notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"), + notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"), + ]; +} + +export function NoticePreviewPanel() { + return ( +
+
+ {noticePreviewItems().map((item) => { + if (item.kind !== "notice") return null; + return ( + undefined : undefined} + onAccept={item.action === "continue_delivery" ? () => undefined : undefined} + /> + ); + })} +
+
+ ); +} diff --git a/desktop/frontend/src/app-shell/SessionStatusBanners.tsx b/desktop/frontend/src/app-shell/SessionStatusBanners.tsx new file mode 100644 index 0000000000..6b4d13d3aa --- /dev/null +++ b/desktop/frontend/src/app-shell/SessionStatusBanners.tsx @@ -0,0 +1,93 @@ +import { lazy, Suspense } from "react"; +import type { Translator } from "../lib/i18n"; +import { RemoteReclaimBanner } from "../components/RemoteReclaimBanner"; +import { UpdateBanner } from "../components/UpdateBanner"; + +const SessionTakeoverDialog = lazy(() => import("../components/SessionTakeoverDialog").then((module) => ({ default: module.SessionTakeoverDialog }))); + +export type SessionStatusBannersProps = { + t: Translator; + takenOver: boolean; + reclaimTabId: string; + reclaimBusyTabId: string | null; + onReclaim: (tabId: string) => void; + leaseBlocked: { tabId: string; message: string } | null; + startupError: string | undefined; + takeoverDialogTabId: string | null; + onOpenTakeover: (tabId: string) => void; + onCloseTakeover: () => void; + configWarnings: readonly string[]; + onOpenConfigFile: () => void; + onReloadConfigFile: () => void; + onDismissConfigWarnings: () => void; + providerSetupNeeded: boolean; + needsOnboarding: boolean | null; + onConfigureProvider: () => void; + updateChecksEnabled: boolean; + onShowReleaseNotes: (latest: string) => void; +}; + +/** Presentation-only banner stack between the topic bar and the main pane. */ +export function SessionStatusBanners(props: SessionStatusBannersProps) { + const { t } = props; + return ( + <> + {props.takenOver ? ( + + ) : null} + {props.leaseBlocked ? ( +
+ {t("topbar.startupError", { msg: props.leaseBlocked.message })} + + +
+ ) : props.startupError ? ( +
+ {t("topbar.startupError", { msg: props.startupError })} +
+ ) : null} + {props.takeoverDialogTabId ? ( + + + + ) : null} + {props.configWarnings.length > 0 && ( +
+ + {t("config.loadWarning", { msg: props.configWarnings[0] })} + + + + + {t("config.doctorHint")} + +
+ )} + {props.providerSetupNeeded && !props.needsOnboarding && ( +
+ {t("onboarding.inlinePrompt")} + + +
+ )} + + + ); +} diff --git a/desktop/frontend/src/app-shell/SidebarImConnectionDetail.tsx b/desktop/frontend/src/app-shell/SidebarImConnectionDetail.tsx new file mode 100644 index 0000000000..722139d468 --- /dev/null +++ b/desktop/frontend/src/app-shell/SidebarImConnectionDetail.tsx @@ -0,0 +1,145 @@ +import { MessageSquare, Settings as SettingsIcon } from "lucide-react"; +import { CopyButton } from "../components/CopyButton"; +import { useT, type Translator } from "../lib/i18n"; +import { sidebarImScopeLabel, sidebarImSessionTarget, type SidebarImConnection } from "../app-runtime/sidebarImProjection"; + +type SidebarImConnectionDetailProps = { + connection: SidebarImConnection; + onClose: () => void; + onOpenSession: () => void; + onOpenSettings: () => void; + onManageAllowlist: () => void; +}; + +function sidebarImSessionLabel(connection: SidebarImConnection, translate: Translator): string { + const target = sidebarImSessionTarget(connection); + if (!target) { + return connection.remoteId ? translate("botDetail.readOnlyChannel") : translate("botDetail.noSession"); + } + if (connection.sessionSource === "auto") return translate("botDetail.readOnlyChannel"); + if (target.kind === "path") return target.value.split(/[\\/]/).pop() || target.value; + return target.value; +} + +function sidebarImAccessModeLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.allowAll) return translate("botDetail.accessAllowAll"); + if (connection.allowlistEnabled) return translate("botDetail.accessWhitelist"); + return translate("botDetail.accessDisabled"); +} + +function sidebarImAccessStatusLabel(connection: SidebarImConnection, translate: Translator): string { + if (connection.allowAll) return translate("botDetail.accessOpen"); + if (!connection.remoteId) return translate("botDetail.accessUnknown"); + return connection.allowlistMatched ? translate("botDetail.accessMatched") : translate("botDetail.accessMissing"); +} + +function sidebarImAccessStatusClass(connection: SidebarImConnection): string { + if (connection.allowAll || connection.allowlistMatched) return "ok"; + if (!connection.remoteId) return "muted"; + return "warn"; +} + +export function SidebarImConnectionDetail({ connection, onClose, onOpenSession, onOpenSettings, onManageAllowlist }: SidebarImConnectionDetailProps) { + const translate = useT(); + const target = sidebarImSessionTarget(connection); + const accessStatusClass = sidebarImAccessStatusClass(connection); + return ( +
+
+ +
+ {translate("botDetail.subtitle")} +

{connection.title}

+
+ {connection.platformLabel} + {connection.statusLabel} + {sidebarImScopeLabel(connection, translate)} +
+
+
+ + + +
+
+ +
+
+ {translate("botDetail.access")} +
+ {connection.remoteId ? ( + + ) : null} + +
+
+
+
+ {translate("botDetail.accessMode")} + {sidebarImAccessModeLabel(connection, translate)} +
+
+ {translate("botDetail.accessCurrentUser")} + {connection.remoteId || "—"} +
+
+ {translate("botDetail.accessStatus")} + + {sidebarImAccessStatusLabel(connection, translate)} + +
+
+
+ {translate("botDetail.channelAllowlistUsers")} +
+ {connection.allowlistUsers.length > 0 ? ( + connection.allowlistUsers.map((id) => ( + + {id} + + )) + ) : ( + {translate("botDetail.emptyAllowlistUsers")} + )} +
+
+
+ +
+
+ {translate("botDetail.summary")} +
+
+
+ {translate("botDetail.remoteId")} + {connection.remoteId || "—"} +
+
+ {translate("botDetail.localTopic")} + {sidebarImSessionLabel(connection, translate)} +
+
+ {translate("botDetail.scope")} + {sidebarImScopeLabel(connection, translate)} +
+
+
+
+ ); +} diff --git a/desktop/frontend/src/app-shell/SidebarRegion.tsx b/desktop/frontend/src/app-shell/SidebarRegion.tsx new file mode 100644 index 0000000000..67fcb355eb --- /dev/null +++ b/desktop/frontend/src/app-shell/SidebarRegion.tsx @@ -0,0 +1,128 @@ +import { lazy, Suspense, type ComponentProps, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"; +import { AlarmClock, Brain, Command, MessageSquare, PanelLeft, PanelRight, Search, Settings, SquarePen, Trash2 } from "lucide-react"; +import { Tooltip } from "../components/Tooltip"; +import type { Translator } from "../lib/i18n"; +import type { SettingsTab } from "../lib/types"; +import logoWordmark from "../assets/logo-wordmark.svg"; + +const ProjectTree = lazy(() => import("../components/ProjectTree").then((module) => ({ default: module.ProjectTree }))); + +export type SidebarRegionProps = { + className: string; + workbench: boolean; + creation: boolean; + automation?: boolean; + collapsed: boolean; + navTooltipDisabled: boolean; + searchOpen: boolean; + togglePressed: boolean; + toggleTitle: string; + resize: { + min: number; + max: number; + value: number; + onPointerDown: (event: PointerEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; + onReset: () => void; + }; + projectTree: ComponentProps; + t: Translator; + onNewSession: () => void; + onOpenTrash: () => void; + onOpenAutomation: () => void; + onOpenSettings: (tab: SettingsTab) => void; + onToggleSearch: () => void; + onToggle: () => void; +}; + +/** Sidebar presentation shared by classic, workbench and creation layouts. */ +export function SidebarRegion(props: SidebarRegionProps) { + const { t } = props; + return ( + <> + + + )} + + ); +} + +function FeatureButton({ icon, label, onClick, active }: { icon: ReactNode; label: string; onClick: () => void; active?: boolean }) { + return ; +} + +function UtilityButton({ icon, label, onClick }: { icon: ReactNode; label: string; onClick: () => void }) { + return ; +} + +function NavButton({ icon, label, disabledTooltip, onClick, active }: { icon: ReactNode; label: string; disabledTooltip: boolean; onClick: () => void; active?: boolean }) { + return ; +} diff --git a/desktop/frontend/src/app-shell/TopicbarActionsRegion.tsx b/desktop/frontend/src/app-shell/TopicbarActionsRegion.tsx new file mode 100644 index 0000000000..bf4793b027 --- /dev/null +++ b/desktop/frontend/src/app-shell/TopicbarActionsRegion.tsx @@ -0,0 +1,21 @@ +import { Fragment, type ComponentProps } from "react"; +import { ExternalOpener } from "../components/ExternalOpener"; +import { TopicbarSessionActions } from "../components/TopicbarSessionActions"; + +type Props = { + sessionIdentity?: string; + external?: ComponentProps; + session?: ComponentProps; +}; + +/** Resource keys are local to a role, never shared by heterogeneous siblings. */ +export function TopicbarActionsRegion({ sessionIdentity, external, session }: Props) { + return <> + + {external && } + + + {session && } + + ; +} diff --git a/desktop/frontend/src/app-shell/TopicbarActionsStack.tsx b/desktop/frontend/src/app-shell/TopicbarActionsStack.tsx new file mode 100644 index 0000000000..9a61504494 --- /dev/null +++ b/desktop/frontend/src/app-shell/TopicbarActionsStack.tsx @@ -0,0 +1,126 @@ +import { lazy, Suspense, type ReactNode } from "react"; +import { Search } from "lucide-react"; +import { Tooltip } from "../components/Tooltip"; +import { TopicbarActionsRegion } from "./TopicbarActionsRegion"; +import { shouldMountExternalOpener } from "../components/ExternalOpener"; +import { tabWorkspaceTitle, topicDisplayTitle, topicTitle } from "../lib/sessionTitles"; +import { sidebarImScopeLabel, type SidebarImTopicSource, type SidebarImConnection } from "../app-runtime/sidebarImProjection"; +import type { TopicbarView } from "./TopicbarRegion"; +import type { TabMeta } from "../lib/types"; +import type { Translator } from "../lib/i18n"; +import type { SessionExportFormat } from "../app-runtime/useSessionExportCommands"; + +const TaskMonitorPanel = lazy(() => import("../components/TaskMonitorPanel").then((module) => ({ default: module.TaskMonitorPanel }))); + +/** Topicbar view projection: IM/bot detail identity, workspace label/subtitle, + * worktree merge entry and rename gating. Pure function of the committed tab + * and preferences; ownership stays in the caller. */ +export function buildTopicbarView(input: { + t: Translator; + locale: string; + activeTab: TabMeta | undefined; + cwd: string | undefined; + imDetail: SidebarImConnection | null; + imTopicSources: Record; + creation: boolean; + chromeHidden: boolean; + automationReturn: boolean; + sidebar: { title: string; blocked: boolean; pressed: boolean; collapsed: boolean }; + rename: { editing: boolean; draft: string }; +}): TopicbarView { + const { t, locale, activeTab, imDetail, creation } = input; + const topicbarTitle = imDetail ? t("botDetail.title", { name: imDetail.title }) : topicDisplayTitle(activeTab); + const topicbarWorkspaceLabel = imDetail ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : ""; + const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || input.cwd : ""; + const topicbarImSource = activeTab?.scope === "global" && activeTab.topicId ? input.imTopicSources[activeTab.topicId] : undefined; + const topicbarImSourceLabel = imDetail + ? imDetail.platformLabel + : topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : ""; + const topicbarImSourcePlatform = imDetail?.platform ?? topicbarImSource?.platform; + const topicbarSubtitleVisible = !creation && Boolean(activeTab?.isolatedWorktree || topicbarImSourceLabel); + const topicbarSubtitleTitle = imDetail + ? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(imDetail, t)].filter(Boolean).join(" · ") + : [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · "); + const topicbarCanRename = !imDetail && (Boolean(activeTab?.topicId) || Boolean(activeTab?.remote)); + const topicbarTitleEditSize = Math.min(56, Math.max(4, input.rename.draft.length || topicbarTitle.length || 1)); + return { + automationReturn: input.automationReturn, + automationReturnLabel: locale === "en" ? "Back to automation" : locale === "zh-TW" ? "返回自動化" : "返回自动化", + chromeHidden: input.chromeHidden, + sidebar: input.sidebar, + title: { text: topicbarTitle, hover: !topicbarCanRename && imDetail ? topicbarTitle : topicTitle(activeTab), + renameLabel: t("topicBar.renameSession"), editing: input.rename.editing, draft: input.rename.draft, + editSize: creation ? topicbarTitleEditSize : undefined, canRename: topicbarCanRename, workspaceLabel: topicbarWorkspaceLabel }, + subtitle: { visible: topicbarSubtitleVisible, title: topicbarSubtitleTitle, worktreeTabId: activeTab?.isolatedWorktree ? activeTab.id : undefined, + mergeLabel: t("worktree.mergeAction"), mergeTooltip: t("worktree.mergeButtonTooltip"), + sourcePlatform: topicbarImSourcePlatform, sourceLabel: topicbarImSourceLabel }, + }; +} + +/** The topicbar actions stack: palette entry, per-session actions, the + * creation dock toggle and the task-monitor popover. Pure prop-driven. */ +export function TopicbarActionsStack(props: { + t: Translator; + paletteShortcut: string; + onOpenPalette: () => void; + activeTab: TabMeta | undefined; + activeTabId: string | undefined; + imDetailActive: boolean; + dismissSignal: number; + sessionHasContent: boolean; + exportCommands: { + getSessionMarkdown: () => Promise; + exportSession: (format: SessionExportFormat) => Promise; + }; + terminal: { toggle: () => void; enabled: boolean; open: boolean; prefetch: () => void }; + tasksOpen: false | "session" | "all"; + setTasksOpen: (update: (open: false | "session" | "all") => false | "session" | "all") => void; + onCloseTasks: () => void; + onOpenTaskSession: (tabID: string, taskID: string) => Promise; + creation: boolean; + dockToggle: ReactNode; +}) { + const { t, activeTab, imDetailActive } = props; + return ( +
+ + + + void props.exportCommands.exportSession(format), + toggleTerminal: props.terminal.toggle, terminalEnabled: props.terminal.enabled, + terminalOpen: props.terminal.open, prefetchTerminal: props.terminal.prefetch, + openSessionSummary: () => props.setTasksOpen((open) => open ? false : "session"), tasksOpen: Boolean(props.tasksOpen), + } : undefined} + /> + {props.creation && props.dockToggle} + {props.tasksOpen && ( +
+ + + +
+ )} +
+ ); +} diff --git a/desktop/frontend/src/app-shell/TopicbarRegion.tsx b/desktop/frontend/src/app-shell/TopicbarRegion.tsx new file mode 100644 index 0000000000..03464f7efa --- /dev/null +++ b/desktop/frontend/src/app-shell/TopicbarRegion.tsx @@ -0,0 +1,79 @@ +import type { ReactNode } from "react"; +import { PanelLeft } from "lucide-react"; +import { Tooltip } from "../components/Tooltip"; +import { WorktreeBadge } from "../components/WorktreeBadge"; + +export type TopicbarView = { + automationReturn: boolean; automationReturnLabel: string; + chromeHidden: boolean; + sidebar: { title: string; blocked: boolean; pressed: boolean; collapsed: boolean }; + title: { + text: string; hover: string; renameLabel: string; editing: boolean; + draft: string; editSize?: number; canRename: boolean; workspaceLabel?: string; + }; + subtitle: { + visible: boolean; title: string; worktreeTabId?: string; + mergeLabel: string; mergeTooltip: string; sourcePlatform?: string; sourceLabel?: string; + }; +}; +type Commands = { + openAutomation(): void; + toggleSidebar(): void; + setTitleDraft(value: string): void; + commitRename(): void | Promise; + cancelRename(): void; + startRename(): void; + openWorktree(tabId: string): void; +}; + +/** Presentation only: consume display values separately from stable commands. */ +export function TopicbarRegion({ view, commands, children }: { + view: TopicbarView; commands: Commands; children: ReactNode; +}) { + const { sidebar, title, subtitle } = view; + return
+ {view.automationReturn && } + {view.chromeHidden && + + } +
+
+ {title.editing ?
+ commands.setTitleDraft(event.target.value)} + onFocus={event => event.currentTarget.select()} + onKeyDown={event => { + if (event.key === "Enter") { event.preventDefault(); void commands.commitRename(); } + if (event.key === "Escape") { event.preventDefault(); commands.cancelRename(); } + }} onBlur={() => void commands.commitRename()} /> +
: title.canRename ?

+ +

:

{title.text}

} + {title.workspaceLabel && {title.workspaceLabel}} +
+ {subtitle.visible &&
+ {subtitle.worktreeTabId && } + {subtitle.worktreeTabId && } + {subtitle.sourcePlatform && {subtitle.sourceLabel}} +
} +
+
+ {children} +
; +} diff --git a/desktop/frontend/src/app-shell/WindowsWindowControls.tsx b/desktop/frontend/src/app-shell/WindowsWindowControls.tsx new file mode 100644 index 0000000000..4faf2639d1 --- /dev/null +++ b/desktop/frontend/src/app-shell/WindowsWindowControls.tsx @@ -0,0 +1,22 @@ +import { Copy as RestoreIcon, Minus, Square, X } from "lucide-react"; + +export function WindowsWindowControls({ maximised, onMinimize, onToggleMaximize, onClose }: { + maximised: boolean; + onMinimize: () => void; + onToggleMaximize: () => void; + onClose: () => void; +}) { + return ( +
+ + + +
+ ); +} diff --git a/desktop/frontend/src/app-shell/WorkspaceDockRegion.tsx b/desktop/frontend/src/app-shell/WorkspaceDockRegion.tsx new file mode 100644 index 0000000000..86a315e7e7 --- /dev/null +++ b/desktop/frontend/src/app-shell/WorkspaceDockRegion.tsx @@ -0,0 +1,85 @@ +import { lazy, Suspense, type ComponentProps, type KeyboardEvent, type PointerEvent, type ReactNode } from "react"; +import { Activity, FileText, GitBranch, Server } from "lucide-react"; +import type { Translator } from "../lib/i18n"; +import type { RightDockMode } from "../store/layout"; + +const ContextPanel = lazy(() => import("../components/ContextPanel").then((module) => ({ default: module.ContextPanel }))); +const RemotePanel = lazy(() => import("../components/RemotePanel").then((module) => ({ default: module.RemotePanel }))); +const WorkspacePanel = lazy(async () => { + const [module] = await Promise.all([ + import("../components/WorkspacePanel"), + import("../components/WorkspacePanelStability.css"), + ]); + return { default: module.WorkspacePanel }; +}); + +export type WorkspaceDockRegionProps = { + visible: boolean; + overlay: boolean; + mode: RightDockMode; + creation: boolean; + remoteAvailable: boolean; + showContext: boolean; + t: Translator; + onMode: (mode: RightDockMode) => void; + onRemote: () => void; + remote: ComponentProps; + context: ComponentProps; + workspace: ComponentProps; + workspaceKey: string; + resizer?: { + min: number; + max: number; + value: number; + onPointerDown: (event: PointerEvent) => void; + onKeyDown: (event: KeyboardEvent) => void; + onReset: () => void; + }; +}; + +/** Shared workbench/creation dock; layout variants change data, not component identity. */ +export function WorkspaceDockRegion(props: WorkspaceDockRegionProps) { + const { visible, overlay, mode, creation, remoteAvailable, showContext, t, onMode, onRemote } = props; + return ( + <> + {props.resizer && ( + + ); +} diff --git a/desktop/frontend/src/app-shell/chromeRegionBuilders.ts b/desktop/frontend/src/app-shell/chromeRegionBuilders.ts new file mode 100644 index 0000000000..de1f1b920d --- /dev/null +++ b/desktop/frontend/src/app-shell/chromeRegionBuilders.ts @@ -0,0 +1,177 @@ +import { defaultCreationSidebarWidth, defaultSidebarWidth, SIDEBAR_MAX_WIDTH } from "../store/layout"; +import type { Translator } from "../lib/i18n"; +import type { Meta, TabMeta } from "../lib/types"; +import type { SidebarImTopicSource } from "../app-runtime/sidebarImProjection"; +import type { useSessionBannerCommands } from "../app-runtime/useSessionBannerCommands"; +import type { useProjectTopicCommands } from "../app-runtime/useProjectTopicCommands"; +import type { useOnboardingCommands } from "../app-runtime/useOnboardingCommands"; +import type { useShellGeometry } from "../app-runtime/useShellGeometry"; +import type { useAppShellStores } from "../app-runtime/useAppShellStores"; +import type { SessionStatusBannersProps } from "./SessionStatusBanners"; +import type { SidebarRegionProps } from "./SidebarRegion"; + +type BannerCommands = ReturnType; +type ShellStores = ReturnType; +type ProjectTopicCommands = ReturnType; +type OnboardingCommands = ReturnType; + +/** Pure prop assembly for the chrome regions (sidebar, app chrome, status + * banners); store and hook ownership stays with the caller. */ + +export function buildSidebarRegionProps(input: { + automation: boolean; + className: string; + toggleTitle: string; + shell: ShellStores; + t: Translator; + geometry: ReturnType; + projectTree: { + activeTab: TabMeta | undefined; + imTopicSources: Record; + refreshSignal: number; + timeFilter: SidebarRegionProps["projectTree"]["timeFilter"]; + onTimeFilterChange: SidebarRegionProps["projectTree"]["onTimeFilterChange"]; + searchExpanded: boolean; + searchFocusSignal: number; + showShortcutBadges: boolean; + shortcutPlatform: SidebarRegionProps["projectTree"]["shortcutPlatform"]; + onVisibleTopicsChange: SidebarRegionProps["projectTree"]["onVisibleTopicsChange"]; + }; + topics: ProjectTopicCommands; + commands: { + onNewSession: () => void; + onOpenTrash: () => void; + onOpenAutomation: () => void; + onOpenSettings: SidebarRegionProps["onOpenSettings"]; + onToggleSearch: () => void; + onToggle: () => void; + onOpenTopic: SidebarRegionProps["projectTree"]["onOpenTopic"]; + }; +}): SidebarRegionProps { + const { geometry, topics, commands } = input; + const shell = input.shell; + return { + automation: input.automation, + className: input.className, + workbench: shell.sidebarWorkbench, + creation: shell.sidebarCreation, + collapsed: shell.sidebarCollapsed, + navTooltipDisabled: !shell.sidebarCollapsed, + searchOpen: shell.sidebarSearchOpen, + togglePressed: shell.sidebarTogglePressed, + toggleTitle: input.toggleTitle, + t: input.t, + onNewSession: commands.onNewSession, + onOpenTrash: commands.onOpenTrash, + onOpenAutomation: commands.onOpenAutomation, + onOpenSettings: commands.onOpenSettings, + onToggleSearch: commands.onToggleSearch, + onToggle: commands.onToggle, + resize: { + min: geometry.sidebarResizeMinWidth, max: SIDEBAR_MAX_WIDTH, value: geometry.sidebarRenderWidth, + onPointerDown: geometry.startSidebarResize, onKeyDown: geometry.resizeSidebarWithKeyboard, + onReset: () => geometry.setExpandedSidebarWidth(shell.sidebarCreation ? defaultCreationSidebarWidth() : defaultSidebarWidth()), + }, + projectTree: { + activeScope: input.projectTree.activeTab?.scope, activeWorkspaceRoot: input.projectTree.activeTab?.workspaceRoot, + activeTopicId: input.projectTree.activeTab?.topicId, activeSessionPath: input.projectTree.activeTab?.sessionPath, + activeRemote: input.projectTree.activeTab?.remote, imTopicSources: input.projectTree.imTopicSources, onOpenTopic: commands.onOpenTopic, + onCreateTopic: topics.onCreateTopic, onCreateIsolatedWorktree: topics.onCreateIsolatedWorktree, + onTopicsChanged: topics.refreshProjectsAndTabs, onRenameTopic: topics.renameTopic, refreshSignal: input.projectTree.refreshSignal, + onAddProject: topics.onAddProject, + timeFilter: input.projectTree.timeFilter, onTimeFilterChange: input.projectTree.onTimeFilterChange, + variant: shell.sidebarWorkbench ? "workbench" : shell.sidebarCreation ? "creation" : "classic", + searchExpanded: input.projectTree.searchExpanded, searchFocusSignal: input.projectTree.searchFocusSignal, + showShortcutBadges: input.projectTree.showShortcutBadges, shortcutPlatform: input.projectTree.shortcutPlatform, + onVisibleTopicsChange: input.projectTree.onVisibleTopicsChange, + }, + }; +} + +export function buildSessionStatusBannerProps(input: { + t: Translator; + activeTab: TabMeta | undefined; + leaseBlocked: SessionStatusBannersProps["leaseBlocked"]; + meta: Meta | null | undefined; + configWarnings: SessionStatusBannersProps["configWarnings"]; + dismissConfigWarnings: () => void; + updateChecksEnabled: boolean; + shell: ShellStores; + banners: BannerCommands; + onboarding: OnboardingCommands; +}): SessionStatusBannersProps { + const { banners, shell, onboarding } = input; + return { + t: input.t, + takenOver: Boolean(input.activeTab?.takenOver), + reclaimTabId: input.activeTab?.id ?? "", + reclaimBusyTabId: shell.reclaimBusyTab, + onReclaim: banners.reclaimSession, + leaseBlocked: input.leaseBlocked, + startupError: input.meta?.startupErr, + takeoverDialogTabId: shell.takeoverDialogTab, + onOpenTakeover: banners.openTakeoverDialog, + onCloseTakeover: banners.closeTakeoverDialog, + configWarnings: input.configWarnings, + onOpenConfigFile: banners.openConfigFile, + onReloadConfigFile: banners.reloadConfigFile, + onDismissConfigWarnings: input.dismissConfigWarnings, + providerSetupNeeded: shell.providerSetupNeeded, + needsOnboarding: shell.needsOnboarding, + onConfigureProvider: () => { + shell.setProviderSetupNeeded(false); + onboarding.chooseOnboardingProvider(); + }, + updateChecksEnabled: input.updateChecksEnabled, + onShowReleaseNotes: banners.showReleaseNotes, + }; +} + +/** The app/layout frame class lists; flags arrive from the caller's stores. */ +export function buildAppShellClassNames(input: { + platform: string; + windowsFrameless: boolean; + browserPreview: boolean; + workbench: boolean; + creation: boolean; + imDetailActive: boolean; + sidebarCollapsed: boolean; + sidebarResizing: boolean; + dockGridOpen: boolean; + dockOverlay: boolean; + terminalOpen: boolean; + terminalResizing: boolean; + dockOpen: boolean; + dockMaximized: boolean; + dockResizing: boolean; +}): { app: string; layout: string } { + return { + app: [ + "app", + `app--${input.platform}`, + input.windowsFrameless ? "app--windows-frameless" : "", + input.browserPreview ? "app--browser-preview" : "", + input.workbench ? "app--workbench" : "", + input.creation ? "app--creation" : "", + !input.workbench && !input.creation ? "app--classic" : "", + ].filter(Boolean).join(" "), + layout: [ + "layout", + input.workbench ? "layout--workbench" : "", + input.workbench ? "layout--workbench-chrome-hidden" : "", + input.creation ? "layout--creation-chrome-hidden" : "", + input.imDetailActive ? "layout--statusbar-hidden" : "", + input.sidebarCollapsed ? "layout--sidebar-collapsed" : "", + input.sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "", + input.dockGridOpen ? "layout--workspace-open" : "", + input.dockOverlay ? "layout--workspace-overlay" : "", + "layout--terminal-drawer-open", + input.terminalOpen ? "layout--terminal-drawer-expanded" : "", + input.terminalResizing ? "layout--terminal-resizing" : "", + input.dockOpen && input.dockMaximized ? "layout--workspace-maximized" : "", + input.dockResizing ? "layout--resizing layout--workspace-resizing" : "", + ] + .filter(Boolean) + .join(" "), + }; +} diff --git a/desktop/frontend/src/app-shell/decisionFooterBuilders.ts b/desktop/frontend/src/app-shell/decisionFooterBuilders.ts new file mode 100644 index 0000000000..015e4d1222 --- /dev/null +++ b/desktop/frontend/src/app-shell/decisionFooterBuilders.ts @@ -0,0 +1,332 @@ +import type { Todo } from "../lib/tools"; +import type { RewindUndoState } from "../lib/rewindTypes"; +import type { WorkspaceConflictView } from "../lib/types"; +import type { DecisionSurfaceKind as MockDecisionSurfaceKind } from "../lib/decisionSurfaceMock"; +import type { Translator } from "../lib/i18n"; +import type { projectConversation } from "../app-runtime/conversationProjection"; +import type { useSessionPromptCommands } from "../app-runtime/useSessionPromptCommands"; +import type { useExtensionSurface } from "../app-runtime/useExtensionSurface"; +import type { useSessionClearCommands } from "../app-runtime/useSessionClearCommands"; +import type { useTabBarCommands } from "../app-runtime/useTabBarCommands"; +import type { useComposerProfileProjection } from "../app-runtime/useComposerProfileProjection"; +import type { useComposerInsertCommands } from "../app-runtime/useComposerInsertCommands"; +import type { useComposerModeActions } from "../lib/useComposerModeActions"; +import type { useComposerGoalCommands } from "../app-runtime/useComposerGoalCommands"; +import type { useRemoteComposerRuntimeActions } from "../lib/useRemoteComposerIntegration"; +import type { useControllerProfileCommands } from "../lib/useControllerProfileCommands"; +import type { + ApprovalProps, + AskProps, + ComposerProps, + DecisionFooterRegionProps, + DecisionFooterSurface, + ExtensionProps, + McpProps, + RuntimeDecisionProps, + TodoProps, +} from "./DecisionFooterRegion"; + +type SurfaceKind = MockDecisionSurfaceKind | "extension_form"; +type ComposerBase = ReturnType["composer"]; +type PromptCommands = ReturnType; +type ExtensionSurfaceApi = ReturnType; +type ClearCommands = Pick, "cancelClearContext" | "confirmClearContext">; +type TabBarApi = Pick, "pendingClose" | "setPendingClose" | "resolvePendingClose" | "revealWorkspaceWriter" | "continueInDeliveryWorktree">; + +/** Pure prop builders for DecisionFooterRegion; every closure keeps the exact + * handler identity and branching the App body previously assembled inline. */ + +export function buildFooterTodo(input: { + show: boolean; + identity: string; + todos: Todo[]; + running: boolean; + pendingPrompt: boolean; + continueReady: boolean; + onContinue: TodoProps["onContinue"]; + onDismiss: TodoProps["onDismiss"]; +}): DecisionFooterRegionProps["todo"] { + if (!input.show) return undefined; + return { + identity: input.identity, + props: { + stateKey: input.identity, + todos: input.todos, + running: input.running, + pendingPrompt: input.pendingPrompt, + onContinue: input.continueReady ? input.onContinue : undefined, + onDismiss: input.onDismiss, + }, + }; +} + +export function buildFooterUndo(input: { + rewindState: RewindUndoState | null; + activeTabId: string | undefined; + onUndo: () => void; +}): DecisionFooterRegionProps["undo"] { + const { rewindState } = input; + if (!rewindState) return undefined; + return { + identity: `${input.activeTabId ?? ""}:${rewindState.transactionId ?? "rewind"}`, + props: { + meta: { + turns: rewindState.turnDiff, + filesRestored: rewindState.filesRestored ?? [], + filesRemoved: rewindState.filesRemoved ?? [], + onUndo: input.onUndo, + }, + }, + }; +} + +export type DecisionFooterSurfaceInput = { + view: { + surface: SurfaceKind | null; + activeTabId: string | undefined; + cwd: string | undefined; + workspaceScopeKey: string; + approval: ApprovalProps["approval"] | null | undefined; + ask: AskProps["ask"] | null | undefined; + mcpInteraction: McpProps["interaction"] | null | undefined; + extensionForm: ExtensionProps["surface"] | null | undefined; + workspaceConflict: WorkspaceConflictView | null; + toolApprovalMode: ApprovalProps["toolApprovalMode"]; + insertRequest: ApprovalProps["insertRequest"]; + }; + prompts: PromptCommands; + extension: ExtensionSurfaceApi; + tabs: TabBarApi; + clear: ClearCommands; + onStop: () => void; + cancelWorkspaceConflict: RuntimeDecisionProps["onCancel"]; + onOpenLink: McpProps["onOpenLink"]; + onRevisionActiveChange: ApprovalProps["onRevisionActiveChange"]; + t: Translator; +}; + +export function buildDecisionFooterSurface(input: DecisionFooterSurfaceInput): DecisionFooterSurface | undefined { + const { view, prompts, extension, tabs, clear, t } = input; + const { surface, activeTabId } = view; + if ((surface === "tool_approval" || surface === "plan_approval") && view.approval) { + return { + kind: "approval", + identity: `${activeTabId ?? ""}:${view.approval.id}`, + props: { + approval: view.approval, + cwd: view.cwd, + tabId: activeTabId, + workspaceScopeKey: view.workspaceScopeKey, + insertRequest: view.insertRequest, + onRevisionActiveChange: input.onRevisionActiveChange, + onAnswer: prompts.handleApprovalAnswer, + onResolveRecovery: prompts.handleRecoveryAnswer, + onRevisePlan: prompts.handleRevisePlan, + onExitPlan: prompts.handleExitPlan, + onStop: input.onStop, + toolApprovalMode: view.toolApprovalMode, + }, + }; + } + if (surface === "ask" && view.ask) { + return { + kind: "ask", + identity: `${activeTabId ?? ""}:${view.ask.id}`, + props: { + ask: view.ask, + onAnswer: prompts.handleQuestionAnswer, + onDismiss: prompts.handleQuestionDismiss, + onStop: input.onStop, + }, + }; + } + if (surface === "mcp_interaction" && view.mcpInteraction) { + return { + kind: "mcp", + identity: `${activeTabId ?? ""}:${view.mcpInteraction.id}`, + props: { + interaction: view.mcpInteraction, + busy: false, + onAnswer: prompts.handleMCPAnswer, + onOpenLink: input.onOpenLink, + }, + }; + } + if (surface === "extension_form" && view.extensionForm) { + return { + kind: "extension", + identity: `${activeTabId ?? ""}:${view.extensionForm.pluginId}:${view.extensionForm.surfaceId}`, + props: { + surface: view.extensionForm, + busy: extension.extensionFormBusy, + onSubmit: (values) => void extension.submitExtensionForm(values), + onCancel: () => void extension.cancelExtensionForm(), + }, + }; + } + if (surface === "workspace_conflict" && view.workspaceConflict) { + const workspaceConflict = view.workspaceConflict; + return { + kind: "runtime", + identity: "workspace-conflict", + props: { + id: "workspace-conflict", + title: t("runtime.workspaceConflictTitle"), + badge: t("runtime.workspaceConflictBadge"), + meta: workspaceConflict.state === "local" + ? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask"), label: workspaceConflict.ownerLabel || t("workspace.title") }) + : t("runtime.workspaceConflictExternal"), + note: t("runtime.workspaceConflictNote"), + onCancel: input.cancelWorkspaceConflict, + actions: [ + ...(workspaceConflict.canReveal ? [{ + key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"), + onClick: () => void tabs.revealWorkspaceWriter(), + }] : []), + ...(workspaceConflict.canCreateWorktree ? [{ + key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"), + onClick: () => void tabs.continueInDeliveryWorktree(), + }] : []), + ], + secondaryAction: { + key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"), + onClick: input.cancelWorkspaceConflict, + }, + }, + }; + } + if (surface === "close_active" && tabs.pendingClose) { + const pendingClose = tabs.pendingClose; + return { + kind: "runtime", + identity: "close-active", + props: { + id: "close-active", + title: t("runtime.closeTitle"), + badge: t("status.jobs", { n: pendingClose.work.jobs.length }), + meta: t("runtime.closeMeta"), + onCancel: () => tabs.setPendingClose(null), + actions: [ + { + key: "1", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"), + onClick: () => void tabs.resolvePendingClose("keep_running"), disabled: pendingClose.stopping, + }, + { + key: "2", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"), + description: t("runtime.stopAndCloseDesc"), onClick: () => void tabs.resolvePendingClose("stop_and_close"), + danger: true, disabled: pendingClose.stopping, + }, + ], + secondaryAction: { + key: "Esc", label: t("runtime.returnToTask"), description: t("runtime.closeCancelDesc"), + onClick: () => tabs.setPendingClose(null), disabled: pendingClose.stopping, + }, + }, + }; + } + if (surface === "clear_context") { + return { + kind: "clear-context", + identity: "clear-context", + props: { onCancel: clear.cancelClearContext, onConfirm: () => void clear.confirmClearContext() }, + }; + } + return undefined; +} + +export type ComposerSurfaceInput = { + view: { + hidden: boolean; + inert: boolean; + hero: boolean; + headline: string; + remote: boolean; + rewindCommitting: boolean; + messageActionPending: boolean; + decisionActive: boolean; + runtimeTransitioning: boolean; + controllerReady: boolean; + showContextWindowRing: boolean; + }; + base: ComposerBase; + tab: { readOnly?: boolean; floorInferred?: boolean } | undefined; + tabId: string | undefined; + profile: ReturnType; + router: { handleSend: ComposerProps["onSend"]; handleSteer: ComposerProps["onSteer"] }; + modes: ReturnType; + goals: ReturnType; + remoteGoal: ReturnType; + modelSwitch: Pick, "switchModelFromUi">; + inserts: Pick, "composerInsertRequest" | "selectedTextRequest">; + control: { handleCancelActive: ComposerProps["onCancel"] }; + remoteComposer: { + send: ComposerProps["onSend"]; + cancel: ComposerProps["onCancel"]; + ready: boolean; + profileReady: boolean; + liveStore: ComposerProps["liveStore"]; + }; + localLiveStore: ComposerProps["liveStore"]; + onInvocationMetadataChange: ComposerProps["onInvocationMetadataChange"]; + onCycleMode: ComposerProps["onCycleMode"]; + transientDismissSignal: ComposerProps["transientDismissSignal"]; + sessionKey: ComposerProps["sessionKey"]; + workspaceScopeKey: ComposerProps["workspaceScopeKey"]; + fileRefRefreshKey: ComposerProps["fileRefRefreshKey"]; + guidance: { key: string; itemId?: string; text: string } | null; + guidanceQueuePreviewItems: ComposerProps["guidanceQueuePreviewItems"]; +}; + +export function buildComposerSurface(input: ComposerSurfaceInput): DecisionFooterRegionProps["composer"] { + const { base, view, profile, router, modes, goals, remoteGoal, modelSwitch, inserts, control, remoteComposer } = input; + return { + hidden: view.hidden, + inert: view.inert, + hero: view.hero, + headline: view.headline, + props: { + ...base, + running: base.running || (!view.remote && view.rewindCommitting), + collaborationMode: profile.collaborationMode, + toolApprovalMode: profile.toolApprovalMode, + qualityFloor: profile.composerProfile.qualityFloor, + floorInferred: (input.tab?.floorInferred ?? false) && !profile.composerProfile.pending.qualityFloor, + onSetQualityFloor: profile.applyQualityFloor, + goal: profile.goal, + tabId: input.tabId, + onSend: view.remote ? remoteComposer.send : router.handleSend, + onInvocationMetadataChange: input.onInvocationMetadataChange, + onSteer: router.handleSteer, + onCancel: view.remote ? remoteComposer.cancel : control.handleCancelActive, + onCycleMode: input.onCycleMode, + onSetMode: modes.applyMode, + onSetCollaborationMode: goals.setCollaborationModeFromUi, + onSetToolApprovalMode: modes.applyToolApprovalMode, + onToggleYoloApprovalMode: modes.toggleYoloApprovalMode, + onClearGoal: goals.clearGoalFromUi, + onPauseGoal: remoteGoal.pauseGoal, + onResumeGoal: remoteGoal.resumeGoal, + onSwitchModel: modelSwitch.switchModelFromUi, + onSetEffort: remoteGoal.setEffort, + insertRequest: inserts.composerInsertRequest, + selectedTextRequest: inserts.selectedTextRequest, + readOnly: Boolean(input.tab?.readOnly), + disabled: view.runtimeTransitioning || view.rewindCommitting || view.messageActionPending || view.decisionActive, + submitDisabled: view.remote ? !remoteComposer.ready || !remoteComposer.profileReady : !view.controllerReady, + decisionPending: view.rewindCommitting || view.messageActionPending || view.decisionActive, + ready: view.remote ? remoteComposer.ready && remoteComposer.profileReady : view.controllerReady, + liveStore: view.remote ? remoteComposer.liveStore : input.localLiveStore, + suspendedByDecision: view.decisionActive, + transientDismissSignal: input.transientDismissSignal, + sessionKey: input.sessionKey, + workspaceScopeKey: input.workspaceScopeKey, + fileRefRefreshKey: input.fileRefRefreshKey, + guidanceConsumedKey: input.guidance?.key, + guidanceConsumedItemId: input.guidance?.itemId, + guidanceConsumedText: input.guidance?.text, + guidanceQueuePreviewItems: input.guidanceQueuePreviewItems, + showContextWindowRing: view.showContextWindowRing, + heroMode: view.hero, + }, + }; +} diff --git a/desktop/frontend/src/app-shell/dockRegionBuilders.ts b/desktop/frontend/src/app-shell/dockRegionBuilders.ts new file mode 100644 index 0000000000..9036b80d2b --- /dev/null +++ b/desktop/frontend/src/app-shell/dockRegionBuilders.ts @@ -0,0 +1,174 @@ +import { workspacePanelAriaMinWidth } from "../lib/workspaceLayout"; +import type { Translator } from "../lib/i18n"; +import type { Meta, RemoteHostView, RemoteConnectionStatus, WireCompletionSummary } from "../lib/types"; +import type { projectConversation } from "../app-runtime/conversationProjection"; +import type { useShellGeometry } from "../app-runtime/useShellGeometry"; +import type { useWorkspacePanelCommands } from "../app-runtime/useWorkspacePanelCommands"; +import type { useComposerInsertCommands } from "../app-runtime/useComposerInsertCommands"; +import type { WorkspaceVerificationRevealRequest } from "../components/WorkspacePanel"; +import type { WorkspaceDockRegionProps } from "./WorkspaceDockRegion"; +import type { AppBottomRegionsProps } from "./AppBottomRegions"; +import type { ComposerProfile } from "../lib/composerProfile"; +import type { RightDockMode } from "../store/layout"; +import { defaultCreationRightDockTreeWidth, defaultRightDockTreeWidth, TERMINAL_DEFAULT_HEIGHT, TERMINAL_MIN_HEIGHT } from "../store/layout"; + +type ShellGeometry = ReturnType; +type WorkspacePanelApi = ReturnType; +type InsertCommands = ReturnType; +type ConversationView = ReturnType; +type StatusBarProps = NonNullable; + +/** Pure prop assembly for the dock and bottom regions; all ownership and + * geometry stays in the caller's hooks, only the object literals moved. */ + +export function buildWorkspaceDockProps(input: { + surface: { renderable: boolean; overlay: boolean; gridOpen: boolean }; + creation: boolean; + remoteAvailable: boolean; + showContext: boolean; + remote: boolean; + t: Translator; + context: ConversationView["context"]; + sessionTurns: number; + contextRefreshKey: number; + workspaceKey: string; + workspaceScopeKey: string; + mode: RightDockMode; + meta: Meta | null | undefined; + tabId: string | undefined; + completionSummary: WireCompletionSummary | undefined; + turnStartAt: number; + layout: { treeWidth: number; previewWidth: number; maximized: boolean }; + geometry: ShellGeometry; + panels: WorkspacePanelApi; + inserts: InsertCommands; + verification: { verificationRevealRequest: WorkspaceVerificationRevealRequest | null }; + qualityFloor: ComposerProfile["qualityFloor"]; + onFileTreeRefresh: () => void; + onSessionRevertCommitted: WorkspaceDockRegionProps["workspace"]["onSessionRevertCommitted"]; + onOpenInTerminal: WorkspaceDockRegionProps["workspace"]["onOpenInTerminal"]; +}): WorkspaceDockRegionProps { + const { surface, geometry, panels } = input; + const workspacePanelResetWidth = input.creation + ? defaultCreationRightDockTreeWidth() + : defaultRightDockTreeWidth(); + const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(geometry.workspacePanelMinWidth, geometry.workspacePanelRenderWidth); + return { + visible: surface.renderable, + overlay: surface.overlay, + mode: input.mode, + creation: input.creation, + remoteAvailable: input.remoteAvailable, + showContext: input.showContext, + t: input.t, + onMode: panels.openRightDockMode, + onRemote: panels.openRemoteDock, + remote: { onClose: panels.closeWorkspacePanel }, + context: { + ...input.context, sessionTurns: input.sessionTurns, + refreshKey: input.contextRefreshKey, + }, + workspaceKey: input.workspaceKey, + workspace: { + open: surface.renderable, tabId: input.tabId, cwd: input.meta?.cwd, + workspaceScopeKey: input.workspaceScopeKey, workspaceMemoryKey: input.workspaceKey, + dockTreeWidth: input.layout.treeWidth, dockPreviewWidth: input.layout.previewWidth, + onRestoreDockWidths: panels.restoreWorkspaceDockWidths, maximized: input.layout.maximized, + panelWidth: geometry.workspacePanelRenderWidth, onClose: panels.closeWorkspacePanel, + onToggleMaximized: panels.toggleWorkspaceMaximized, + onPreviewModeChange: panels.handleWorkspacePreviewModeChange, onAddToChat: input.inserts.addWorkspaceTextToComposer, + onAddCodeToChat: input.inserts.addWorkspaceCodeToComposer, onRequestPanelWidth: geometry.ensureWorkspacePanelWidth, + onFileTreeRefresh: input.onFileTreeRefresh, onSessionRevertCommitted: input.onSessionRevertCommitted, + onOpenInTerminal: input.onOpenInTerminal, + initialViewMode: input.mode === "changed" ? "changed" : "files", + completionSummary: input.completionSummary, turnStartAt: input.turnStartAt, + verificationRevealRequest: input.verification.verificationRevealRequest, qualityFloor: input.qualityFloor, + showViewTabs: false, creationMode: input.creation, + }, + resizer: surface.gridOpen ? { + min: workspacePanelResizeMinWidth, + max: Math.max(geometry.workspacePanelAvailableWidth, geometry.workspacePanelRenderWidth), + value: geometry.workspacePanelRenderWidth, + onPointerDown: geometry.startWorkspacePanelResize, + onKeyDown: geometry.resizeWorkspacePanelWithKeyboard, + onReset: () => geometry.setSavedWorkspacePanelWidth(workspacePanelResetWidth), + } : undefined, + }; +} + +export function buildBottomRegionsProps(input: { + t: Translator; + chatSurfaceVisible: boolean; + surfaceOpen: boolean; + contentVisible: boolean; + remote: boolean; + readOnly: boolean; + tabId: string | undefined; + meta: Meta | null | undefined; + fitEnabled: boolean; + liveTerminalHeight: number | null; + geometry: ShellGeometry; + terminal: { + onClose: () => void; + onAddOutput: (sessionId: string) => void; + onAddToChat: (text: string) => void; + }; + status?: { + base: ConversationView["status"]; + rewindCommitting: boolean; + sessionTurns: number; + labelStyle: StatusBarProps["labelStyle"]; + items: StatusBarProps["items"]; + extensionStatuses: StatusBarProps["extensionStatuses"]; + remoteHosts: RemoteHostView[]; + remoteStatuses: Record; + onCancelJob: StatusBarProps["onCancelJob"]; + onCancelRuntimeJob: StatusBarProps["onCancelRuntimeJob"]; + onRevealRuntime: StatusBarProps["onRevealRuntime"]; + onConnectRemote: StatusBarProps["onConnectRemote"]; + onDisconnectRemote: StatusBarProps["onDisconnectRemote"]; + onManageRemote: StatusBarProps["onManageRemote"]; + onOpenRemote: StatusBarProps["onOpenRemote"]; + onOpenRemoteWorkspace: StatusBarProps["onOpenRemoteWorkspace"]; + }; +}): AppBottomRegionsProps { + const { geometry, status } = input; + return { + terminal: { + surfaceVisible: input.chatSurfaceVisible, + open: input.surfaceOpen, contentVisible: input.contentVisible, + remoteSurface: input.remote, t: input.t, + panel: { + tabId: input.tabId ?? "", cwd: input.meta?.cwd, readOnly: input.readOnly, + open: input.surfaceOpen, fitEnabled: input.fitEnabled, + onClose: input.terminal.onClose, + onAddOutput: input.terminal.onAddOutput, + onAddToChat: input.terminal.onAddToChat, + }, + resizer: { + min: TERMINAL_MIN_HEIGHT, max: geometry.terminalResizeMaxHeight, + value: input.liveTerminalHeight ?? geometry.terminalRenderHeight, + onPointerDown: geometry.startTerminalResize, onKeyDown: geometry.resizeTerminalWithKeyboard, + onReset: () => geometry.setSavedTerminalHeight(TERMINAL_DEFAULT_HEIGHT), + }, + }, + status: status ? { + ...status.base, + running: status.base.running || (!input.remote && status.rewindCommitting), + onCancelJob: status.onCancelJob, + onCancelRuntimeJob: status.onCancelRuntimeJob, + onRevealRuntime: status.onRevealRuntime, + sessionTurns: status.sessionTurns, + labelStyle: status.labelStyle, + items: status.items, + extensionStatuses: status.extensionStatuses, + onConnectRemote: status.onConnectRemote, + onDisconnectRemote: status.onDisconnectRemote, + onManageRemote: status.onManageRemote, + onOpenRemote: status.onOpenRemote, + onOpenRemoteWorkspace: status.onOpenRemoteWorkspace, + remoteHosts: status.remoteHosts, + remoteStatuses: status.remoteStatuses, + } : undefined, + }; +} diff --git a/desktop/frontend/src/app-shell/overlayBuilders.ts b/desktop/frontend/src/app-shell/overlayBuilders.ts new file mode 100644 index 0000000000..2e949cb347 --- /dev/null +++ b/desktop/frontend/src/app-shell/overlayBuilders.ts @@ -0,0 +1,107 @@ +import { requestSessionVersions } from "../lib/sessionRecoveryVersionHostBridge"; +import type { AppOverlayHostProps } from "./AppOverlayHost"; +import type { HistoryViewState } from "../app-runtime/historyViewProjection"; +import type { useHistoryCommands } from "../app-runtime/useHistoryCommands"; +import type { useSessionNavigationCommands } from "../app-runtime/useSessionNavigationCommands"; +import type { useAppChromeCommands } from "../app-runtime/useAppChromeCommands"; +import type { useOnboardingCommands } from "../app-runtime/useOnboardingCommands"; +import type { useWorktreeMergeCommands } from "../app-runtime/useWorktreeMergeCommands"; +import type { useAppShellStores } from "../app-runtime/useAppShellStores"; +import type { TabMeta } from "../lib/types"; +import type { Translator } from "../lib/i18n"; + +type HistoryCommands = ReturnType; +type NavigationCommands = ReturnType; +type ChromeCommands = ReturnType; +type OnboardingCommands = ReturnType; +type WorktreeMergeCommands = ReturnType; + +type OverlayPalette = NonNullable; +type ShellStores = ReturnType; + +/** Pure prop assembly for AppOverlayHost; overlay visibility flags come from + * the caller's stores, command identity from its owner hooks. */ +export function buildOverlayHostProps(input: { + t: Translator; + running: boolean; + histView: HistoryViewState | null; + pageKind: string; + automationTopic: NonNullable["commands"]["onOpenTopic"]; + shell: ShellStores; + activeTab: TabMeta | undefined; + activeTabId: string | undefined; + cwd: string | undefined; + paletteItems: OverlayPalette["view"]["items"]; + startupSplashHold: boolean; + selectionEnabled: boolean; + history: HistoryCommands; + navigation: NavigationCommands; + chrome: ChromeCommands; + onboarding: OnboardingCommands; + worktree: WorktreeMergeCommands; + onAddSelectedText: (text: string) => void; + prefillSubagentCommand: (command: string) => void; + + sessionActions: { + previewSession: NonNullable["commands"]["onPreview"]; + listTrashedSessions: NonNullable["commands"]["list"]; + restoreSession: NonNullable["commands"]["restore"]; + purgeTrashedSession: NonNullable["commands"]["purge"]; + }; + setSettingsTarget: NonNullable["commands"]["onNavigate"]; +}): AppOverlayHostProps { + const { t, history, navigation, chrome, onboarding, worktree, shell, sessionActions } = input; + const histView = input.histView; + const settingsTarget = shell.settingsTarget; + return { + history: histView ? { + view: { kind: histView.kind, sessions: histView.sessions, running: input.running }, + commands: { + onResume: navigation.onResumeSession, onPreview: sessionActions.previewSession, onDelete: history.onDeleteSession, + onRename: history.onRenameHistorySession, + onInspectVersions: requestSessionVersions, onClose: history.closeHistory, + }, + } : undefined, + trash: shell.visitedTrash ? { view: { active: input.pageKind === "trash" }, + commands: { onBack: shell.returnToWorkspace, list: sessionActions.listTrashedSessions, restore: sessionActions.restoreSession, purge: sessionActions.purgeTrashedSession } } : undefined, + automation: shell.visitedAutomation ? { view: { active: input.pageKind === "automation" }, + commands: { onBack: shell.returnToWorkspace, onOpenTopic: input.automationTopic } } : undefined, + recovery: { + view: { sessions: histView?.sessions }, + commands: { onResumeSession: navigation.onResumeSession, onRecoveryCreated: navigation.onRecoveryCreated, onLineageChanged: navigation.onRecoveryLineageChanged }, + }, + settings: settingsTarget ? { + view: { + initialTab: settingsTarget, initialFocus: shell.settingsFocus ?? undefined, + agentRunning: input.running, desktopPlatform: shell.desktopPlatform, + activeWorkspaceKey: `${input.activeTab?.id ?? input.activeTabId ?? ""}\u0000${input.activeTab?.workspaceRoot ?? input.activeTab?.cwd ?? input.cwd ?? ""}`, + }, + commands: { onUseSubagent: input.prefillSubagentCommand, onClose: chrome.closeSettings, onNavigate: input.setSettingsTarget, onChanged: chrome.handleSettingsChanged }, + } : undefined, + palette: shell.paletteOpen ? { + view: { open: true, items: input.paletteItems, placeholder: t("palette.placeholder"), emptyText: t("palette.empty") }, + commands: { onClose: () => shell.setPaletteOpen(false) }, + } : undefined, + shortcuts: { + view: { open: shell.shortcutsOpen, platform: shell.desktopPlatform, t }, + commands: { onClose: () => shell.setShortcutsOpen(false) }, + }, + startup: shell.startupSplashVisible ? { + view: { hold: input.startupSplashHold }, commands: { onDone: () => shell.setStartupSplashVisible(false) }, + } : undefined, + onboarding: shell.needsOnboarding ? { + view: {}, commands: { onComplete: onboarding.completeOnboarding, onChooseProvider: onboarding.chooseOnboardingProvider, onSkip: onboarding.skipOnboarding }, + } : undefined, + selection: { + view: { + enabled: input.selectionEnabled, + resetKey: input.activeTabId ?? "", + }, + commands: { onAddToChat: input.onAddSelectedText }, + }, + worktree: worktree.worktreeMergeTabId ? { + view: { tabId: worktree.worktreeMergeTabId, isOpen: true }, + commands: { onClose: worktree.closeWorktreeMerge, onMerged: worktree.handleWorktreeMerged }, + } : undefined, + }; +} diff --git a/desktop/frontend/src/components/AppChrome.tsx b/desktop/frontend/src/components/AppChrome.tsx index b7c7a9574a..aa6ce87f7e 100644 --- a/desktop/frontend/src/components/AppChrome.tsx +++ b/desktop/frontend/src/components/AppChrome.tsx @@ -5,7 +5,7 @@ import { useT } from "../lib/i18n"; type DesktopPlatform = "darwin" | "windows" | "linux"; -interface AppChromeProps { +export interface AppChromeProps { platform: DesktopPlatform; browserPreviewChrome: boolean; workbenchChrome?: boolean; diff --git a/desktop/frontend/src/components/ProjectTreeRemoteGroups.tsx b/desktop/frontend/src/components/ProjectTreeRemoteGroups.tsx index 3ad21ede60..428483f5ae 100644 --- a/desktop/frontend/src/components/ProjectTreeRemoteGroups.tsx +++ b/desktop/frontend/src/components/ProjectTreeRemoteGroups.tsx @@ -6,6 +6,7 @@ import type { Translator } from "../lib/i18n"; import type { ProjectNode, RemoteServerView, RemoteSessionView, RemoteTabRefView } from "../lib/types"; import type { ToastContextValue } from "../lib/toast"; import { loadRemoteSessionCache, removeRemoteSessionCache, saveRemoteSessionCache } from "../lib/remoteSessionCache"; +import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands"; import { publishNavigationIntent } from "../lib/useNavigationIntentFence"; import { useRemoteStore, waitForRemoteConnection } from "../store/remote"; import type { ContextMenuItem } from "./ContextMenu"; @@ -122,6 +123,7 @@ export function useRemoteProjectGroups( expanded: Set, query: string, ) { + const navigateRemote = useRemoteNavigationCommand(); const statuses = useRemoteStore((state) => state.statuses); const servers = useRemoteStore((state) => state.servers); const [sessions, setSessions] = useState>({}); @@ -166,18 +168,19 @@ export function useRemoteProjectGroups( if (opening.current.has(key)) return; opening.current.add(key); try { - await publishNavigationIntent("remote-project"); - await app.OpenRemoteProjectTab(ref.hostId, ref.workspace, + const outcome = await navigateRemote(ref, opts?.focus ? {} : opts?.sessionName || opts?.sessionPath ? { sessionName: opts.sessionName, sessionPath: opts.sessionPath, sessionTitle: opts.sessionTitle } : { newSession: true }); + if (outcome.status === "cancelled") return; + if (outcome.status === "failed") throw outcome.error; if (!opts?.focus) setRevision((current) => current + 1); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); } finally { opening.current.delete(key); } - }, [showToast]); + }, [navigateRemote, showToast]); const ensureRemoteGroupSessions = useCallback(async (hostId: string, workspace: string) => { const key = `${hostId}\u0000${workspace}`; diff --git a/desktop/frontend/src/components/RemoteConnectWizard.tsx b/desktop/frontend/src/components/RemoteConnectWizard.tsx index fa60d7f534..762a5d3566 100644 --- a/desktop/frontend/src/components/RemoteConnectWizard.tsx +++ b/desktop/frontend/src/components/RemoteConnectWizard.tsx @@ -3,7 +3,7 @@ import { createPortal } from "react-dom"; import { Check, ChevronDown, FileText, Folder, Plus } from "lucide-react"; import { app } from "../lib/bridge"; import { useT } from "../lib/i18n"; -import { publishNavigationIntent } from "../lib/useNavigationIntentFence"; +import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands"; import { useRemoteStore, waitForRemoteConnection } from "../store/remote"; import { RemoteStatusChip } from "./RemoteHostsPage"; import type { RemoteDirEntry, RemoteHostInput, RemoteHostView } from "../lib/types"; @@ -67,6 +67,7 @@ export function RemoteConnectWizard({ onMerged?: (message: string) => void; }) { const t = useT(); + const navigateRemote = useRemoteNavigationCommand(); const hosts = useRemoteStore((s) => s.hosts); const statuses = useRemoteStore((s) => s.statuses); const setHosts = useRemoteStore((s) => s.setHosts); @@ -339,8 +340,9 @@ export function RemoteConnectWizard({ const canonical = project.merged ? project.workspace : target; if (project.merged) onMerged?.(t("remoteWizard.mergedProject", { path: canonical })); try { - await publishNavigationIntent("remote-wizard"); - await app.OpenRemoteProjectTab(hostId, canonical, { newSession: true }); + const outcome = await navigateRemote({ hostId, workspace: canonical }, { newSession: true }); + if (outcome.status === "cancelled") return; + if (outcome.status === "failed") throw outcome.error; } catch (e) { setError(e instanceof Error ? e.message : String(e)); if (!project.merged) { diff --git a/desktop/frontend/src/components/RemoteSessionSurface.tsx b/desktop/frontend/src/components/RemoteSessionSurface.tsx index 2a3c561c14..7e3471cb20 100644 --- a/desktop/frontend/src/components/RemoteSessionSurface.tsx +++ b/desktop/frontend/src/components/RemoteSessionSurface.tsx @@ -2,8 +2,8 @@ import { CloudOff, Loader2, RotateCw, TriangleAlert } from "lucide-react"; import { useEffect, useState } from "react"; import { useT } from "../lib/i18n"; import { app } from "../lib/bridge"; -import { publishNavigationIntent } from "../lib/useNavigationIntentFence"; -import { Transcript } from "./Transcript"; +import { useRemoteNavigationCommand } from "../lib/remoteNavigationCommands"; +import { Transcript, type TranscriptProps } from "./Transcript"; import { AskCard } from "./AskCard"; import { ApprovalModal } from "./ApprovalModal"; import { ExtensionFormDialog } from "./ExtensionFormDialog"; @@ -19,8 +19,11 @@ import type { TabMeta, WireApproval, WireAsk } from "../lib/types"; * the approval/ask cards are remote-specific; the composer lives in the * app shell, shared with local tabs. */ -export function RemoteSessionSurface({ tab, session }: { tab: TabMeta; session: RemoteSessionApi }) { +export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfacePaintReady }: { + tab: TabMeta; session: RemoteSessionApi; +} & Pick) { const t = useT(); + const navigateRemote = useRemoteNavigationCommand(); const approval = session.transcript.approval as WireApproval | undefined; const ask = session.transcript.ask as WireAsk | undefined; const extensionForm = session.transcript.extensionForm; @@ -50,8 +53,8 @@ export function RemoteSessionSurface({ tab, session }: { tab: TabMeta; session: // With no explicit target, the backend preserves the parked tab's // current named/fresh-session intent instead of silently starting over. runAction(async () => { - await publishNavigationIntent("remote-reconnect"); - return app.OpenRemoteProjectTab(tab.remote!.hostId, tab.remote!.workspace, {}); + const outcome = await navigateRemote(tab.remote!, {}); + if (outcome.status === "failed") throw outcome.error; }); }; return ( @@ -107,6 +110,9 @@ export function RemoteSessionSurface({ tab, session }: { tab: TabMeta; session: live={session.transcript.live} tabId={tab.id} revealSignal={session.surfaceGeneration} + hydrating={!session.hydrated} + surfaceCommitToken={surfaceCommitToken} + onSurfacePaintReady={onSurfacePaintReady} running={session.transcript.running} checkpoints={session.transcript.checkpoints} onPrompt={(prompt) => runAction(() => session.submit(prompt))} diff --git a/desktop/frontend/src/components/StartupSplash.tsx b/desktop/frontend/src/components/StartupSplash.tsx index 97d21846b9..40ee1ce155 100644 --- a/desktop/frontend/src/components/StartupSplash.tsx +++ b/desktop/frontend/src/components/StartupSplash.tsx @@ -1,28 +1,12 @@ import { useEffect, useRef, useState } from "react"; import logoSymbol from "../assets/logo-symbol.svg"; import { useT } from "../lib/i18n"; +import { markSplashShown } from "../lib/startupSplashState"; -const SPLASH_FLAG = "reasonix.splash.shown"; const MIN_VISIBLE_MS = 1400; const FADE_OUT_MS = 420; const MAX_HOLD_MS = 6000; -export function shouldShowStartupSplash(): boolean { - try { - return window.sessionStorage.getItem(SPLASH_FLAG) !== "1"; - } catch { - return true; - } -} - -function markSplashShown(): void { - try { - window.sessionStorage.setItem(SPLASH_FLAG, "1"); - } catch { - /* sessionStorage unavailable */ - } -} - export function StartupSplash({ hold, onDone }: { hold: boolean; onDone: () => void }) { const t = useT(); const [minElapsed, setMinElapsed] = useState(false); diff --git a/desktop/frontend/src/components/TerminalPanel.tsx b/desktop/frontend/src/components/TerminalPanel.tsx index 86ce9d8af9..2d0b4050ba 100644 --- a/desktop/frontend/src/components/TerminalPanel.tsx +++ b/desktop/frontend/src/components/TerminalPanel.tsx @@ -131,8 +131,8 @@ export function TerminalPanel({ open && Boolean(selectionAction), ); + useEffect(startTerminalEventBridge, []); useEffect(() => { - startTerminalEventBridge(); const previous = capabilityRef.current; const capabilityChanged = previous.tabId === tabId && previous.readOnly !== readOnly; capabilityRef.current = { tabId, readOnly }; diff --git a/desktop/frontend/src/components/TerminalView.tsx b/desktop/frontend/src/components/TerminalView.tsx index 98b44d8fe6..126f4de7ae 100644 --- a/desktop/frontend/src/components/TerminalView.tsx +++ b/desktop/frontend/src/components/TerminalView.tsx @@ -142,8 +142,8 @@ export const TerminalView = forwardRef detectShortcutPlatform(), []); + useEffect(startTerminalEventBridge, []); useEffect(() => { - startTerminalEventBridge(); const host = hostRef.current; if (!host) return; const terminal = new Terminal({ diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 0fb242db46..eaa6fddb68 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -13,7 +13,7 @@ import { t } from "./i18n"; import { makeMockForkBindings } from "./forkWorktree"; import { makeMockWorktreeMergeBindings } from "./worktreeMergeMock"; import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels"; -import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems"; +import { DEFAULT_STATUS_BAR_ITEMS } from "./statusBarItems"; import { registerTrustedThemeBackgroundURLs } from "./themePack"; import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeToolApprovalMode } from "./types"; import { makeMockProjectTreeOrganizationBindings } from "./mockProjectTreeOrganization"; @@ -28,7 +28,7 @@ import type { RemoteProjectBindings } from "./remoteProjectBridge"; import type { ScrollDiagnosticBindings } from "./scrollDiagnosticBridge"; import { makeMockMCPAppBindings, type MCPAppBindings } from "./mcpAppBridge"; import { makeMockPinnedContextBindings, type PinnedContextBindings } from "./pinnedContextBridge"; -import { applyMockLegacyReasoningMode, applyMockSessionExperience } from "./sessionExperienceMock"; +import { createDesktopPreferencesMock } from "./desktopPreferencesMock"; import type { RemoteHostView, RemoteHostInput, @@ -1245,6 +1245,12 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" { return value === "darwin" || value === "windows" || value === "linux" ? value : ""; } +function browserMockDesktopLayoutStyle(): "classic" | "workbench" | "creation" { + if (typeof window === "undefined" || window.go?.main?.App) return "workbench"; + const value = new URLSearchParams(window.location.search).get("layout"); + return value === "classic" || value === "creation" ? value : "workbench"; +} + function browserPreviewBashSandboxMode(): "enforce" | "off" { return browserPlatformOverride() === "windows" ? "off" : "enforce"; } @@ -1451,7 +1457,23 @@ function mockExternalOpenerIconDataURL(color: string, label: string): string { } function makeMockApp(): AppBindings { const scenario = mockScenario(); - const remoteProjects = createMockRemoteProjects(); + // Both bridge families publish into the same catalog, as ListTabs does in + // the desktop backend. A remote event is not a second source of tab state. + const remoteProjects = createMockRemoteProjects({ + get: id => { const tab = mockTabs.find(item => item.id === id); return tab && { ...tab }; }, + publish: tab => { + const existing = mockTabs.some(item => item.id === tab.id); + mockTabs = mockTabs.map(item => item.id === tab.id ? { ...tab } : tab.active ? { ...item, active: false } : item); + if (!existing) mockTabs.push({ ...tab }); + }, + remove: id => { + if (!mockTabs.some(tab => tab.id === id)) return; + if (mockTabs.length === 1) throw new Error("cannot close the last tab"); + const index = mockTabs.findIndex(tab => tab.id === id), active = mockTabs[index].active; + mockTabs = mockTabs.filter(tab => tab.id !== id); + if (active) setMockActiveTab(mockTabs[Math.min(index, mockTabs.length - 1)].id); + }, + }); const freshMock = scenario === "fresh"; const guidanceMock = scenario === "guidance", recoveryMock = typeof import.meta.env !== "undefined" && import.meta.env.DEV && scenario === "recovery"; const runningMock = scenario === "running" || guidanceMock; @@ -1895,7 +1917,7 @@ function makeMockApp(): AppBindings { }, desktopLanguage: "", desktopCurrency: "", - desktopLayoutStyle: "workbench", + desktopLayoutStyle: browserMockDesktopLayoutStyle(), desktopTheme: "auto", desktopThemeStyle: "graphite", desktopTerminalTheme: "auto", @@ -4923,22 +4945,7 @@ function makeMockApp(): AppBindings { const occurredAt = new Date().toISOString(); return { id: "dingtalk", label: "DingTalk", status: "ok", message: "Mock dingtalk test sent", messageId: "mock-dingtalk-id", phase: "send", code: "dingtalk_test_send_ok", reportKind: "", reportDetail: "", occurredAt }; }, - async SetCloseBehavior(mode: string) { - settings.closeBehavior = mode === "quit" ? "quit" : "background"; - }, - async SetDisplayMode() { applyMockSessionExperience(settings, "standard"); }, - async SetStatusBarStyle(style: string) { - settings.statusBarStyle = style === "text" ? "text" : "icon"; - }, - async SetStatusBarItems(items: string[]) { - settings.statusBarItems = normalizeStatusBarItems(items); - }, - async SetDesktopLanguage(lang: string) { - settings.desktopLanguage = lang === "en" || lang === "zh" ? lang : ""; - }, - async SetDesktopCurrency(currency: string) { - settings.desktopCurrency = currency === "CNY" || currency === "USD" ? currency : ""; - }, + ...createDesktopPreferencesMock(settings), async SetDesktopAppearance(theme: string, style: string) { settings.desktopTheme = theme === "auto" || theme === "light" ? theme : "dark"; settings.desktopThemeStyle = style; @@ -5083,30 +5090,6 @@ function makeMockApp(): AppBindings { async GetDesktopShellStatus() { return { trayState: "ready", backgroundCloseAvailable: true } as DesktopShellStatusView; }, - async SetDesktopCheckUpdates(enabled: boolean) { - settings.checkUpdates = enabled; - }, - async SetDesktopUpdateChannel(channel: string) { - void channel; - settings.updateChannel = "stable"; - }, - async SetDesktopTelemetry(enabled: boolean) { - settings.telemetry = enabled; - }, - async SetDesktopMetrics(enabled: boolean) { - settings.metrics = enabled; - }, - async SetDesktopConversationWidth(width: string) { settings.conversationWidth = width; }, - async SetReasoningDisplayMode(mode: "hidden" | "summary" | "auto" | "expanded") { applyMockLegacyReasoningMode(settings, mode); }, - async SetSessionExperience(mode: "standard" | "deep") { applyMockSessionExperience(settings, mode); }, - async SetExpandThinking() { applyMockSessionExperience(settings, "standard"); }, - async MigrateDesktopPreferences(language: string, theme: string, style: string) { - if (!settings.desktopLanguage) settings.desktopLanguage = language === "en" || language === "zh" || language === "zh-TW" ? language : ""; - if (!settings.desktopTheme && !settings.desktopThemeStyle) { - settings.desktopTheme = theme === "auto" || theme === "light" ? theme : "dark"; - settings.desktopThemeStyle = style; - } - }, async SetAgentParams(temperature: number, maxSteps: number, plannerMaxSteps: number, systemPrompt: string) { settings.agent = { ...settings.agent, temperature, maxSteps, plannerMaxSteps, systemPrompt }; }, @@ -5390,9 +5373,10 @@ function makeMockApp(): AppBindings { return { ...mockTabs[0] }; }, async SetActiveTab(_tabID: string) { - setMockActiveTab(_tabID); const tab = mockTabs.find((item) => item.id === _tabID); - if (tab) queueMockTopicRuntime(tab); + if (!tab) throw new Error(`tab ${_tabID} not found`); + setMockActiveTab(_tabID); + if (!tab.remote) queueMockTopicRuntime(tab); }, async ReorderTabs(_tabIDs: string[]) { const byId = new Map(mockTabs.map((tab) => [tab.id, tab])); diff --git a/desktop/frontend/src/lib/controllerModelCommands.ts b/desktop/frontend/src/lib/controllerModelCommands.ts new file mode 100644 index 0000000000..f65a5fe1b0 --- /dev/null +++ b/desktop/frontend/src/lib/controllerModelCommands.ts @@ -0,0 +1,84 @@ +import { app } from "./bridge"; +import type { BalanceInfo } from "./types"; + +type Ref = { current: T }; +type Ports = { + statesRef: Ref>; + modelSwitchSeqByTab: Ref>; + modelSwitchSuccessVersionByTab: Ref>; + modelSwitchQueueByTab: Ref>; + enqueueModelSwitch: (tabId: string, name: string, balance?: BalanceInfo) => Promise<"applied" | "superseded">; + clearBalanceForTab: (tabId: string) => void; + dispatchTo: (tabId: string, action: { type: "local_notice"; level: "warn"; text: string } | { type: "balance"; balance: BalanceInfo }) => void; + refreshBalanceForTab: (tabId: string) => Promise; + refreshMetaForTab: (tabId: string) => Promise; +}; + +/** Uses Controller-owned queues and stores; never resolves an active tab after await. */ +export function createControllerModelCommands(ports: Ports) { + const { statesRef, modelSwitchSeqByTab, modelSwitchSuccessVersionByTab, modelSwitchQueueByTab, + enqueueModelSwitch, clearBalanceForTab, dispatchTo, refreshBalanceForTab, refreshMetaForTab } = ports; + const setModelForTab = async (tabId: string, name: string) => { + if (!tabId) return false; + const switchSeq = (modelSwitchSeqByTab.current.get(tabId) ?? 0) + 1; + const successVersion = modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0; + const existingQueue = modelSwitchQueueByTab.current.get(tabId); + // Every attempt in one queued burst shares the balance that was visible + // before the first switch cleared it. Otherwise a later queued failure + // captures the placeholder and cannot restore the outgoing provider. + const fallbackBalance = existingQueue + ? existingQueue.fallbackBalance + : statesRef.current.get(tabId)?.balance; + modelSwitchSeqByTab.current.set(tabId, switchSeq); + // Hide the outgoing provider's wallet as soon as the user starts a hot + // switch. If the rebuild fails, the catch path re-queries the still-active + // provider and restores its balance. + clearBalanceForTab(tabId); + try { + const result = await enqueueModelSwitch(tabId, name, fallbackBalance); + if (result === "superseded") return false; + modelSwitchSuccessVersionByTab.current.set( + tabId, + (modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0) + 1, + ); + } catch (err) { + if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false; + const { modelSwitchNoticeText } = await import("./controllerSwitchNotices"); + if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false; + dispatchTo(tabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) }); + const olderSwitchSucceeded = + (modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0) !== successVersion; + // Restore the known balance only when no older overlapping switch + // completed after this attempt began. Otherwise the backend now owns a + // different provider and the refresh below must establish its balance. + if (fallbackBalance && !olderSwitchSucceeded) { + dispatchTo(tabId, { type: "balance", balance: fallbackBalance }); + } + void refreshBalanceForTab(tabId); + // A superseded success deliberately skips its own UI reconciliation. + // If this latest queued switch then fails, reconcile the model metadata + // to the provider that actually became active in the backend. + if (olderSwitchSucceeded) await refreshMetaForTab(tabId); + return false; + } + if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false; + void refreshBalanceForTab(tabId); + await refreshMetaForTab(tabId); + return modelSwitchSeqByTab.current.get(tabId) === switchSeq; + }; + + const setEffortForTab = async (tabId: string, level: string) => { + if (!tabId) return; + try { + await app.SetEffortForTab(tabId, level); + } catch (err) { + const { effortSwitchNoticeText } = await import("./controllerSwitchNotices"); + dispatchTo(tabId, { type: "local_notice", level: "warn", text: effortSwitchNoticeText(err) }); + return; + } + await refreshMetaForTab(tabId); + }; + + + return { setModelForTab, setEffortForTab }; +} diff --git a/desktop/frontend/src/lib/deliveryContinue.ts b/desktop/frontend/src/lib/deliveryContinue.ts index 0714a7712b..692ee4d3b8 100644 --- a/desktop/frontend/src/lib/deliveryContinue.ts +++ b/desktop/frontend/src/lib/deliveryContinue.ts @@ -10,7 +10,11 @@ export interface DeliveryContinueOptions { tabId: string | null | undefined; ready: boolean; goal: string | undefined; - activeTabId: () => string | null | undefined; + /** Full committed UI ownership; unlike activeTabId this cannot revive on A → B → A. */ + uiOwnership?: unknown; + ownsUI?: (ownership: unknown) => boolean; + /** One-release compatibility adapter for callers without a surface fence. */ + activeTabId?: () => string | null | undefined; resumeGoal: (tabId: string) => Promise; send: (tabId: string) => Promise; } @@ -21,7 +25,7 @@ export async function continueDelivery(opts: DeliveryContinueOptions): Promise= 0 && offsetY < MACOS_WORKBENCH_TITLEBAR_HEIGHT; +} diff --git a/desktop/frontend/src/lib/desktopPreferencesMock.ts b/desktop/frontend/src/lib/desktopPreferencesMock.ts new file mode 100644 index 0000000000..465de72251 --- /dev/null +++ b/desktop/frontend/src/lib/desktopPreferencesMock.ts @@ -0,0 +1,49 @@ +import type { SettingsView } from "./types"; +import { normalizeStatusBarItems } from "./statusBarItems"; +import { applyMockLegacyReasoningMode, applyMockSessionExperience } from "./sessionExperienceMock"; + +/** Browser fixtures share one preference snapshot and its compatibility mirrors. */ +export function createDesktopPreferencesMock(settings: SettingsView) { + return { + async SetCloseBehavior(mode: string) { + settings.closeBehavior = mode === "quit" ? "quit" : "background"; + }, + async SetDisplayMode() { applyMockSessionExperience(settings, "standard"); }, + async SetStatusBarStyle(style: string) { + settings.statusBarStyle = style === "text" ? "text" : "icon"; + }, + async SetStatusBarItems(items: string[]) { + settings.statusBarItems = normalizeStatusBarItems(items); + }, + async SetDesktopLanguage(lang: string) { + settings.desktopLanguage = lang === "en" || lang === "zh" ? lang : ""; + }, + async SetDesktopCurrency(currency: string) { + settings.desktopCurrency = currency === "CNY" || currency === "USD" ? currency : ""; + }, + async SetDesktopCheckUpdates(enabled: boolean) { + settings.checkUpdates = enabled; + }, + async SetDesktopUpdateChannel(channel: string) { + void channel; + settings.updateChannel = "stable"; + }, + async SetDesktopTelemetry(enabled: boolean) { + settings.telemetry = enabled; + }, + async SetDesktopMetrics(enabled: boolean) { + settings.metrics = enabled; + }, + async SetDesktopConversationWidth(width: string) { settings.conversationWidth = width; }, + async SetReasoningDisplayMode(mode: "hidden" | "summary" | "auto" | "expanded") { applyMockLegacyReasoningMode(settings, mode); }, + async SetSessionExperience(mode: "standard" | "deep") { applyMockSessionExperience(settings, mode); }, + async SetExpandThinking() { applyMockSessionExperience(settings, "standard"); }, + async MigrateDesktopPreferences(language: string, theme: string, style: string) { + if (!settings.desktopLanguage) settings.desktopLanguage = language === "en" || language === "zh" || language === "zh-TW" ? language : ""; + if (!settings.desktopTheme && !settings.desktopThemeStyle) { + settings.desktopTheme = theme === "auto" || theme === "light" ? theme : "dark"; + settings.desktopThemeStyle = style; + } + }, + }; +} diff --git a/desktop/frontend/src/lib/goalSubmit.ts b/desktop/frontend/src/lib/goalSubmit.ts deleted file mode 100644 index 298e989e0f..0000000000 --- a/desktop/frontend/src/lib/goalSubmit.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { StructuredInvocationSubmit } from "./invocationDisplay"; - -/** - * Activate a Goal, then submit the first turn. - * - * For structured Skill/Subagent submissions there is no `/goal` prose fallback: - * if Goal activation fails, this must not call `send` (the Skill would otherwise - * run without an active Goal). Callers should let activation errors propagate. - */ -export async function activateGoalAndSubmit({ - displayText, - submitText, - structured, - applyGoal, - send, -}: { - displayText: string; - submitText: string; - structured?: StructuredInvocationSubmit; - applyGoal: (goal: string) => void | Promise; - send: (displayText: string, submitText: string, structured?: StructuredInvocationSubmit) => void | Promise; -}): Promise { - const goal = displayText.trim(); - // Fail closed: structured paths have no `/goal` wrap, so a no-op or rejected - // activation must abort before SubmitInvocationsToTab. - await applyGoal(goal); - await send( - goal, - structured ? submitText.trim() : `/goal ${submitText.trim()}`, - structured, - ); -} - -/** - * Tab-scoped first Goal turn. The backend receives the source tab in one call, - * so Goal activation and the structured Skill submit cannot be split by a tab - * switch. - */ -export async function activateGoalAndSubmitOnTab({ - tabId, - displayText, - submitText, - structured, - sendToTab, -}: { - tabId: string; - displayText: string; - submitText: string; - structured?: StructuredInvocationSubmit; - sendToTab: ( - tabId: string, - goal: string, - displayText: string, - submitText: string, - structured?: StructuredInvocationSubmit, - ) => void | Promise; -}): Promise { - const sourceTabId = tabId; - const goal = displayText.trim(); - await sendToTab( - sourceTabId, - goal, - goal, - structured ? submitText.trim() : `/goal ${submitText.trim()}`, - structured, - ); -} diff --git a/desktop/frontend/src/lib/mockRemoteProjects.ts b/desktop/frontend/src/lib/mockRemoteProjects.ts index 1d0384cf4d..01054a25c2 100644 --- a/desktop/frontend/src/lib/mockRemoteProjects.ts +++ b/desktop/frontend/src/lib/mockRemoteProjects.ts @@ -2,7 +2,13 @@ import type { ProjectNode, RemoteProjectView, RemoteSessionView, TabMeta } from import type { RemoteProjectBindings } from "./remoteProjectBridge"; import { __emitMockRemoteTab, __emitMockRemoteTabOpened } from "./remoteTabEvents"; -export function createMockRemoteProjects(): { +export type MockRemoteTabCatalog = { + get(id: string): TabMeta | undefined; + publish(tab: TabMeta): void; + remove(id: string): void; +}; + +export function createMockRemoteProjects(tabs: MockRemoteTabCatalog): { bindings: RemoteProjectBindings; appendToTree: (tree: ProjectNode[]) => ProjectNode[]; } { @@ -13,8 +19,11 @@ export function createMockRemoteProjects(): { ], }; const key = (hostId: string, workspace: string) => `${hostId}\u0000${workspace}`; - const tabs = new Map(); const tabIdFor = (hostId: string, workspace: string) => `remote-mock-${hostId}-${workspace}`.replace(/[^a-z0-9-]/gi, "_"); + const status = (tabId: string) => ({ + label: tabs.get(tabId)?.label ?? "", running: false, pendingPrompt: false, + backgroundJobs: 0, plan: false, toolApprovalMode: "ask", goal: "", + }); const bindings: RemoteProjectBindings = { async AddRemoteProject(hostId, workspace) { @@ -51,7 +60,6 @@ export function createMockRemoteProjects(): { remote: { hostId, workspace }, remoteState: "ready", }; - tabs.set(id, tab); } if (opts?.newSession) tab.topicTitle = "New session"; if (opts?.sessionName) { @@ -59,6 +67,7 @@ export function createMockRemoteProjects(): { tab.topicTitle = rows.find((row) => row.name === opts.sessionName)?.title || tab.workspaceName; for (const row of rows) row.current = row.name === opts.sessionName; } + tabs.publish(tab); __emitMockRemoteTab(id, "state", { state: "ready" }); __emitMockRemoteTabOpened({ ...tab }); return { ...tab }; @@ -84,7 +93,7 @@ export function createMockRemoteProjects(): { async DeleteRemoteProjectSession(hostId, workspace, name) { sessions[key(hostId, workspace)] = (sessions[key(hostId, workspace)] ?? []).filter((item) => item.name !== name); }, - async CloseRemoteTab(tabId) { tabs.delete(tabId); }, + async CloseRemoteTab(tabId) { tabs.remove(tabId); }, async SubmitRemoteTab(tabId, text) { __emitMockRemoteTab(tabId, "event", { kind: "turn_started" }); __emitMockRemoteTab(tabId, "event", { kind: "message", text: `Mock remote reply: ${text}` }); @@ -104,15 +113,16 @@ export function createMockRemoteProjects(): { const tab = tabs.get(tabId); if (tab) { tab.label = ref; + tabs.publish(tab); __emitMockRemoteTabOpened({ ...tab }); } }, async RewindRemoteTab() {}, async SetRemoteTabGoal() {}, async RemoteTabSnapshot(tabId) { - return { history: [], status: { label: tabs.get(tabId)?.label ?? "" } }; + return { history: [], status: status(tabId) }; }, - async RemoteTabStatus() { return { running: false, pendingPrompt: false, backgroundJobs: 0 }; }, + async RemoteTabStatus(tabId) { return status(tabId); }, async SetRemoteTabEffort() {}, async PauseRemoteTabGoal() {}, async ResumeRemoteTabGoal() {}, diff --git a/desktop/frontend/src/lib/mockScenarios.ts b/desktop/frontend/src/lib/mockScenarios.ts new file mode 100644 index 0000000000..3e00fc54c9 --- /dev/null +++ b/desktop/frontend/src/lib/mockScenarios.ts @@ -0,0 +1,14 @@ +export const GUIDANCE_QUEUE_MOCK_ITEMS = [ + "先确认发送后输入框为什么残留刚发的消息,再决定修哪里。", + "保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。", + "最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。", +] as const; + +export function browserMockScenarioParam(): string { + if (typeof window === "undefined" || window.runtime) return ""; + return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? ""; +} + +export function isGuidanceMockScenario(value: string): boolean { + return value === "guidance" || value === "guide" || value === "steer"; +} diff --git a/desktop/frontend/src/lib/navigationSurfaceTransition.ts b/desktop/frontend/src/lib/navigationSurfaceTransition.ts index 615bad1ead..cad150e9e2 100644 --- a/desktop/frontend/src/lib/navigationSurfaceTransition.ts +++ b/desktop/frontend/src/lib/navigationSurfaceTransition.ts @@ -52,6 +52,46 @@ export function advanceSurfacePaintCommit( export type NavigationSurfaceIntent = number | null; +export type NavigationSurfaceTicket = Readonly<{ + token: string; + intent: number; + targetTabId: string; + targetSessionKey: string; +}>; + +let nextPaintReceipt = 0; + +/** Opaque public token plus the complete internal target identity. */ +export function createNavigationSurfaceTicket( + intent: number, + targetTabId: string, + targetSessionKey: string, +): NavigationSurfaceTicket { + return Object.freeze({ + token: `navigation-${intent}-${++nextPaintReceipt}`, + intent, + targetTabId, + targetSessionKey, + }); +} + +export function matchesNavigationSurfaceTicket( + ticket: NavigationSurfaceTicket | null, + token: string, + intent: number | null, + targetTabId: string | undefined, + targetSessionKey: string, +): boolean { + return Boolean( + ticket + && intent !== null + && ticket.token === token + && ticket.intent === intent + && ticket.targetTabId === targetTabId + && ticket.targetSessionKey === targetSessionKey, + ); +} + export function beginNavigationSurfaceState(intent: number): NavigationSurfaceState { return { intent, phase: "source-retained" }; } diff --git a/desktop/frontend/src/lib/remoteNavigationCommands.ts b/desktop/frontend/src/lib/remoteNavigationCommands.ts new file mode 100644 index 0000000000..de58ddc656 --- /dev/null +++ b/desktop/frontend/src/lib/remoteNavigationCommands.ts @@ -0,0 +1,9 @@ +import { createContext, useContext } from "react"; +import type { CommandOutcome } from "./commandOutcome"; +import type { RemoteTabOpenOptions, RemoteTabRefView, TabMeta } from "./types"; + +/** Command-only dependency; no App snapshot or service lookup lives here. */ +export type RemoteNavigationCommand = (remote: RemoteTabRefView, options: RemoteTabOpenOptions) => Promise>; +const notReady: RemoteNavigationCommand = async () => ({ status: "cancelled", reason: "not-ready" }); +export const RemoteNavigationContext = createContext(notReady); +export function useRemoteNavigationCommand(): RemoteNavigationCommand { return useContext(RemoteNavigationContext); } diff --git a/desktop/frontend/src/lib/remoteSessionActions.ts b/desktop/frontend/src/lib/remoteSessionActions.ts deleted file mode 100644 index dcb7caa504..0000000000 --- a/desktop/frontend/src/lib/remoteSessionActions.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { app } from "./bridge"; -import type { TabMeta } from "./types"; - -export async function renameCurrentRemoteSession(tab: TabMeta | undefined, title: string): Promise { - if (!tab?.remote) return false; - const sessions = await app.RemoteProjectSessions(tab.remote.hostId, tab.remote.workspace); - const current = sessions.find((session) => session.current); - if (current) await app.RenameRemoteProjectSession(tab.remote.hostId, tab.remote.workspace, current.name, title); - return true; -} diff --git a/desktop/frontend/src/lib/sessionTitles.ts b/desktop/frontend/src/lib/sessionTitles.ts new file mode 100644 index 0000000000..df178f9a69 --- /dev/null +++ b/desktop/frontend/src/lib/sessionTitles.ts @@ -0,0 +1,25 @@ +import type { TabMeta } from "./types"; + +export function tabWorkspaceTitle(tab?: TabMeta): string { + if (!tab) return "Global"; + if (tab.scope === "project") return tab.workspaceName || tab.workspaceRoot || "Project"; + if (tab.scope === "global") return tab.workspaceName || "Global"; + return tab.workspaceName || tab.workspaceRoot || "Global"; +} + +export function topicTitle(tab?: TabMeta): string { + if (!tab) return "Global"; + const workspaceTitle = tabWorkspaceTitle(tab); + const topic = tab.topicTitle || (tab.scope === "global" ? workspaceTitle : "Untitled"); + return topic === workspaceTitle ? workspaceTitle : `${workspaceTitle} / ${topic}`; +} + +export function topicDisplayTitle(tab?: TabMeta): string { + if (!tab) return "Global"; + return tab.topicTitle || (tab.scope === "global" ? tabWorkspaceTitle(tab) : "Untitled"); +} + +export function safeFilename(name: string): string { + const cleaned = name.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, " ").slice(0, 80); + return cleaned || "reasonix-session"; +} diff --git a/desktop/frontend/src/lib/startupSplashState.ts b/desktop/frontend/src/lib/startupSplashState.ts new file mode 100644 index 0000000000..c640080aec --- /dev/null +++ b/desktop/frontend/src/lib/startupSplashState.ts @@ -0,0 +1,17 @@ +const SPLASH_FLAG = "reasonix.splash.shown"; + +export function shouldShowStartupSplash(): boolean { + try { + return window.sessionStorage.getItem(SPLASH_FLAG) !== "1"; + } catch { + return true; + } +} + +export function markSplashShown(): void { + try { + window.sessionStorage.setItem(SPLASH_FLAG, "1"); + } catch { + // Session storage is optional in restricted hosts. + } +} diff --git a/desktop/frontend/src/lib/subscriptionScope.ts b/desktop/frontend/src/lib/subscriptionScope.ts new file mode 100644 index 0000000000..f9d8fdc629 --- /dev/null +++ b/desktop/frontend/src/lib/subscriptionScope.ts @@ -0,0 +1,39 @@ +type ListenerSlot = { listener?: (...args: Args) => void }; + +function bindListener(slot: ListenerSlot, lifecycle: { disposed: boolean }) { + return (...args: Args) => { if (!lifecycle.disposed) slot.listener?.(...args); }; +} + +/** A disposed subscription is inert even if its source already queued delivery. */ +export function createSubscriptionScope(track: (delta: 1 | -1) => void = () => {}) { + const cleanups = new Set<() => void>(); + const lifecycle = { disposed: false }; + return { + listen(register: (listener: (...args: Args) => void) => () => void, + listener: (...args: Args) => void): void { + if (lifecycle.disposed) return; + const slot: ListenerSlot = { listener }; + let unsubscribe: () => void; + try { unsubscribe = register(bindListener(slot, lifecycle)); } + catch (error) { slot.listener = undefined; throw error; } + track(1); + const cleanup = () => { + slot.listener = undefined; + try { unsubscribe(); } finally { track(-1); } + }; + if (lifecycle.disposed) cleanup(); + else cleanups.add(cleanup); + }, + dispose(): void { + if (lifecycle.disposed) return; + lifecycle.disposed = true; + const errors: unknown[] = []; + for (const cleanup of cleanups) { + try { cleanup(); } catch (error) { errors.push(error); } + } + cleanups.clear(); + if (errors.length) throw errors[0]; + }, + get size() { return cleanups.size; }, + }; +} diff --git a/desktop/frontend/src/lib/terminalEvents.ts b/desktop/frontend/src/lib/terminalEvents.ts index 72cea522ce..cf1576851a 100644 --- a/desktop/frontend/src/lib/terminalEvents.ts +++ b/desktop/frontend/src/lib/terminalEvents.ts @@ -1,4 +1,5 @@ import { onTerminalExit, onTerminalOutput, type TerminalExitEvent, type TerminalOutputEvent } from "./bridge"; +import { createSubscriptionScope } from "./subscriptionScope"; const MAX_HISTORY_BYTES = 1024 * 1024; @@ -9,8 +10,7 @@ const exitListeners = new Set<(event: TerminalExitEvent) => void>(); const history = new Map(); const historyBytes = new Map(); const nextSequence = new Map(); -let started = false; -let stopBridge: (() => void) | null = null; +let bridge: { users: number; scope: ReturnType } | null = null; function decodeBase64(value: string): Uint8Array { if (typeof atob !== "function") return new Uint8Array(); @@ -42,18 +42,23 @@ function deliverExit(event: TerminalExitEvent): void { } export function startTerminalEventBridge(): () => void { - if (!started) { - started = true; - const stopOutput = onTerminalOutput(deliverOutput); - const stopExit = onTerminalExit(deliverExit); - stopBridge = () => { - stopOutput(); - stopExit(); - started = false; - stopBridge = null; - }; + if (!bridge) { + const scope = createSubscriptionScope(); + scope.listen(onTerminalOutput, deliverOutput); + scope.listen(onTerminalExit, deliverExit); + bridge = { users: 0, scope }; } - return () => stopBridge?.(); + const owned = bridge; + owned.users += 1; + let released = false; + return () => { + if (released) return; + released = true; + owned.users -= 1; + if (owned.users !== 0) return; + owned.scope.dispose(); + if (bridge === owned) bridge = null; + }; } export function registerTerminalOutputSink(id: string, sink: SequencedTerminalSink): readonly [ @@ -85,7 +90,8 @@ export function __resetTerminalEventBus(): void { history.clear(); historyBytes.clear(); nextSequence.clear(); - stopBridge?.(); + bridge?.scope.dispose(); + bridge = null; } export const terminalEventBufferLimit = MAX_HISTORY_BYTES; diff --git a/desktop/frontend/src/lib/todoDismissalStorage.ts b/desktop/frontend/src/lib/todoDismissalStorage.ts new file mode 100644 index 0000000000..59931f0efd --- /dev/null +++ b/desktop/frontend/src/lib/todoDismissalStorage.ts @@ -0,0 +1,25 @@ +const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys"; +const MAX_DISMISSED_TODO_KEYS = 160; + +export function loadDismissedTodoKeys(): Set { + try { + const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY); + if (!saved) return new Set(); + const parsed = JSON.parse(saved) as unknown; + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0)); + } catch { + return new Set(); + } +} + +export function saveDismissedTodoKeys(keys: ReadonlySet): void { + try { + window.localStorage.setItem( + DISMISSED_TODO_STORAGE_KEY, + JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)), + ); + } catch { + /* ignore quota errors */ + } +} diff --git a/desktop/frontend/src/lib/useComposerModeActions.ts b/desktop/frontend/src/lib/useComposerModeActions.ts index f7b0d76cbb..eff2f93cd3 100644 --- a/desktop/frontend/src/lib/useComposerModeActions.ts +++ b/desktop/frontend/src/lib/useComposerModeActions.ts @@ -1,117 +1,66 @@ -import { useCallback, type MutableRefObject } from "react"; -import { app } from "./bridge"; -import { - composerProfileWithMode, - updateUserPlanModeIntent, - type ComposerProfile, - type ComposerProfileField, - type UserPlanModeIntents, -} from "./composerProfile"; -import { restorableToolApprovalMode, type RestorableToolApprovalMode } from "./toolApprovalMode"; -import { modeHasPlan, type CollaborationMode, type Mode, type ToolApprovalMode } from "./types"; - -type PatchProfile = ( - patch: Partial>, - pendingFields: ComposerProfileField[], -) => void; +import { useCommittedCommand } from "./useCommittedCommand"; +import { executeComposerMode, type ComposerModePorts, type ComposerModeRequest } from "../app-runtime/composerModeOwner"; +import { restorableToolApprovalMode, toggleYoloToolApprovalMode, type RestorableToolApprovalMode } from "./toolApprovalMode"; +import { updateUserPlanModeIntent, type UserPlanModeIntents } from "./composerProfile"; +import type { SessionResource, useSessionOperations } from "../app-runtime/useSessionOperations"; +import type { CollaborationMode, Mode, ToolApprovalMode } from "./types"; type ComposerModeActionsOptions = { - activeTabId?: string; + target: SessionResource; remote: boolean; collaborationMode: CollaborationMode; toolApprovalMode: ToolApprovalMode; goal: string; - planIntentRef: MutableRefObject; - yoloRestoreRef: MutableRefObject>; - patchProfile: PatchProfile; - setControllerMode: (mode: Mode) => Promise | void; - setControllerCollaborationMode: (mode: CollaborationMode) => Promise; - setControllerToolApprovalMode: (mode: ToolApprovalMode) => Promise | void; - clearControllerGoal: () => Promise; - drainRemoteApprovals: (ids: string[]) => void; + operations: ReturnType; + ports: Omit; + planIntentsRef: { current: UserPlanModeIntents }; + yoloRestoreRef: { current: Record }; showError: (message: string) => void; }; +/** Display inputs commit here; source-bound execution lives outside React. */ export function useComposerModeActions(options: ComposerModeActionsOptions) { - const { - activeTabId, remote, collaborationMode, toolApprovalMode, goal, - planIntentRef, yoloRestoreRef, patchProfile, setControllerMode, - setControllerCollaborationMode, setControllerToolApprovalMode, - clearControllerGoal, drainRemoteApprovals, showError, - } = options; - const rememberPlanMode = useCallback((enabled: boolean) => { - planIntentRef.current = updateUserPlanModeIntent(planIntentRef.current, activeTabId, enabled); - }, [activeTabId, planIntentRef]); - - const applyMode = useCallback((mode: Mode) => { - if (remote && activeTabId) { - const next = composerProfileWithMode(mode); - void (async () => { - try { - const drained = await app.SetRemoteTabComposerProfile( - activeTabId, - next.collaborationMode ?? "normal", - next.toolApprovalMode ?? "ask", - "", - ); - drainRemoteApprovals(drained); - rememberPlanMode(modeHasPlan(mode)); - patchProfile(next, ["collaborationMode", "toolApprovalMode", "goal"]); - } catch (error) { - showError(error instanceof Error ? error.message : String(error)); - } - })(); - return; - } - rememberPlanMode(modeHasPlan(mode)); - patchProfile(composerProfileWithMode(mode), ["collaborationMode", "toolApprovalMode", "goal"]); - void setControllerMode(mode); - }, [activeTabId, drainRemoteApprovals, patchProfile, rememberPlanMode, remote, setControllerMode, showError]); - - const applyCollaborationMode = useCallback(async (mode: CollaborationMode): Promise => { - if (remote && activeTabId) { - const controllerMode = mode === "goal" ? "normal" : mode; - const drained = await app.SetRemoteTabComposerProfile(activeTabId, controllerMode, toolApprovalMode, ""); - drainRemoteApprovals(drained); - rememberPlanMode(mode === "plan"); - patchProfile(mode === "goal" - ? { collaborationMode: "normal", goalDraftMode: true, goal: "" } - : { collaborationMode: mode, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]); - return; - } - if (mode === "goal") { - rememberPlanMode(false); - patchProfile({ collaborationMode: "normal", goalDraftMode: true, goal: "" }, ["collaborationMode", "goal"]); - return setControllerCollaborationMode("normal"); - } - if (goal.trim()) await clearControllerGoal(); - await setControllerCollaborationMode(mode); - rememberPlanMode(mode === "plan"); - patchProfile({ collaborationMode: mode, goalDraftMode: false, goal: "" }, ["collaborationMode", "goal"]); - }, [activeTabId, clearControllerGoal, drainRemoteApprovals, patchProfile, rememberPlanMode, remote, setControllerCollaborationMode, toolApprovalMode]); + const notePlanModeForTab = useCommittedCommand((tabId: string, enabled: boolean) => { + options.planIntentsRef.current = updateUserPlanModeIntent(options.planIntentsRef.current, tabId, enabled); + }); + const rememberApprovalForTab = useCommittedCommand((tabId: string, previous: ToolApprovalMode, next: ToolApprovalMode) => { + if (next !== "yolo") options.yoloRestoreRef.current[tabId] = restorableToolApprovalMode(next); + else if (previous !== "yolo") options.yoloRestoreRef.current[tabId] = restorableToolApprovalMode(previous); + }); - const applyToolApprovalMode = useCallback((mode: ToolApprovalMode) => { - if (!activeTabId) return; - const rememberRestoreMode = () => { - if (mode === "yolo" && toolApprovalMode !== "yolo") { - yoloRestoreRef.current[activeTabId] = restorableToolApprovalMode(toolApprovalMode); - } else if (mode !== "yolo") { - yoloRestoreRef.current[activeTabId] = restorableToolApprovalMode(mode); - } + const run = useCommittedCommand(async (request: ComposerModeRequest): Promise => { + const { target, remote, collaborationMode, toolApprovalMode, goal, operations } = options; + // All axes share the backend profile transaction; stop/send have other channels. + const ports: ComposerModePorts = { + ...options.ports, + rememberPlan: notePlanModeForTab, + rememberApproval: rememberApprovalForTab, }; - if (remote) { - const controllerMode = goal.trim() ? "goal" : collaborationMode === "plan" ? "plan" : "normal"; - void app.SetRemoteTabComposerProfile(activeTabId, controllerMode, mode, goal).then((drained) => { - drainRemoteApprovals(drained); - rememberRestoreMode(); - patchProfile({ toolApprovalMode: mode }, ["toolApprovalMode"]); - }).catch((error) => showError(error instanceof Error ? error.message : String(error))); - return; - } - rememberRestoreMode(); - patchProfile({ toolApprovalMode: mode }, ["toolApprovalMode"]); - void setControllerToolApprovalMode(mode); - }, [activeTabId, collaborationMode, drainRemoteApprovals, goal, patchProfile, remote, setControllerToolApprovalMode, showError, toolApprovalMode, yoloRestoreRef]); + const result = await operations(target, "composer-profile", { + target, remote, collaborationMode, toolApprovalMode, goal, ports, request, + }, executeComposerMode); + if (result.status === "failed") throw result.error; + }); + const report = useCommittedCommand((error: unknown) => { + options.showError(error instanceof Error ? error.message : String(error)); + }); + const applyMode = useCommittedCommand((mode: Mode) => { void run({ kind: "mode", mode }).catch(report); }); + const applyCollaborationMode = useCommittedCommand((mode: CollaborationMode) => run({ kind: "collaboration", mode })); + const applyToolApprovalMode = useCommittedCommand((mode: ToolApprovalMode) => { void run({ kind: "approval", mode }).catch(report); }); - return { applyMode, applyCollaborationMode, applyToolApprovalMode }; + // Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the + // tool-permission axis while preserving the Ask/Auto base mode. + const toggleYoloApprovalMode = useCommittedCommand(() => { + const tabId = options.target.tabId; + if (!tabId) return; + const next = toggleYoloToolApprovalMode( + options.toolApprovalMode, + options.yoloRestoreRef.current[tabId], + ); + if (next.restore) { + options.yoloRestoreRef.current[tabId] = next.restore; + } + applyToolApprovalMode(next.mode); + }); + return { applyMode, applyCollaborationMode, applyToolApprovalMode, notePlanModeForTab, rememberApprovalForTab, toggleYoloApprovalMode }; } diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index b7bc71764e..4d9451da5d 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -4,6 +4,7 @@ import { runtimeStatusSnapshotIsStale } from "./runtimeStatusFreshness"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { asArray } from "./array"; +import { createControllerModelCommands } from "./controllerModelCommands"; import { compactArchivedToolItems } from "./archivedToolItems"; import { addBreadcrumb } from "./breadcrumbs"; import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge"; @@ -3932,23 +3933,30 @@ export function useController() { } }, [bumpCancelHydrateSeq, dispatchTo, scheduleCancelReconcile]); - const cancel = useCallback(async (inboxItemIDs: string[] = []): Promise => { - const cur = stateRef.current, tabId = activeTabId; + const cancelForTab = useCallback(async (tabId: string, inboxItemIDs: string[] = []): Promise => { + const cur = statesRef.current.get(tabId); let restoredText: string | undefined; - if (cur.running && cur.pendingUser !== undefined) { + if (cur?.running && cur.pendingUser !== undefined) { restoredText = cur.pendingUser; - if (tabId) dispatchTo(tabId, { type: "unsend" }); - } else if (tabId) { + dispatchTo(tabId, { type: "unsend" }); + } else { dispatchTo(tabId, { type: "cancel_requested" }); } - if (!tabId) return { restoredText, discardedItemIds: [] }; const result = await cancelTab(tabId, inboxItemIDs); return { restoredText, ...result }; - }, [activeTabId, cancelTab, dispatchTo]); + }, [cancelTab, dispatchTo]); - const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean) => { - if (!activeTabId) return; + const cancel = useCallback(async (inboxItemIDs: string[] = []): Promise => { const tabId = activeTabId; + if (!tabId) return { discardedItemIds: [] }; + return cancelForTab(tabId, inboxItemIDs); + }, [activeTabId, cancelForTab]); + + const isPromptCurrentForTab = useCallback((tabId: string, kind: "approval" | "ask" | "mcpInteraction", id: string) => ( + statesRef.current.get(tabId)?.[kind]?.id === id + ), []); + const approveForTab = useCallback((tabId: string, id: string, allow: boolean, session: boolean, persist: boolean) => { + if (!tabId) return; const promptState = statesRef.current.get(tabId); // Pin the failure callback to the prompt-id epoch the RPC was issued in: // if a controller rebuild lands while the call is in flight, a late @@ -3957,29 +3965,38 @@ export function useController() { const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0; dispatchTo(tabId, { type: "clearApproval" }); resolvePromptForTab(app, tabId, id, "approval", { allow, session, persist }, promptState?.approval?.turnId ?? promptState?.activeTurnId, promptState?.approval?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "approval")); - }, [activeTabId, dispatchTo]); + }, [dispatchTo]); - const resolvePlanDecision = useCallback((id: string, action: "start_execution" | "revise_plan" | "exit_plan") => { - if (!activeTabId) return; - const tabId = activeTabId; + const approve = useCallback((id: string, allow: boolean, session: boolean, persist: boolean) => { + if (activeTabId) approveForTab(activeTabId, id, allow, session, persist); + }, [activeTabId, approveForTab]); + + const resolvePlanDecisionForTab = useCallback((tabId: string, id: string, action: "start_execution" | "revise_plan" | "exit_plan") => { + if (!tabId) return; const promptState = statesRef.current.get(tabId); const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0; dispatchTo(tabId, { type: "clearApproval" }); resolvePromptForTab(app, tabId, id, "plan", { action }, promptState?.approval?.turnId ?? promptState?.activeTurnId, promptState?.approval?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "approval")); - }, [activeTabId, dispatchTo]); + }, [dispatchTo]); - const resolveRecovery = useCallback((id: string, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => { - if (!activeTabId) return; - const tabId = activeTabId; + const resolvePlanDecision = useCallback((id: string, action: "start_execution" | "revise_plan" | "exit_plan") => { + if (activeTabId) resolvePlanDecisionForTab(activeTabId, id, action); + }, [activeTabId, resolvePlanDecisionForTab]); + + const resolveRecoveryForTab = useCallback((tabId: string, id: string, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => { + if (!tabId) return; const promptState = statesRef.current.get(tabId); const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0; dispatchTo(tabId, { type: "clearApproval" }); resolvePromptForTab(app, tabId, id, "recovery", { action, feedback }, promptState?.approval?.turnId ?? promptState?.activeTurnId, promptState?.approval?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "approval")); - }, [activeTabId, dispatchTo]); + }, [dispatchTo]); - const answerQuestion = useCallback((id: string, answers: QuestionAnswer[]): Promise => { - if (!activeTabId) return Promise.reject(new Error("active tab is unavailable")); - const tabId = activeTabId; + const resolveRecovery = useCallback((id: string, action: "continue" | "continue_task" | "revise" | "stop", feedback = "") => { + if (activeTabId) resolveRecoveryForTab(activeTabId, id, action, feedback); + }, [activeTabId, resolveRecoveryForTab]); + + const answerQuestionForTab = useCallback((tabId: string, id: string, answers: QuestionAnswer[]): Promise => { + if (!tabId) return Promise.reject(new Error("source tab is unavailable")); const state = statesRef.current.get(tabId); const epoch = state?.promptEpoch ?? 0; return answerPromptForActiveTurn(app, tabId, id, answers, state?.ask?.turnId ?? state?.activeTurnId, state?.ask?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).then( @@ -3991,23 +4008,33 @@ export function useController() { throw error; }, ); - }, [activeTabId, dispatchTo, reconcileRuntimeAfterRejectedMutation]); + }, [dispatchTo, reconcileRuntimeAfterRejectedMutation]); - const answerMCPInteraction = useCallback( - (id: string, action: "accept" | "decline" | "cancel", content?: Record) => { - if (!activeTabId) return; - const tabId = activeTabId; + const answerQuestion = useCallback((id: string, answers: QuestionAnswer[]): Promise => { + if (!activeTabId) return Promise.reject(new Error("active tab is unavailable")); + return answerQuestionForTab(activeTabId, id, answers); + }, [activeTabId, answerQuestionForTab]); + + const answerMCPInteractionForTab = useCallback( + (tabId: string, id: string, action: "accept" | "decline" | "cancel", content?: Record) => { + if (!tabId) return; const promptState = statesRef.current.get(tabId); const epoch = promptState?.promptEpoch ?? 0; dispatchTo(tabId, { type: "expire_prompt", id, epoch, kind: "mcp" }); resolvePromptForTab(app, tabId, id, "mcp", { action, content: content ?? null }, promptState?.mcpInteraction?.turnId ?? promptState?.activeTurnId, promptState?.mcpInteraction?.runtimeEpoch ?? runtimeEpochByTabRef.current.get(tabId)).catch((error) => handlePromptFailure(dispatchTo, tabId, id, epoch, error, "mcp")); }, - [activeTabId, dispatchTo], + [dispatchTo], ); - const setControllerMode = useCallback((mode: Mode): Promise => { - if (!activeTabId) return Promise.resolve(); - const tabId = activeTabId; + const answerMCPInteraction = useCallback( + (id: string, action: "accept" | "decline" | "cancel", content?: Record) => { + if (activeTabId) answerMCPInteractionForTab(activeTabId, id, action, content); + }, + [activeTabId, answerMCPInteractionForTab], + ); + + const setControllerModeForTab = useCallback((tabId: string, mode: Mode): Promise => { + if (!tabId) return Promise.resolve(); const epoch = statesRef.current.get(tabId)?.promptEpoch ?? 0; return app.SetModeForTab(tabId, mode).then((drained) => { // Only dismiss the approvals the backend reports it actually @@ -4016,7 +4043,12 @@ export function useController() { const ids = Array.isArray(drained) ? drained : []; if (ids.length) dispatchTo(tabId, { type: "approval_drained", ids, epoch }); }).catch(() => {}); - }, [activeTabId, dispatchTo]); + }, [dispatchTo]); + + const setControllerMode = useCallback((mode: Mode): Promise => { + if (!activeTabId) return Promise.resolve(); + return setControllerModeForTab(activeTabId, mode); + }, [activeTabId, setControllerModeForTab]); const setCollaborationModeForTab = useCallback(async (tabId: string, mode: CollaborationMode): Promise => { if (!tabId) return; @@ -4428,67 +4460,12 @@ export function useController() { }); }, []); - const setModel = useCallback(async (name: string) => { - if (!activeTabId) return false; - const tabId = activeTabId; - const switchSeq = (modelSwitchSeqByTab.current.get(tabId) ?? 0) + 1; - const successVersion = modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0; - const existingQueue = modelSwitchQueueByTab.current.get(tabId); - // Every attempt in one queued burst shares the balance that was visible - // before the first switch cleared it. Otherwise a later queued failure - // captures the placeholder and cannot restore the outgoing provider. - const fallbackBalance = existingQueue - ? existingQueue.fallbackBalance - : statesRef.current.get(tabId)?.balance; - modelSwitchSeqByTab.current.set(tabId, switchSeq); - // Hide the outgoing provider's wallet as soon as the user starts a hot - // switch. If the rebuild fails, the catch path re-queries the still-active - // provider and restores its balance. - clearBalanceForTab(tabId); - try { - const result = await enqueueModelSwitch(tabId, name, fallbackBalance); - if (result === "superseded") return false; - modelSwitchSuccessVersionByTab.current.set( - tabId, - (modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0) + 1, - ); - } catch (err) { - if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false; - const { modelSwitchNoticeText } = await import("./controllerSwitchNotices"); - if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false; - dispatchTo(tabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) }); - const olderSwitchSucceeded = - (modelSwitchSuccessVersionByTab.current.get(tabId) ?? 0) !== successVersion; - // Restore the known balance only when no older overlapping switch - // completed after this attempt began. Otherwise the backend now owns a - // different provider and the refresh below must establish its balance. - if (fallbackBalance && !olderSwitchSucceeded) { - dispatchTo(tabId, { type: "balance", balance: fallbackBalance }); - } - void refreshBalanceForTab(tabId); - // A superseded success deliberately skips its own UI reconciliation. - // If this latest queued switch then fails, reconcile the model metadata - // to the provider that actually became active in the backend. - if (olderSwitchSucceeded) await refreshMetaForTab(tabId); - return false; - } - if (modelSwitchSeqByTab.current.get(tabId) !== switchSeq) return false; - void refreshBalanceForTab(tabId); - await refreshMetaForTab(tabId); - return modelSwitchSeqByTab.current.get(tabId) === switchSeq; - }, [activeTabId, clearBalanceForTab, dispatchTo, enqueueModelSwitch, refreshBalanceForTab, refreshMetaForTab]); - - const setEffort = useCallback(async (level: string) => { - if (!activeTabId) return; - try { - await app.SetEffortForTab(activeTabId, level); - } catch (err) { - const { effortSwitchNoticeText } = await import("./controllerSwitchNotices"); - dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: effortSwitchNoticeText(err) }); - return; - } - await refreshMetaForTab(activeTabId); - }, [activeTabId, dispatchTo, refreshMetaForTab]); + const { setModelForTab, setEffortForTab } = useMemo(() => createControllerModelCommands({ + statesRef, modelSwitchSeqByTab, modelSwitchSuccessVersionByTab, modelSwitchQueueByTab, + enqueueModelSwitch, clearBalanceForTab, dispatchTo, refreshBalanceForTab, refreshMetaForTab, + }), [enqueueModelSwitch, clearBalanceForTab, dispatchTo, refreshBalanceForTab, refreshMetaForTab]); + const setModel = useCallback((name: string) => activeTabId ? setModelForTab(activeTabId, name) : Promise.resolve(false), [activeTabId, setModelForTab]); + const setEffort = useCallback((level: string) => activeTabId ? setEffortForTab(activeTabId, level) : Promise.resolve(), [activeTabId, setEffortForTab]); const cancelJob = useCallback(async (jobID: string): Promise => { const tabId = activeTabId; @@ -5039,13 +5016,16 @@ export function useController() { state: activeState, liveStore, activeTabId, - send, sendToTab, recoverDeliveryToTab, runShell, runShellForTab, steer, steerForTab, notice, cancel, approve, resolvePlanDecision, resolveRecovery, answerQuestion, answerMCPInteraction, setControllerMode, + send, sendToTab, recoverDeliveryToTab, runShell, runShellForTab, steer, steerForTab, notice, + cancel, cancelForTab, approve, approveForTab, isPromptCurrentForTab, resolvePlanDecision, resolvePlanDecisionForTab, + resolveRecovery, resolveRecoveryForTab, answerQuestion, answerQuestionForTab, + answerMCPInteraction, answerMCPInteractionForTab, setControllerMode, setControllerModeForTab, dismissExtensionForm, drainExtensionNotifications, setCollaborationMode, setCollaborationModeForTab, setToolApprovalMode, setToolApprovalModeForTab, setQualityFloor, setComposerProfileForTab, setGoal, setGoalForTab, clearGoal, clearGoalForTab, resumeGoal, resumeGoalForTab, pauseGoal, pauseGoalForTab, newSession, clearSession, listSessions, listTrashedSessions, retrySessionHistory, resumeSession, openChannelSession, previewSession, deleteSession, restoreSession, purgeTrashedSession, renameSession, loadOlderHistory, requestHistoryFullContent, - refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, rewindForTabDetailed, undoRewindForTab, setModel, setEffort, cancelJob, + refreshMeta, pickWorkspace, switchWorkspace, compact, rewind, rewindForTab, rewindForTabDetailed, undoRewindForTab, setModel, setModelForTab, setEffort, setEffortForTab, cancelJob, fetchMemory, remember, forget, saveDoc, switchTab, switchRemoteTab, openProjectTab, openGlobalTab, openTopicSession, ensureBlankTab, activateTopic, ensureBlankSurface, createIsolatedWorktree, commitSingleSurfaceNavigation, closeTab, reorderTabs, // Invalidate in-flight navigation completions (activateTopic's stale diff --git a/desktop/frontend/src/lib/useControllerProfileCommands.ts b/desktop/frontend/src/lib/useControllerProfileCommands.ts new file mode 100644 index 0000000000..0ec73b8b58 --- /dev/null +++ b/desktop/frontend/src/lib/useControllerProfileCommands.ts @@ -0,0 +1,55 @@ +import { useEffect, useMemo } from "react"; +import { useCommittedSlot, type CommittedSlot } from "./useCommittedSlot"; +import { useCommittedCommand } from "./useCommittedCommand"; +import { CommandCancelled } from "./commandOutcome"; +import { executeControllerModel, executeControllerProfile, type ControllerProfilePorts, type ControllerProfileResource } from "../app-runtime/controllerProfileOwner"; +import type { SessionResource, useSessionOperations } from "../app-runtime/useSessionOperations"; + +function bindProfileRead(slot: CommittedSlot) { + return (target: SessionResource): ControllerProfileResource => { + if (slot.phase !== "ready") throw new CommandCancelled(slot.phase === "disposed" ? "disposed" : "not-ready"); + const resource = slot.value?.find(value => value.target.tabId === target.tabId && value.target.sessionKey === target.sessionKey); + if (!resource) throw new CommandCancelled("superseded"); + return resource; + }; +} + +export function useControllerProfileCommands(options: { + target: SessionResource; profiles: readonly ControllerProfileResource[]; ready: boolean; remote: boolean; runtimeEpoch?: string; + ports: ControllerProfilePorts; remoteModel(name: string): Promise; + operations: ReturnType; report(error: unknown): void; +}) { + const { target, profiles, ready, remote, runtimeEpoch, operations, ports, remoteModel, report } = options; + const slot = useCommittedSlot(profiles); + const read = useMemo(() => bindProfileRead(slot), [slot]); + const restore = useCommittedCommand(async (source: SessionResource): Promise => { + const result = await operations(source, "controller-profile", { target: source, read, ports }, executeControllerProfile); + if (result.status === "failed") throw result.error; + return result.status === "completed" && result.value; + }); + const applyProfile = useCommittedCommand(async (tabId = target.tabId, propagateError = true): Promise => { + const source = profiles.find(value => value.target.tabId === tabId); + if (!source) return false; + try { return await restore(source.target); } catch (error) { + if (propagateError) throw error; + return false; + } + }); + const switchModel = useCommittedCommand(async (name: string, tabId = target.tabId): Promise => { + const source = profiles.find(value => value.target.tabId === tabId); + if (!source || (source.remote && tabId !== target.tabId)) return false; + const result = await operations(source.target, "model", { target: source.target, read, ports, restore, + name, remote: source.remote ? remoteModel : undefined }, executeControllerModel); + if (result.status === "failed") throw result.error; + return result.status === "completed" && result.value; + }); + const reportError = useCommittedCommand(report); + const switchModelFromUi = useCommittedCommand(async (name: string): Promise => { + try { return await switchModel(name); } catch (error) { reportError(error); return false; } + }); + const active = profiles.find(value => value.target.tabId === target.tabId)?.profile; + useEffect(() => { + if (ready && target.tabId && !remote) void applyProfile().catch(reportError); + }, [ready, remote, runtimeEpoch, target.tabId, target.sessionKey, active?.collaboration, active?.approval, active?.goal, applyProfile, reportError]); + return { applyProfile, switchModel, switchModelFromUi }; +} diff --git a/desktop/frontend/src/lib/useNavigationSurface.ts b/desktop/frontend/src/lib/useNavigationSurface.ts index 35a13f913b..44dc0e42f3 100644 --- a/desktop/frontend/src/lib/useNavigationSurface.ts +++ b/desktop/frontend/src/lib/useNavigationSurface.ts @@ -1,13 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; import type { Item } from "./useController"; import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge"; import { beginNavigationSurfaceState, + createNavigationSurfaceTicket, markNavigationTargetMasked, + matchesNavigationSurfaceTicket, settleNavigationSurfaceState, + type NavigationSurfaceTicket, type NavigationSurfaceState, } from "./navigationSurfaceTransition"; +import { useCommittedCommand } from "./useCommittedCommand"; export type PreservedTranscriptSurface = { tabId?: string; @@ -17,6 +21,7 @@ export type PreservedTranscriptSurface = { export function useNavigationSurface(target: { activeTabId?: string; + sessionKey: string; ready: boolean; backendActivationPending: boolean; hydrating: boolean; @@ -36,18 +41,19 @@ export function useNavigationSurface(target: { !target.backendActivationPending && !target.hydrating && target.hydrateError, ); - const begin = useCallback((nextIntent: number) => { + const begin = useCommittedCommand((nextIntent: number) => { recordFrontendDiagnostic("navigation", "navigation.begin", { intent: nextIntent, phase: "begin" }); const rendered = renderedRef.current; flushSync(() => { setPreserved(rendered?.items.length ? rendered : null); setSurface(beginNavigationSurfaceState(nextIntent)); }); - }, []); - const maskTarget = useCallback((completedIntent: number) => { + renderedRef.current = null; + }); + const maskTarget = useCommittedCommand((completedIntent: number) => { setSurface((current) => markNavigationTargetMasked(current, completedIntent)); - }, []); - const settle = useCallback((completedIntent: number, outcome: "ready" | "degraded" | "failed") => { + }); + const settle = useCommittedCommand((completedIntent: number, outcome: "ready" | "degraded" | "failed") => { if (outcome !== "failed") recordFrontendDiagnostic("navigation", "navigation.paint-ready", { intent: completedIntent, outcome }); recordFrontendDiagnostic("navigation", "navigation.terminal", { intent: completedIntent, outcome }); recordFrontendDiagnostic("navigation", "navigation.settle", { @@ -56,10 +62,36 @@ export function useNavigationSurface(target: { outcome, }); setSurface((current) => settleNavigationSurfaceState(current, completedIntent)); + setPreserved(null); + }); + const ticket = useMemo(() => { + if (!dataReady || intent === null || !target.activeTabId) return null; + return createNavigationSurfaceTicket(intent, target.activeTabId, target.sessionKey); + }, [dataReady, intent, target.activeTabId, target.sessionKey]); + const committedTicketRef = useRef(null); + useLayoutEffect(() => { + committedTicketRef.current = ticket; + }, [ticket]); + useLayoutEffect(() => () => { + committedTicketRef.current = null; + renderedRef.current = null; }, []); - const commitPaint = useCallback((completedIntent: number, outcome: "ready" | "degraded") => { - settle(completedIntent, outcome); - }, [settle]); + const commitPaint = useCommittedCommand((token: string, outcome: "ready" | "degraded") => { + const committedTicket = committedTicketRef.current; + if (!matchesNavigationSurfaceTicket( + committedTicket, + token, + surface?.intent ?? null, + target.activeTabId, + target.sessionKey, + )) return null; + committedTicketRef.current = null; + settle(committedTicket!.intent, outcome); + return committedTicket; + }); + const commitRendered = useCommittedCommand((rendered: PreservedTranscriptSurface | null) => { + renderedRef.current = rendered; + }); const dataReadyIntentRef = useRef(null); useEffect(() => { @@ -83,7 +115,8 @@ export function useNavigationSurface(target: { transitioning, dataReady, preserved, - renderedRef, + surfaceCommitToken: ticket?.token, + commitRendered, begin, maskTarget, commitPaint, diff --git a/desktop/frontend/src/lib/usePendingPlanRevisions.ts b/desktop/frontend/src/lib/usePendingPlanRevisions.ts new file mode 100644 index 0000000000..94500a3506 --- /dev/null +++ b/desktop/frontend/src/lib/usePendingPlanRevisions.ts @@ -0,0 +1,18 @@ +import { useLayoutEffect, useMemo } from "react"; +import { useCommittedSlot, type CommittedSlot } from "./useCommittedSlot"; +import { createPendingRevisionOwner, type PendingRevisionInput } from "../app-runtime/pendingRevisionOwner"; + +function bindOwner(slot: CommittedSlot) { + return createPendingRevisionOwner(() => slot.phase === "ready" && slot.value ? { epoch: slot.epoch, input: slot.value } : undefined); +} +export function reportPendingRevisionFailure(error: unknown) { + console.warn("Failed to submit pending plan revision", error); +} + +export function usePendingPlanRevisions(input: PendingRevisionInput) { + const slot = useCommittedSlot(input); + const owner = useMemo(() => bindOwner(slot), [slot]); + useLayoutEffect(() => { owner.pump(); }); + useLayoutEffect(() => () => owner.dispose(), [owner]); + return owner.remember; +} diff --git a/desktop/frontend/src/lib/useRemoteComposerIntegration.ts b/desktop/frontend/src/lib/useRemoteComposerIntegration.ts index b41a764ca4..0ee5be1ae6 100644 --- a/desktop/frontend/src/lib/useRemoteComposerIntegration.ts +++ b/desktop/frontend/src/lib/useRemoteComposerIntegration.ts @@ -1,19 +1,15 @@ -import { useCallback, useEffect, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; -import { app } from "./bridge"; +import { useEffect, type Dispatch, type SetStateAction } from "react"; +import { useCommittedCommand } from "./useCommittedCommand"; +import { executeRemoteSend, executeComposerRuntime } from "../app-runtime/remoteComposerOwner"; +import type { SessionResource, useSessionOperations } from "../app-runtime/useSessionOperations"; +import type { RemoteNavigationCommand } from "./remoteNavigationCommands"; import { reconcileComposerProfile, type ComposerProfile, type ComposerProfilesByTab } from "./composerProfile"; import type { GoalAction } from "./goalAction"; import type { CollaborationMode, QualityFloor, RemoteTabRefView, ToolApprovalMode } from "./types"; -import { publishNavigationIntent } from "./useNavigationIntentFence"; import type { RemoteSessionApi } from "./useRemoteSession"; type RemoteProfile = RemoteSessionApi["composerProfile"]; -export async function openRemoteNewSession(remote: RemoteTabRefView, retryHydration: () => Promise): Promise { - await publishNavigationIntent("remote-new-session"); - await app.OpenRemoteProjectTab(remote.hostId, remote.workspace, { newSession: true }); - await retryHydration(); -} - export function remoteRuntimeCommand(input: string): | { method: "setModel" | "setEffort"; value: string } | { method: "newSession" | "clearSession" } @@ -59,21 +55,19 @@ export function useRemoteComposerSend( send: (displayText: string, submitText?: string) => Promise, applyGoal: (tabId: string, goal: string) => Promise, requestClear: () => void, + ownership: { target: SessionResource; operations: ReturnType; navigateRemote: RemoteNavigationCommand }, ) { - return useCallback(async (displayText: string, submitText = displayText): Promise => { + const ports = { compact: session.compact, runManagementCommand: session.runManagementCommand, + setModel: session.setModel, setEffort: session.setEffort, + send, applyGoal, requestClear, newSession: ownership.navigateRemote }; + return useCommittedCommand(async (displayText: string, submitText = displayText): Promise => { const trimmed = (submitText || displayText).trim(); - const command = remoteRuntimeCommand(trimmed); - if (command?.method === "clearSession") return requestClear(); - if (command?.method === "newSession") { - if (!activeRemote) return; - return openRemoteNewSession(activeRemote, session.retryHydration); - } - if (command?.method === "compact") return session.compact(command.value); - if (command?.method === "runManagementCommand") return session.runManagementCommand(trimmed, command.rehydrate); - if (command?.method === "setModel" || command?.method === "setEffort") return session[command.method](command.value); - if (activeTabId && collaborationMode === "goal" && !goal.trim() && trimmed) await applyGoal(activeTabId, trimmed); - await send(displayText, submitText); - }, [activeRemote, activeTabId, applyGoal, collaborationMode, goal, requestClear, send, session]); + const outcome = await ownership.operations(ownership.target, "send", { + tabId: activeTabId ?? "", remote: activeRemote, display: displayText, submit: submitText, commandText: trimmed, + command: remoteRuntimeCommand(trimmed), activateGoal: collaborationMode === "goal" && !goal.trim() && Boolean(trimmed), ports, + }, executeRemoteSend); + if (outcome.status === "failed") throw outcome.error; + }); } export function useRemoteComposerProfileSync(options: { @@ -113,32 +107,27 @@ export function useRemoteComposerProfileSync(options: { } export function useRemoteComposerRuntimeActions(options: { - activeTabIdRef: MutableRefObject; + target: SessionResource; + operations: ReturnType; remote: boolean; session: RemoteSessionApi; runGoalAction: (action: GoalAction) => void; pauseLocal: (tabId: string) => Promise; resumeLocal: (tabId: string) => Promise; - setLocalEffort: (level: string) => void; + setLocalEffort: (tabId: string, level: string) => Promise; showError: (message: string) => void; }) { - const { activeTabIdRef, remote, session, runGoalAction, pauseLocal, resumeLocal, setLocalEffort, showError } = options; - const pauseGoal = useCallback(() => runGoalAction(async () => { - const tabId = activeTabIdRef.current; - if (!tabId) return; - await (remote ? session.pauseGoal() : pauseLocal(tabId)); - }), [activeTabIdRef, pauseLocal, remote, runGoalAction, session]); - const resumeGoal = useCallback(() => runGoalAction(async () => { - const tabId = activeTabIdRef.current; - if (!tabId) return; - await (remote ? session.resumeGoal() : resumeLocal(tabId)); - }), [activeTabIdRef, remote, resumeLocal, runGoalAction, session]); - const setEffort = useCallback((level: string) => { - if (!remote) { - setLocalEffort(level); - return; - } - void session.setEffort(level).catch((error) => showError(error instanceof Error ? error.message : String(error))); - }, [remote, session, setLocalEffort, showError]); + const { target, operations, remote, session, runGoalAction, pauseLocal, resumeLocal, setLocalEffort, showError } = options; + const ports = { pauseGoal: session.pauseGoal, resumeGoal: session.resumeGoal, setEffort: session.setEffort, + pauseLocal, resumeLocal, effortLocal: setLocalEffort }; + const execute = useCommittedCommand(async (action: "pause" | "resume" | "effort", level?: string) => { + const outcome = await operations(target, action === "effort" ? "effort" : "goal-lifecycle", + { tabId: target.tabId, remote, action, level, ports }, executeComposerRuntime); + if (outcome.status === "failed") throw outcome.error; + }); + const pauseGoal = useCommittedCommand(() => runGoalAction(() => execute("pause"))); + const resumeGoal = useCommittedCommand(() => runGoalAction(() => execute("resume"))); + const report = useCommittedCommand((error: unknown) => showError(error instanceof Error ? error.message : String(error))); + const setEffort = useCommittedCommand((level: string) => { void execute("effort", level).catch(report); }); return { pauseGoal, resumeGoal, setEffort }; } diff --git a/desktop/frontend/src/lib/useRemoteTabOpened.ts b/desktop/frontend/src/lib/useRemoteTabOpened.ts index 4f98de8fe9..bddaccbd8d 100644 --- a/desktop/frontend/src/lib/useRemoteTabOpened.ts +++ b/desktop/frontend/src/lib/useRemoteTabOpened.ts @@ -1,26 +1,24 @@ -import { useEffect, type MutableRefObject } from "react"; +import { useEffect } from "react"; import { onRemoteTabOpened, onRemoteTabUpdated } from "./bridge"; import type { TabMeta } from "./types"; +import { createSubscriptionScope } from "./subscriptionScope"; export function useRemoteTabOpened( - activeTabIdRef: MutableRefObject, - seedActiveTabMeta: (tab: TabMeta) => void, + registerTabMeta: (tab: TabMeta) => void, updateTabMeta: (tab: TabMeta) => void, - switchRemoteTab: (tab: TabMeta) => Promise, ) { useEffect(() => { - const off = onRemoteTabOpened((meta) => { + const scope = createSubscriptionScope(); + scope.listen(onRemoteTabOpened, (meta) => { if (!meta?.id || !meta.remote) return; - seedActiveTabMeta(meta); - if (activeTabIdRef.current !== meta.id) void switchRemoteTab(meta); + // Events are resource notifications. Only a request-owned navigation + // completion may adopt the surface, even if this event arrives first. + registerTabMeta(meta); }); - const offUpdated = onRemoteTabUpdated((meta) => { + scope.listen(onRemoteTabUpdated, (meta) => { if (!meta?.id || !meta.remote) return; updateTabMeta(meta); }); - return () => { - off(); - offUpdated(); - }; - }, [activeTabIdRef, seedActiveTabMeta, switchRemoteTab, updateTabMeta]); + return () => scope.dispose(); + }, [registerTabMeta, updateTabMeta]); } diff --git a/desktop/frontend/src/lib/useSessionSubmission.ts b/desktop/frontend/src/lib/useSessionSubmission.ts new file mode 100644 index 0000000000..50c6f10ef0 --- /dev/null +++ b/desktop/frontend/src/lib/useSessionSubmission.ts @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import { useCommittedSlot, type CommittedSlot } from "./useCommittedSlot"; +import { useCommittedCommand } from "./useCommittedCommand"; +import { CommandCancelled } from "./commandOutcome"; +import { executeSubmission, type InitialGoal, type SubmissionInput, type SubmissionPorts, type SubmissionResource } from "../app-runtime/sessionSubmissionOwner"; +import type { SessionOperationAuthority, SessionResource, useSessionOperations } from "../app-runtime/useSessionOperations"; +import type { StructuredInvocationSubmit } from "./invocationDisplay"; + +function bindRead(slot: CommittedSlot) { + return (target: SessionResource) => { + if (slot.phase !== "ready") throw new CommandCancelled("disposed"); + const source = slot.value?.find(value => value.target.tabId === target.tabId && value.target.sessionKey === target.sessionKey); + if (!source) throw new CommandCancelled("superseded"); + return source; + }; +} + +export function useSessionSubmission(options: { + target: SessionResource; resources: readonly SubmissionResource[]; + operations: ReturnType; ports: SubmissionPorts; missingSource: string; +}) { + const { target, resources, operations, ports, missingSource } = options; + const slot = useCommittedSlot(resources); + const read = useMemo(() => bindRead(slot), [slot]); + const run = useCommittedCommand(async (tab: string, request: SubmissionInput["request"]) => { + const source = resources.find(value => value.target.tabId === tab); + if (!source) throw Error(missingSource); + const result = await operations(source.target, request.kind === "goal" ? "composer-profile" : "send", { + target: source.target, request, read, ports, + }, executeSubmission); + if (result.status === "failed") throw result.error; + }); + const commitThenSend = useCommittedCommand((tab: string, display: string, submit?: string, + structured?: StructuredInvocationSubmit, initialGoal?: InitialGoal) => run(tab, { kind: "direct", content: { display, submit, structured, initialGoal } })); + const submit = useCommittedCommand((tab: string, display: string, content = display, structured?: StructuredInvocationSubmit) => + run(tab, { kind: "composer", content: { display, submit: content, structured } })); + const applyGoalForTab = useCommittedCommand((tab: string, goal: string) => run(tab, { kind: "goal", goal })); + const applyGoal = useCommittedCommand((goal: string) => target.tabId ? applyGoalForTab(target.tabId, goal) : Promise.resolve()); + // A queue already owns its request and must retain resource failures before + // its own UI outcome boundary. Reuse the executor, not a nested UI command. + const sendRevision = useCommittedCommand((source: SessionResource, text: string, authority: SessionOperationAuthority) => + executeSubmission({ target: source, read, ports, request: { kind: "direct", content: { display: text } } }, authority)); + return { commitThenSend, submit, applyGoalForTab, applyGoal, sendRevision }; +} diff --git a/desktop/frontend/src/store/layout.ts b/desktop/frontend/src/store/layout.ts index 0df42611bf..ea8f49cbfb 100644 --- a/desktop/frontend/src/store/layout.ts +++ b/desktop/frontend/src/store/layout.ts @@ -141,8 +141,10 @@ export function saveRightDockPreviewWidth(width: number): void { // rightDockMode selects what the right dock shows. workspacePanelOpen is // restored from localStorage (same pattern as sidebarCollapsed) so a collapsed // dock survives restart. maximized/preview stay session-local — they are view -// layout, not a durable preference. (Resize drag flags, button-press animation -// flags, measured footer height, and viewport width stay as useState in App.tsx.) +// layout, not a durable preference. Transient geometry (drag flags, live drag +// widths, the sidebar button-press flag) is session-local state on this store +// so resize lifecycles and their consumers read one source of truth; measured +// footer height and viewport width live in the windowChrome store. export type RightDockMode = "context" | "files" | "changed" | "remote"; // terminalPanelOpen is independent from rightDockMode — the terminal is a @@ -248,6 +250,12 @@ export type LayoutState = { rightDockMode: RightDockMode; terminalPanelOpen: boolean; terminalHeight: number; + sidebarTogglePressed: boolean; + sidebarResizing: boolean; + liveSidebarWidth: number | null; + workspacePanelResizing: boolean; + liveWorkspacePanelRenderWidth: number | null; + liveTerminalHeight: number | null; setSidebarCollapsed: (collapsed: boolean) => void; setSidebarWidth: (width: number) => void; setRightDockTreeWidth: (width: number) => void; @@ -258,6 +266,12 @@ export type LayoutState = { setRightDockMode: Dispatch>; setTerminalPanelOpen: Dispatch>; setTerminalHeight: (height: number) => void; + setSidebarTogglePressed: (pressed: boolean) => void; + setSidebarResizing: (resizing: boolean) => void; + setLiveSidebarWidth: (width: number | null) => void; + setWorkspacePanelResizing: (resizing: boolean) => void; + setLiveWorkspacePanelRenderWidth: (width: number | null) => void; + setLiveTerminalHeight: (height: number | null) => void; }; export const useLayoutStore = create((set) => ({ @@ -271,6 +285,12 @@ export const useLayoutStore = create((set) => ({ rightDockMode: "context", terminalPanelOpen: loadTerminalPanelOpen(), terminalHeight: loadTerminalHeight(), + sidebarTogglePressed: false, + sidebarResizing: false, + liveSidebarWidth: null, + workspacePanelResizing: false, + liveWorkspacePanelRenderWidth: null, + liveTerminalHeight: null, setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), setSidebarWidth: (width) => set({ sidebarWidth: width }), setRightDockTreeWidth: (width) => set({ rightDockTreeWidth: width }), @@ -281,6 +301,12 @@ export const useLayoutStore = create((set) => ({ setRightDockMode: (update) => set((s) => ({ rightDockMode: applySetState(s.rightDockMode, update) })), setTerminalPanelOpen: (update) => set((s) => ({ terminalPanelOpen: applySetState(s.terminalPanelOpen, update) })), setTerminalHeight: (height) => set({ terminalHeight: height }), + setSidebarTogglePressed: (pressed) => set({ sidebarTogglePressed: pressed }), + setSidebarResizing: (resizing) => set({ sidebarResizing: resizing }), + setLiveSidebarWidth: (width) => set({ liveSidebarWidth: width }), + setWorkspacePanelResizing: (resizing) => set({ workspacePanelResizing: resizing }), + setLiveWorkspacePanelRenderWidth: (width) => set({ liveWorkspacePanelRenderWidth: width }), + setLiveTerminalHeight: (height) => set({ liveTerminalHeight: height }), })); export function applyLayoutStyleDefaults(style: "classic" | "workbench" | "creation"): void { diff --git a/desktop/frontend/src/store/overlays.ts b/desktop/frontend/src/store/overlays.ts index 969454cf51..be6ea6baa3 100644 --- a/desktop/frontend/src/store/overlays.ts +++ b/desktop/frontend/src/store/overlays.ts @@ -2,7 +2,7 @@ import type { Dispatch, SetStateAction } from "react"; import { create } from "zustand"; -import { shouldShowStartupSplash } from "../components/StartupSplash"; +import { shouldShowStartupSplash } from "../lib/startupSplashState"; import type { ExtensionActionView, SessionMeta } from "../lib/types"; import { applySetState } from "./setState"; @@ -20,6 +20,9 @@ export type OverlayState = { transientOverlayDismissSignal: number; startupSplashVisible: boolean; needsOnboarding: boolean | null; + takeoverDialogTab: string | null; + reclaimBusyTab: string | null; + providerSetupNeeded: boolean; setPaletteOpen: Dispatch>; setPaletteSessions: Dispatch>; setPaletteExtensionActions: Dispatch>; @@ -30,6 +33,9 @@ export type OverlayState = { setTransientOverlayDismissSignal: Dispatch>; setStartupSplashVisible: Dispatch>; setNeedsOnboarding: Dispatch>; + setTakeoverDialogTab: Dispatch>; + setReclaimBusyTab: Dispatch>; + setProviderSetupNeeded: Dispatch>; }; export const useOverlayStore = create((set) => ({ @@ -43,6 +49,9 @@ export const useOverlayStore = create((set) => ({ transientOverlayDismissSignal: 0, startupSplashVisible: shouldShowStartupSplash(), needsOnboarding: null, + takeoverDialogTab: null, + reclaimBusyTab: null, + providerSetupNeeded: false, setPaletteOpen: (update) => set((s) => ({ paletteOpen: applySetState(s.paletteOpen, update) })), setPaletteSessions: (update) => set((s) => ({ paletteSessions: applySetState(s.paletteSessions, update) })), setPaletteExtensionActions: (update) => set((s) => ({ paletteExtensionActions: applySetState(s.paletteExtensionActions, update) })), @@ -53,4 +62,7 @@ export const useOverlayStore = create((set) => ({ setTransientOverlayDismissSignal: (update) => set((s) => ({ transientOverlayDismissSignal: applySetState(s.transientOverlayDismissSignal, update) })), setStartupSplashVisible: (update) => set((s) => ({ startupSplashVisible: applySetState(s.startupSplashVisible, update) })), setNeedsOnboarding: (update) => set((s) => ({ needsOnboarding: applySetState(s.needsOnboarding, update) })), + setTakeoverDialogTab: (update) => set((s) => ({ takeoverDialogTab: applySetState(s.takeoverDialogTab, update) })), + setReclaimBusyTab: (update) => set((s) => ({ reclaimBusyTab: applySetState(s.reclaimBusyTab, update) })), + setProviderSetupNeeded: (update) => set((s) => ({ providerSetupNeeded: applySetState(s.providerSetupNeeded, update) })), })); diff --git a/desktop/frontend/src/store/windowChrome.ts b/desktop/frontend/src/store/windowChrome.ts new file mode 100644 index 0000000000..b82761834d --- /dev/null +++ b/desktop/frontend/src/store/windowChrome.ts @@ -0,0 +1,49 @@ +// windowChrome owns the desktop shell's native chrome state — detected +// desktop platform, viewport geometry and the main-window maximised flag — as +// a selectable store rather than App-local useState. Runtime wiring (platform +// probe, resize listener, maximised sync) lives in the app-runtime +// WindowChromeLifecycle/useNativeWindowController modules; components only +// read slices, which keeps every chrome consumer on one source of truth +// without prop drilling and without duplicating listeners per region. + +import { create } from "zustand"; +import { detectBrowserPlatform } from "../lib/desktopPlatform"; +import type { DesktopPlatform } from "../lib/desktopPlatform"; + +function initialViewportSize(): { width: number; height: number } { + if (typeof window === "undefined") return { width: 1440, height: 720 }; + return { width: window.innerWidth, height: window.innerHeight }; +} + +type WindowChromeState = { + platform: DesktopPlatform; + viewportWidth: number; + viewportHeight: number; + mainWindowMaximised: boolean; +}; + +export const useWindowChromeStore = create(() => { + const viewport = initialViewportSize(); + return { + platform: detectBrowserPlatform(), + viewportWidth: viewport.width, + viewportHeight: viewport.height, + mainWindowMaximised: false, + }; +}); + +export const setDesktopPlatform = (platform: DesktopPlatform): void => { + useWindowChromeStore.setState({ platform }); +}; + +export const setViewportSize = (width: number, height: number): void => { + useWindowChromeStore.setState((current) => + current.viewportWidth === width && current.viewportHeight === height ? current : { viewportWidth: width, viewportHeight: height }, + ); +}; + +export const setMainWindowMaximised = (maximised: boolean): void => { + useWindowChromeStore.setState((current) => + current.mainWindowMaximised === maximised ? current : { mainWindowMaximised: maximised }, + ); +}; diff --git a/docs/APP_SESSION_OWNERSHIP.md b/docs/APP_SESSION_OWNERSHIP.md new file mode 100644 index 0000000000..1f0d8494db --- /dev/null +++ b/docs/APP_SESSION_OWNERSHIP.md @@ -0,0 +1,46 @@ +# App session ownership + +[简体中文](APP_SESSION_OWNERSHIP.zh-CN.md) + +Session actions capture their source when invoked. A later tab change cannot +redirect a pending send, cancel, approval, model update, or navigation completion +to the newly selected session. Layout-committed command registrations publish +authority; replacement generations and unmount revoke old continuations. +Background cancellation resolves the canonical controller target rather than a +UI tab identifier. Missing or replaced targets produce a stale outcome. + +Subscription scopes revoke queued deliveries before releasing registrations. +Terminal output uses reference-counted leases so an old cleanup cannot release +a newer subscriber. App composition wires these owners to the existing page +tree; the runtime root and page tree still live together in App.tsx in this +stage. Presentation-only extraction is a separate change. + +## Verification + +`pnpm test:app-lifecycle` exercises source capture, committed publication, +supersession, A-to-B-to-A navigation, canonical background cancellation, +unmount, subscription disposal, and negative memory-protocol fixtures. +`pnpm test:app-browser` replays real local/remote navigation, send/Stop, +three layouts, and Composer/Workspace DOM identity. `pnpm test:all` discovers +the remaining frontend regression suites. + +## Independent memory screening + +The App memory workflow builds the requested clean commit once. Three isolated +runner jobs download that same build; each starts a new Chromium process and +executes 128 full, 128 windowed, 128 safety, and 512 mixed round trips. The +aggregate requires all 2,688 trips, all checkpoints and heap snapshot metadata, +three distinct shard identities, the same workflow attempt, source/build hashes, +Node/platform/architecture, fixture configuration, and browser version. Missing, +cancelled, mismatched, or failing shards cannot produce a passing final check. + +The workflow runs for frontend changes and unknown paths. Known independent +backend and documentation paths may skip this mock-frontend soak; existing +platform CI continues to cover those paths. The stable `app-memory` job checks +that any skip was explicitly selected and its prerequisite states agree. + +A `SHARD_PASS` is only one complete process. Aggregate `PASS` is automated +screening, not a whole-App memory-leak proof: heap-retainer analysis and a +mainline control comparison remain separate attribution work. Reports preserve +that pending status. PR-head evidence also does not replace integration and +native checks against the current target branch. diff --git a/docs/APP_SESSION_OWNERSHIP.zh-CN.md b/docs/APP_SESSION_OWNERSHIP.zh-CN.md new file mode 100644 index 0000000000..c0dcb7fcac --- /dev/null +++ b/docs/APP_SESSION_OWNERSHIP.zh-CN.md @@ -0,0 +1,35 @@ +# App 会话命令所有权 + +[English](APP_SESSION_OWNERSHIP.md) + +会话操作在调用时捕获来源。后续切换标签页不能把尚未完成的发送、取消、审批、 +模型修改或导航结果转交给新会话。命令只在布局提交后获得执行权限;替换会话代次 +或卸载会撤销旧异步续体。后台取消使用规范控制器目标,不使用界面标签标识; +目标缺失或已替换时返回过期结果。 + +订阅作用域先撤销排队通知,再释放注册。终端输出使用引用计数租约,旧清理不能 +释放新订阅。此阶段已将所有权模块接入 App,运行时根和页面树仍共同保留在 +App.tsx;纯展示层提取单独交付。 + +## 验证 + +`pnpm test:app-lifecycle` 覆盖来源捕获、提交发布、替换、A→B→A 导航、规范后台 +取消、卸载、订阅清理及内存协议反例。`pnpm test:app-browser` 通过真实界面验证 +本地/远程导航、发送/停止、三种布局以及 Composer/Workspace 节点身份。 +`pnpm test:all` 发现并运行其余前端回归测试。 + +## 独立内存筛查 + +工作流对指定干净提交只构建一次,三个独立 runner 下载同一产物,各自启动新的 +Chromium 进程,完整执行 128 次 full、128 次 windowed、128 次 safety 和 512 次 +mixed 往返。汇总要求全部 2,688 次往返、完整检查点与堆快照元数据、三个唯一分片、 +相同工作流执行批次、源码与构建摘要、Node/平台/架构、夹具配置和浏览器版本。 +缺失、取消、身份不一致或失败的分片都不能产生通过结果。 + +前端及未知路径会触发工作流,明确独立的后端和文档改动可跳过 mock 前端长测; +现有平台 CI 继续覆盖这些路径。最终 `app-memory` 检查会核验跳过条件和依赖任务 +状态,不能通过意外 skipped 隐藏失败。 + +`SHARD_PASS` 只代表一个完整进程。汇总 `PASS` 代表自动筛查通过,不代表整个 App +不存在内存泄漏;堆保留链分析及主分支对照仍是独立归因工作,报告持续保留待归因 +状态。PR head 的证据也不替代最新目标分支集成检查和原生平台验证。 From a141c4aa1adafee7e695a13b99551b509d7aac4a Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:51:10 +0800 Subject: [PATCH 2/7] refactor(app): separate runtime composition from the shared page tree Problem: the migrated command owners still share a large App entry with the entire page tree, obscuring the source-authority and presentation boundary. Root cause: runtime composition and JSX assembly remained in one module; context and subagent presentation code also remained in eager consumers. Fix: retain the original hook and command order in AppRuntime, render the same stable regions through AppRuntimeView, and make App.tsx a small facade. Extract pure context helpers and lazy subagent presentation with preserved live outcome tuples and historical output parsing. Enforce the entry and transitive AST layer contracts, remove the obsolete App size allowance, reduce remaining ContextPanel debt, and ratchet measured raw assets to 2381.3 KiB for the 2381.2 KiB result. Keep independent memory screening. Verification: all 302 discovered suites plus the remaining test:all groups; App lifecycle and actual browser replay; typechecks; production build and bundle budgets; AST negative fixtures and repository lint; independent read-only review of hook order, command provenance and outcome compatibility. --- desktop/frontend/package.json | 7 +- .../scripts/check-app-entry-contract.mjs | 18 + desktop/frontend/scripts/check-app-layers.mjs | 125 ++++ .../scripts/check-app-layers.test.mjs | 52 ++ .../frontend/scripts/check-bundle-budget.mjs | 6 +- desktop/frontend/src/App.tsx | 677 +----------------- desktop/frontend/src/AppRuntime.tsx | 161 +++++ .../src/__tests__/add-project-entries.test.ts | 2 +- .../src/__tests__/app-chrome-tabs.test.ts | 4 +- .../automation-surface-layout.test.ts | 4 +- .../footer-decision-overflow.test.ts | 2 +- .../history-load-failure-contract.test.ts | 2 +- .../src/__tests__/mcp-interaction.test.tsx | 2 +- .../navigation-surface-transition.test.ts | 4 +- .../__tests__/recovery-banner-privacy.test.ts | 2 +- .../src/__tests__/send-failed.test.ts | 2 +- .../__tests__/subagent-progress-card.test.tsx | 51 ++ .../src/__tests__/subagent-progress.test.ts | 21 + .../frontend/src/__tests__/theme-pack.test.ts | 2 +- .../src/__tests__/topicbar-controls.test.ts | 2 +- .../frontend/src/app-shell/AppRuntimeView.tsx | 516 +++++++++++++ .../frontend/src/components/ContextPanel.tsx | 22 +- .../src/components/ContextWindowRing.tsx | 5 +- .../src/components/SubagentDetails.css | 75 ++ .../src/components/SubagentOutcomeCard.tsx | 38 + .../src/components/SubagentPreview.tsx | 64 ++ desktop/frontend/src/components/ToolCard.tsx | 98 +-- desktop/frontend/src/lib/contextPanelUtils.ts | 21 + desktop/frontend/src/lib/subagentOutcome.ts | 25 +- desktop/frontend/src/lib/useController.ts | 14 +- desktop/frontend/src/locales/en.ts | 5 - desktop/frontend/src/locales/zh-TW.ts | 5 - desktop/frontend/src/locales/zh.ts | 5 - desktop/frontend/src/styles.css | 158 +--- docs/APP_SESSION_OWNERSHIP.md | 6 +- docs/APP_SESSION_OWNERSHIP.zh-CN.md | 4 +- docs/APP_SHELL.md | 25 + docs/APP_SHELL.zh-CN.md | 21 + tools/repolint/baseline.json | 7 +- 39 files changed, 1280 insertions(+), 980 deletions(-) create mode 100644 desktop/frontend/scripts/check-app-entry-contract.mjs create mode 100644 desktop/frontend/scripts/check-app-layers.mjs create mode 100644 desktop/frontend/scripts/check-app-layers.test.mjs create mode 100644 desktop/frontend/src/AppRuntime.tsx create mode 100644 desktop/frontend/src/app-shell/AppRuntimeView.tsx create mode 100644 desktop/frontend/src/components/SubagentDetails.css create mode 100644 desktop/frontend/src/components/SubagentOutcomeCard.tsx create mode 100644 desktop/frontend/src/components/SubagentPreview.tsx create mode 100644 desktop/frontend/src/lib/contextPanelUtils.ts create mode 100644 docs/APP_SHELL.md create mode 100644 docs/APP_SHELL.zh-CN.md diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index cec6356102..814e5b0090 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -6,7 +6,7 @@ "packageManager": "pnpm@10.34.5", "scripts": { "dev": "vite", - "build": "pnpm lint:hooks && pnpm check:waapi && pnpm check:scroll-writer && node scripts/check-css-syntax.mjs src/styles.css src/components/RemoteConnectWizard.css src/components/TranscriptSelectionMenu.css src/components/MCPInteractionCard.css && node scripts/check-z-index-tokens.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-theme-token-contract.mjs && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs", + "build": "pnpm lint:hooks && pnpm check:waapi && pnpm check:scroll-writer && pnpm check:app-layers && node scripts/check-css-syntax.mjs src/styles.css src/components/RemoteConnectWizard.css src/components/TranscriptSelectionMenu.css src/components/MCPInteractionCard.css src/components/SubagentDetails.css && node scripts/check-z-index-tokens.mjs src/styles.css src/components/RemoteConnectWizard.css && node scripts/check-theme-token-contract.mjs && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs", "check:bundle": "node scripts/check-bundle-budget.mjs", "check:scroll-writer": "node scripts/check-single-scroll-writer.mjs", "preview": "vite preview", @@ -42,9 +42,10 @@ "test:updater": "tsx src/__tests__/updater-shared-state.test.tsx", "test:window-state": "tsx src/__tests__/window-state-ordering.test.ts", "test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance", - "test:app-lifecycle": "tsx src/__tests__/app-lifecycle.test.tsx && tsx src/__tests__/committed-command-lifecycle.test.tsx && tsx src/__tests__/committed-command-execution.test.tsx && tsx src/__tests__/navigation-surface-lifecycle.test.tsx && tsx src/__tests__/app-lifecycle-probe.test.ts && tsx src/__tests__/subscription-scope.test.ts && tsx src/__tests__/composer-source-operations.test.tsx && tsx src/__tests__/session-prompt-lifecycle.test.tsx && tsx src/__tests__/desktop-preferences-lifecycle.test.tsx && tsx src/__tests__/onboarding-commands.test.tsx && tsx src/__tests__/topicbar-actions-lifecycle.test.tsx && tsx src/__tests__/decision-slots-lifecycle.test.tsx && tsx src/__tests__/session-experience-settings.test.tsx && tsx src/__tests__/project-topic-lifecycle.test.tsx && tsx src/__tests__/conversation-projection.test.ts && tsx src/__tests__/remote-composer-presentation.test.tsx && tsx src/__tests__/remote-composer-commands.test.tsx && tsx src/__tests__/terminal-panel-commands.test.tsx && tsx src/__tests__/workspace-panel-commands.test.tsx && tsx src/__tests__/desktop-navigation-lifecycle.test.tsx && tsx src/__tests__/runtime-status-lifecycle.test.tsx && tsx src/__tests__/session-control-commands.test.ts && tsx src/__tests__/automation-navigation-lifecycle.test.tsx && tsx src/__tests__/mock-remote-catalog.test.ts && tsx src/__tests__/topicbar-region.test.tsx && tsx src/__tests__/controller-profile-lifecycle.test.tsx && tsx src/__tests__/session-submission-lifecycle.test.tsx && tsx src/__tests__/pending-plan-revision-lifecycle.test.tsx && tsx src/__tests__/session-undo-lifecycle.test.tsx && tsx src/__tests__/session-clear-commands.test.tsx && tsx src/__tests__/turn-verification-commands.test.tsx && tsx src/__tests__/delivery-continue-commands.test.tsx && tsx src/__tests__/active-tab-mirror.test.tsx && tsx src/__tests__/windows-maximised-sync.test.tsx && tsx src/__tests__/topic-summary-commands.test.tsx && tsx src/__tests__/worktree-merge-commands.test.tsx && tsx src/__tests__/composer-insert-commands.test.tsx && node --test bench/app-memory-evidence.test.mjs && node --test bench/app-memory-shards.test.mjs bench/app-memory-paths.test.mjs", + "test:app-lifecycle": "tsx src/__tests__/app-lifecycle.test.tsx && tsx src/__tests__/committed-command-lifecycle.test.tsx && tsx src/__tests__/committed-command-execution.test.tsx && tsx src/__tests__/navigation-surface-lifecycle.test.tsx && tsx src/__tests__/app-lifecycle-probe.test.ts && tsx src/__tests__/subscription-scope.test.ts && tsx src/__tests__/composer-source-operations.test.tsx && tsx src/__tests__/session-prompt-lifecycle.test.tsx && tsx src/__tests__/desktop-preferences-lifecycle.test.tsx && tsx src/__tests__/onboarding-commands.test.tsx && tsx src/__tests__/topicbar-actions-lifecycle.test.tsx && tsx src/__tests__/decision-slots-lifecycle.test.tsx && tsx src/__tests__/session-experience-settings.test.tsx && tsx src/__tests__/project-topic-lifecycle.test.tsx && tsx src/__tests__/conversation-projection.test.ts && tsx src/__tests__/remote-composer-presentation.test.tsx && tsx src/__tests__/remote-composer-commands.test.tsx && tsx src/__tests__/terminal-panel-commands.test.tsx && tsx src/__tests__/workspace-panel-commands.test.tsx && tsx src/__tests__/desktop-navigation-lifecycle.test.tsx && tsx src/__tests__/runtime-status-lifecycle.test.tsx && tsx src/__tests__/session-control-commands.test.ts && tsx src/__tests__/automation-navigation-lifecycle.test.tsx && tsx src/__tests__/mock-remote-catalog.test.ts && tsx src/__tests__/topicbar-region.test.tsx && tsx src/__tests__/controller-profile-lifecycle.test.tsx && tsx src/__tests__/session-submission-lifecycle.test.tsx && tsx src/__tests__/pending-plan-revision-lifecycle.test.tsx && tsx src/__tests__/session-undo-lifecycle.test.tsx && tsx src/__tests__/session-clear-commands.test.tsx && tsx src/__tests__/turn-verification-commands.test.tsx && tsx src/__tests__/delivery-continue-commands.test.tsx && tsx src/__tests__/active-tab-mirror.test.tsx && tsx src/__tests__/windows-maximised-sync.test.tsx && tsx src/__tests__/topic-summary-commands.test.tsx && tsx src/__tests__/worktree-merge-commands.test.tsx && tsx src/__tests__/composer-insert-commands.test.tsx && node --test bench/app-memory-evidence.test.mjs && node --test bench/app-memory-shards.test.mjs bench/app-memory-paths.test.mjs && node --test scripts/check-app-layers.test.mjs", "test:app-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-browser.mjs", - "test:app-memory": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-memory.mjs" + "test:app-memory": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-memory.mjs", + "check:app-layers": "node scripts/check-app-entry-contract.mjs && node scripts/check-app-layers.test.mjs && node scripts/check-app-layers.mjs" }, "dependencies": { "@modelcontextprotocol/ext-apps": "1.7.5", diff --git a/desktop/frontend/scripts/check-app-entry-contract.mjs b/desktop/frontend/scripts/check-app-entry-contract.mjs new file mode 100644 index 0000000000..9ae3ddb350 --- /dev/null +++ b/desktop/frontend/scripts/check-app-entry-contract.mjs @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const file = resolve("src/App.tsx"); +const source = readFileSync(file, "utf8"); +const lines = source.split(/\r?\n/).length; +const failures = []; +if (lines > 200) failures.push(`App.tsx is ${lines} lines; composition boundary is 200`); +if (/\bapp\./.test(source) || /from ["']\.\/lib\/bridge["']/.test(source)) failures.push("App.tsx directly accesses the Wails bridge"); +if (/\buseEffect\s*\(/.test(source) || /\bawait\b/.test(source)) failures.push("App.tsx owns an effect or asynchronous operation"); +if (!/from ["']\.\/AppRuntime["']/.test(source)) failures.push("App.tsx must compose AppRuntime"); +if (failures.length) { + for (const failure of failures) console.error(`app-entry-contract: ${failure}`); + process.exitCode = 1; +} else { + console.log("app-entry-contract: App.tsx is a pure composition boundary"); +} diff --git a/desktop/frontend/scripts/check-app-layers.mjs b/desktop/frontend/scripts/check-app-layers.mjs new file mode 100644 index 0000000000..effa42fa64 --- /dev/null +++ b/desktop/frontend/scripts/check-app-layers.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; + +const common = new Set(["useCommittedSlot.ts", "useCommittedCommand.ts", "useCommittedAsyncCommand.ts", "commandOutcome.ts", "composeDomRef.ts", "subscriptionScope.ts"]); +const domNames = new Set(["window", "document", "HTMLElement", "HTMLDivElement", "ReactNode", "SyntheticEvent"]); + +function sourceFiles(root) { + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => + entry.isDirectory() ? sourceFiles(join(root, entry.name)) + : /\.[cm]?[jt]sx?$/.test(entry.name) ? [join(root, entry.name)] : []); +} + +export function moduleEdges(code, file) { + const tree = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true); + const edges = []; + const identifiers = new Set(); + const namedTypesOnly = (bindings) => bindings && ts.isNamedImports(bindings) + && bindings.elements.length > 0 && bindings.elements.every((entry) => entry.isTypeOnly); + function visit(node) { + // A DTO field named `window` is not a reference to the browser global. + if (ts.isIdentifier(node) && !(ts.isPropertySignature(node.parent) && node.parent.name === node)) identifiers.add(node.text); + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + const clause = node.importClause; + edges.push({ specifier: node.moduleSpecifier.text, + typeOnly: Boolean(clause?.isTypeOnly || (clause && !clause.name && namedTypesOnly(clause.namedBindings))) }); + return; + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) { + edges.push({ specifier: node.moduleSpecifier.text, typeOnly: Boolean(node.isTypeOnly + || (node.exportClause && ts.isNamedExports(node.exportClause) && node.exportClause.elements.length > 0 + && node.exportClause.elements.every((entry) => entry.isTypeOnly))) }); + return; + } else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword + || (ts.isIdentifier(node.expression) && node.expression.text === "require"))) { + const argument = node.arguments[0]; + if (argument && ts.isStringLiteral(argument)) edges.push({ specifier: argument.text, typeOnly: false }); + else edges.push({ specifier: "", typeOnly: false, unresolved: true }); + } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) + && node.moduleReference.expression && ts.isStringLiteral(node.moduleReference.expression)) { + edges.push({ specifier: node.moduleReference.expression.text, typeOnly: Boolean(node.isTypeOnly) }); + } + ts.forEachChild(node, visit); + } + visit(tree); + return { edges, identifiers }; +} + +export function checkAppLayers(sourceRoot, compilerOptions = {}) { + const failures = new Set(); + const cache = new Map(); + const normalize = (file) => relative(sourceRoot, file).replaceAll("\\", "/"); + const parse = (file) => { + if (!cache.has(file)) cache.set(file, moduleEdges(readFileSync(file, "utf8"), file)); + return cache.get(file); + }; + const resolved = (edge, from) => { + if (edge.unresolved) return null; + const result = ts.resolveModuleName(edge.specifier, from, compilerOptions, ts.sys).resolvedModule; + return result && !result.isExternalLibraryImport ? result.resolvedFileName : null; + }; + const files = ["app-shell", "app-runtime", "app-features", "app-domain"] + .flatMap((directory) => sourceFiles(join(sourceRoot, directory))); + for (const name of common) { + const file = join(sourceRoot, "lib", name); + if (existsSync(file)) files.push(file); + } + for (const file of files) { + const name = normalize(file); + const shell = name.startsWith("app-shell/"); + const domain = /Owner\.ts$/.test(basename(file)) || basename(file) === "sessionTarget.ts" || name.startsWith("app-domain/"); + const foundation = common.has(basename(file)); + const visited = new Set(); + function inspect(current, chain) { + if (visited.has(current)) return; + visited.add(current); + const parsed = parse(current); + if (domain && [...parsed.identifiers].some((id) => domNames.has(id))) { + failures.add(name + ": domain reaches DOM/React objects through " + chain.join(" -> ")); + } + for (const edge of parsed.edges) { + if (edge.typeOnly) continue; + if (/\.(?:css|svg|png|webp|woff2?)(?:\?.*)?$/.test(edge.specifier) + && existsSync(resolve(dirname(current), edge.specifier.split("?")[0]))) continue; + const target = resolved(edge, current); + const targetName = target ? normalize(target) : edge.specifier; + const next = [...chain, targetName]; + if (edge.unresolved || (!target && edge.specifier.startsWith("."))) { + failures.add(name + ": unresolvable runtime dependency " + next.join(" -> ")); + } + if (domain && (/^react(?:-dom)?(?:\/|$)/.test(edge.specifier) + || /^(?:app-shell|components)\//.test(targetName))) { + failures.add(name + ": domain reaches presentation through " + next.join(" -> ")); + } + if (!shell && targetName.startsWith("app-shell/")) { + failures.add(name + ": upstream reaches presentation through " + next.join(" -> ")); + } + if (foundation && /^app-(?:runtime|features|shell)\//.test(targetName)) { + failures.add(name + ": shared primitive reaches App through " + next.join(" -> ")); + } + if (shell && targetName === "lib/bridge.ts") { + failures.add(name + ": presentation reaches bridge through " + next.join(" -> ")); + } + // Existing leaf components retain their own contracts; follow shell-local + // wrappers and the complete runtime graph of domain/common modules. + if (target && (domain || foundation || (shell && targetName.startsWith("app-shell/")))) inspect(target, next); + } + } + inspect(file, [name]); + } + return [...failures]; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const frontend = dirname(dirname(fileURLToPath(import.meta.url))); + const config = ts.readConfigFile(join(frontend, "tsconfig.json"), ts.sys.readFile); + if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, frontend); + const failures = checkAppLayers(join(frontend, "src"), parsed.options); + for (const failure of failures) console.error("check-app-layers: " + failure); + if (failures.length) process.exitCode = 1; + else console.log("check-app-layers: migrated App modules satisfy the AST dependency contracts"); +} diff --git a/desktop/frontend/scripts/check-app-layers.test.mjs b/desktop/frontend/scripts/check-app-layers.test.mjs new file mode 100644 index 0000000000..1ed51b5193 --- /dev/null +++ b/desktop/frontend/scripts/check-app-layers.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import ts from "typescript"; +import { checkAppLayers, moduleEdges } from "./check-app-layers.mjs"; + +const fixture = mkdtempSync(join(tmpdir(), "reasonix-app-layers-")); +const options = { moduleResolution: ts.ModuleResolutionKind.Bundler, baseUrl: fixture, paths: { "@/*": ["*"] } }; +const write = (name, source) => { + const file = join(fixture, name); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, source); +}; +try { + const parsed = moduleEdges(` + // import React from 'react'; + import type { ReactNode } from 'react'; + import { type Config } from './types'; + export { type Target } from './types'; + export * from './runtime'; + const later = () => import('./lazy'); + `, "fixture.ts"); + assert.deepEqual(parsed.edges.map((edge) => [edge.specifier, edge.typeOnly]), [ + ["react", true], ["./types", true], ["./types", true], ["./runtime", false], ["./lazy", false], + ]); + write("app-domain/owner.ts", "export { run } from '@/lib/middle';"); + write("lib/middle.ts", "export const run = () => import('./leaf');"); + write("lib/leaf.ts", "import React from 'react'; export const value = React;"); + assert.ok(checkAppLayers(fixture, options).some((failure) => failure.includes("domain reaches presentation")), + "alias, re-export and lazy edges cannot conceal a transitive React dependency"); + write("lib/leaf.ts", "export const value = document.title;"); + assert.ok(checkAppLayers(fixture, options).some((failure) => failure.includes("domain reaches DOM"))); + write("lib/leaf.ts", "export interface Size { window: number }; export const value = 1;"); + assert.deepEqual(checkAppLayers(fixture, options), [], "DTO field names are not browser runtime references"); + write("lib/leaf.ts", "import type { ReactNode } from 'react'; export const value = 1;"); + assert.deepEqual(checkAppLayers(fixture, options), []); + write("lib/leaf.ts", "export const load = (name: string) => import(name);"); + assert.ok(checkAppLayers(fixture, options).some((failure) => failure.includes("unresolvable runtime dependency"))); + write("lib/leaf.ts", "export const value = 1;"); + write("app-shell/Region.tsx", "export { run } from './wrapper';"); + write("app-shell/wrapper.ts", "export { app as run } from '@/lib/bridge';"); + write("lib/bridge.ts", "export const app = {};"); + assert.ok(checkAppLayers(fixture, options).some((failure) => failure.includes("presentation reaches bridge"))); + write("app-shell/wrapper.ts", "export const run = 1;"); + write("lib/useCommittedSlot.ts", "export * from '../app-runtime/adapter';"); + write("app-runtime/adapter.ts", "export const value = 1;"); + assert.ok(checkAppLayers(fixture, options).some((failure) => failure.includes("shared primitive reaches App"))); + console.log("PASS AST layer checks resolve runtime edges and reject transitive boundary violations"); +} finally { + rmSync(fixture, { recursive: true, force: true }); +} diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index 045d5f1e1a..ff0d892c21 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -391,8 +391,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // measure 2496.4 KiB locally; retain the smallest bounded ceiling. // The context truncation-rescue notice and its three locale strings measure // 2496.6 KiB; retain the smallest bounded ceiling. -// Source-bound command owners and lifecycle composition measure 2408.0 KiB. -// Deferred presentation extraction in the next slice is budgeted separately. -const rawInitialBudgetKiB = 2_408.1; +// The final App shell and lazy presentation extraction measure 2381.2 KiB. +// Ratchet down the interim ownership slice ceiling to the measured result. +const rawInitialBudgetKiB = 2_381.3; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 8ad7a6f19b..e27f04a391 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -1,677 +1,10 @@ -import { useLayoutEffect, useMemo, useRef, useState, lazy, type CSSProperties } from "react"; -import { useCommittedCommand } from "./lib/useCommittedCommand"; -import { openExternal } from "./lib/bridge"; -import { useT, useI18n, type Translator } from "./lib/i18n"; -import { useToast } from "./lib/toast"; -import { useGoalActionHandler } from "./lib/goalAction"; -import { useActiveRemoteSession, type RemoteSessionApi } from "./lib/useRemoteSession"; -import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel"; -import { setReasoningDisplayPending } from "./lib/reasoningDisplayPreference"; -import { type RestorableToolApprovalMode } from "./lib/toolApprovalMode"; -import { type ComposerProfile, type UserPlanModeIntents } from "./lib/composerProfile"; -import { type TabMeta } from "./lib/types"; -import { type HistoryViewState } from "./app-runtime/historyViewProjection"; -import { useNavigationSurface } from "./lib/useNavigationSurface"; -import { projectNavigationSurfaceTarget } from "./app-runtime/conversationProjection"; -import { useSessionOperations } from "./app-runtime/useSessionOperations"; -import { createSessionSurfaceFence, sessionIdentityKey } from "./app-runtime/sessionTarget"; -import { commitAppRenderToken, createAppRenderToken } from "./app-runtime/appLifecycleProbe"; -import { useAppRuntimeAdapter } from "./app-runtime/useAppRuntimeAdapter"; -import { useAppShellStores } from "./app-runtime/useAppShellStores"; -import { useAppSessionComposition } from "./app-runtime/useAppSessionComposition"; -import { useAppNavigationComposition } from "./app-runtime/useAppNavigationComposition"; -import { useTopicTimeFilter, type TopicTimeFilter } from "./app-runtime/useLocalUiLifecycles"; -import { ShellExpandProvider } from "./lib/shellExpand"; -import { RemoteNavigationContext } from "./lib/remoteNavigationCommands"; -import { UpdaterProvider } from "./lib/useUpdater"; -import { type State } from "./lib/useController"; -import { ShellHotkeys, TextSizeHotkeys } from "./app-shell/HotkeyRegistrations"; -import { WindowChromeLifecycle } from "./app-runtime/WindowChromeLifecycle"; -import { StartupGateLifecycle } from "./app-runtime/StartupGateLifecycle"; -import { AppRuntimeEffects } from "./app-runtime/AppRuntimeEffects"; -import { ThemeBackground } from "./components/ThemeBackground"; -import { AppChrome } from "./components/AppChrome"; -import { SidebarRegion } from "./app-shell/SidebarRegion"; -import { TopicbarRegion } from "./app-shell/TopicbarRegion"; -import { buildTopicbarView, TopicbarActionsStack } from "./app-shell/TopicbarActionsStack"; -import { DockToggleButton } from "./app-shell/DockToggleButton"; -import { SessionStatusBanners } from "./app-shell/SessionStatusBanners"; -import { ChatPaneRegion } from "./app-shell/ChatPaneRegion"; -import { DecisionFooterRegion } from "./app-shell/DecisionFooterRegion"; -import { WorkspaceDockRegion } from "./app-shell/WorkspaceDockRegion"; -import { AppBottomRegions } from "./app-shell/AppBottomRegions"; -import { AppOverlayHost } from "./app-shell/AppOverlayHost"; -import { buildAppShellClassNames, buildSessionStatusBannerProps, buildSidebarRegionProps } from "./app-shell/chromeRegionBuilders"; -import { buildBottomRegionsProps, buildWorkspaceDockProps } from "./app-shell/dockRegionBuilders"; -import { buildOverlayHostProps } from "./app-shell/overlayBuilders"; -import { buildComposerSurface, buildDecisionFooterSurface, buildFooterTodo, buildFooterUndo } from "./app-shell/decisionFooterBuilders"; - - -// Hold reasoning UI until the authoritative desktop startup settings arrive; -// this prevents a hidden preference from flashing content during first paint. -setReasoningDisplayPending(); - +import { AppRuntime } from "./AppRuntime"; /** - * Composition root: owns the controller adapter, the session identity/fence, - * the navigation surface and every store-backed state, then delegates all - * command domains to the session/navigation compositions and the tree to the - * shell view. Wiring only — no domain logic lives here. + * The application entry is intentionally a composition boundary. Runtime + * ownership, domain commands and region view models live below this seam; + * this module must remain free of bridge calls and async coordination. */ export default function App() { - const appRenderToken = createAppRenderToken(); - useLayoutEffect(() => commitAppRenderToken(appRenderToken)); - const runtime = useAppRuntimeAdapter(); - const { state, liveStore, activeTabId, notice } = runtime.snapshot; - const t = useT(); - const { locale } = useI18n(); - const { showToast } = useToast(); - const { runGoalAction, handleGoalActionError } = useGoalActionHandler(); - const [composerProfilesByTab, setComposerProfilesByTab] = useState>({}); - const yoloRestoreToolApprovalModesRef = useRef>({}); - const userPlanModeByTabRef = useRef({}); - const [tabMetas, setTabMetas] = useState([]); - const [tabOrderIds, setTabOrderIds] = useState([]); - const activeTab = useMemo( - () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), - [activeTabId, tabMetas], - ); - const { active: remoteSurfaceActive, session: remoteSession, ready: remoteComposerReady, onSend: remoteSend, onCancel: remoteCancel } = useActiveRemoteSession(activeTab, showToast); - const activeSessionIdentity = sessionIdentityKey({ - tabId: activeTabId, - sessionPath: activeTab?.sessionPath ?? state.meta?.sessionPath, - sessionGeneration: activeTab?.sessionGeneration ?? state.meta?.sessionGeneration ?? state.sessionGen, - scope: activeTab?.scope, - workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd, - topicId: activeTab?.topicId, - }); - const sessionSurfaceFenceRef = useRef | null>(null); - if (!sessionSurfaceFenceRef.current) sessionSurfaceFenceRef.current = createSessionSurfaceFence(); - const sessionSurfaceFence = sessionSurfaceFenceRef.current; - const sessionOperations = useSessionOperations({ - visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, - resources: [ - { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, - ...tabMetas.filter(tab => tab.id !== activeTabId).map(tab => ({ - tabId: tab.id, - sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath, - sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }), - })), - ], - }); - useLayoutEffect(() => { - sessionSurfaceFence.commit(activeTabId, activeSessionIdentity); - return () => sessionSurfaceFence.dispose(); - }, [activeSessionIdentity, activeTabId, sessionSurfaceFence]); - const navigationSurface = useNavigationSurface(projectNavigationSurfaceTarget({ - activeTabId, sessionKey: activeSessionIdentity, local: state, remote: remoteSurfaceActive ? remoteSession : undefined, - })); - const shell = useAppShellStores(); - const [tabRevealSignal, setTabRevealSignal] = useState(0); - const [transcriptRevealSignal, setTranscriptRevealSignal] = useState(0); - const [histView, setHistView] = useState(null); - const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState(""); - const [topicTimeFilter, setTopicTimeFilter] = useTopicTimeFilter(); - const [tasksOpen, setTasksOpen] = useState(false); - const workspaceScopeActiveTabRef = useRef(activeTabId); - const [workspaceControllerEpoch, setWorkspaceControllerEpoch] = useState(0); - workspaceScopeActiveTabRef.current = activeTabId; - const { mounted: terminalContentVisible, fitEnabled: terminalFitEnabled, prefetch: prefetchTerminalPanel } = useWarmTerminalPanel(shell.terminalPanelOpen, shell.terminalResizing, !shell.managementActive); - const [dockRefreshKey, setDockRefreshKey] = useState(0); - const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0); - const refreshComposerFileRefs = useCommittedCommand(() => setFileRefRefreshKey((value) => value + 1)); - const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`; - const [projectRevision, setProjectRevision] = useState(0); - - const session = useAppSessionComposition({ - runtime, - t, - showToast, - shell, - core: { - state, liveStore, activeTabId, notice, activeTab, remoteSurfaceActive, remoteSession, remoteComposerReady, - remoteSend, remoteCancel, activeSessionIdentity, sessionSurfaceFence, sessionOperations, - }, - surface: navigationSurface, - stores: { - composerProfilesByTab, setComposerProfilesByTab, tabMetas, setTabMetas, tabOrderIds, setTabOrderIds, - yoloRestoreToolApprovalModesRef, userPlanModeByTabRef, - }, - local: { - setHistView, setTabRevealSignal, setTranscriptRevealSignal, - sidebarImDetailConnectionId, setSidebarImDetailConnectionId, - workspaceScopeActiveTabRef, workspaceControllerEpoch, setWorkspaceControllerEpoch, - dockRefreshKey, setDockRefreshKey, fileRefRefreshKey, setFileRefRefreshKey, projectRevision, setProjectRevision, - }, - goal: { runGoalAction, handleGoalActionError }, - }); - const navigation = useAppNavigationComposition({ - runtime, - t, - notice, - showToast, - shell, - state, - activeTab, - activeTabId, - activeSessionIdentity, - remoteSurfaceActive, - surface: navigationSurface, - local: { - setHistView, setProjectRevision, - setSidebarImDetailConnectionId, setTasksOpen, - }, - session, - }); - - return ( - - ); + return ; } - - -const WindowsWindowControls = lazy(() => import("./app-shell/WindowsWindowControls").then((module) => ({ default: module.WindowsWindowControls }))); - - -const WORKSPACE_RESIZER_WIDTH = 8; - -const SHOW_CONTEXT_DOCK = true; - - -type Runtime = ReturnType; - -type Shell = ReturnType; - -type SessionComposition = ReturnType; - -type NavigationComposition = ReturnType; - -type LiveStore = Runtime["snapshot"]["liveStore"]; - - -export type AppRuntimeViewProps = { - core: { - state: State; - activeTab: TabMeta | undefined; - activeTabId: string | undefined; - liveStore: LiveStore; - remoteSurfaceActive: boolean; - remoteSession: RemoteSessionApi; - remoteComposerReady: boolean; - remoteCancel: (queuedItemIDs?: string[]) => Promise; - surface: ReturnType; - t: Translator; - locale: string; - onOpenLink: (url: string) => void; - }; - shell: Shell; - session: SessionComposition; - navigation: NavigationComposition; - runtime: Runtime; - local: { - tasksOpen: false | "session" | "all"; - setTasksOpen: React.Dispatch>; - topicTimeFilter: TopicTimeFilter; - setTopicTimeFilter: (value: TopicTimeFilter) => void; - sidebarImDetailConnectionId: string; - setSidebarImDetailConnectionId: React.Dispatch>; - tabRevealSignal: number; - transcriptRevealSignal: number; - histView: HistoryViewState | null; - projectRevision: number; - dockRefreshKey: number; - composerFileRefRefreshKey: string; - refreshComposerFileRefs: () => void; - terminalContentVisible: boolean; - terminalFitEnabled: boolean; - prefetchTerminalPanel: () => void; - }; -}; - - -/** - * Pure assembly of the App shell tree: every region receives its props from - * the session/navigation composition bags and the caller's stores. No hooks - * beyond value memoization live here; ownership stays in the compositions. - */ -export function AppRuntimeView(props: AppRuntimeViewProps) { - const { core, shell, session, navigation, runtime, local } = props; - const { state, activeTab, activeTabId, t, locale } = core; - const { sidebarWorkbench, sidebarCreation, windowsFramelessChrome, managementActive, mainWindowMaximised } = shell; - const { - conversationView, visibleRuntimeState, sidebarImDetailConnection, - surfaceWorkspacePanelRenderable, surfaceWorkspacePanelGridOpen, surfaceWorkspacePanelOverlay, terminalSurfaceOpen, - controllerReady, decisionSurface, visibleDecisionSurface, composerSurfaceHidden, - shellGeometry, appRef, layoutRef, footerHeight, footerRef, - } = session; - const { chromeCommands, navigationCommands } = navigation; - const runtimeTransitioning = core.surface.transitioning; - const browserPreviewChrome = navigation.browserPreviewChrome; - - // Creation keeps the classic sidebar/chat structure while gating chrome tweaks - // behind its own style flag so classic/workbench remain unchanged. - const appChromeHidden = sidebarWorkbench || sidebarCreation; - const workbenchChromeHidden = sidebarWorkbench; - const sidebarClassName = [ - "sidebar", - shell.sidebarCollapsed ? "sidebar--collapsed" : "", - sidebarWorkbench ? "sidebar--workbench" : "", - ].filter(Boolean).join(" "); - const startupSplashHold = !activeTabId && state.meta?.ready !== true && !state.meta?.startupErr; - - const layoutStyle = useMemo( - () => - ({ - "--sidebar-expanded-width": `${shellGeometry.sidebarRenderWidth}px`, - "--chat-min-width": `${shellGeometry.chatReservedWidth}px`, - "--workspace-width": `${shellGeometry.workspacePanelRenderWidth}px`, - "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, - "--terminal-height": `${terminalSurfaceOpen ? shell.liveTerminalHeight ?? shellGeometry.terminalRenderHeight : 0}px`, - }) as CSSProperties, - [shellGeometry.chatReservedWidth, shell.liveTerminalHeight, shellGeometry.sidebarRenderWidth, shellGeometry.terminalRenderHeight, terminalSurfaceOpen, shellGeometry.workspacePanelRenderWidth], - ); - - const shellClassNames = buildAppShellClassNames({ - platform: shell.desktopPlatform, - windowsFrameless: windowsFramelessChrome, - browserPreview: browserPreviewChrome, - workbench: sidebarWorkbench, - creation: sidebarCreation, - imDetailActive: Boolean(sidebarImDetailConnection), - sidebarCollapsed: shell.sidebarCollapsed, - sidebarResizing: shell.sidebarResizing, - dockGridOpen: surfaceWorkspacePanelGridOpen, - dockOverlay: surfaceWorkspacePanelOverlay, - terminalOpen: terminalSurfaceOpen, - terminalResizing: shell.terminalResizing, - dockOpen: shell.workspacePanelOpen, - dockMaximized: shell.workspacePanelMaximized, - dockResizing: shell.workspacePanelResizing, - }); - const footerTodo = buildFooterTodo({ - show: session.todoPanel.showTodos, - identity: session.todoPanel.scopedTodoBatch, - todos: session.todoPanel.todos, - running: visibleRuntimeState.running, - pendingPrompt: visibleRuntimeState.pendingPrompt, - continueReady: Boolean(activeTabId && !activeTab?.readOnly && (core.remoteSurfaceActive ? core.remoteComposerReady : controllerReady)), - onContinue: session.todoPanel.handleTodoContinue, - onDismiss: session.todoPanel.dismissTodos, - }); - const footerUndo = buildFooterUndo({ rewindState: session.sessionUndo.rewindState, activeTabId, onUndo: session.sessionUndo.handleUndoRewind }); - const decisionFooterSurface = buildDecisionFooterSurface({ - view: { - surface: visibleDecisionSurface, - activeTabId, - cwd: state.meta?.cwd, - workspaceScopeKey: session.workspaceScopeKey, - approval: state.approval, - ask: state.ask, - mcpInteraction: state.mcpInteraction, - extensionForm: state.extensionForm, - workspaceConflict: session.workspaceConflict, - toolApprovalMode: session.profileProjection.toolApprovalMode, - insertRequest: session.insertCommands.activePlanRevisionInsertRequest, - }, - prompts: session.promptCommands, - extension: session.extensionSurface, - tabs: session.tabBarCommands, - clear: session.clearCommands, - onStop: () => void session.controlCommands.handleCancelActive(), - cancelWorkspaceConflict: session.controlCommands.cancelWorkspaceConflict, - onOpenLink: core.onOpenLink, - onRevisionActiveChange: session.insertCommands.handleRevisionActiveChange, - t, - }); - - return ( - - - - - - - - -
- - {sidebarWorkbench &&
} -
- {!appChromeHidden && ( - void session.tabBarCommands.handleTabChange(id)} - onTabClose={(id) => void session.tabBarCommands.handleTabClose(id)} - onTabsClose={(ids, nextActiveTabId) => void session.tabBarCommands.handleTabsClose(ids, nextActiveTabId)} - onTabsReorder={(ids) => void session.tabBarCommands.handleTabsReorder(ids)} - onNewTab={() => void navigationCommands.handleNewTab()} - onOpenPalette={() => void navigation.paletteCommands.openPalette()} - /> - )} - - {t("shortcuts.skipToComposer")} - - - void navigationCommands.handleNewTab(), - onOpenTrash: () => void navigation.historyCommands.openTrash(), - onOpenAutomation: () => shell.openPage({ kind: "automation" }), - onOpenSettings: chromeCommands.openSidebarSettings, - onToggleSearch: chromeCommands.toggleSidebarSearch, - onToggle: shellGeometry.toggleSidebar, - onOpenTopic: navigationCommands.handleOpenTopic, - }, - })} /> - -
- shell.openPage({ kind: "automation" }), toggleSidebar: shellGeometry.toggleSidebar, - setTitleDraft: navigation.projectTopicCommands.setTopicTitleDraft, commitRename: navigation.projectTopicCommands.commitActiveTopicRename, cancelRename: navigation.projectTopicCommands.cancelActiveTopicRename, - startRename: navigation.projectTopicCommands.startActiveTopicRename, openWorktree: navigation.worktreeMergeCommands.openWorktreeMerge, - }}> - void navigation.paletteCommands.openPalette()} - activeTab={activeTab} - activeTabId={activeTabId} - imDetailActive={Boolean(sidebarImDetailConnection)} - dismissSignal={shell.transientOverlayDismissSignal} - sessionHasContent={session.sessionHasContent} - exportCommands={session.sessionExportCommands} - terminal={{ toggle: session.terminalPanelCommands.toggleTerminalPanel, enabled: !core.remoteSurfaceActive, open: shell.terminalPanelOpen && !core.remoteSurfaceActive, prefetch: local.prefetchTerminalPanel }} - tasksOpen={local.tasksOpen} - setTasksOpen={local.setTasksOpen} - onCloseTasks={() => local.setTasksOpen(false)} - onOpenTaskSession={navigationCommands.openTaskMonitorSession} - creation={sidebarCreation} - dockToggle={} - /> - - - - - local.setSidebarImDetailConnectionId(""), - onOpenSettings: chromeCommands.openBotSettings, - onManageAllowlist: chromeCommands.openBotAllowlistSettings, - onOpenSession: (connection) => void navigationCommands.openSidebarImConnectionSession(connection), - } : null} - remote={activeTab?.remote ? { tab: activeTab, session: core.remoteSession } : undefined} - transcript={{ - state, - items: session.transcript.visibleTranscriptItems, - tabId: session.transcript.visibleTranscriptTabId, - geometrySessionKey: session.transcript.visibleTranscriptGeometryKey, - footerHeight, - revealSignal: local.transcriptRevealSignal, - invocationMetadata: session.transcript.visibleTranscriptTabId ? session.invocation.invocationMetadataByTab[session.transcript.visibleTranscriptTabId] : undefined, - surfaceCommitToken: core.surface.surfaceCommitToken, - liveStore: core.liveStore, - transcriptHydrating: session.transcript.transcriptHydrating, - navigationDataReady: core.surface.dataReady, - readOnly: Boolean(activeTab?.readOnly), - controllerReady, - hydratePlaceholderActive: session.hydratePlaceholderActive, - clearContextPending: session.clearCommands.clearContextPending, - creation: sidebarCreation, - rewind: { stateActive: session.sessionUndo.rewindState != null, committing: session.sessionUndo.rewindCommitting, signal: session.sessionUndo.rewindSignal }, - }} - onRetryHistory={() => void runtime.sessionActions.retrySessionHistory(activeTabId)} - commands={{ - onPrompt: session.transcript.handleTranscriptPrompt, - onDeliveryContinue: () => void session.delivery.handleDeliveryContinue(), - onAcceptDelivery: session.controlCommands.handleAcceptDelivery, - onOpenChanges: () => session.workspacePanelCommands.openRightDockMode("changed"), - onOpenVerification: session.turnVerificationCommands.openTurnVerification, - onEditPrompt: session.sessionUndo.handleEditPrompt, - onRewind: session.sessionUndo.handleMessageAction, - onLoadOlderHistory: session.transcript.handleLoadOlderHistory, - onSurfacePaintReady: session.transcript.handleSurfacePaintReady, - }} - /> - -
- - 0, - showContext: SHOW_CONTEXT_DOCK, - remote: core.remoteSurfaceActive, - t, - context: conversationView.context, - sessionTurns: session.sessionTurns, - contextRefreshKey: local.dockRefreshKey + visibleRuntimeState.contextPanelSeq, - workspaceKey: session.workspaceTreeMemoryKey, - workspaceScopeKey: session.workspaceScopeKey, - mode: shell.rightDockMode, - meta: state.meta, - tabId: activeTabId, - completionSummary: state.completionSummary, - turnStartAt: state.turnStartAt, - layout: { treeWidth: shell.rightDockTreeWidth, previewWidth: shell.rightDockPreviewWidth, maximized: shell.workspacePanelMaximized }, - geometry: shellGeometry, - panels: session.workspacePanelCommands, - inserts: session.insertCommands, - verification: session.turnVerificationCommands, - qualityFloor: session.profileProjection.composerProfile.qualityFloor, - onFileTreeRefresh: local.refreshComposerFileRefs, - onSessionRevertCommitted: session.sessionUndo.handleSessionRevertCommitted, - onOpenInTerminal: core.remoteSurfaceActive ? undefined : session.terminalPanelCommands.openTerminalForPath, - })} /> - void session.insertCommands.addTerminalOutputToComposer(sessionId), - onAddToChat: session.insertCommands.addTerminalSelectionToComposer, - }, - status: !session.statusBarVisible ? undefined : { - base: conversationView.status, - rewindCommitting: session.sessionUndo.rewindCommitting, - sessionTurns: session.sessionTurns, - labelStyle: shell.preferences.statusBarStyle, - items: shell.preferences.statusBarItems, - extensionStatuses: session.extensionStatusList, - remoteHosts: shell.remoteHosts, - remoteStatuses: shell.remoteStatuses, - onCancelJob: core.remoteSurfaceActive ? core.remoteSession.cancelJob : runtime.composer.cancelJob, - onCancelRuntimeJob: session.controlCommands.cancelRuntimeJob, - onRevealRuntime: session.tabBarCommands.revealBackgroundRuntime, - onConnectRemote: session.remoteWorkspaceCommands.connectAndOpenRemoteWorkspace, - onDisconnectRemote: session.controlCommands.handleDisconnectRemote, - onManageRemote: () => shell.setSettingsTarget("remote"), - onOpenRemote: shell.requestRemoteExplorer, - onOpenRemoteWorkspace: session.remoteWorkspaceCommands.openRemoteWorkspaceFromStatus, - }, - })} /> -
- - - {windowsFramelessChrome && ( - - )} -
-
-
-
- ); -} \ No newline at end of file diff --git a/desktop/frontend/src/AppRuntime.tsx b/desktop/frontend/src/AppRuntime.tsx new file mode 100644 index 0000000000..0cb013fe43 --- /dev/null +++ b/desktop/frontend/src/AppRuntime.tsx @@ -0,0 +1,161 @@ +import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCommittedCommand } from "./lib/useCommittedCommand"; +import { openExternal } from "./lib/bridge"; +import { useT, useI18n } from "./lib/i18n"; +import { useToast } from "./lib/toast"; +import { useGoalActionHandler } from "./lib/goalAction"; +import { useActiveRemoteSession } from "./lib/useRemoteSession"; +import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel"; +import { setReasoningDisplayPending } from "./lib/reasoningDisplayPreference"; +import type { RestorableToolApprovalMode } from "./lib/toolApprovalMode"; +import type { ComposerProfile, UserPlanModeIntents } from "./lib/composerProfile"; +import type { TabMeta } from "./lib/types"; +import type { HistoryViewState } from "./app-runtime/historyViewProjection"; +import { useNavigationSurface } from "./lib/useNavigationSurface"; +import { projectNavigationSurfaceTarget } from "./app-runtime/conversationProjection"; +import { useSessionOperations } from "./app-runtime/useSessionOperations"; +import { createSessionSurfaceFence, sessionIdentityKey } from "./app-runtime/sessionTarget"; +import { commitAppRenderToken, createAppRenderToken } from "./app-runtime/appLifecycleProbe"; +import { useAppRuntimeAdapter } from "./app-runtime/useAppRuntimeAdapter"; +import { useAppShellStores } from "./app-runtime/useAppShellStores"; +import { useAppSessionComposition } from "./app-runtime/useAppSessionComposition"; +import { useAppNavigationComposition } from "./app-runtime/useAppNavigationComposition"; +import { useTopicTimeFilter } from "./app-runtime/useLocalUiLifecycles"; +import { AppRuntimeView } from "./app-shell/AppRuntimeView"; + +// Hold reasoning UI until the authoritative desktop startup settings arrive; +// this prevents a hidden preference from flashing content during first paint. +setReasoningDisplayPending(); + +/** + * Composition root: owns the controller adapter, the session identity/fence, + * the navigation surface and every store-backed state, then delegates all + * command domains to the session/navigation compositions and the tree to the + * shell view. Wiring only — no domain logic lives here. + */ +export function AppRuntime() { + const appRenderToken = createAppRenderToken(); + useLayoutEffect(() => commitAppRenderToken(appRenderToken)); + const runtime = useAppRuntimeAdapter(); + const { state, liveStore, activeTabId, notice } = runtime.snapshot; + const t = useT(); + const { locale } = useI18n(); + const { showToast } = useToast(); + const { runGoalAction, handleGoalActionError } = useGoalActionHandler(); + const [composerProfilesByTab, setComposerProfilesByTab] = useState>({}); + const yoloRestoreToolApprovalModesRef = useRef>({}); + const userPlanModeByTabRef = useRef({}); + const [tabMetas, setTabMetas] = useState([]); + const [tabOrderIds, setTabOrderIds] = useState([]); + const activeTab = useMemo( + () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), + [activeTabId, tabMetas], + ); + const { active: remoteSurfaceActive, session: remoteSession, ready: remoteComposerReady, onSend: remoteSend, onCancel: remoteCancel } = useActiveRemoteSession(activeTab, showToast); + const activeSessionIdentity = sessionIdentityKey({ + tabId: activeTabId, + sessionPath: activeTab?.sessionPath ?? state.meta?.sessionPath, + sessionGeneration: activeTab?.sessionGeneration ?? state.meta?.sessionGeneration ?? state.sessionGen, + scope: activeTab?.scope, + workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd, + topicId: activeTab?.topicId, + }); + const sessionSurfaceFenceRef = useRef | null>(null); + if (!sessionSurfaceFenceRef.current) sessionSurfaceFenceRef.current = createSessionSurfaceFence(); + const sessionSurfaceFence = sessionSurfaceFenceRef.current; + const sessionOperations = useSessionOperations({ + visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + resources: [ + { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, + ...tabMetas.filter(tab => tab.id !== activeTabId).map(tab => ({ + tabId: tab.id, + sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath, + sessionGeneration: tab.sessionGeneration, scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }), + })), + ], + }); + useLayoutEffect(() => { + sessionSurfaceFence.commit(activeTabId, activeSessionIdentity); + return () => sessionSurfaceFence.dispose(); + }, [activeSessionIdentity, activeTabId, sessionSurfaceFence]); + const navigationSurface = useNavigationSurface(projectNavigationSurfaceTarget({ + activeTabId, sessionKey: activeSessionIdentity, local: state, remote: remoteSurfaceActive ? remoteSession : undefined, + })); + const shell = useAppShellStores(); + const [tabRevealSignal, setTabRevealSignal] = useState(0); + const [transcriptRevealSignal, setTranscriptRevealSignal] = useState(0); + const [histView, setHistView] = useState(null); + const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState(""); + const [topicTimeFilter, setTopicTimeFilter] = useTopicTimeFilter(); + const [tasksOpen, setTasksOpen] = useState(false); + const workspaceScopeActiveTabRef = useRef(activeTabId); + const [workspaceControllerEpoch, setWorkspaceControllerEpoch] = useState(0); + workspaceScopeActiveTabRef.current = activeTabId; + const { mounted: terminalContentVisible, fitEnabled: terminalFitEnabled, prefetch: prefetchTerminalPanel } = useWarmTerminalPanel(shell.terminalPanelOpen, shell.terminalResizing, !shell.managementActive); + const [dockRefreshKey, setDockRefreshKey] = useState(0); + const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0); + const refreshComposerFileRefs = useCommittedCommand(() => setFileRefRefreshKey((value) => value + 1)); + const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`; + const [projectRevision, setProjectRevision] = useState(0); + + const session = useAppSessionComposition({ + runtime, + t, + showToast, + shell, + core: { + state, liveStore, activeTabId, notice, activeTab, remoteSurfaceActive, remoteSession, remoteComposerReady, + remoteSend, remoteCancel, activeSessionIdentity, sessionSurfaceFence, sessionOperations, + }, + surface: navigationSurface, + stores: { + composerProfilesByTab, setComposerProfilesByTab, tabMetas, setTabMetas, tabOrderIds, setTabOrderIds, + yoloRestoreToolApprovalModesRef, userPlanModeByTabRef, + }, + local: { + setHistView, setTabRevealSignal, setTranscriptRevealSignal, + sidebarImDetailConnectionId, setSidebarImDetailConnectionId, + workspaceScopeActiveTabRef, workspaceControllerEpoch, setWorkspaceControllerEpoch, + dockRefreshKey, setDockRefreshKey, fileRefRefreshKey, setFileRefRefreshKey, projectRevision, setProjectRevision, + }, + goal: { runGoalAction, handleGoalActionError }, + }); + const navigation = useAppNavigationComposition({ + runtime, + t, + notice, + showToast, + shell, + state, + activeTab, + activeTabId, + activeSessionIdentity, + remoteSurfaceActive, + surface: navigationSurface, + local: { + setHistView, setProjectRevision, + setSidebarImDetailConnectionId, setTasksOpen, + }, + session, + }); + + return ( + + ); +} diff --git a/desktop/frontend/src/__tests__/add-project-entries.test.ts b/desktop/frontend/src/__tests__/add-project-entries.test.ts index 17428c10c8..5fa9b938cd 100644 --- a/desktop/frontend/src/__tests__/add-project-entries.test.ts +++ b/desktop/frontend/src/__tests__/add-project-entries.test.ts @@ -24,7 +24,7 @@ const here = dirname(fileURLToPath(import.meta.url)); const treeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8"); const addControlsSource = readFileSync(resolve(here, "../components/ProjectTreeAddControls.tsx"), "utf8"); const hookSource = readFileSync(resolve(here, "../components/useProjectCreation.tsx"), "utf8"); -const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); +const appSource = readFileSync(resolve(here, "../AppRuntime.tsx"), "utf8"); const locales = ["en", "zh", "zh-TW"].map((name) => readFileSync(resolve(here, `../locales/${name}.ts`), "utf8"), ); diff --git a/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts b/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts index 4b5e0e4563..f90633fb67 100644 --- a/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts +++ b/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts @@ -7,7 +7,7 @@ import { createBoundedRefreshCoordinator, sameTabMetaLists, shouldRefreshTabMeta import type { TabMeta } from "../lib/types"; const testDir = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"), workspaceFocusSource = readFileSync(resolve(testDir, "../lib/workspaceRefreshStore.ts"), "utf8"); +const appSource = readFileSync(resolve(testDir, "../AppRuntime.tsx"), "utf8"), workspaceFocusSource = readFileSync(resolve(testDir, "../lib/workspaceRefreshStore.ts"), "utf8"); const appChromeSource = readFileSync(resolve(testDir, "../components/AppChrome.tsx"), "utf8"); const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8"); const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8"); @@ -19,7 +19,7 @@ const chromeCommandsSource = readFileSync(resolve(testDir, "../app-runtime/useAp const dockToggleSource = readFileSync(resolve(testDir, "../app-shell/DockToggleButton.tsx"), "utf8"); const chatPaneSource = readFileSync(resolve(testDir, "../app-shell/ChatPaneRegion.tsx"), "utf8"); const transcriptSurfaceSource = readFileSync(resolve(testDir, "../app-runtime/useTranscriptSurfaceProjection.ts"), "utf8"); -const appViewSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const appViewSource = readFileSync(resolve(testDir, "../app-shell/AppRuntimeView.tsx"), "utf8"); const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript.tsx"), "utf8"); const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8"); const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8"), forkWorktreeSource = readFileSync(resolve(testDir, "../lib/forkWorktree.ts"), "utf8"); diff --git a/desktop/frontend/src/__tests__/automation-surface-layout.test.ts b/desktop/frontend/src/__tests__/automation-surface-layout.test.ts index 914eb2e10a..2eadae2a5c 100644 --- a/desktop/frontend/src/__tests__/automation-surface-layout.test.ts +++ b/desktop/frontend/src/__tests__/automation-surface-layout.test.ts @@ -3,14 +3,14 @@ import { readFileSync } from "node:fs"; import { JSDOM } from "jsdom"; const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); -const app = read("../App.tsx"); +const app = read("../AppRuntime.tsx"); const isolation = read("../lib/useManagementWorkspace.ts"); const shell = read("../components/ManagementPageShell.tsx"); const css = read("../components/ManagementPageShell.css"); const heartbeat = read("../custom/features/heartbeat/HeartbeatPanel.tsx"); const warmth = read("../lib/useWarmTerminalPanel.ts"); const sessionComposition = read("../app-runtime/useAppSessionComposition.ts"); -const appView = read("../App.tsx"); +const appView = read("../app-shell/AppRuntimeView.tsx"); const chromeCommands = read("../app-runtime/useAppChromeCommands.ts"); const palette = read("../app-runtime/usePaletteCommands.tsx"); diff --git a/desktop/frontend/src/__tests__/footer-decision-overflow.test.ts b/desktop/frontend/src/__tests__/footer-decision-overflow.test.ts index e02320857a..1ff105b520 100644 --- a/desktop/frontend/src/__tests__/footer-decision-overflow.test.ts +++ b/desktop/frontend/src/__tests__/footer-decision-overflow.test.ts @@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url"; const testDir = dirname(fileURLToPath(import.meta.url)); // Strip comments so declaration parsing never matches prose inside them. const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); -const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const appSource = readFileSync(resolve(testDir, "../app-shell/AppRuntimeView.tsx"), "utf8"); const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8"); let passed = 0; diff --git a/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts b/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts index 5a9915ca5e..69ade6c79b 100644 --- a/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts +++ b/desktop/frontend/src/__tests__/history-load-failure-contract.test.ts @@ -8,7 +8,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const controller = readFileSync(join(root, "lib/useController.ts"), "utf8"); const store = readFileSync(join(root, "lib/transcriptStore.ts"), "utf8"); const chatPane = readFileSync(join(root, "app-shell/ChatPaneRegion.tsx"), "utf8"); -const appView = readFileSync(join(root, "App.tsx"), "utf8"); +const appView = readFileSync(join(root, "app-shell/AppRuntimeView.tsx"), "utf8"); assert.match(controller, /deferResetUntilHistory \?\? true/, "history reset waits for successful load"); assert.match(controller, /type: "hydrate_error"/, "history failure dispatches hydrate_error"); diff --git a/desktop/frontend/src/__tests__/mcp-interaction.test.tsx b/desktop/frontend/src/__tests__/mcp-interaction.test.tsx index bc6e21fd08..2ea017c6b4 100644 --- a/desktop/frontend/src/__tests__/mcp-interaction.test.tsx +++ b/desktop/frontend/src/__tests__/mcp-interaction.test.tsx @@ -48,7 +48,7 @@ function ok(value: boolean, label: string) { type ControllerState = Parameters[0]; -const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8"); +const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8"); const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8"); ok( /\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource), diff --git a/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts b/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts index 3ef66ea6fd..35b84a3a8f 100644 --- a/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts +++ b/desktop/frontend/src/__tests__/navigation-surface-transition.test.ts @@ -109,9 +109,9 @@ releaseReassert(); ok(await staleAcceptedPromise === false, "a stale backend-activating result is rejected after reassertion"); ok(reasserted === "tab.reveal-background:tab-stale", "stale reassertion receives the mutating target identity"); -const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8"); +const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8"); const chatPaneSource = readFileSync(new URL("../app-shell/ChatPaneRegion.tsx", import.meta.url), "utf8"); -const appViewSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8"); +const appViewSource = readFileSync(new URL("../app-shell/AppRuntimeView.tsx", import.meta.url), "utf8"); const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8"); const surfaceHookSource = readFileSync(new URL("../lib/useNavigationSurface.ts", import.meta.url), "utf8"); const tabBarSource = readFileSync(new URL("../app-runtime/useTabBarCommands.ts", import.meta.url), "utf8"); diff --git a/desktop/frontend/src/__tests__/recovery-banner-privacy.test.ts b/desktop/frontend/src/__tests__/recovery-banner-privacy.test.ts index 5a446a031a..e7a77094ce 100644 --- a/desktop/frontend/src/__tests__/recovery-banner-privacy.test.ts +++ b/desktop/frontend/src/__tests__/recovery-banner-privacy.test.ts @@ -18,7 +18,7 @@ function ok(cond: boolean, label: string) { } const here = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); +const appSource = readFileSync(resolve(here, "../AppRuntime.tsx"), "utf8"); console.log("\nquiet recovery prompt privacy"); diff --git a/desktop/frontend/src/__tests__/send-failed.test.ts b/desktop/frontend/src/__tests__/send-failed.test.ts index 66cded9f86..7b7f3f17a7 100644 --- a/desktop/frontend/src/__tests__/send-failed.test.ts +++ b/desktop/frontend/src/__tests__/send-failed.test.ts @@ -270,7 +270,7 @@ eq( ); const here = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8"); +const appSource = readFileSync(resolve(here, "../AppRuntime.tsx"), "utf8"); const sessionCompositionSource = readFileSync(resolve(here, "../app-runtime/useAppSessionComposition.ts"), "utf8"); const typesSource = readFileSync(resolve(here, "../lib/types.ts"), "utf8"); const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8"); diff --git a/desktop/frontend/src/__tests__/subagent-progress-card.test.tsx b/desktop/frontend/src/__tests__/subagent-progress-card.test.tsx index e1f7c31ca1..e1534e7479 100644 --- a/desktop/frontend/src/__tests__/subagent-progress-card.test.tsx +++ b/desktop/frontend/src/__tests__/subagent-progress-card.test.tsx @@ -302,5 +302,56 @@ console.log("\nsubagent progress card"); dom.window.close(); } +{ + const dom = installDom(); + const rootEl = document.getElementById("root"); + if (!rootEl) throw new Error("missing root"); + const root = createRoot(rootEl); + const live = makeItem("partial"); + live.status = "error"; + live.subagentOutcome = ["sa_live", "partial", "completion_uncertain", true]; + + await act(async () => { + root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { item: live }))); + await flushTimers(); + }); + await act(async () => { + document.querySelector(".tool__head")?.click(); + for (let i = 0; i < 50; i += 1) { + await flushTimers(); + if (document.querySelector(".tool__subagent-outcome")) break; + } + }); + ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("partially complete"), "live outcome tuple renders through the lazy card boundary"); + ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("sa_live"), "live outcome keeps the stable subagent reference"); + ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("completion_uncertain"), "live outcome exposes the bounded error code"); + + const history: ToolItem = { + kind: "tool", + id: "task-history-outcome", + name: "task", + args: "{}", + readOnly: true, + status: "error", + output: "Subagent reference (failed): sa_history\nSubagent outcome: status=failed retryable=false error_code=provider_error", + }; + await act(async () => { + root.render(React.createElement(LocaleProvider, null, React.createElement(ToolCard, { key: history.id, item: history }))); + await flushTimers(); + }); + await act(async () => { + document.querySelector(".tool__head")?.click(); + for (let i = 0; i < 50; i += 1) { + await flushTimers(); + if (document.querySelector(".tool__subagent-outcome code")?.textContent === "sa_history") break; + } + }); + ok(document.querySelector(".tool__subagent-outcome code")?.textContent === "sa_history", "history outcome is parsed only when the card is opened"); + ok(document.querySelector(".tool__subagent-outcome")?.textContent?.includes("failed"), "history outcome uses the same localized status projection"); + + await act(async () => root.unmount()); + dom.window.close(); +} + console.log(`\nsubagent progress card: ${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/subagent-progress.test.ts b/desktop/frontend/src/__tests__/subagent-progress.test.ts index cf0cdbfda5..9b17f96771 100644 --- a/desktop/frontend/src/__tests__/subagent-progress.test.ts +++ b/desktop/frontend/src/__tests__/subagent-progress.test.ts @@ -298,5 +298,26 @@ console.log("\nsubagent progress reducer"); ok(archived.subagentProgress !== undefined, "subagentProgress survives result archiving"); } +// --- 11. Terminal outcome metadata survives output archiving --------------- + +{ + let s = initialState; + s = dispatch(s, { id: "outcome-1", name: "task", args: "{}", readOnly: true }); + s = progress(s, progressTool("outcome-1", SUBAGENT_PROGRESS_STATUS, "partial")); + s = result(s, { + id: "outcome-1", + name: "task", + readOnly: true, + output: "Subagent reference: sa_child\nSubagent outcome: status=partial retryable=true error_code=completion_uncertain", + subagentRef: "sa_child", + subagentStatus: "partial", + subagentErrorCode: "completion_uncertain", + subagentRetryable: true, + }); + const archived = toolById(s, "outcome-1"); + eq(JSON.stringify(archived.subagentOutcome), JSON.stringify(["sa_child", "partial", "completion_uncertain", true]), "terminal outcome is normalized once at the result boundary"); + eq(archived.output, undefined, "outcome metadata survives without retaining archived tool output"); +} + console.log(`\nsubagent progress: ${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); diff --git a/desktop/frontend/src/__tests__/theme-pack.test.ts b/desktop/frontend/src/__tests__/theme-pack.test.ts index 541bfbaa9d..8d734281ae 100644 --- a/desktop/frontend/src/__tests__/theme-pack.test.ts +++ b/desktop/frontend/src/__tests__/theme-pack.test.ts @@ -42,7 +42,7 @@ import { const testDir = dirname(fileURLToPath(import.meta.url)); const packSource = readFileSync(resolve(testDir, "../lib/themePack.ts"), "utf8"); const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8"); -const appViewSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const appViewSource = readFileSync(resolve(testDir, "../app-shell/AppRuntimeView.tsx"), "utf8"); const exportOwnerSource = readFileSync(resolve(testDir, "../app-runtime/useSessionExportCommands.ts"), "utf8"); const composerRouterSource = readFileSync(resolve(testDir, "../app-runtime/useComposerRouter.ts"), "utf8"); const librarySource = readFileSync(resolve(testDir, "../components/ThemeLibrary.tsx"), "utf8"); diff --git a/desktop/frontend/src/__tests__/topicbar-controls.test.ts b/desktop/frontend/src/__tests__/topicbar-controls.test.ts index de615cfd37..58c690c2ef 100644 --- a/desktop/frontend/src/__tests__/topicbar-controls.test.ts +++ b/desktop/frontend/src/__tests__/topicbar-controls.test.ts @@ -6,7 +6,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const testDir = dirname(fileURLToPath(import.meta.url)); -const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8"); +const appSource = readFileSync(resolve(testDir, "../AppRuntime.tsx"), "utf8"); const dockToggleSource = readFileSync(resolve(testDir, "../app-shell/DockToggleButton.tsx"), "utf8"); const sessionActionsSource = readFileSync(resolve(testDir, "../components/TopicbarSessionActions.tsx"), "utf8"); diff --git a/desktop/frontend/src/app-shell/AppRuntimeView.tsx b/desktop/frontend/src/app-shell/AppRuntimeView.tsx new file mode 100644 index 0000000000..8583fab8f8 --- /dev/null +++ b/desktop/frontend/src/app-shell/AppRuntimeView.tsx @@ -0,0 +1,516 @@ +import { lazy, useMemo, type CSSProperties } from "react"; +import { ShellExpandProvider } from "../lib/shellExpand"; +import { RemoteNavigationContext } from "../lib/remoteNavigationCommands"; +import { UpdaterProvider } from "../lib/useUpdater"; +import type { State } from "../lib/useController"; +import type { TabMeta } from "../lib/types"; +import type { RemoteSessionApi } from "../lib/useRemoteSession"; +import type { Translator } from "../lib/i18n"; +import type { useAppRuntimeAdapter } from "../app-runtime/useAppRuntimeAdapter"; +import type { useNavigationSurface } from "../lib/useNavigationSurface"; +import type { useAppShellStores } from "../app-runtime/useAppShellStores"; +import type { useAppSessionComposition } from "../app-runtime/useAppSessionComposition"; +import type { useAppNavigationComposition } from "../app-runtime/useAppNavigationComposition"; +import type { HistoryViewState } from "../app-runtime/historyViewProjection"; +import type { TopicTimeFilter } from "../app-runtime/useLocalUiLifecycles"; +import { ShellHotkeys, TextSizeHotkeys } from "./HotkeyRegistrations"; +import { WindowChromeLifecycle } from "../app-runtime/WindowChromeLifecycle"; +import { StartupGateLifecycle } from "../app-runtime/StartupGateLifecycle"; +import { AppRuntimeEffects } from "../app-runtime/AppRuntimeEffects"; +import { ThemeBackground } from "../components/ThemeBackground"; +import { AppChrome } from "../components/AppChrome"; +import { SidebarRegion } from "./SidebarRegion"; +import { TopicbarRegion } from "./TopicbarRegion"; +import { buildTopicbarView, TopicbarActionsStack } from "./TopicbarActionsStack"; +import { DockToggleButton } from "./DockToggleButton"; +import { SessionStatusBanners } from "./SessionStatusBanners"; +import { ChatPaneRegion } from "./ChatPaneRegion"; +import { DecisionFooterRegion } from "./DecisionFooterRegion"; +import { WorkspaceDockRegion } from "./WorkspaceDockRegion"; +import { AppBottomRegions } from "./AppBottomRegions"; +import { AppOverlayHost } from "./AppOverlayHost"; +import { buildAppShellClassNames, buildSessionStatusBannerProps, buildSidebarRegionProps } from "./chromeRegionBuilders"; +import { buildBottomRegionsProps, buildWorkspaceDockProps } from "./dockRegionBuilders"; +import { buildOverlayHostProps } from "./overlayBuilders"; +import { buildComposerSurface, buildDecisionFooterSurface, buildFooterTodo, buildFooterUndo } from "./decisionFooterBuilders"; + +const WindowsWindowControls = lazy(() => import("./WindowsWindowControls").then((module) => ({ default: module.WindowsWindowControls }))); + +const WORKSPACE_RESIZER_WIDTH = 8; +const SHOW_CONTEXT_DOCK = true; + +type Runtime = ReturnType; +type Shell = ReturnType; +type SessionComposition = ReturnType; +type NavigationComposition = ReturnType; +type LiveStore = Runtime["snapshot"]["liveStore"]; + +export type AppRuntimeViewProps = { + core: { + state: State; + activeTab: TabMeta | undefined; + activeTabId: string | undefined; + liveStore: LiveStore; + remoteSurfaceActive: boolean; + remoteSession: RemoteSessionApi; + remoteComposerReady: boolean; + remoteCancel: (queuedItemIDs?: string[]) => Promise; + surface: ReturnType; + t: Translator; + locale: string; + onOpenLink: (url: string) => void; + }; + shell: Shell; + session: SessionComposition; + navigation: NavigationComposition; + runtime: Runtime; + local: { + tasksOpen: false | "session" | "all"; + setTasksOpen: React.Dispatch>; + topicTimeFilter: TopicTimeFilter; + setTopicTimeFilter: (value: TopicTimeFilter) => void; + sidebarImDetailConnectionId: string; + setSidebarImDetailConnectionId: React.Dispatch>; + tabRevealSignal: number; + transcriptRevealSignal: number; + histView: HistoryViewState | null; + projectRevision: number; + dockRefreshKey: number; + composerFileRefRefreshKey: string; + refreshComposerFileRefs: () => void; + terminalContentVisible: boolean; + terminalFitEnabled: boolean; + prefetchTerminalPanel: () => void; + }; +}; + +/** + * Pure assembly of the App shell tree: every region receives its props from + * the session/navigation composition bags and the caller's stores. No hooks + * beyond value memoization live here; ownership stays in the compositions. + */ +export function AppRuntimeView(props: AppRuntimeViewProps) { + const { core, shell, session, navigation, runtime, local } = props; + const { state, activeTab, activeTabId, t, locale } = core; + const { sidebarWorkbench, sidebarCreation, windowsFramelessChrome, managementActive, mainWindowMaximised } = shell; + const { + conversationView, visibleRuntimeState, sidebarImDetailConnection, + surfaceWorkspacePanelRenderable, surfaceWorkspacePanelGridOpen, surfaceWorkspacePanelOverlay, terminalSurfaceOpen, + controllerReady, decisionSurface, visibleDecisionSurface, composerSurfaceHidden, + shellGeometry, appRef, layoutRef, footerHeight, footerRef, + } = session; + const { chromeCommands, navigationCommands } = navigation; + const runtimeTransitioning = core.surface.transitioning; + const browserPreviewChrome = navigation.browserPreviewChrome; + + // Creation keeps the classic sidebar/chat structure while gating chrome tweaks + // behind its own style flag so classic/workbench remain unchanged. + const appChromeHidden = sidebarWorkbench || sidebarCreation; + const workbenchChromeHidden = sidebarWorkbench; + const sidebarClassName = [ + "sidebar", + shell.sidebarCollapsed ? "sidebar--collapsed" : "", + sidebarWorkbench ? "sidebar--workbench" : "", + ].filter(Boolean).join(" "); + const startupSplashHold = !activeTabId && state.meta?.ready !== true && !state.meta?.startupErr; + + const layoutStyle = useMemo( + () => + ({ + "--sidebar-expanded-width": `${shellGeometry.sidebarRenderWidth}px`, + "--chat-min-width": `${shellGeometry.chatReservedWidth}px`, + "--workspace-width": `${shellGeometry.workspacePanelRenderWidth}px`, + "--workspace-resizer-width": `${WORKSPACE_RESIZER_WIDTH}px`, + "--terminal-height": `${terminalSurfaceOpen ? shell.liveTerminalHeight ?? shellGeometry.terminalRenderHeight : 0}px`, + }) as CSSProperties, + [shellGeometry.chatReservedWidth, shell.liveTerminalHeight, shellGeometry.sidebarRenderWidth, shellGeometry.terminalRenderHeight, terminalSurfaceOpen, shellGeometry.workspacePanelRenderWidth], + ); + + const shellClassNames = buildAppShellClassNames({ + platform: shell.desktopPlatform, + windowsFrameless: windowsFramelessChrome, + browserPreview: browserPreviewChrome, + workbench: sidebarWorkbench, + creation: sidebarCreation, + imDetailActive: Boolean(sidebarImDetailConnection), + sidebarCollapsed: shell.sidebarCollapsed, + sidebarResizing: shell.sidebarResizing, + dockGridOpen: surfaceWorkspacePanelGridOpen, + dockOverlay: surfaceWorkspacePanelOverlay, + terminalOpen: terminalSurfaceOpen, + terminalResizing: shell.terminalResizing, + dockOpen: shell.workspacePanelOpen, + dockMaximized: shell.workspacePanelMaximized, + dockResizing: shell.workspacePanelResizing, + }); + const footerTodo = buildFooterTodo({ + show: session.todoPanel.showTodos, + identity: session.todoPanel.scopedTodoBatch, + todos: session.todoPanel.todos, + running: visibleRuntimeState.running, + pendingPrompt: visibleRuntimeState.pendingPrompt, + continueReady: Boolean(activeTabId && !activeTab?.readOnly && (core.remoteSurfaceActive ? core.remoteComposerReady : controllerReady)), + onContinue: session.todoPanel.handleTodoContinue, + onDismiss: session.todoPanel.dismissTodos, + }); + const footerUndo = buildFooterUndo({ rewindState: session.sessionUndo.rewindState, activeTabId, onUndo: session.sessionUndo.handleUndoRewind }); + const decisionFooterSurface = buildDecisionFooterSurface({ + view: { + surface: visibleDecisionSurface, + activeTabId, + cwd: state.meta?.cwd, + workspaceScopeKey: session.workspaceScopeKey, + approval: state.approval, + ask: state.ask, + mcpInteraction: state.mcpInteraction, + extensionForm: state.extensionForm, + workspaceConflict: session.workspaceConflict, + toolApprovalMode: session.profileProjection.toolApprovalMode, + insertRequest: session.insertCommands.activePlanRevisionInsertRequest, + }, + prompts: session.promptCommands, + extension: session.extensionSurface, + tabs: session.tabBarCommands, + clear: session.clearCommands, + onStop: () => void session.controlCommands.handleCancelActive(), + cancelWorkspaceConflict: session.controlCommands.cancelWorkspaceConflict, + onOpenLink: core.onOpenLink, + onRevisionActiveChange: session.insertCommands.handleRevisionActiveChange, + t, + }); + + return ( + + + + + + + + +
+ + {sidebarWorkbench &&
} +
+ {!appChromeHidden && ( + void session.tabBarCommands.handleTabChange(id)} + onTabClose={(id) => void session.tabBarCommands.handleTabClose(id)} + onTabsClose={(ids, nextActiveTabId) => void session.tabBarCommands.handleTabsClose(ids, nextActiveTabId)} + onTabsReorder={(ids) => void session.tabBarCommands.handleTabsReorder(ids)} + onNewTab={() => void navigationCommands.handleNewTab()} + onOpenPalette={() => void navigation.paletteCommands.openPalette()} + /> + )} + + {t("shortcuts.skipToComposer")} + + + void navigationCommands.handleNewTab(), + onOpenTrash: () => void navigation.historyCommands.openTrash(), + onOpenAutomation: () => shell.openPage({ kind: "automation" }), + onOpenSettings: chromeCommands.openSidebarSettings, + onToggleSearch: chromeCommands.toggleSidebarSearch, + onToggle: shellGeometry.toggleSidebar, + onOpenTopic: navigationCommands.handleOpenTopic, + }, + })} /> + +
+ shell.openPage({ kind: "automation" }), toggleSidebar: shellGeometry.toggleSidebar, + setTitleDraft: navigation.projectTopicCommands.setTopicTitleDraft, commitRename: navigation.projectTopicCommands.commitActiveTopicRename, cancelRename: navigation.projectTopicCommands.cancelActiveTopicRename, + startRename: navigation.projectTopicCommands.startActiveTopicRename, openWorktree: navigation.worktreeMergeCommands.openWorktreeMerge, + }}> + void navigation.paletteCommands.openPalette()} + activeTab={activeTab} + activeTabId={activeTabId} + imDetailActive={Boolean(sidebarImDetailConnection)} + dismissSignal={shell.transientOverlayDismissSignal} + sessionHasContent={session.sessionHasContent} + exportCommands={session.sessionExportCommands} + terminal={{ toggle: session.terminalPanelCommands.toggleTerminalPanel, enabled: !core.remoteSurfaceActive, open: shell.terminalPanelOpen && !core.remoteSurfaceActive, prefetch: local.prefetchTerminalPanel }} + tasksOpen={local.tasksOpen} + setTasksOpen={local.setTasksOpen} + onCloseTasks={() => local.setTasksOpen(false)} + onOpenTaskSession={navigationCommands.openTaskMonitorSession} + creation={sidebarCreation} + dockToggle={} + /> + + + + + local.setSidebarImDetailConnectionId(""), + onOpenSettings: chromeCommands.openBotSettings, + onManageAllowlist: chromeCommands.openBotAllowlistSettings, + onOpenSession: (connection) => void navigationCommands.openSidebarImConnectionSession(connection), + } : null} + remote={activeTab?.remote ? { tab: activeTab, session: core.remoteSession } : undefined} + transcript={{ + state, + items: session.transcript.visibleTranscriptItems, + tabId: session.transcript.visibleTranscriptTabId, + geometrySessionKey: session.transcript.visibleTranscriptGeometryKey, + footerHeight, + revealSignal: local.transcriptRevealSignal, + invocationMetadata: session.transcript.visibleTranscriptTabId ? session.invocation.invocationMetadataByTab[session.transcript.visibleTranscriptTabId] : undefined, + surfaceCommitToken: core.surface.surfaceCommitToken, + liveStore: core.liveStore, + transcriptHydrating: session.transcript.transcriptHydrating, + navigationDataReady: core.surface.dataReady, + readOnly: Boolean(activeTab?.readOnly), + controllerReady, + hydratePlaceholderActive: session.hydratePlaceholderActive, + clearContextPending: session.clearCommands.clearContextPending, + creation: sidebarCreation, + rewind: { stateActive: session.sessionUndo.rewindState != null, committing: session.sessionUndo.rewindCommitting, signal: session.sessionUndo.rewindSignal }, + }} + onRetryHistory={() => void runtime.sessionActions.retrySessionHistory(activeTabId)} + commands={{ + onPrompt: session.transcript.handleTranscriptPrompt, + onDeliveryContinue: () => void session.delivery.handleDeliveryContinue(), + onAcceptDelivery: session.controlCommands.handleAcceptDelivery, + onOpenChanges: () => session.workspacePanelCommands.openRightDockMode("changed"), + onOpenVerification: session.turnVerificationCommands.openTurnVerification, + onEditPrompt: session.sessionUndo.handleEditPrompt, + onRewind: session.sessionUndo.handleMessageAction, + onLoadOlderHistory: session.transcript.handleLoadOlderHistory, + onSurfacePaintReady: session.transcript.handleSurfacePaintReady, + }} + /> + +
+ + 0, + showContext: SHOW_CONTEXT_DOCK, + remote: core.remoteSurfaceActive, + t, + context: conversationView.context, + sessionTurns: session.sessionTurns, + contextRefreshKey: local.dockRefreshKey + visibleRuntimeState.contextPanelSeq, + workspaceKey: session.workspaceTreeMemoryKey, + workspaceScopeKey: session.workspaceScopeKey, + mode: shell.rightDockMode, + meta: state.meta, + tabId: activeTabId, + completionSummary: state.completionSummary, + turnStartAt: state.turnStartAt, + layout: { treeWidth: shell.rightDockTreeWidth, previewWidth: shell.rightDockPreviewWidth, maximized: shell.workspacePanelMaximized }, + geometry: shellGeometry, + panels: session.workspacePanelCommands, + inserts: session.insertCommands, + verification: session.turnVerificationCommands, + qualityFloor: session.profileProjection.composerProfile.qualityFloor, + onFileTreeRefresh: local.refreshComposerFileRefs, + onSessionRevertCommitted: session.sessionUndo.handleSessionRevertCommitted, + onOpenInTerminal: core.remoteSurfaceActive ? undefined : session.terminalPanelCommands.openTerminalForPath, + })} /> + void session.insertCommands.addTerminalOutputToComposer(sessionId), + onAddToChat: session.insertCommands.addTerminalSelectionToComposer, + }, + status: !session.statusBarVisible ? undefined : { + base: conversationView.status, + rewindCommitting: session.sessionUndo.rewindCommitting, + sessionTurns: session.sessionTurns, + labelStyle: shell.preferences.statusBarStyle, + items: shell.preferences.statusBarItems, + extensionStatuses: session.extensionStatusList, + remoteHosts: shell.remoteHosts, + remoteStatuses: shell.remoteStatuses, + onCancelJob: core.remoteSurfaceActive ? core.remoteSession.cancelJob : runtime.composer.cancelJob, + onCancelRuntimeJob: session.controlCommands.cancelRuntimeJob, + onRevealRuntime: session.tabBarCommands.revealBackgroundRuntime, + onConnectRemote: session.remoteWorkspaceCommands.connectAndOpenRemoteWorkspace, + onDisconnectRemote: session.controlCommands.handleDisconnectRemote, + onManageRemote: () => shell.setSettingsTarget("remote"), + onOpenRemote: shell.requestRemoteExplorer, + onOpenRemoteWorkspace: session.remoteWorkspaceCommands.openRemoteWorkspaceFromStatus, + }, + })} /> +
+ + + {windowsFramelessChrome && ( + + )} +
+
+
+
+ ); +} diff --git a/desktop/frontend/src/components/ContextPanel.tsx b/desktop/frontend/src/components/ContextPanel.tsx index d13b4b0b32..8ca0b5d2e6 100644 --- a/desktop/frontend/src/components/ContextPanel.tsx +++ b/desktop/frontend/src/components/ContextPanel.tsx @@ -8,11 +8,11 @@ import { useI18n, type Locale, type Translator } from "../lib/i18n"; import { formatMoneyLocalized } from "../lib/money"; import { formatTokens, formatOptionalTokens } from "../lib/format"; import { appendRateBand, normalizeRateBand, rateBandLabel, type DisplayRateBand } from "../lib/costRateBand"; -import type { DictKey } from "../locales/en"; import type { BalanceInfo, ContextInfo, ContextPanelInfo, UsageSourceStats, WireUsage } from "../lib/types"; import { contextSessionCache } from "../lib/contextSessionCache"; import { ContextBudgetCard, resolveContextBudget } from "./ContextBudgetCard"; import type { Item } from "../lib/useController"; +import { contextWindowStatus, formatCacheHitRate } from "../lib/contextPanelUtils"; export { contextSessionCache } from "../lib/contextSessionCache"; const McpListLayers = lazy(() => import("./McpListLayers").then((module) => ({ default: module.McpListLayers }))); interface ContextPanelProps { @@ -72,11 +72,7 @@ function fmtUsageCacheRate(usage?: WireUsage): string { return `${((usage.cacheHitTokens / denom) * 100).toFixed(2)}%`; } -export function formatCacheHitRate(hitTokens: number, missTokens: number): string { - const denom = hitTokens + missTokens; - if (denom <= 0) return "-"; - return `${((hitTokens / denom) * 100).toFixed(2)}%`; -} +export { formatCacheHitRate } from "../lib/contextPanelUtils"; type MetricTone = "accent" | "good" | "notice" | "warn"; type UsageAnalysisView = "source" | "type"; @@ -113,11 +109,6 @@ export function formatSharePercent(value: number, total: number): string { return `${Math.round(pct)}%`; } -interface ContextWindowStatus { - tone: "good" | "notice" | "warn"; - key: DictKey; -} - export function contextCostDisplay({ info, sessionCost, @@ -293,14 +284,7 @@ export function contextBreakdown( }; } -export function contextWindowStatus(rawUsagePct: number, compactPct: number): ContextWindowStatus { - if (rawUsagePct > 100) return { tone: "warn", key: "context.windowStatusOverLimit" }; - const usagePct = Math.min(100, Math.max(0, rawUsagePct)); - if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" }; - if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" }; - if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" }; - return { tone: "good", key: "context.windowStatusHealthy" }; -} +export { contextWindowStatus } from "../lib/contextPanelUtils"; const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"]; diff --git a/desktop/frontend/src/components/ContextWindowRing.tsx b/desktop/frontend/src/components/ContextWindowRing.tsx index 284b4ea56c..6a56c3208b 100644 --- a/desktop/frontend/src/components/ContextWindowRing.tsx +++ b/desktop/frontend/src/components/ContextWindowRing.tsx @@ -6,10 +6,7 @@ import { formatMoneyLocalized } from "../lib/money"; import { appendRateBand, rateBandLabel } from "../lib/costRateBand"; import type { BalanceInfo, ContextInfo, ContextPanelInfo } from "../lib/types"; import { AnchoredPopover } from "./AnchoredPopover"; -import { - contextWindowStatus, - formatCacheHitRate, -} from "./ContextPanel"; +import { contextWindowStatus, formatCacheHitRate } from "../lib/contextPanelUtils"; interface ContextWindowRingProps { enabled?: boolean; diff --git a/desktop/frontend/src/components/SubagentDetails.css b/desktop/frontend/src/components/SubagentDetails.css new file mode 100644 index 0000000000..9d0ba5c8e3 --- /dev/null +++ b/desktop/frontend/src/components/SubagentDetails.css @@ -0,0 +1,75 @@ +.tool__subagent-preview { + display: flex; + flex-direction: column; + gap: 8px; + margin: 4px 0 8px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-soft); + min-width: 0; +} + +.tool__subagent-preview-label { + font-family: var(--font-code-family); + font-size: var(--font-caption); + font-weight: 600; + color: var(--fg-dim); + margin-bottom: 2px; +} + +.tool__subagent-preview-label--toggle { + padding: 0; + border: none; + background: none; + text-align: left; + cursor: pointer; + -webkit-app-region: no-drag; +} + +.tool__subagent-preview-label--toggle:hover { + color: var(--fg); +} + +.tool__subagent-preview .reasoning-summary { + margin: 0; + border-left: none; + padding-left: 0; + font-family: var(--font-code-family); + font-size: var(--font-caption); + line-height: 1.5; +} + +.tool__subagent-preview-text { + margin: 0; + font-family: var(--font-code-family); + font-size: var(--font-caption); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; + max-height: 260px; + overflow-y: auto; + color: inherit; +} + +.tool__subagent-outcome { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 8px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-soft); + font-family: var(--font-code-family); + font-size: var(--font-caption); +} + +.tool__subagent-outcome-status { + color: var(--fg-dim); +} + +.tool__subagent-outcome code { + overflow-wrap: anywhere; +} diff --git a/desktop/frontend/src/components/SubagentOutcomeCard.tsx b/desktop/frontend/src/components/SubagentOutcomeCard.tsx new file mode 100644 index 0000000000..c8016686ec --- /dev/null +++ b/desktop/frontend/src/components/SubagentOutcomeCard.tsx @@ -0,0 +1,38 @@ +import "./SubagentDetails.css"; + +import { useT, type Translator } from "../lib/i18n"; +import { parseSubagentOutcomeText, type SubagentOutcome } from "../lib/subagentOutcome"; + +type SubagentOutcomeCardProps = { + text?: string; + outcome?: SubagentOutcome; +}; + +function outcomeLabel(t: Translator, status: string): string { + switch (status) { + case "completed": return t("subagent.phase.completed"); + case "partial": return t("subagent.phase.partial"); + case "failed": return t("subagent.phase.failed"); + case "cancelled": return t("subagent.phase.cancelled"); + default: return status; + } +} + +export function SubagentOutcomeCard({ + text, + outcome, +}: SubagentOutcomeCardProps) { + const t = useT(); + const [ref, status, errorCode, retryable] = outcome ?? parseSubagentOutcomeText(text) ?? []; + if (!ref && !status) return null; + return ( +
+
+ {t("caps.subagent")} {outcomeLabel(t, status ?? "unknown")} + {retryable ? ` · ${t("subagent.outcome.retryable")}` : ""} +
+ {ref && {ref}} + {errorCode &&
{errorCode}
} +
+ ); +} diff --git a/desktop/frontend/src/components/SubagentPreview.tsx b/desktop/frontend/src/components/SubagentPreview.tsx new file mode 100644 index 0000000000..e45e90eca4 --- /dev/null +++ b/desktop/frontend/src/components/SubagentPreview.tsx @@ -0,0 +1,64 @@ +import "./SubagentDetails.css"; + +import { Markdown } from "./Markdown"; +import { ReasoningSummary } from "./ReasoningSummary"; +import { useT } from "../lib/i18n"; +import type { SubagentProgress } from "../lib/useController"; + +type SubagentPreviewProps = { + progress: SubagentProgress; + showReasoning: boolean; + reasoningOpen: boolean; + onReasoningToggle: () => void; + onReasoningOpen: () => void; +}; + +export function SubagentPreview({ + progress, + showReasoning, + reasoningOpen, + onReasoningToggle, + onReasoningOpen, +}: SubagentPreviewProps) { + const t = useT(); + return ( +
+ {progress.reasoning && showReasoning && ( +
+ + {reasoningOpen ? ( +
+ +
+ ) : ( + + )} +
+ )} + {progress.text && ( +
+
{t("subagent.preview.text")}
+
{progress.text}
+
+ )} + {progress.notice && ( +
+
{t("subagent.preview.notice")}
+
{progress.notice}
+
+ )} + {progress.truncated &&
{t("subagent.preview.truncated")}
} +
+ ); +} diff --git a/desktop/frontend/src/components/ToolCard.tsx b/desktop/frontend/src/components/ToolCard.tsx index ac6d6ec7d6..92ba36e669 100644 --- a/desktop/frontend/src/components/ToolCard.tsx +++ b/desktop/frontend/src/components/ToolCard.tsx @@ -11,6 +11,8 @@ import { app } from "../lib/bridge"; import type { MCPAppInstanceView, MCPAppPresentation } from "../lib/types"; const MCPAppCard = lazy(() => import("./MCPAppCard").then((m) => ({ default: m.MCPAppCard }))); +const SubagentOutcomeCard = lazy(() => import("./SubagentOutcomeCard").then((m) => ({ default: m.SubagentOutcomeCard }))); +const SubagentPreview = lazy(() => import("./SubagentPreview").then((m) => ({ default: m.SubagentPreview }))); function MCPAppCardLazy({ instance, @@ -41,8 +43,6 @@ import { useCollapseAnimation } from "../lib/useCollapseAnimation"; import { isBatchedReadOnlyTool, isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController"; import type { Translator } from "../lib/i18n"; import { ReadOnlyBatch } from "./ReadOnlyBatch"; -import { Markdown } from "./Markdown"; -import { ReasoningSummary } from "./ReasoningSummary"; import { useWorkProcessPresentation } from "../lib/sessionExperience"; import { useTranscriptUserResizeIntent } from "./TranscriptLayoutIntentContext"; import { resolveToolCardDefaultOpen } from "../lib/transcriptRowGeometry"; @@ -67,16 +67,6 @@ function subagentPhaseLabel(t: Translator, phase: SubagentPhase): string { } } -function subagentOutcomeLabel(t: Translator, status: string): string { - switch (status) { - case "completed": return t("subagent.outcome.completed"); - case "partial": return t("subagent.outcome.partial"); - case "failed": return t("subagent.outcome.failed"); - case "cancelled": return t("subagent.outcome.cancelled"); - default: return status; - } -} - function formatElapsedSeconds(ms: number): string { return String(Math.max(0, Math.round(ms / 1000))); } @@ -356,7 +346,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN const shellOutput = isShellCard && displayOutput ? displayOutput : null; const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null; const hasStderrDetails = Boolean(execution?.outputTail && execution.outputTail.trim()); - const hasSubagentOutcome = Boolean(item.subagentStatus || item.subagentRef); + const hasSubagentOutcome = Boolean(item.subagentOutcome || effectiveOutput?.includes("Subagent outcome:")); const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error || hasSubagentPreview || hasSubagentOutcome || hasStderrDetails || riskLabel || verificationLabel); const errorText = item.error ? normalizeErrorText(item.error) : ""; const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : ""; @@ -473,65 +463,35 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN )} {open && hasSubagentPreview && sp && ( -
- {sp.reasoning && presentation.showWhileRunning && ( -
- - {subagentReasoningOpen ? ( -
- -
- ) : ( - { - beginUserResize(); - subagentReasoningUserOverridden.current = true; - setUserOpen(true); - setSubagentReasoningOpen(true); - }} - /> - )} -
- )} - {sp.text && ( -
-
{t("subagent.preview.text")}
-
{sp.text}
-
- )} - {sp.notice && ( -
-
{t("subagent.preview.notice")}
-
{sp.notice}
-
- )} - {sp.truncated &&
{t("subagent.preview.truncated")}
} -
+ + { + beginUserResize(); + subagentReasoningUserOverridden.current = true; + const next = !subagentReasoningOpen; + if (next) setUserOpen(true); + setSubagentReasoningOpen(next); + }} + onReasoningOpen={() => { + beginUserResize(); + subagentReasoningUserOverridden.current = true; + setUserOpen(true); + setSubagentReasoningOpen(true); + }} + /> + )} {open && hasSubagentOutcome && ( -
-
- {t("subagent.outcome.label")} {subagentOutcomeLabel(t, item.subagentStatus ?? "unknown")}{item.subagentRetryable ? ` · ${t("subagent.outcome.retryable")}` : ""} -
- {item.subagentRef && {item.subagentRef}} - {item.subagentErrorCode &&
{item.subagentErrorCode}
} -
+ + + )} {hasNested && ( diff --git a/desktop/frontend/src/lib/contextPanelUtils.ts b/desktop/frontend/src/lib/contextPanelUtils.ts new file mode 100644 index 0000000000..a43cc65502 --- /dev/null +++ b/desktop/frontend/src/lib/contextPanelUtils.ts @@ -0,0 +1,21 @@ +import type { DictKey } from "../locales/en"; + +export interface ContextWindowStatus { + tone: "good" | "notice" | "warn"; + key: DictKey; +} + +export function formatCacheHitRate(hitTokens: number, missTokens: number): string { + const denom = hitTokens + missTokens; + if (denom <= 0) return "-"; + return `${((hitTokens / denom) * 100).toFixed(2)}%`; +} + +export function contextWindowStatus(rawUsagePct: number, compactPct: number): ContextWindowStatus { + if (rawUsagePct > 100) return { tone: "warn", key: "context.windowStatusOverLimit" }; + const usagePct = Math.min(100, Math.max(0, rawUsagePct)); + if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" }; + if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" }; + if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" }; + return { tone: "good", key: "context.windowStatusHealthy" }; +} diff --git a/desktop/frontend/src/lib/subagentOutcome.ts b/desktop/frontend/src/lib/subagentOutcome.ts index 746b3e8b2f..5b7230b4ac 100644 --- a/desktop/frontend/src/lib/subagentOutcome.ts +++ b/desktop/frontend/src/lib/subagentOutcome.ts @@ -1,20 +1,15 @@ -export type SubagentOutcomeFields = { - subagentRef?: string; - subagentStatus?: string; - subagentErrorCode?: string; - subagentRetryable?: boolean; -}; +export type SubagentOutcome = readonly [ + ref: string | undefined, + status: string | undefined, + errorCode: string | undefined, + retryable: boolean | undefined, +]; -export function parseSubagentOutcomeText(text?: string): SubagentOutcomeFields { - if (!text) return {}; +export function parseSubagentOutcomeText(text?: string): SubagentOutcome | undefined { + if (!text) return undefined; const head = text.slice(0, 1024); const ref = head.match(/^Subagent reference(?: \(failed\))?: ([^\n]+)/m)?.[1]?.trim(); const match = head.match(/^Subagent outcome: status=([^\s]+) retryable=(true|false)(?: error_code=([^\s]+))?/m); - if (!ref || !match) return {}; - return { - subagentRef: ref, - subagentStatus: match[1], - subagentRetryable: match[2] === "true", - subagentErrorCode: match[3], - }; + if (!ref || !match) return undefined; + return [ref, match[1], match[3], match[2] === "true"]; } diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 4d9451da5d..f160ac8b71 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -55,7 +55,6 @@ import type { SearchSource } from "./searchSources"; import { attachWebSearchOutput, historySearchAndAnswer } from "./searchTranscript"; import { fileDiffFromWire, parseTodos, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools"; import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode, type QualityFloor } from "./types"; -import { parseSubagentOutcomeText } from "./subagentOutcome"; import type { BalanceInfo, CheckpointMeta, @@ -286,7 +285,7 @@ export type Item = args: string; readOnly: boolean; resolvedName?: string; - capabilityId?: string; subagentRef?: string; subagentStatus?: string; subagentErrorCode?: string; subagentRetryable?: boolean; + capabilityId?: string; subagentOutcome?: import("./subagentOutcome").SubagentOutcome; status: ToolStatus; output?: string; searchSources?: SearchSource[]; searchSourcesStatus?: "available" | "not_provided"; searchSummary?: string; // display-only provider search results; replay data stays in output/serverSearch error?: string; @@ -970,7 +969,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str summary: summarizeFileDiff(fileDiff) || tc.summary, fileDiff, isShell: tc.name === "bash" || (tc.id || "").startsWith("shell-"), - execution: result?.execution, ...parseSubagentOutcomeText(output), + execution: result?.execution, }); seq++; } @@ -991,7 +990,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str error, dataArchived: m.toolResultArchived || undefined, isShell: (m.toolName || "") === "bash" || (m.toolCallId || "").startsWith("shell-"), - execution: m.execution, ...parseSubagentOutcomeText(output), + execution: m.execution, }); seq++; continue; @@ -1684,7 +1683,7 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State const args = t.args ? t.args : it.args; const fileDiff = fileDiffFromWire(t); const summary = summarizeFileDiff(fileDiff) || summarize(t.name, args) || (t.name === it.name && args === it.args ? it.summary : undefined); - next[idx] = { ...it, name: t.name, args, readOnly: t.readOnly, resolvedName: t.resolvedName ?? it.resolvedName, capabilityId: t.capabilityId ?? it.capabilityId, profile: t.profile ?? it.profile, subagentRef: t.subagentRef ?? it.subagentRef, subagentStatus: t.subagentStatus ?? it.subagentStatus, subagentErrorCode: t.subagentErrorCode ?? it.subagentErrorCode, subagentRetryable: t.subagentRetryable ?? it.subagentRetryable, summary, fileDiff, argChars: undefined, isShell: it.isShell || t.name === "bash" || id.startsWith("shell-"), execution: t.execution ?? it.execution, subagentProgress: it.subagentProgress ?? (SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined) }; + next[idx] = { ...it, name: t.name, args, readOnly: t.readOnly, resolvedName: t.resolvedName ?? it.resolvedName, capabilityId: t.capabilityId ?? it.capabilityId, profile: t.profile ?? it.profile, summary, fileDiff, argChars: undefined, isShell: it.isShell || t.name === "bash" || id.startsWith("shell-"), execution: t.execution ?? it.execution, subagentProgress: it.subagentProgress ?? (SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined) }; } if (t.parentId) touchSubagentParent(next, t.parentId); return { ...settled, items: next }; @@ -1752,7 +1751,10 @@ function applyEvent(s: State, e: WireEvent, preserveToolPayloads = false): State durationMs: t.durationMs, summary, isShell: existing.isShell || existing.name === "bash" || t.name === "bash", - execution: t.execution ?? existing.execution, subagentRef: t.subagentRef ?? existing.subagentRef, subagentStatus: t.subagentStatus ?? existing.subagentStatus, subagentErrorCode: t.subagentErrorCode ?? existing.subagentErrorCode, subagentRetryable: t.subagentRetryable ?? existing.subagentRetryable, + execution: t.execution ?? existing.execution, + subagentOutcome: t.subagentRef || t.subagentStatus + ? [t.subagentRef, t.subagentStatus, t.subagentErrorCode, t.subagentRetryable] as const + : existing.subagentOutcome, }; } } diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index 369b720d1e..c06af779c3 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -3351,11 +3351,6 @@ export const en = { "subagent.preview.text": "Response preview", "subagent.preview.notice": "Notices", "subagent.preview.truncated": "preview truncated", - "subagent.outcome.label": "subagent", - "subagent.outcome.completed": "completed", - "subagent.outcome.partial": "partially complete", - "subagent.outcome.failed": "failed", - "subagent.outcome.cancelled": "cancelled", "subagent.outcome.retryable": "retryable", // software update diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 6414493d72..dd611eca77 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -2409,11 +2409,6 @@ export const zhTW: Record = { "subagent.preview.text": "回答預覽", "subagent.preview.notice": "提示", "subagent.preview.truncated": "預覽已截斷", - "subagent.outcome.label": "子代理", - "subagent.outcome.completed": "已完成", - "subagent.outcome.partial": "部分完成", - "subagent.outcome.failed": "失敗", - "subagent.outcome.cancelled": "已取消", "subagent.outcome.retryable": "可重試", // 軟體更新 diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index 9403124408..f03125378c 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -3354,11 +3354,6 @@ export const zh: Record = { "subagent.preview.text": "回答预览", "subagent.preview.notice": "提示", "subagent.preview.truncated": "预览已截断", - "subagent.outcome.label": "子代理", - "subagent.outcome.completed": "已完成", - "subagent.outcome.partial": "部分完成", - "subagent.outcome.failed": "失败", - "subagent.outcome.cancelled": "已取消", "subagent.outcome.retryable": "可重试", // 软件更新 diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 7035f74b7d..bb692922fa 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -6466,9 +6466,8 @@ body > .mermaid-diagram--fullscreen { white-space: nowrap; min-width: 0; } -/* Sub-agent progress chip: phase + running elapsed + recent activity. The dot -/* Sub-agent progress chip: phase + running elapsed + recent activity. The dot - * carries the phase color; the chip never grows the head beyond one line. */ +/* The compact phase chip is structural header chrome. Expanded sub-agent + * details load with their own presentation component. */ .tool__subagent-chip { display: inline-flex; align-items: center; @@ -6502,9 +6501,7 @@ body > .mermaid-diagram--fullscreen { animation: subagent-pulse 1.4s ease-in-out infinite; } .tool__subagent-chip--reasoning .tool__subagent-dot, -.tool__subagent-chip--responding .tool__subagent-dot { - background: var(--accent); -} +.tool__subagent-chip--responding .tool__subagent-dot, .tool__subagent-chip--tool .tool__subagent-dot, .tool__subagent-chip--retrying .tool__subagent-dot { background: var(--accent); @@ -6519,75 +6516,9 @@ body > .mermaid-diagram--fullscreen { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } - -/* Expanded sub-agent preview: reasoning / response preview / notices live in -/* Expanded sub-agent preview: reasoning / response preview / notices live in - * their own block and never mix with ordinary tool output. Long text wraps; - * it must not widen the chat column. */ -.tool__subagent-preview { - display: flex; - flex-direction: column; - gap: 8px; - margin: 4px 0 8px; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-soft); - min-width: 0; -} -.tool__subagent-preview-label { - font-family: var(--font-code-family); - font-size: var(--font-caption); - font-weight: 600; - color: var(--fg-dim); - margin-bottom: 2px; -} -.tool__subagent-preview-label--toggle { - padding: 0; - border: none; - background: none; - text-align: left; - cursor: pointer; - -webkit-app-region: no-drag; -} -.tool__subagent-preview-label--toggle:hover { - color: var(--fg); -} -/* The collapsed reasoning preview matches the plain-text preview blocks. */ -.tool__subagent-preview .reasoning-summary { - margin: 0; - border-left: none; - padding-left: 0; - font-family: var(--font-code-family); - font-size: var(--font-caption); - line-height: 1.5; -} -.tool__subagent-preview-text { - margin: 0; - font-family: var(--font-code-family); - font-size: var(--font-caption); - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: anywhere; - max-height: 260px; - overflow-y: auto; - color: inherit; -} -.tool__subagent-outcome { - display: flex; - flex-direction: column; - gap: 4px; - margin: 4px 0 8px; - padding: 8px 10px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--bg-soft); - font-family: var(--font-code-family); - font-size: var(--font-caption); +@media (prefers-reduced-motion: reduce) { + .tool__subagent-chip--running .tool__subagent-dot { animation: none; } } -.tool__subagent-outcome-status { color: var(--fg-dim); } -.tool__subagent-outcome code { overflow-wrap: anywhere; } .tool__nested-count { display: inline-flex; align-items: center; @@ -18195,82 +18126,6 @@ body > .mermaid-diagram--fullscreen { font-family: var(--font-sans); font-size: var(--text-2xs); } -.provider-image-input { - display: flex; - flex-direction: column; - gap: 5px; - min-width: 0; - font-family: var(--font-sans); -} -.provider-image-input__head { - display: flex; - align-items: center; - justify-content: flex-start; - flex-wrap: wrap; - gap: 10px; -} -.provider-image-input__label { - min-width: 60px; - color: var(--fg-dim); - font-size: var(--font-control-small); - font-weight: 650; - white-space: nowrap; -} -.provider-image-input__meta { - display: flex; - min-width: 0; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 8px; -} -.provider-image-input__status { - display: inline-flex; - align-items: center; - gap: 5px; - min-width: 0; - color: var(--fg-faint); - font-size: var(--text-2xs); - line-height: 1.35; -} -.provider-image-input__status-dot { - width: 5px; - height: 5px; - flex: 0 0 5px; - border-radius: 50%; - background: currentColor; -} -.provider-image-input__status--supported { - color: var(--ok); -} -.provider-image-input__status--unsupported, -.provider-image-input__status--restricted { - color: var(--fg-dim); -} -.provider-image-input__modes { - flex: 0 0 auto; -} -.provider-image-input__mode { - position: relative; - display: flex; - min-width: 54px; - align-items: center; - justify-content: center; - cursor: pointer; -} -.provider-image-input__mode:focus-within { - outline: 2px solid color-mix(in srgb, var(--accent) 68%, transparent); - outline-offset: 1px; -} -.provider-image-input__mode--disabled { - cursor: not-allowed; - opacity: 0.42; -} -.provider-image-input__detail { - flex: 1 1 180px; - color: var(--fg-faint); - font-size: var(--text-2xs); - line-height: 1.45; -} .provider-model-draft__capabilities span { padding: 2px 6px; border: 1px solid var(--border-soft); @@ -18747,6 +18602,9 @@ body > .mermaid-diagram--fullscreen { align-items: center; gap: 6px; } +.set-key .btn { + flex: 0 0 auto; +} .set-rules { margin-bottom: 10px; } diff --git a/docs/APP_SESSION_OWNERSHIP.md b/docs/APP_SESSION_OWNERSHIP.md index 1f0d8494db..7a69698f45 100644 --- a/docs/APP_SESSION_OWNERSHIP.md +++ b/docs/APP_SESSION_OWNERSHIP.md @@ -11,9 +11,9 @@ UI tab identifier. Missing or replaced targets produce a stale outcome. Subscription scopes revoke queued deliveries before releasing registrations. Terminal output uses reference-counted leases so an old cleanup cannot release -a newer subscriber. App composition wires these owners to the existing page -tree; the runtime root and page tree still live together in App.tsx in this -stage. Presentation-only extraction is a separate change. +a newer subscriber. AppRuntime wires these owners to AppRuntimeView. App.tsx is a small composition +entry; the view receives committed commands and presentation data without +creating a second session authority. ## Verification diff --git a/docs/APP_SESSION_OWNERSHIP.zh-CN.md b/docs/APP_SESSION_OWNERSHIP.zh-CN.md index c0dcb7fcac..0a96be2445 100644 --- a/docs/APP_SESSION_OWNERSHIP.zh-CN.md +++ b/docs/APP_SESSION_OWNERSHIP.zh-CN.md @@ -8,8 +8,8 @@ 目标缺失或已替换时返回过期结果。 订阅作用域先撤销排队通知,再释放注册。终端输出使用引用计数租约,旧清理不能 -释放新订阅。此阶段已将所有权模块接入 App,运行时根和页面树仍共同保留在 -App.tsx;纯展示层提取单独交付。 +释放新订阅。AppRuntime 将所有权模块接入 AppRuntimeView。App.tsx 仅保留组合入口;页面树 +接收已提交的命令与展示数据,不创建第二套会话权限。 ## 验证 diff --git a/docs/APP_SHELL.md b/docs/APP_SHELL.md new file mode 100644 index 0000000000..67d82d1390 --- /dev/null +++ b/docs/APP_SHELL.md @@ -0,0 +1,25 @@ +# App composition boundary + +[简体中文](APP_SHELL.zh-CN.md) + +App.tsx only mounts AppRuntime. AppRuntime composes session, navigation and +shell-store owners; AppRuntimeView renders the existing shared regions. +Effects and source-bound commands stay with their domain owners. Extracting +the view must preserve hook order, component identity, draft state and command +registration, and must not introduce a second mutable active-session authority. + +The App entry contract rejects direct bridge access, effects and async work. +The AST layer gate follows runtime imports, re-exports, aliases and dynamic +imports, and rejects transitive domain/common dependencies on App owners. +Type-only edges remain distinct. Negative fixtures verify those checks. + +Context-window presentation helpers and lazy subagent outcome/preview cards +are separate view modules. The controller retains tool output and a compact tuple for live wire outcomes; +historical outcome text is parsed only when the lazy card renders. The rendered result and source command boundary +remain unchanged. + +Use `pnpm check:app-layers`, `pnpm test:all`, `pnpm test:app-lifecycle` and +`pnpm test:app-browser` to verify these contracts. The independent App memory +workflow and native Transcript gates remain required qualification. See +[session ownership](APP_SESSION_OWNERSHIP.md) for the screening protocol and +the separate pending heap-retainer/control attribution duty. diff --git a/docs/APP_SHELL.zh-CN.md b/docs/APP_SHELL.zh-CN.md new file mode 100644 index 0000000000..80e9a7479e --- /dev/null +++ b/docs/APP_SHELL.zh-CN.md @@ -0,0 +1,21 @@ +# App 组合边界 + +[English](APP_SHELL.md) + +App.tsx 只挂载 AppRuntime。AppRuntime 组合会话、导航和界面状态所有者, +AppRuntimeView 渲染已有共享区域。副作用和来源绑定命令保留在对应领域模块。 +提取页面树必须保持 Hook 顺序、组件身份、草稿状态及命令注册,不能建立第二套 +可变的当前会话权限。 + +入口契约禁止直接访问桥接、执行副作用或异步工作。AST 分层检查跟踪运行时导入、 +重导出、别名和动态导入,拒绝领域/公共模块经传递依赖访问 App 所有者;类型边 +单独处理,并通过反例验证。 + +上下文窗口展示辅助函数、延迟加载的子代理结果和预览卡片均独立为展示模块。 +控制器保留工具输出及实时事件的紧凑结果元组,历史结果文本在延迟卡片渲染时 +解析;最终展示结果和来源命令边界保持一致。 + +使用 `pnpm check:app-layers`、`pnpm test:all`、`pnpm test:app-lifecycle` 和 +`pnpm test:app-browser` 验证这些契约。独立 App 内存工作流和原生 Transcript +检查仍是验收要求。[会话所有权](APP_SESSION_OWNERSHIP.zh-CN.md) 说明筛查协议, +以及单独保留的堆保留链/主分支对照归因要求。 diff --git a/tools/repolint/baseline.json b/tools/repolint/baseline.json index 23a80d8cce..50d4b5e727 100644 --- a/tools/repolint/baseline.json +++ b/tools/repolint/baseline.json @@ -4,7 +4,7 @@ "commented-code": 0, "complexity": 1961, "essay": 1966, - "file-size": 108685, + "file-size": 103891, "function-size": 8448, "layering": 1, "marker": 0, @@ -65,9 +65,6 @@ "desktop/external_opener_windows_test.go": { "essay": 2 }, - "desktop/frontend/src/App.tsx": { - "file-size": 4778 - }, "desktop/frontend/src/__tests__/app-chrome-tabs.test.ts": { "test-file-size": 27 }, @@ -105,7 +102,7 @@ "file-size": 4002 }, "desktop/frontend/src/components/ContextPanel.tsx": { - "file-size": 52 + "file-size": 36 }, "desktop/frontend/src/components/HistoryPanel.tsx": { "file-size": 7 From fbf65d74dc1f105a8d8637cdaccf37626cb6d387 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:22:32 +0800 Subject: [PATCH 3/7] fix(desktop): publish rejected remote resumes atomically Problem: rejected remote session opens could publish an error while observers still saw the rejected session identity. Root cause: failure state publication and restoration of the previous selection had separate owners, allowing reconnect or later selection work to interleave. Fix: commit restoration and failure state through one revision-checked completion owner. Order route metadata, terminal state, close and generation replacement with the existing publication fence; cover HTTP, busy, listing, missing-target and reconciled transport failures. Verification: deterministic regression failed before the repair. Remote tests, five race repetitions of rejection and lifecycle cases, full desktop tests, full desktop race tests, golangci-lint and repository lint pass. No public API, persisted format or provider prompt bytes change. --- desktop/remote_projects.go | 15 +- desktop/remote_tab.go | 110 ++---------- desktop/remote_tab_commands.go | 27 ++- desktop/remote_tab_pending_selection.go | 30 +--- desktop/remote_tab_pending_selection_test.go | 1 - desktop/remote_tab_publication.go | 170 ++++++++++++++++++ desktop/remote_tab_registry.go | 84 +++++---- desktop/remote_tab_rejection_commit_test.go | 56 ++++++ desktop/remote_tab_rejection_order_test.go | 131 ++++++++++++++ desktop/remote_tab_rejection_paths_test.go | 102 +++++++++++ desktop/remote_tab_resume_failure.go | 38 ++++ desktop/remote_tab_resume_route.go | 41 ++--- desktop/remote_tab_review_regressions_test.go | 32 ---- docs/APP_SESSION_OWNERSHIP.md | 14 ++ docs/APP_SESSION_OWNERSHIP.zh-CN.md | 8 + 15 files changed, 616 insertions(+), 243 deletions(-) create mode 100644 desktop/remote_tab_publication.go create mode 100644 desktop/remote_tab_rejection_commit_test.go create mode 100644 desktop/remote_tab_rejection_order_test.go create mode 100644 desktop/remote_tab_rejection_paths_test.go create mode 100644 desktop/remote_tab_resume_failure.go diff --git a/desktop/remote_projects.go b/desktop/remote_projects.go index 82bd2f706f..7380ce82fd 100644 --- a/desktop/remote_projects.go +++ b/desktop/remote_projects.go @@ -232,6 +232,8 @@ func (a *App) commitRemoteTabOpenRegistration(registration *remoteTabOpenRegistr return true } defer existing.selectionMu.Unlock() + existing.routeEventMu.Lock() + defer existing.routeEventMu.Unlock() a.remoteTabMu.Lock() defer a.remoteTabMu.Unlock() if a.remoteTabs[registration.reuseID] != existing { @@ -731,19 +733,6 @@ func waitForRemoteHost(rt remoteKernel, hostID string, timeout time.Duration) er } } -func (a *App) emitRemoteTabState(tabID, state, errMsg string) { - a.remoteTabMu.Lock() - tab := a.remoteTabs[tabID] - if tab == nil { - a.remoteTabMu.Unlock() - return - } - tab.state = state - tab.err = errMsg - a.remoteTabMu.Unlock() - a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg}) -} - // remoteWorkspaceName is posix-safe (remote paths on a Windows host must not // go through filepath). func remoteWorkspaceName(ws string) string { diff --git a/desktop/remote_tab.go b/desktop/remote_tab.go index af1aadba36..318ef0eeb1 100644 --- a/desktop/remote_tab.go +++ b/desktop/remote_tab.go @@ -70,28 +70,10 @@ func (a *App) attachRemoteTabServe(ctx context.Context, tabID, base, token, inst } } - a.remoteTabMu.Lock() - if a.remoteTabs[tabID] != tab { - a.remoteTabMu.Unlock() - return false, fmt.Errorf("remote tab %q closed during bootstrap", tabID) - } - // Retire any pump installed by a concurrent reconnect so exactly one - // generation owns the event stream. - tab.gen++ - if tab.cancel != nil { - tab.cancel() - } - tab.client = client - tab.base = base - tab.token = token - if !opts.NewSession { - commitRemoteTabAttachRoute(tab, target.Path, false) + pumpCtx, gen, attachPathRevision, err := a.installRemoteTabAttachPump(ctx, tabID, tab, client, base, token, target.Path, !opts.NewSession) + if err != nil { + return false, err } - attachPathRevision := tab.routing.pathRevision - gen := tab.gen - pumpCtx, cancelPump := context.WithCancel(ctx) - tab.cancel = cancelPump - a.remoteTabMu.Unlock() opened := make(chan error, 1) a.goRemoteTabSafe("remoteTabPump", func() { a.remoteTabPump(pumpCtx, tabID, gen, opened) }) @@ -312,10 +294,14 @@ func (a *App) markRemoteTabAttached(tabID string, gen uint64) bool { } func (a *App) publishRemoteTabAttachedReady(tabID string, gen uint64) bool { + tab := a.lockRemoteTabPublication(tabID) + if tab == nil { + return false + } a.remoteTabMu.Lock() - tab := a.remoteTabs[tabID] - if tab == nil || tab.gen != gen || tab.attachedGen != gen || tab.state != "connecting" { + if a.remoteTabs[tabID] != tab || tab.gen != gen || tab.attachedGen != gen || tab.state != "connecting" { a.remoteTabMu.Unlock() + tab.routeEventMu.Unlock() return false } tab.attachedGen = 0 @@ -323,6 +309,7 @@ func (a *App) publishRemoteTabAttachedReady(tabID string, gen uint64) bool { tab.err = "" a.remoteTabMu.Unlock() a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready"}) + tab.routeEventMu.Unlock() a.applyPendingRemoteTabOpenSelection(tabID) return true } @@ -334,83 +321,6 @@ func (a *App) remoteTabGenerationCurrent(tabID string, gen uint64) bool { return tab != nil && tab.gen == gen } -func (a *App) retireRemoteTabGeneration(tabID string, gen uint64) { - a.remoteTabMu.Lock() - tab := a.remoteTabs[tabID] - if tab == nil || tab.gen != gen { - a.remoteTabMu.Unlock() - return - } - cancel := tab.cancel - tab.gen++ - tab.attachedGen = 0 - tab.cancel = nil - tab.client = nil - tab.base = "" - tab.token = "" - a.remoteTabMu.Unlock() - if cancel != nil { - cancel() - } -} - -// reconnectRemoteTabGeneration retires a dead pump and atomically parks its -// tab in reconnecting. The bool reports whether this pump should start the -// retry loop; a pump opened by an existing retry loop leaves retries to its -// caller so two loops cannot race each other. -func (a *App) reconnectRemoteTabGeneration(tabID string, gen uint64) bool { - a.remoteTabMu.Lock() - tab := a.remoteTabs[tabID] - if tab == nil || tab.gen != gen { - a.remoteTabMu.Unlock() - return false - } - startRetry := tab.state != "reconnecting" - cancel := tab.cancel - tab.gen++ - tab.attachedGen = 0 - tab.cancel = nil - tab.client = nil - tab.base = "" - tab.token = "" - tab.state = "reconnecting" - tab.err = "" - a.remoteTabMu.Unlock() - if cancel != nil { - cancel() - } - a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "reconnecting"}) - return startRetry -} - -func (a *App) emitRemoteTabStateForGeneration(tabID string, gen uint64, state, errMsg string) bool { - a.remoteTabMu.Lock() - tab := a.remoteTabs[tabID] - if tab == nil || tab.gen != gen { - a.remoteTabMu.Unlock() - return false - } - tab.state = state - tab.err = errMsg - a.remoteTabMu.Unlock() - a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg}) - return true -} - -func (a *App) transitionRemoteTabState(tabID string, gen uint64, from, state, errMsg string) bool { - a.remoteTabMu.Lock() - tab := a.remoteTabs[tabID] - if tab == nil || tab.gen != gen || tab.state != from { - a.remoteTabMu.Unlock() - return false - } - tab.state = state - tab.err = errMsg - a.remoteTabMu.Unlock() - a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg}) - return true -} - // remoteTabPump forwards Serve events for one tab generation. Cancellation, // stream death, or a generation mismatch retires the pump. func (a *App) remoteTabPump(ctx context.Context, tabID string, gen uint64, opened chan<- error) { diff --git a/desktop/remote_tab_commands.go b/desktop/remote_tab_commands.go index d199378c6a..385755a6f8 100644 --- a/desktop/remote_tab_commands.go +++ b/desktop/remote_tab_commands.go @@ -138,6 +138,10 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat } consumeQueuedRemoteTabOpenSelectionLocked(tab, selectionRevision) client, base, gen := tab.client, tab.base, tab.gen + failureRoute := remoteTabProvisionalResume{ + targetPath: tab.routing.currentPath, pathRevision: tab.routing.pathRevision, + selectionRevision: tab.selectionRevision, previousSelection: previous, + } a.remoteTabMu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -148,8 +152,7 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat } else { entries, err := serveSessions(ctx, client, base) if err != nil { - a.transitionRemoteTabState(tabID, gen, "ready", "ready", fmt.Sprintf("Could not open remote session %q: %v", name, err)) - return false + return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, failureRoute, fmt.Sprintf("Could not open remote session %q: %v", name, err)) } for _, entry := range entries { if entry.Name == name { @@ -164,23 +167,16 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat // before the request returns so the all-session pump does not discard its // handoff output or prompt replay as background work. route := a.beginRemoteTabProvisionalResume(tabID, tab, client, gen, target.Path) + route.previousSelection = previous mountedPath, err := servePostSessionPath(ctx, client, serveURL(base, "/resume"), body) if err != nil { var statusErr *serveHTTPStatusError if errors.As(err, &statusErr) { - if !a.rollbackRemoteTabProvisionalResume(tabID, tab, client, gen, route) { - // A newer route already superseded this request. Its identity is - // authoritative, so the open-selection rollback must not run. - return true - } + message := err.Error() if remoteSessionTransitionBusy(err) { - a.transitionRemoteTabState(tabID, gen, "ready", "ready", "Finish the current turn before switching sessions.") - return false + message = "Finish the current turn before switching sessions." } - // A received HTTP rejection is definitive: Serve did not commit the - // target, so the previous ready route remains authoritative. - a.transitionRemoteTabState(tabID, gen, "ready", "ready", err.Error()) - return false + return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, route, message) } // A transport failure is ambiguous: Serve may have committed the // resume before the tunnel lost its response. Query its current route @@ -224,8 +220,7 @@ func (a *App) resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPat a.goRemoteTabSafe("remoteTabResumeStatus", func() { _, _ = a.RemoteTabStatus(tabID) }) return true } - a.transitionRemoteTabState(tabID, gen, "ready", "ready", fmt.Sprintf("remote session %q not found", name)) - return false + return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, failureRoute, fmt.Sprintf("remote session %q not found", name)) } func (a *App) SetRemoteSessionPinned(hostID, workspace, name string, pinned bool) error { @@ -699,6 +694,6 @@ func (a *App) rotateRemoteTabSession(tabID, path string) error { a.remoteTabMu.Unlock() a.emitRemoteEvent("remote-tab:updated", meta) a.saveTabsFromRemote() - a.emitRemoteTabState(tabID, "ready", "") + a.emitRemoteTabStateLocked(tab, "ready", "") return nil } diff --git a/desktop/remote_tab_pending_selection.go b/desktop/remote_tab_pending_selection.go index d629e6cf79..3dcfba80d7 100644 --- a/desktop/remote_tab_pending_selection.go +++ b/desktop/remote_tab_pending_selection.go @@ -148,40 +148,12 @@ func (a *App) resumeRemoteTabOpenAsync(tabID, name, sessionPath, sessionTitle st func() { tab.selectionMu.Lock() defer tab.selectionMu.Unlock() - handled := a.resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPath, sessionTitle, revision, selection) - if !handled { - a.restoreRejectedRemoteTabOpenSelection(tabID, selection) - } + a.resumeRemoteTabSessionPathForOpenSelection(tabID, name, sessionPath, sessionTitle, revision, selection) }() a.applyPendingRemoteTabOpenSelection(tabID) }) } -func (a *App) restoreRejectedRemoteTabOpenSelection(tabID string, previous *remoteTabOpenSelection) { - if previous == nil { - return - } - a.remoteTabMu.Lock() - tab := a.remoteTabs[tabID] - a.remoteTabMu.Unlock() - if tab == nil { - return - } - tab.routeEventMu.Lock() - defer tab.routeEventMu.Unlock() - a.remoteTabMu.Lock() - current := a.remoteTabs[tabID] - if current != tab || current.selectionRevision != previous.revision || current.state != "ready" || strings.TrimSpace(current.err) == "" { - a.remoteTabMu.Unlock() - return - } - restoreRemoteTabOpenSelectionLocked(current, previous) - meta := remoteTabMetaLocked(current) - a.remoteTabMu.Unlock() - a.emitRemoteEvent("remote-tab:updated", meta) - a.saveTabsFromRemote() -} - func restoreRemoteTabOpenSelectionLocked(current *remoteTab, previous *remoteTabOpenSelection) { current.session = previous.session current.topicTitle = previous.topicTitle diff --git a/desktop/remote_tab_pending_selection_test.go b/desktop/remote_tab_pending_selection_test.go index c364f9fcb5..f229301f5f 100644 --- a/desktop/remote_tab_pending_selection_test.go +++ b/desktop/remote_tab_pending_selection_test.go @@ -145,7 +145,6 @@ func TestReadyTabRapidSelectionsRollbackToServeAuthoritativeSnapshot(t *testing. if handled := a.resumeRemoteTabSessionPathForOpenSelection(tab.id, "second", secondPath, "Second", second.selection.revision, second.previousSelection); handled { t.Fatal("rejected second selection was treated as committed") } - a.restoreRejectedRemoteTabOpenSelection(tab.id, second.previousSelection) if requests != 1 || tab.routing.currentPath != oldPath || tab.session.path != oldPath || tab.topicTitle != "Old" { t.Fatalf("rejected rapid selection left requests/route/session/title = %d/%q/%q/%q", requests, tab.routing.currentPath, tab.session.path, tab.topicTitle) } diff --git a/desktop/remote_tab_publication.go b/desktop/remote_tab_publication.go new file mode 100644 index 0000000000..eb08015057 --- /dev/null +++ b/desktop/remote_tab_publication.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "fmt" + "net/http" +) + +// Route events, terminal state, explicit close and generation replacement +// share one publication order. Never wait for this fence while holding +// remoteTabMu; callers recheck the captured tab after acquiring both locks. +func (a *App) lockRemoteTabPublication(tabID string) *remoteTab { + a.remoteTabMu.Lock() + tab := a.remoteTabs[tabID] + a.remoteTabMu.Unlock() + if tab != nil { + tab.routeEventMu.Lock() + } + return tab +} + +func (a *App) retireRemoteTabGeneration(tabID string, gen uint64) { + tab := a.lockRemoteTabPublication(tabID) + if tab == nil { + return + } + defer tab.routeEventMu.Unlock() + a.remoteTabMu.Lock() + if a.remoteTabs[tabID] != tab || tab.gen != gen { + a.remoteTabMu.Unlock() + return + } + cancel := tab.cancel + tab.gen++ + tab.attachedGen = 0 + tab.cancel = nil + tab.client = nil + tab.base = "" + tab.token = "" + a.remoteTabMu.Unlock() + if cancel != nil { + cancel() + } +} + +// reconnectRemoteTabGeneration retires a dead pump and atomically parks its +// tab in reconnecting. The bool reports whether this pump should start the +// retry loop; a pump opened by an existing retry loop leaves retries to its +// caller so two loops cannot race each other. +func (a *App) reconnectRemoteTabGeneration(tabID string, gen uint64) bool { + tab := a.lockRemoteTabPublication(tabID) + if tab == nil { + return false + } + defer tab.routeEventMu.Unlock() + a.remoteTabMu.Lock() + if a.remoteTabs[tabID] != tab || tab.gen != gen { + a.remoteTabMu.Unlock() + return false + } + startRetry := tab.state != "reconnecting" + cancel := tab.cancel + tab.gen++ + tab.attachedGen = 0 + tab.cancel = nil + tab.client = nil + tab.base = "" + tab.token = "" + tab.state = "reconnecting" + tab.err = "" + a.remoteTabMu.Unlock() + if cancel != nil { + cancel() + } + a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "reconnecting"}) + return startRetry +} + +func (a *App) emitRemoteTabStateForGeneration(tabID string, gen uint64, state, errMsg string) bool { + tab := a.lockRemoteTabPublication(tabID) + if tab == nil { + return false + } + defer tab.routeEventMu.Unlock() + a.remoteTabMu.Lock() + if a.remoteTabs[tabID] != tab || tab.gen != gen { + a.remoteTabMu.Unlock() + return false + } + tab.state = state + tab.err = errMsg + a.remoteTabMu.Unlock() + a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg}) + return true +} + +func (a *App) transitionRemoteTabState(tabID string, gen uint64, from, state, errMsg string) bool { + tab := a.lockRemoteTabPublication(tabID) + if tab == nil { + return false + } + defer tab.routeEventMu.Unlock() + return a.transitionRemoteTabStateLocked(tab, gen, from, state, errMsg) +} + +func (a *App) transitionRemoteTabStateLocked(tab *remoteTab, gen uint64, from, state, errMsg string) bool { + tabID := tab.id + a.remoteTabMu.Lock() + if a.remoteTabs[tabID] != tab || tab.gen != gen || tab.state != from { + a.remoteTabMu.Unlock() + return false + } + tab.state = state + tab.err = errMsg + a.remoteTabMu.Unlock() + a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg}) + return true +} + +func (a *App) emitRemoteTabState(tabID, state, errMsg string) { + tab := a.lockRemoteTabPublication(tabID) + if tab == nil { + return + } + defer tab.routeEventMu.Unlock() + a.emitRemoteTabStateLocked(tab, state, errMsg) +} + +func (a *App) emitRemoteTabStateLocked(tab *remoteTab, state, errMsg string) { + tabID := tab.id + a.remoteTabMu.Lock() + if a.remoteTabs[tabID] != tab { + a.remoteTabMu.Unlock() + return + } + tab.state = state + tab.err = errMsg + a.remoteTabMu.Unlock() + a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errMsg}) +} + +func (a *App) installRemoteTabAttachPump(ctx context.Context, tabID string, tab *remoteTab, client *http.Client, base, token, targetPath string, installRoute bool) (context.Context, uint64, uint64, error) { + tab.routeEventMu.Lock() + a.remoteTabMu.Lock() + if a.remoteTabs[tabID] != tab { + a.remoteTabMu.Unlock() + tab.routeEventMu.Unlock() + return nil, 0, 0, fmt.Errorf("remote tab %q closed during bootstrap", tabID) + } + // Retire any pump installed by a concurrent reconnect so exactly one + // generation owns the event stream. + tab.gen++ + if tab.cancel != nil { + tab.cancel() + } + tab.client = client + tab.base = base + tab.token = token + if installRoute { + commitRemoteTabAttachRoute(tab, targetPath, false) + } + attachPathRevision := tab.routing.pathRevision + gen := tab.gen + pumpCtx, cancelPump := context.WithCancel(ctx) + tab.cancel = cancelPump + a.remoteTabMu.Unlock() + tab.routeEventMu.Unlock() + + return pumpCtx, gen, attachPathRevision, nil +} diff --git a/desktop/remote_tab_registry.go b/desktop/remote_tab_registry.go index dc847ebd8c..4c474e1b19 100644 --- a/desktop/remote_tab_registry.go +++ b/desktop/remote_tab_registry.go @@ -178,6 +178,10 @@ func (a *App) removeRemoteTabsForHost(hostID string) error { // already hold singleSurfaceMu use allowEmpty only to roll back a tab whose // open transaction failed before it became a usable surface. func (a *App) closeRemoteTabRegistration(tabID string, allowEmpty bool) error { + publicationTab := a.lockRemoteTabPublication(tabID) + if publicationTab != nil { + defer publicationTab.routeEventMu.Unlock() + } if !allowEmpty { a.mu.RLock() localCount := len(a.tabs) @@ -192,6 +196,10 @@ func (a *App) closeRemoteTabRegistration(tabID string, allowEmpty bool) error { a.remoteTabMu.Lock() } tab := a.remoteTabs[tabID] + if tab != publicationTab { + a.remoteTabMu.Unlock() + return nil + } closingActive := a.remoteTabLayout.activeID == tabID nextLocalID := "" closingIndex := -1 @@ -252,28 +260,37 @@ func (a *App) remoteTabsHostStatus(hostID, state, errText string) { } } -func (a *App) suspendRemoteTabPumps(hostID, state, errText string) { +func (a *App) remoteTabsForHost(hostID string) []*remoteTab { a.remoteTabMu.Lock() - affected := make([]string, 0, 2) + defer a.remoteTabMu.Unlock() + tabs := make([]*remoteTab, 0, 2) for _, tab := range a.remoteTabs { - if tab.ref.HostID != hostID || tab.state == "disconnected" || (tab.state == "connecting" && tab.client == nil) { - // A restored shell was never connected this run: host status - // transitions must not flip it into a runtime state. The same is - // true for a first bootstrap that is still waiting for that host. + if tab.ref.HostID == hostID { + tabs = append(tabs, tab) + } + } + return tabs +} + +func (a *App) suspendRemoteTabPumps(hostID, state, errText string) { + for _, tab := range a.remoteTabsForHost(hostID) { + tab.routeEventMu.Lock() + a.remoteTabMu.Lock() + if a.remoteTabs[tab.id] != tab || tab.ref.HostID != hostID || tab.state == "disconnected" || tab.state == "connecting" && tab.client == nil { + a.remoteTabMu.Unlock() + tab.routeEventMu.Unlock() continue } tab.gen++ - if tab.cancel != nil { - tab.cancel() - tab.cancel = nil + cancel := tab.cancel + tab.cancel = nil + tab.state, tab.err = state, errText + a.remoteTabMu.Unlock() + if cancel != nil { + cancel() } - tab.state = state - tab.err = errText - affected = append(affected, tab.id) - } - a.remoteTabMu.Unlock() - for _, tabID := range affected { - a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errText}) + a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tab.id), RemoteTabStateView{State: state, Error: errText}) + tab.routeEventMu.Unlock() } } @@ -281,27 +298,27 @@ func (a *App) suspendRemoteTabPumps(hostID, state, errText string) { // Cancelling generations before StopServer prevents their EOF path from // interpreting an explicit stop as an unexpected disconnect and restarting it. func (a *App) parkRemoteTabsForServer(hostID, workspace, state, errText string) []string { - a.remoteTabMu.Lock() affected := make([]string, 0, 2) - for _, tab := range a.remoteTabs { - if tab.ref.HostID != hostID || tab.ref.Workspace != workspace { + for _, tab := range a.remoteTabsForHost(hostID) { + tab.routeEventMu.Lock() + a.remoteTabMu.Lock() + if a.remoteTabs[tab.id] != tab || tab.ref.HostID != hostID || tab.ref.Workspace != workspace { + a.remoteTabMu.Unlock() + tab.routeEventMu.Unlock() continue } tab.gen++ - if tab.cancel != nil { - tab.cancel() - } - tab.cancel = nil - tab.client = nil - tab.base = "" - tab.token = "" - tab.state = state - tab.err = errText + cancel := tab.cancel + tab.cancel, tab.client = nil, nil + tab.base, tab.token = "", "" + tab.state, tab.err = state, errText affected = append(affected, tab.id) - } - a.remoteTabMu.Unlock() - for _, tabID := range affected { - a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: state, Error: errText}) + a.remoteTabMu.Unlock() + if cancel != nil { + cancel() + } + a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tab.id), RemoteTabStateView{State: state, Error: errText}) + tab.routeEventMu.Unlock() } return affected } @@ -404,9 +421,11 @@ func (a *App) reattachRemoteTabOnce(tabID string) bool { return false } + tab.routeEventMu.Lock() a.remoteTabMu.Lock() if cur := a.remoteTabs[tabID]; cur != tab || tab.state != "reconnecting" { a.remoteTabMu.Unlock() + tab.routeEventMu.Unlock() return true } tab.gen++ @@ -420,6 +439,7 @@ func (a *App) reattachRemoteTabOnce(tabID string) bool { pumpCtx, cancelPump := context.WithCancel(ctx) tab.cancel = cancelPump a.remoteTabMu.Unlock() + tab.routeEventMu.Unlock() opened := make(chan error, 1) a.goRemoteTabSafe("remoteTabPump", func() { a.remoteTabPump(pumpCtx, tabID, gen, opened) }) diff --git a/desktop/remote_tab_rejection_commit_test.go b/desktop/remote_tab_rejection_commit_test.go new file mode 100644 index 0000000000..e311146be5 --- /dev/null +++ b/desktop/remote_tab_rejection_commit_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func TestRemoteResumeFailurePublishesRestoredIdentity(t *testing.T) { + const oldPath = "/remote/sessions/s1.jsonl" + const targetPath = "/remote/sessions/s2.jsonl" + fs := newFakeServe(t, "s3cret", []serveSessionEntry{ + {Name: "s1", Path: oldPath, Title: "First", Current: true}, + {Name: "s2", Path: targetPath, Title: "Second"}, + }) + kernel := &fakeRemoteKernel{ + statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}}, + ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL}, + ensureToken: "s3cret", + } + seedBridgeTestHost(t, "box") + a := &App{remoteRuntime: kernel} + cleanupRemoteTabPumps(t, a) + type identity struct{ name, path, route, title string } + observed := make(chan identity, 1) + a.remoteEventHook = func(name string, payload any) { + state, ok := payload.(RemoteTabStateView) + if !ok || !strings.Contains(state.Error, "already leased") { + return + } + tabID := strings.TrimSuffix(strings.TrimPrefix(name, "remote-tab:"), ":state") + a.remoteTabMu.Lock() + tab := a.remoteTabs[tabID] + got := identity{tab.session.name, tab.session.path, tab.routing.currentPath, tab.topicTitle} + a.remoteTabMu.Unlock() + observed <- got + } + openReadyRemoteTab(t, a, RemoteTabOpenOptions{SessionName: "s1", SessionPath: oldPath, SessionTitle: "First"}) + fs.mu.Lock() + fs.failEnter = "session is already leased by another process" + fs.mu.Unlock() + if _, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{ + SessionName: "s2", SessionPath: targetPath, SessionTitle: "Second", + }); err != nil { + t.Fatal(err) + } + select { + case got := <-observed: + want := identity{"s1", oldPath, oldPath, "First"} + if got != want { + t.Fatalf("failure publication identity = %+v, want %+v", got, want) + } + case <-time.After(5 * time.Second): + t.Fatal("rejection did not publish a failure") + } +} diff --git a/desktop/remote_tab_rejection_order_test.go b/desktop/remote_tab_rejection_order_test.go new file mode 100644 index 0000000000..01f64fac10 --- /dev/null +++ b/desktop/remote_tab_rejection_order_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "net/http" + "strings" + "sync" + "testing" + "time" +) + +func TestRemoteResumeFailurePublicationOrdersRetirement(t *testing.T) { + for _, kind := range []string{"reconnect", "retire", "suspend", "park", "close", "state"} { + t.Run(kind, func(t *testing.T) { + isolateDesktopUserDirs(t) + client := &http.Client{} + tab := &remoteTab{id: "remote-1", state: "ready", client: client, gen: 7, selectionRevision: 9, + ref: RemoteTabRef{HostID: "box", Workspace: "app"}, + session: remoteTabSessionState{name: "target", path: "/target"}, topicTitle: "Target", + routing: remoteTabSessionRouting{currentPath: "/target", pathRevision: 11, running: map[string]bool{}}, + } + a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} + previous := &remoteTabOpenSelection{session: remoteTabSessionState{name: "old", path: "/old"}, topicTitle: "Old", currentPath: "/old", revision: 9} + route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, 7, "/target") + route.previousSelection = previous + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + events := &eventLog{} + a.remoteEventHook = func(name string, payload any) { + if name == "remote-tab:updated" { + close(entered) + <-release + } + events.add(name, payload) + } + finished := make(chan struct{}) + go func() { a.completeRemoteTabResumeFailure(tab.id, tab, client, 7, route, "rejected"); close(finished) }() + select { + case <-entered: + case <-time.After(3 * time.Second): + t.Fatal("failure did not reach metadata publication") + } + attempted, retired := make(chan struct{}), make(chan struct{}) + go func() { + close(attempted) + switch kind { + case "reconnect": + a.reconnectRemoteTabGeneration(tab.id, 7) + case "retire": + a.retireRemoteTabGeneration(tab.id, 7) + case "suspend": + a.suspendRemoteTabPumps("box", "reconnecting", "") + case "park": + a.parkRemoteTabsForServer("box", "app", "serve_down", "") + case "close": + _ = a.closeRemoteTabRegistration(tab.id, true) + case "state": + a.emitRemoteTabStateForGeneration(tab.id, 7, "error", "stream ended") + } + close(retired) + }() + <-attempted + select { + case <-retired: + t.Fatal("retirement overtook an in-flight failure publication") + case <-time.After(30 * time.Millisecond): + } + a.remoteTabMu.Lock() + intact := a.remoteTabs[tab.id] == tab && tab.gen == 7 && tab.state == "ready" && tab.err == "rejected" && tab.session.path == "/old" + a.remoteTabMu.Unlock() + if !intact { + t.Fatal("retirement mutated identity before prior publication completed") + } + unblock() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("failure did not finish") + } + select { + case <-retired: + case <-time.After(3 * time.Second): + t.Fatal("retirement did not finish") + } + records := events.recorded() + if len(records) < 2 { + t.Fatalf("missing ordered failure events: %v", records) + } + // The terminal failure follows its metadata; any retirement state follows both. + if !strings.Contains(records[0], "remote-tab:updated") || !strings.Contains(records[1], "rejected") { + t.Fatalf("publication order = %v", records) + } + }) + } +} + +func TestRemoteResumeFailureRejectsLostOwnership(t *testing.T) { + for _, kind := range []string{"generation", "selection", "route-revision", "path", "client", "replacement"} { + t.Run(kind, func(t *testing.T) { + client := &http.Client{} + tab := &remoteTab{id: "remote-1", state: "ready", client: client, gen: 7, selectionRevision: 9, + session: remoteTabSessionState{path: "/old"}, routing: remoteTabSessionRouting{currentPath: "/old", pathRevision: 11, running: map[string]bool{}}, + } + log := &eventLog{} + a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}, remoteEventHook: log.add} + route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, 7, "/target") + switch kind { + case "generation": + tab.gen++ + case "selection": + tab.selectionRevision++ + case "route-revision": + tab.routing.pathRevision++ + case "path": + tab.routing.currentPath = "/newer" + case "client": + tab.client = &http.Client{} + case "replacement": + a.remoteTabs[tab.id] = &remoteTab{id: tab.id, state: "ready", gen: 7, client: client} + } + beforeRoute := tab.routing.currentPath + if !a.completeRemoteTabResumeFailure(tab.id, tab, client, 7, route, "obsolete") { + t.Fatal("stale failure claimed completion") + } + if tab.err != "" || tab.routing.currentPath != beforeRoute || len(log.recorded()) != 0 { + t.Fatalf("stale failure mutated or published: error=%q route=%q events=%v", tab.err, tab.routing.currentPath, log.recorded()) + } + }) + } +} diff --git a/desktop/remote_tab_rejection_paths_test.go b/desktop/remote_tab_rejection_paths_test.go new file mode 100644 index 0000000000..2f18c13ad3 --- /dev/null +++ b/desktop/remote_tab_rejection_paths_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +func TestRemoteResumeFailurePathsPublishOneRestoredSnapshot(t *testing.T) { + for _, kind := range []string{"http", "busy", "listing", "notfound", "transport"} { + t.Run(kind, func(t *testing.T) { + isolateDesktopUserDirs(t) + const oldPath, targetPath = "/sessions/old.jsonl", "/sessions/target.jsonl" + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + code, body := http.StatusConflict, "rejected" + if kind == "busy" { + body = "while a turn is running" + } + if kind == "listing" { + code = http.StatusInternalServerError + } + if kind == "notfound" { + code, body = http.StatusOK, `[]` + } + if kind == "transport" { + if req.URL.Path == "/resume" { + return nil, errors.New("response lost") + } + code, body = http.StatusOK, `[{"name":"old","path":"/sessions/old.jsonl","title":"Old","current":true}]` + } + return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil + })} + tab := &remoteTab{id: "remote-1", state: "ready", client: client, base: "http://fixture.invalid", gen: 7, selectionRevision: 9, + session: remoteTabSessionState{name: "target", path: targetPath}, topicTitle: "Target", + routing: remoteTabSessionRouting{currentPath: targetPath, pathRevision: 11, running: map[string]bool{}}, + } + oldPending := json.RawMessage(`{"kind":"approval_request","callId":"old"}`) + previous := &remoteTabOpenSelection{session: remoteTabSessionState{name: "old", path: oldPath}, topicTitle: "Old", currentPath: oldPath, revision: 9, + pending: map[string]json.RawMessage{"old": oldPending}, runtime: remoteTabRuntimeState{running: true, cancellable: true, revision: 3}, + } + a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} + failures := 0 + a.remoteEventHook = func(_ string, payload any) { + state, ok := payload.(RemoteTabStateView) + if !ok || state.Error == "" { + return + } + failures++ + a.remoteTabMu.Lock() + defer a.remoteTabMu.Unlock() + if tab.session.name != "old" || tab.session.path != oldPath || tab.routing.currentPath != oldPath || tab.topicTitle != "Old" || + !tab.runtime.running || !tab.runtime.cancellable || string(tab.pendingEvents["old"]) != string(oldPending) || tab.err != state.Error { + t.Errorf("failure exposed a partial identity/runtime/prompt restore: session=%+v route=%q title=%q runtime=%+v error=%q", tab.session, tab.routing.currentPath, tab.topicTitle, tab.runtime, tab.err) + } + } + path := targetPath + if kind == "listing" || kind == "notfound" { + path = "" + } + a.resumeRemoteTabSessionPathForOpenSelection(tab.id, "target", path, "Target", 9, previous) + if failures != 1 { + t.Fatalf("failure publications = %d, want 1", failures) + } + }) + } +} + +func TestRemoteRejectedResumePreservesProbedAuthoritativeSelection(t *testing.T) { + isolateDesktopUserDirs(t) + const previousPath = "/sessions/previous.jsonl" + const targetPath = "/sessions/target.jsonl" + const authoritativePath = "/sessions/authoritative.jsonl" + client := &http.Client{} + tab := &remoteTab{ + id: "remote-1", state: "ready", client: client, gen: 7, selectionRevision: 11, + session: remoteTabSessionState{name: "previous", path: previousPath}, topicTitle: "Previous", + routing: remoteTabSessionRouting{currentPath: previousPath, running: map[string]bool{}}, + } + a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} + previous := &remoteTabOpenSelection{ + session: tab.session, topicTitle: tab.topicTitle, currentPath: previousPath, revision: tab.selectionRevision, + } + route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, tab.gen, targetPath) + route.previousSelection = previous + handled := a.reconcileRemoteTabRejectedResume( + tab.id, tab, client, tab.gen, route, + serveSessionEntry{Name: "authoritative", Path: authoritativePath, Title: "Authoritative"}, + errors.New("resume response lost"), + ) + if !handled { + t.Fatal("authoritative reconciliation requested stale rollback") + } + a.remoteTabMu.Lock() + gotPath, gotSession, gotTitle := tab.routing.currentPath, tab.session.path, tab.topicTitle + a.remoteTabMu.Unlock() + if gotPath != authoritativePath || gotSession != authoritativePath || gotTitle != "Authoritative" { + t.Fatalf("ambiguous resume restored stale selection: route/session/title = %q/%q/%q", gotPath, gotSession, gotTitle) + } +} diff --git a/desktop/remote_tab_resume_failure.go b/desktop/remote_tab_resume_failure.go new file mode 100644 index 0000000000..4e62e10c54 --- /dev/null +++ b/desktop/remote_tab_resume_failure.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "net/http" +) + +// A rejection is one publication transaction: observers of its error must +// already see the restored identity. The route fence also prevents a newer +// selection or authoritative frame from being overwritten by the old failure. +func (a *App) completeRemoteTabResumeFailure(tabID string, tab *remoteTab, client *http.Client, gen uint64, route remoteTabProvisionalResume, message string) bool { + tab.routeEventMu.Lock() + defer tab.routeEventMu.Unlock() + a.remoteTabMu.Lock() + current := a.remoteTabs[tabID] + if current != tab || current.client != client || current.gen != gen || current.state != "ready" || + current.selectionRevision != route.selectionRevision || current.routing.currentPath != route.targetPath || + route.active && (current.routing.rehydratingPath != route.targetPath || current.routing.pathRevision != route.pathRevision+1) || + !route.active && current.routing.pathRevision != route.pathRevision || + route.previousSelection != nil && current.selectionRevision != route.previousSelection.revision { + a.remoteTabMu.Unlock() + return true + } + if route.previousSelection != nil { + restoreRemoteTabOpenSelectionLocked(current, route.previousSelection) + } else if route.active { + restoreRemoteTabProvisionalRouteLocked(current, route) + } + current.err = message + meta := remoteTabMetaLocked(current) + a.remoteTabMu.Unlock() + if route.previousSelection != nil { + a.emitRemoteEvent("remote-tab:updated", meta) + a.saveTabsFromRemote() + } + a.emitRemoteEvent(fmt.Sprintf("remote-tab:%s:state", tabID), RemoteTabStateView{State: "ready", Error: message}) + return false +} diff --git a/desktop/remote_tab_resume_route.go b/desktop/remote_tab_resume_route.go index 5eeac99470..236bcc38db 100644 --- a/desktop/remote_tab_resume_route.go +++ b/desktop/remote_tab_resume_route.go @@ -7,12 +7,14 @@ import ( ) type remoteTabProvisionalResume struct { - targetPath string - previousPath string - pathRevision uint64 - previousPending map[string]json.RawMessage - previousRuntime remoteTabRuntimeState - active bool + targetPath string + previousPath string + pathRevision uint64 + previousPending map[string]json.RawMessage + previousRuntime remoteTabRuntimeState + active bool + selectionRevision uint64 + previousSelection *remoteTabOpenSelection } func probeRemoteTabFrame(frame string) (kind, path string, current, reset bool) { @@ -39,6 +41,7 @@ func (a *App) beginRemoteTabProvisionalResume(tabID string, tab *remoteTab, clie if current != tab || current.client != client || current.gen != gen || current.state != "ready" { return route } + route.selectionRevision = current.selectionRevision route.previousPath = current.routing.currentPath route.pathRevision = current.routing.pathRevision if route.targetPath == route.previousPath { @@ -65,6 +68,7 @@ func (a *App) rollbackRemoteTabProvisionalResume(tabID string, tab *remoteTab, c defer a.remoteTabMu.Unlock() current := a.remoteTabs[tabID] if current != tab || current.client != client || current.gen != gen || current.state != "ready" || + current.selectionRevision != route.selectionRevision || current.routing.currentPath != route.targetPath { return false } @@ -77,6 +81,11 @@ func (a *App) rollbackRemoteTabProvisionalResume(tabID string, tab *remoteTab, c if current.routing.rehydratingPath != route.targetPath { return false } + restoreRemoteTabProvisionalRouteLocked(current, route) + return true +} + +func restoreRemoteTabProvisionalRouteLocked(current *remoteTab, route remoteTabProvisionalResume) { current.routing.currentPath = route.previousPath current.routing.pathRevision++ current.routing.rehydratingPath = "" @@ -86,32 +95,24 @@ func (a *App) rollbackRemoteTabProvisionalResume(tabID string, tab *remoteTab, c restoredRuntime := route.previousRuntime restoredRuntime.revision = max(current.runtime.revision, route.previousRuntime.revision) + 1 current.runtime = restoredRuntime - return true } // reconcileRemoteTabRejectedResume installs the route Serve reports after an // ambiguous transport failure. The common unchanged case restores the exact // preflight snapshot; an externally changed route drops controller-local state // and publishes the authoritative identity behind a new ready barrier. It -// returns false only when the caller must also restore the pre-open selection. +// commits rejection and any pre-open restoration before publishing its error. func (a *App) reconcileRemoteTabRejectedResume(tabID string, tab *remoteTab, client *http.Client, gen uint64, route remoteTabProvisionalResume, authoritative serveSessionEntry, resumeErr error) bool { authoritative.Path = strings.TrimSpace(authoritative.Path) - if authoritative.Path == route.previousPath { - if a.rollbackRemoteTabProvisionalResume(tabID, tab, client, gen, route) { - a.transitionRemoteTabState(tabID, gen, "ready", "ready", resumeErr.Error()) - // Serve stayed on the previous route, so the caller should restore - // the rest of the pre-open selection snapshot too. - return false - } - // A newer route superseded the failed request while it was being - // reconciled. Preserve that newer authority. - return true + if authoritative.Path == route.previousPath || route.previousSelection != nil && authoritative.Path == route.previousSelection.currentPath { + return a.completeRemoteTabResumeFailure(tabID, tab, client, gen, route, resumeErr.Error()) } tab.routeEventMu.Lock() defer tab.routeEventMu.Unlock() a.remoteTabMu.Lock() current := a.remoteTabs[tabID] if current != tab || current.client != client || current.gen != gen || current.state != "ready" || + current.selectionRevision != route.selectionRevision || current.routing.currentPath != route.targetPath || route.active && current.routing.rehydratingPath != route.targetPath || !route.active && current.routing.pathRevision != route.pathRevision { @@ -141,7 +142,7 @@ func (a *App) reconcileRemoteTabRejectedResume(tabID string, tab *remoteTab, cli a.remoteTabMu.Unlock() a.emitRemoteEvent("remote-tab:updated", meta) a.saveTabsFromRemote() - a.transitionRemoteTabState(tabID, gen, "ready", "ready", resumeErr.Error()) + a.transitionRemoteTabStateLocked(tab, gen, "ready", "ready", resumeErr.Error()) // The probed third path is Serve-authoritative. The generic open-selection // rollback must not replace it with the preflight route. return true @@ -204,7 +205,7 @@ func (a *App) publishRemoteTabResumeReady(tabID string, tab *remoteTab, client * // publishRemoteTabResumeReadyLocked publishes while tab.routeEventMu is held. func (a *App) publishRemoteTabResumeReadyLocked(tabID string, tab *remoteTab, client *http.Client, gen uint64, route remoteTabProvisionalResume) { - if !a.transitionRemoteTabState(tabID, gen, "ready", "ready", "") { + if !a.transitionRemoteTabStateLocked(tab, gen, "ready", "ready", "") { return } for { diff --git a/desktop/remote_tab_review_regressions_test.go b/desktop/remote_tab_review_regressions_test.go index 21828950ac..f15a66a4a1 100644 --- a/desktop/remote_tab_review_regressions_test.go +++ b/desktop/remote_tab_review_regressions_test.go @@ -564,38 +564,6 @@ func TestRemoteRejectedResumeReconcilesReselectedCurrentSession(t *testing.T) { } } -func TestRemoteRejectedResumePreservesProbedAuthoritativeSelection(t *testing.T) { - isolateDesktopUserDirs(t) - const previousPath = "/sessions/previous.jsonl" - const targetPath = "/sessions/target.jsonl" - const authoritativePath = "/sessions/authoritative.jsonl" - client := &http.Client{} - tab := &remoteTab{ - id: "remote-1", state: "ready", client: client, gen: 7, selectionRevision: 11, - session: remoteTabSessionState{name: "previous", path: previousPath}, topicTitle: "Previous", - routing: remoteTabSessionRouting{currentPath: previousPath, running: map[string]bool{}}, - } - a := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}} - previous := &remoteTabOpenSelection{ - session: tab.session, topicTitle: tab.topicTitle, currentPath: previousPath, revision: tab.selectionRevision, - } - route := a.beginRemoteTabProvisionalResume(tab.id, tab, client, tab.gen, targetPath) - handled := a.reconcileRemoteTabRejectedResume( - tab.id, tab, client, tab.gen, route, - serveSessionEntry{Name: "authoritative", Path: authoritativePath, Title: "Authoritative"}, - errors.New("resume response lost"), - ) - if !handled { - a.restoreRejectedRemoteTabOpenSelection(tab.id, previous) - } - a.remoteTabMu.Lock() - gotPath, gotSession, gotTitle := tab.routing.currentPath, tab.session.path, tab.topicTitle - a.remoteTabMu.Unlock() - if gotPath != authoritativePath || gotSession != authoritativePath || gotTitle != "Authoritative" { - t.Fatalf("ambiguous resume restored stale selection: route/session/title = %q/%q/%q", gotPath, gotSession, gotTitle) - } -} - func TestRemoteRejectedResumeRollbackCannotMarkNewerRouteErrored(t *testing.T) { const previousPath = "/sessions/previous.jsonl" const targetPath = "/sessions/target.jsonl" diff --git a/docs/APP_SESSION_OWNERSHIP.md b/docs/APP_SESSION_OWNERSHIP.md index 1f0d8494db..6a1c836c39 100644 --- a/docs/APP_SESSION_OWNERSHIP.md +++ b/docs/APP_SESSION_OWNERSHIP.md @@ -15,6 +15,16 @@ a newer subscriber. App composition wires these owners to the existing page tree; the runtime root and page tree still live together in App.tsx in this stage. Presentation-only extraction is a separate change. +Remote resume rejection completes behind the tab's publication fence. Session +identity, title, route, pending prompts and runtime state are restored before +the error becomes observable. HTTP rejection, busy, listing failure, missing +target and transport reconciliation share that completion owner. Generation, +client, selection and route ownership are rechecked before restoration. + +Generation replacement, retirement, reconnect, host suspension and explicit +close follow the same per-tab publication order. Network handshakes and pump +waits remain outside the fence; map snapshots are revalidated after taking it. + ## Verification `pnpm test:app-lifecycle` exercises source capture, committed publication, @@ -24,6 +34,10 @@ unmount, subscription disposal, and negative memory-protocol fixtures. three layouts, and Composer/Workspace DOM identity. `pnpm test:all` discovers the remaining frontend regression suites. +`cd desktop && go test -race . -run 'TestRemoteResumeFailure|TestOpenRemoteProjectTabRejectedResumeRestoresPreviousIdentity|TestRemoteRejectedResume'` +covers error-time identity, all rejection paths, lost ownership and publication +interleavings with retirement, reconnect, host suspension and close. + ## Independent memory screening The App memory workflow builds the requested clean commit once. Three isolated diff --git a/docs/APP_SESSION_OWNERSHIP.zh-CN.md b/docs/APP_SESSION_OWNERSHIP.zh-CN.md index c0dcb7fcac..00c6374809 100644 --- a/docs/APP_SESSION_OWNERSHIP.zh-CN.md +++ b/docs/APP_SESSION_OWNERSHIP.zh-CN.md @@ -33,3 +33,11 @@ mixed 往返。汇总要求全部 2,688 次往返、完整检查点与堆快照 `SHARD_PASS` 只代表一个完整进程。汇总 `PASS` 代表自动筛查通过,不代表整个 App 不存在内存泄漏;堆保留链分析及主分支对照仍是独立归因工作,报告持续保留待归因 状态。PR head 的证据也不替代最新目标分支集成检查和原生平台验证。 + +## 远端恢复失败的原子完成 + +远端恢复被拒绝时,会话身份、标题、路由、待处理提示和运行态必须先恢复,错误才能对外可见。HTTP 拒绝、忙碌、列表失败、目标不存在及传输失败后回查旧会话,共用同一个失败完成入口,并复核 tab、client、代际、选择与路由权限。 + +代际安装/退役、重连、主机挂起和显式关闭使用同一个 tab 发布顺序;不会在持全局 map 锁时等待发布锁,网络握手和 pump 等待仍在锁外。 + +在 desktop 模块执行 `go test -race . -run 'TestRemoteResumeFailure|TestOpenRemoteProjectTabRejectedResumeRestoresPreviousIdentity|TestRemoteRejectedResume'`,覆盖错误可见时的完整身份、所有拒绝路径、旧请求失权,以及错误发布期间重连/退役/关闭的交错。 From 54c53472dc5dbdc979a71825cd3dc426b321d5b5 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:14:31 +0800 Subject: [PATCH 4/7] fix(remote): handle lock release during bootstrap acquisition Problem: concurrent clients could fail to open a remote workspace when the serve owner released its lock between a failed mkdir and Stat. Root cause: lock acquisition treated a missing observation as a permanent creation failure. A frontend wiring check also still referenced the replaced post-publication rollback helper. Fix: recontend through exclusive mkdir once per missing observation for structured contention-capable errors. Bound consecutive ambiguous failures and preserve cancellation, permission and transport errors. Test real SFTP release ordering and update the async handoff contract; leave stale-lock reclamation unchanged. Verification: deterministic regression fails before the repair. Bootstrap race tests, bounded-error cases, root lint and repolint pass. The root suite passed except an unchanged control timeout; its full package passed on one isolated rerun. All 302 frontend suites and remaining test:all groups pass. No API, persisted-format or provider-byte change. --- .../__tests__/remote-project-tree.test.tsx | 2 +- docs/APP_SESSION_OWNERSHIP.md | 14 ++ docs/APP_SESSION_OWNERSHIP.zh-CN.md | 6 + internal/remote/bootstrap/lock.go | 22 +++- internal/remote/bootstrap/lock_fs.go | 29 +++++ .../bootstrap/lock_release_race_test.go | 122 ++++++++++++++++++ 6 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 internal/remote/bootstrap/lock_fs.go create mode 100644 internal/remote/bootstrap/lock_release_race_test.go diff --git a/desktop/frontend/src/__tests__/remote-project-tree.test.tsx b/desktop/frontend/src/__tests__/remote-project-tree.test.tsx index a2120fbf39..f43eebad7c 100644 --- a/desktop/frontend/src/__tests__/remote-project-tree.test.tsx +++ b/desktop/frontend/src/__tests__/remote-project-tree.test.tsx @@ -157,7 +157,7 @@ ok( ); ok( /existing\.selectionRevision\+\+/.test(remoteOpenSource) && - /a\.goRemoteTabSafe\("remoteTabResume"[\s\S]*?restoreRejectedRemoteTabOpenSelection/.test(remotePendingSelectionSource), + /a\.goRemoteTabSafe\("remoteTabResume"[\s\S]*?resumeRemoteTabSessionPathForOpenSelection\(tabID, name, sessionPath, sessionTitle, revision, selection\)/.test(remotePendingSelectionSource), "session switches resume in the background behind a generation guard", ); ok( diff --git a/docs/APP_SESSION_OWNERSHIP.md b/docs/APP_SESSION_OWNERSHIP.md index 6a1c836c39..f33c88c59f 100644 --- a/docs/APP_SESSION_OWNERSHIP.md +++ b/docs/APP_SESSION_OWNERSHIP.md @@ -25,6 +25,20 @@ Generation replacement, retirement, reconnect, host suspension and explicit close follow the same per-tab publication order. Network handshakes and pump waits remain outside the fence; map snapshots are revalidated after taking it. +## Remote bootstrap lock handoff + +A remote server owner can release its directory between a competing exclusive +mkdir and the contender's Stat. The acquisition owner retries this missing +observation once, through exclusive mkdir again. Only Exists or structured +SFTP v3 generic failure qualifies; permission, transport and cancellation +errors remain terminal. A second consecutive missing observation fails closed, +because the protocol cannot distinguish repeated contention from a permanent +generic failure. Observing a live lock restores the normal context-bound wait. +This does not change the separate stale-lock reclamation policy. + +`go test -race ./internal/remote/bootstrap` covers the release interleaving, +bounded permanent failure, cancellation and one-launch concurrent clients. + ## Verification `pnpm test:app-lifecycle` exercises source capture, committed publication, diff --git a/docs/APP_SESSION_OWNERSHIP.zh-CN.md b/docs/APP_SESSION_OWNERSHIP.zh-CN.md index 00c6374809..48a08cd5b6 100644 --- a/docs/APP_SESSION_OWNERSHIP.zh-CN.md +++ b/docs/APP_SESSION_OWNERSHIP.zh-CN.md @@ -41,3 +41,9 @@ mixed 往返。汇总要求全部 2,688 次往返、完整检查点与堆快照 代际安装/退役、重连、主机挂起和显式关闭使用同一个 tab 发布顺序;不会在持全局 map 锁时等待发布锁,网络握手和 pump 等待仍在锁外。 在 desktop 模块执行 `go test -race . -run 'TestRemoteResumeFailure|TestOpenRemoteProjectTabRejectedResumeRestoresPreviousIdentity|TestRemoteRejectedResume'`,覆盖错误可见时的完整身份、所有拒绝路径、旧请求失权,以及错误发布期间重连/退役/关闭的交错。 + +## 远端启动锁交接 + +远端服务持有者可能在竞争方排他 mkdir 失败与 Stat 之间释放目录。获取入口允许对这个缺失观测重新竞争一次,仍须通过排他 mkdir 才能成为持有者。只有 Exists 或结构化 SFTP v3 通用失败允许此路径;权限、传输与取消保持终止。连续第二次缺失会保守报错,因为协议不能区分重复竞争和永久通用失败;确实观察到存活锁后恢复原有受 context 控制的等待。此修复不改变独立的过期锁回收策略。 + +根模块执行 `go test -race ./internal/remote/bootstrap`,覆盖释放交错、永久错误有限退出、取消及并发客户端只启动一次服务。 diff --git a/internal/remote/bootstrap/lock.go b/internal/remote/bootstrap/lock.go index 416bbe336b..10719ec27a 100644 --- a/internal/remote/bootstrap/lock.go +++ b/internal/remote/bootstrap/lock.go @@ -3,11 +3,10 @@ package bootstrap import ( "context" "fmt" + "os" "strconv" "strings" "time" - - "reasonix/internal/remote/sftpfs" ) const ( @@ -16,7 +15,7 @@ const ( ) type serveLock struct { - fs *sftpfs.FS + fs serveLockFS paths StatePaths owner string } @@ -26,7 +25,7 @@ type serveLock struct { // locate/install phase stays outside the lock. A crashed owner's directory is // reclaimed only after a minute; the guarded health check itself is bounded to // 20 seconds, so a live owner cannot legitimately age past that threshold. -func acquireServeLock(ctx context.Context, fs *sftpfs.FS, paths StatePaths, clock func() time.Time) (*serveLock, error) { +func acquireServeLock(ctx context.Context, fs serveLockFS, paths StatePaths, clock func() time.Time) (*serveLock, error) { if err := fs.MkdirAll(ctx, paths.Dir); err != nil { return nil, err } @@ -35,6 +34,7 @@ func acquireServeLock(ctx context.Context, fs *sftpfs.FS, paths StatePaths, cloc return nil, err } owner := strconv.FormatInt(clock().Unix(), 10) + ":" + token + retriedMissing := false for { mkdirErr := fs.MkdirExclusive(ctx, paths.LockDir) if mkdirErr == nil { @@ -44,11 +44,25 @@ func acquireServeLock(ctx context.Context, fs *sftpfs.FS, paths StatePaths, cloc } return &serveLock{fs: fs, paths: paths, owner: owner}, nil } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("bootstrap: wait for serve lock: %w", err) + } + if !lockCreationMayContend(mkdirErr) { + return nil, fmt.Errorf("bootstrap: create serve lock: %w", mkdirErr) + } lockInfo, statErr := fs.Stat(ctx, paths.LockDir) + // The owner may release between mkdir and Stat. Recompete once per + // observed lock: SFTP v3 generic failures cannot prove contention, so + // repeated missing observations must not spin on permanent failures. + if os.IsNotExist(statErr) && !retriedMissing { + retriedMissing = true + continue + } if statErr != nil || !lockInfo.IsDir { return nil, fmt.Errorf("bootstrap: create serve lock: %w", mkdirErr) } + retriedMissing = false data, _, _, readErr := fs.ReadFile(ctx, paths.LockOwner, 512) if readErr == nil { observed := strings.TrimSpace(string(data)) diff --git a/internal/remote/bootstrap/lock_fs.go b/internal/remote/bootstrap/lock_fs.go new file mode 100644 index 0000000000..1f9c713aad --- /dev/null +++ b/internal/remote/bootstrap/lock_fs.go @@ -0,0 +1,29 @@ +package bootstrap + +import ( + "context" + "errors" + "io/fs" + "os" + + "github.com/pkg/sftp" + + "reasonix/internal/remote/sftpfs" +) + +type serveLockFS interface { + MkdirAll(context.Context, string) error + MkdirExclusive(context.Context, string) error + Stat(context.Context, string) (sftpfs.Entry, error) + ReadFile(context.Context, string, int64) ([]byte, bool, sftpfs.Kind, error) + WriteFileAtomic(context.Context, string, []byte, fs.FileMode) error + Remove(context.Context, string, bool) error +} + +func lockCreationMayContend(err error) bool { + if errors.Is(err, os.ErrExist) { + return true + } + var status *sftp.StatusError + return errors.As(err, &status) && status.FxCode() == sftp.ErrSSHFxFailure +} diff --git a/internal/remote/bootstrap/lock_release_race_test.go b/internal/remote/bootstrap/lock_release_race_test.go new file mode 100644 index 0000000000..c8b4d93e65 --- /dev/null +++ b/internal/remote/bootstrap/lock_release_race_test.go @@ -0,0 +1,122 @@ +package bootstrap + +import ( + "context" + "errors" + "fmt" + "os" + "testing" + "time" + + "github.com/pkg/sftp" + + "reasonix/internal/remote" + "reasonix/internal/remote/sftpfs" +) + +type releaseRaceFS struct { + *sftpfs.FS + mkdir func(context.Context, string) error + stat func(context.Context, string) (sftpfs.Entry, error) +} + +func (f releaseRaceFS) MkdirExclusive(ctx context.Context, path string) error { + return f.mkdir(ctx, path) +} + +func (f releaseRaceFS) Stat(ctx context.Context, path string) (sftpfs.Entry, error) { + if f.stat != nil { + return f.stat(ctx, path) + } + return f.FS.Stat(ctx, path) +} + +func TestServeLockAcquiresAfterObservedOwnerRelease(t *testing.T) { + skipOnWindows(t) + root := t.TempDir() + conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) + paths := pathsFor(root, root) + owner, err := acquireServeLock(context.Background(), conn.fs, paths, time.Now) + if err != nil { + t.Fatal(err) + } + t.Cleanup(owner.release) + calls := 0 + wrapped := releaseRaceFS{FS: conn.fs, mkdir: func(ctx context.Context, path string) error { + calls++ + err := conn.fs.MkdirExclusive(ctx, path) + if calls == 1 { + if err == nil { + t.Fatal("first owner was not exclusive") + } + owner.release() // deterministically release after mkdir failed, before Stat + } + return err + }} + next, err := acquireServeLock(context.Background(), wrapped, paths, time.Now) + if err != nil { + t.Fatal(err) + } + defer next.release() + if calls != 2 || next.owner == owner.owner { + t.Fatalf("acquisition calls=%d, replacement owner unique=%v", calls, next.owner != owner.owner) + } +} + +func TestServeLockDoesNotRetryNonMissingObservations(t *testing.T) { + skipOnWindows(t) + for _, statErr := range []error{os.ErrPermission, errors.New("stat disconnected"), nil} { + root := t.TempDir() + conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) + calls := 0 + wrapped := releaseRaceFS{FS: conn.fs, + mkdir: func(context.Context, string) error { calls++; return os.ErrExist }, + stat: func(context.Context, string) (sftpfs.Entry, error) { + return sftpfs.Entry{IsDir: false}, statErr + }, + } + _, err := acquireServeLock(context.Background(), wrapped, pathsFor(root, root), time.Now) + if !errors.Is(err, os.ErrExist) || calls != 1 { + t.Fatalf("stat=%v error=%v calls=%d; expected immediate creation failure", statErr, err, calls) + } + } +} + +func TestServeLockMissingObservationRetriesAreBounded(t *testing.T) { + skipOnWindows(t) + for _, tc := range []struct { + name string + err error + cancel bool + wantCalls int + }{ + {"generic-failure", &sftp.StatusError{Code: uint32(sftp.ErrSSHFxFailure)}, false, 2}, + {"exists", os.ErrExist, false, 2}, + {"permission", os.ErrPermission, false, 1}, + {"transport", errors.New("transport disconnected"), false, 1}, + {"cancelled", os.ErrExist, true, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + conn := newFakeConn(t, root, func(string) (remote.ExecResult, error) { return ok("") }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + calls := 0 + wrapped := releaseRaceFS{FS: conn.fs, mkdir: func(context.Context, string) error { + calls++ + if tc.cancel { + cancel() + } + return fmt.Errorf("mkdir: %w", tc.err) + }} + _, err := acquireServeLock(ctx, wrapped, pathsFor(root, root), time.Now) + wantErr := tc.err + if tc.cancel { + wantErr = context.Canceled + } + if !errors.Is(err, wantErr) || calls != tc.wantCalls { + t.Fatalf("error=%v calls=%d, want %v/%d", err, calls, wantErr, tc.wantCalls) + } + }) + } +} From 4595e83d4eb9eca6a95c192ebac33083b8d5ca6f Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:18:15 +0800 Subject: [PATCH 5/7] fix(desktop): commit first materialization before transcript paint Problem: native GTK scrolling could reverse visible rows after input paused, even when the scroll writer accepted no reader writes. Root cause: newly mounted rows inherited estimates until the reader lease expired. Releasing their accumulated size debt then moved later visible rows. Short Markdown parsing added another asynchronous first-paint handoff. Fix: measure new DOM before paint, preserve existing visible coordinates with a generation-bound window origin, and acknowledge only the complete prefix. Preload the history renderer and synchronously format bounded windowed answers; large worker results still format automatically after active input ends. Keep the native single-writer and existing safety thresholds. Normalize the small attributable raw bundle delta from 2408.2 to 2408.5 KiB with a 2408.7 KiB ceiling. Model native extent clamping in the deterministic DOM harness. Verification: fixed-height regressions cover both estimate directions, reverse travel, input release and the leading edge. Markdown tests, viewport tests, production build, repository lint and isolated GTK diagnostic replay pass. Final-head browser and CI qualification are tracked in the PR. --- desktop/AGENTS.md | 18 ++- desktop/frontend/package.json | 2 +- .../frontend/scripts/check-bundle-budget.mjs | 4 +- .../src/__tests__/markdown-history.test.tsx | 57 +++++-- .../src/__tests__/transcript-dom-harness.tsx | 29 ++++ .../transcript-materialization.test.tsx | 149 ++++++++++++++++++ desktop/frontend/src/components/Markdown.tsx | 11 +- .../src/components/MarkdownHistory.tsx | 72 ++++++--- .../TranscriptPresentationContext.tsx | 11 ++ .../src/components/TranscriptViewport.tsx | 10 +- .../src/components/TranscriptWindow.tsx | 105 +++++++++--- .../frontend/src/lib/useTranscriptKernel.ts | 8 +- docs/TRANSCRIPT_ARCHITECTURE.md | 10 +- docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md | 10 +- 14 files changed, 433 insertions(+), 63 deletions(-) create mode 100644 desktop/frontend/src/__tests__/transcript-materialization.test.tsx create mode 100644 desktop/frontend/src/components/TranscriptPresentationContext.tsx diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 69a3635ceb..a6466aedac 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -67,15 +67,24 @@ contracts when touching anything that can move the transcript viewport. position and native scroll state into independently committed compositor transactions. - **Anchor-safe measurement commit**: DOM measurements enter a block-keyed - staging ledger before they can change TanStack's prefix sizes. While native - input owns reader intent, the entire painted viewport is immutable: both the pre-measurement + staging ledger before they can change TanStack's prefix sizes. First + materialization is distinct: each new native host must publish its real size + and complete prefix before its first paint, not wait for gesture release. + One generation-bound window origin can preserve common visible positions + while new preceding blocks are measured. Translate positions, extent, range + lookup and publication-frontier checks together; consume the origin + continuously toward the native leading edge, never reset it abruptly at zero. + Clear it under a current-DOM anchor restore in one prepaint commit after + input ends. Acknowledge geometry only after materialization finishes. For + subsequent size changes, while native input owns reader intent, the entire painted viewport is immutable: both the pre-measurement prefix range and mounted DOM must place a block after the viewport before it becomes a publish boundary. The logical Kernel anchor may only move that boundary later. This prevents stale listeners, underestimated ranges, or lazy blocks from reflowing any content the reader can see. TanStack's `scrollMargin` is measured in the native scroller's coordinate space, including Transcript padding and any prefix. Earlier and visible sizes remain - staged during native ownership; only post-viewport overscan may publish. + staged during native ownership; only post-viewport overscan may publish later + changes. Newly materialized natural sizes follow the prepaint rule above. After ownership ends, publish staged DOM sizes under a Kernel logical-anchor restore transaction. Prefix layout and anchor correction must complete in one before-paint commit, cancelling any queued older geometry work. @@ -83,7 +92,8 @@ contracts when touching anything that can move the transcript viewport. blocks to move with actual content growth; freezing every old top would overlap expanded content. Observe mounted absolute blocks as well as the projection root, since local folds do not change the root extent. Tail intent - does not refine invisible cold history; its exact geometry belongs to resident DOM. The measurement ledger owns sizes only. + does not refine subsequent invisible cold-history changes; first materialization + still establishes real mounted sizes. The measurement ledger owns sizes only. Input leases belong to the Kernel and must not be duplicated in the window adapter. Re-read physical viewport geometry at measurement admission; both painted prefix and measured DOM must place the publication boundary beyond diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index cec6356102..e4be93384f 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -26,7 +26,7 @@ "test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs", "test:theme-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/theme-surface-contract.mjs", "test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx", - "test:transcript": "tsx src/__tests__/transcript-command.test.tsx && tsx src/__tests__/transcript-kernel.test.ts && tsx src/__tests__/transcript-geometry-commit.test.ts && tsx src/__tests__/transcript-kernel-races.test.ts && tsx src/__tests__/transcript-measurement-ledger.test.ts && tsx src/__tests__/transcript-timeline.test.ts && tsx src/__tests__/transcript-viewport.test.tsx && tsx src/__tests__/transcript-question-jump.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts", + "test:transcript": "tsx src/__tests__/transcript-command.test.tsx && tsx src/__tests__/transcript-kernel.test.ts && tsx src/__tests__/transcript-geometry-commit.test.ts && tsx src/__tests__/transcript-kernel-races.test.ts && tsx src/__tests__/transcript-measurement-ledger.test.ts && tsx src/__tests__/transcript-materialization.test.tsx && tsx src/__tests__/transcript-timeline.test.ts && tsx src/__tests__/transcript-viewport.test.tsx && tsx src/__tests__/transcript-question-jump.test.ts && tsx src/__tests__/transcript-row-geometry.test.ts && tsx src/__tests__/transcript-geometry-environment.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/frontend-diagnostics.test.ts && tsx src/__tests__/project-tree-diagnostics.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/reasoning-scroll-follow.test.tsx && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-selection-rendering.test.ts && tsx src/__tests__/transcript-store.test.ts", "test:transcript-browser": "node bench/transcript-selection.mjs && node bench/transcript-scroll-stability.mjs && node bench/composer-transcript-stability.mjs", "test:transcript-reader-browser": "node bench/transcript-reader-transaction.mjs", "pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:diagnostics && pnpm test:transcript", diff --git a/desktop/frontend/scripts/check-bundle-budget.mjs b/desktop/frontend/scripts/check-bundle-budget.mjs index b08432d774..fdfc32cfaf 100644 --- a/desktop/frontend/scripts/check-bundle-budget.mjs +++ b/desktop/frontend/scripts/check-bundle-budget.mjs @@ -393,6 +393,8 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS] // 2496.6 KiB; retain the smallest bounded ceiling. // Source-bound owners plus input-release identity measure 2408.2 KiB. // Deferred presentation extraction in the next slice is budgeted separately. -const rawInitialBudgetKiB = 2_408.3; +// First-materialization presentation preloading adds 0.3 KiB raw; the +// measured payload is 2408.5 KiB. Keep 0.2 KiB for build-identity drift. +const rawInitialBudgetKiB = 2_408.7; assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024); assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024); diff --git a/desktop/frontend/src/__tests__/markdown-history.test.tsx b/desktop/frontend/src/__tests__/markdown-history.test.tsx index af43a47dd3..cacd6b84ff 100644 --- a/desktop/frontend/src/__tests__/markdown-history.test.tsx +++ b/desktop/frontend/src/__tests__/markdown-history.test.tsx @@ -6,9 +6,10 @@ // client with a spy — jsdom has no Worker. import { JSDOM } from "jsdom"; -import React, { act } from "react"; +import React, { useLayoutEffect, act } from "react"; import { createRoot } from "react-dom/client"; import MarkdownHistory from "../components/MarkdownHistory"; +import { TranscriptPresentationProvider } from "../components/TranscriptPresentationContext"; import { TranscriptScrollWriteProvider } from "../components/TranscriptLayoutIntentContext"; import { parseMarkdown, markdownContentRevision } from "../lib/markdownPipeline"; import { @@ -125,7 +126,7 @@ console.log("\nmarkdown history rendering"); // ── parse → render → cache; second mount does not re-parse ────────────────── { - const text = "# Cached\n\nFirst **render** parses.\n\nSecond mount must not."; + const text = "# Cached\n\nFirst **render** parses.\n\nSecond mount must not." + " source".repeat(1_400); const entryId = "md-history-cache-1"; // A deferred fake worker keeps the parse in flight so the fallback can be @@ -180,14 +181,16 @@ console.log("\nmarkdown history rendering"); { const entryId = "md-history-cache-2"; const root3 = createRoot(rootEl); + const firstVersion = "version one" + " source".repeat(1_400); + const nextVersion = "version two" + " source".repeat(1_400); await act(async () => { - root3.render(); + root3.render(); }); await flush(); eq(parseCalls.length, 1, "first version parses"); - eq(parseCalls[0], "version one", "the parse receives the exact source text"); + eq(parseCalls[0], firstVersion, "the parse receives the exact source text"); await act(async () => { - root3.render(); + root3.render(); }); await flush(); eq(parseCalls.length, 2, "changed content (new revision) re-parses"); @@ -199,7 +202,7 @@ console.log("\nmarkdown history rendering"); { const root4 = createRoot(rootEl); await act(async () => { - root4.render(); + root4.render(); }); await flush(); eq(parseCalls.length, 3, "live rows parse without a cache key"); @@ -282,21 +285,57 @@ console.log("\nmarkdown history rendering"); Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 1_000 }); Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 }); const root5a = createRoot(rootEl); + let firstPaintFormatted = false; + function FirstPaint({ children }: { children: React.ReactNode }) { + useLayoutEffect(() => { firstPaintFormatted = Boolean(rootEl.querySelector(".md strong")); }, []); + return {} }}>{children}; + } const text = "# Short answer\n\nThis **must render** without a trip to the bottom."; await act(async () => { root5a.render( -
+
{text}
} /> -
, + , ); }); await flush(); + ok(firstPaintFormatted, "short history materializes real Markdown in its first layout, before worker effects"); ok(rootEl.querySelector('.md[data-markdown-blocks="2"]'), "a short visible answer commits while the reader remains above the bottom"); ok(rootEl.querySelector(".md strong"), "the short answer exposes rendered markdown instead of the raw fallback"); await act(async () => root5a.unmount()); rootEl.className = ""; } +// A large source can still be one block. It remains worker-owned, pauses +// only for active input, and formats for a stationary reader without a trip +// to the bottom. Its new DOM requests measurement before paint. +{ + rootEl.className = "transcript"; + rootEl.scrollTop = 400; + Object.defineProperty(rootEl, "scrollHeight", { configurable: true, value: 1_000 }); + Object.defineProperty(rootEl, "clientHeight", { configurable: true, value: 300 }); + const rootLarge = createRoot(rootEl); + const text = "**Large complete answer** " + "source ".repeat(1_500); + let geometryChanges = 0; + const geometryChanged = () => { geometryChanges++; }; + const render = (gestureActive: boolean) => rootLarge.render( + +
{text}
} /> +
, + ); + await act(async () => render(true)); + await flush(); + ok(Boolean(getTranscriptStore().getMarkdown("large-input-lease", markdownContentRevision(text))), "large worker output becomes cache-ready during input"); + ok(!rootEl.querySelector("[data-markdown-blocks]"), "ready large output cannot replace displayed content during active input"); + const before = geometryChanges; + await act(async () => render(false)); + ok(rootEl.querySelector(".md strong"), "a stationary reader receives the complete large answer when input ends"); + ok(geometryChanges > before, "the changed presentation invalidates window geometry in its layout commit"); + await act(async () => rootLarge.unmount()); + rootEl.className = ""; +} + // ── an off-screen long answer commits without waiting for the reader ───────── { rootEl.className = "transcript"; @@ -439,7 +478,7 @@ console.log("\nmarkdown history rendering"); const root6 = createRoot(rootEl); await act(async () => { root6.render( - broken} onError={() => { errors += 1; }} />, + broken} onError={() => { errors += 1; }} />, ); }); await flush(); diff --git a/desktop/frontend/src/__tests__/transcript-dom-harness.tsx b/desktop/frontend/src/__tests__/transcript-dom-harness.tsx index 73c7a7358d..cf465fc766 100644 --- a/desktop/frontend/src/__tests__/transcript-dom-harness.tsx +++ b/desktop/frontend/src/__tests__/transcript-dom-harness.tsx @@ -191,6 +191,28 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions return 0; }, }); + // Native layout clamps scrollTop when the extent shrinks. jsdom stores an + // unconstrained number instead; without this, first measurements can leave + // a fictitious viewport thousands of pixels beyond the entire document. + const nativeScrollTop = Object.getOwnPropertyDescriptor(dom.window.Element.prototype, "scrollTop")!; + Object.defineProperty(proto, "scrollTop", { + configurable: true, + get(this: HTMLElement) { + const raw = nativeScrollTop.get!.call(this) as number; + if (!this.classList.contains("transcript")) return raw; + const maximum = this.scrollHeight - this.clientHeight; + if (!Number.isFinite(maximum)) return raw; + const top = Math.max(0, Math.min(Math.max(0, maximum), raw)); + if (top !== raw) nativeScrollTop.set!.call(this, top); + return top; + }, + set(this: HTMLElement, value: number) { + const maximum = this.scrollHeight - this.clientHeight; + const top = this.classList.contains("transcript") && Number.isFinite(maximum) + ? Math.max(0, Math.min(Math.max(0, maximum), value)) : value; + nativeScrollTop.set!.call(this, top); + }, + }); // Keep generic element scroll methods available to nested controls. The // transcript itself writes through TranscriptViewportWriter. (proto as unknown as { scrollTo: (arg?: number | ScrollToOptions) => void }).scrollTo = function ( @@ -227,6 +249,13 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions }; preference.hydrateReasoningDisplayMode(options.reasoningDisplayMode, true); } + // Module I/O is not owned by the fake animation clock. Await the same + // presentation prerequisite as the production window before advancing + // deterministic frames; a tight fake-clock loop cannot finish disk imports. + if (options.deterministic) { + const markdown = await server.ssrLoadModule("/src/components/Markdown.tsx"); + await markdown.preloadMarkdownHistory(); + } const { TranscriptTestSurface } = await server.ssrLoadModule("/src/__tests__/transcript-test-surface.tsx"); const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx"); const TranscriptComponent = TranscriptTestSurface as React.ComponentType>; diff --git a/desktop/frontend/src/__tests__/transcript-materialization.test.tsx b/desktop/frontend/src/__tests__/transcript-materialization.test.tsx new file mode 100644 index 0000000000..583bf0cf7f --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-materialization.test.tsx @@ -0,0 +1,149 @@ +import React, { act, useState, type ComponentType, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { createTranscriptHarness } from "./transcript-dom-harness"; +import { TranscriptKernel, type LogicalAnchor, type TranscriptViewportSnapshot } from "../lib/transcriptKernel"; +import { TranscriptViewportWriter } from "../lib/transcriptViewportWriter"; +import type { TimelineProjection } from "../lib/transcriptTimeline"; +import type { ProjectionViewProps } from "../components/TranscriptProjectionView"; + +// Fixed natural boxes isolate first materialization from Markdown parsing, +// font delivery, async hydration, and the estimator's text heuristics. +async function verifyMaterialization(naturalHeight: number): Promise { + console.log(`\nMaterialization: ${naturalHeight}px DOM / 171px estimate`); + const harness = await createTranscriptHarness({ deterministic: true, viewportHeight: 600, rowHeight: naturalHeight / 3 }); + const kernel = new TranscriptKernel({ clock: harness.clock }); + const writer = new TranscriptViewportWriter(); + kernel.replaceSurface("materialization"); + kernel.connectWriter(writer.write); + const projection: TimelineProjection = { hasOlderHistory: false, + completedBlocks: Array.from({ length: 160 }, (_, index) => ({ key: `fixed-${index}`, rows: [], + phase: "completed", contentRevision: 1, measurementRevision: "1" })) }; + const { default: Window } = await harness.loadModule<{ default: ComponentType> }>("/src/components/TranscriptWindow.tsx"); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + let scroller: HTMLDivElement; + let failed = 0; + function check(value: boolean, label: string) { console.log(`${value ? "PASS" : "FAIL"} ${label}`); if (!value) failed++; } + function snapshot(): TranscriptViewportSnapshot { + return { scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: 600, + visibleBlocks: Array.from(scroller.querySelectorAll("[data-transcript-block-key]")) + .map(element => ({ key: element.dataset.transcriptBlockKey!, top: element.getBoundingClientRect().top + scroller.scrollTop, + bottom: element.getBoundingClientRect().bottom + scroller.scrollTop })) }; + } + function Fixture() { + const [element, setElement] = useState(null); + scroller = element!; + return
+ ()} + forceFull={false} estimateBlock={() => 171} onPinnedJumpVisible={() => {}} + onGeometryWillChange={(anchor?: LogicalAnchor) => { if (!kernel.userGestureActive) kernel.begin("restore", anchor); }} + onGeometryChange={() => { + kernel.advanceGeometry(); + const transaction = kernel.activeTransaction; + if (transaction && element) kernel.correctAnchor(transaction, key => snapshot().visibleBlocks.find(block => block.key === key)?.top); + }} + renderProjection={(layout: ProjectionViewProps): ReactNode =>
+
+ {layout.blocks.map(block => { + const place = layout.placements?.get(block.key); + return
+
+
; + })} +
} /> +
; + } + const visible = () => snapshot().visibleBlocks.filter(block => block.bottom > scroller.scrollTop && block.top < scroller.scrollTop + 600) + .map(block => ({ ...block, top: block.top - scroller.scrollTop, bottom: block.bottom - scroller.scrollTop })); + try { + await act(async () => root.render()); + writer.attach(scroller!, kernel.generation); + await act(async () => { + kernel.beginUserGesture(snapshot()); + scroller.scrollTop = 15_000; + kernel.observeNativeScroll(snapshot()); + scroller.dispatchEvent(new Event("scroll")); + }); + const first = visible(); + check(first.length >= 3, "cold jump exposes multiple real blocks"); + check(first.length > 0 && first.every(block => Math.abs(block.bottom - block.top - naturalHeight) < 0.01), "actual DOM height is fixed independently of parsing"); + check(first.length >= 3 && first.slice(1).every((block, index) => Math.abs(block.top - first[index].bottom) < 0.5), + "first paint commits real sizes without overlaps or estimate gaps during native input"); + // Reverse native travel adds previously unmounted blocks before several + // existing visible blocks. Every common position must track native input, + // not the newly discovered prefix size. + let previousTop = scroller.scrollTop; + let previous = visible(); + let drift = 0; + let overlap = 0; + const writes: string[] = []; + window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = write => { + if (kernel.userGestureActive && write.outcome === "accepted") writes.push(write.owner ?? "unknown"); + }; + for (let step = 0; step < 32; step++) { + await act(async () => { + scroller.scrollTop = Math.max(0, scroller.scrollTop - 180); + kernel.observeNativeScroll(snapshot()); + scroller.dispatchEvent(new Event("scroll")); + }); + const current = visible(); + for (const before of previous) { + const after = current.find(block => block.key === before.key); + if (after) drift = Math.max(drift, Math.abs(after.top - before.top + scroller.scrollTop - previousTop)); + } + for (let i = 1; i < current.length; i++) overlap = Math.max(overlap, Math.abs(current[i].top - current[i - 1].bottom)); + previous = current; + previousTop = scroller.scrollTop; + } + check(drift <= 0.5, `reverse materialization preserves every common visible position (${drift}px)`); + check(overlap <= 0.5, `reverse materialization has no inter-block gap or overlap (${overlap}px)`); + check(writes.length === 0, "materialization does not write native scroll during input"); + // Reach the leading edge without releasing the input lease. Prefix-origin + // calibration must be continuous, including the final one-pixel step. + let maxEdgeExcess = 0; + while (scroller.scrollTop > 0) { + const before = visible(); + const oldTop = scroller.scrollTop; + const travel = Math.min(oldTop, oldTop <= 3 ? 1 : 180); + await act(async () => { + scroller.scrollTop = oldTop - travel; + kernel.observeNativeScroll(snapshot()); + scroller.dispatchEvent(new Event("scroll")); + }); + const current = visible(); + for (const prior of before) { + const next = current.find(block => block.key === prior.key); + if (next) maxEdgeExcess = Math.max(maxEdgeExcess, Math.abs(next.top - prior.top) - 2 * travel); + } + } + check(maxEdgeExcess <= 0.5, `leading-edge origin is continuous, without a final reset jump (${maxEdgeExcess}px)`); + const held = visible(); + await act(async () => { kernel.endUserGesture(); root.render(); }); + await harness.settle(); + const after = visible(); + check(held.length >= 3 && held.every(block => { + const current = after.find(value => value.key === block.key); + return current != null && Math.abs(current.top - block.top) < 0.5; + }), "lease release cannot repay first-paint geometry debt inside the visible range"); + await act(async () => { + kernel.beginUserGesture(snapshot()); + scroller.scrollTop = 0; + kernel.observeNativeScroll(snapshot()); + scroller.dispatchEvent(new Event("scroll")); + }); + const leading = scroller.querySelector('[data-transcript-block-key="fixed-0"]'); + check(!!leading && Math.abs(leading.getBoundingClientRect().top) <= 0.5, "native top exposes the complete first block"); + } finally { + await act(async () => root.unmount()); + host.remove(); + kernel.detachSurface(); + await harness.unmount(); + await harness.close(); + } + return failed; +} +const failures = await verifyMaterialization(191) + await verifyMaterialization(151); +if (failures) process.exit(1); diff --git a/desktop/frontend/src/components/Markdown.tsx b/desktop/frontend/src/components/Markdown.tsx index f72530c351..8b6f09ab2d 100644 --- a/desktop/frontend/src/components/Markdown.tsx +++ b/desktop/frontend/src/components/Markdown.tsx @@ -6,7 +6,15 @@ async function loadMarkdownView(component: Promise): Promise { } const MarkdownRenderer = lazy(() => loadMarkdownView(import("./MarkdownRenderer"))); -const MarkdownHistory = lazy(() => loadMarkdownView(import("./MarkdownHistory"))); +let historyView: typeof import("./MarkdownHistory").default | undefined; +let historyViewPromise: Promise | undefined; +export function preloadMarkdownHistory(): Promise { + return historyViewPromise ??= loadMarkdownView(import("./MarkdownHistory")).then(module => { + historyView = module.default; + return module; + }); +} +const LazyMarkdownHistory = lazy(preloadMarkdownHistory); const STREAMING_TAIL_THRESHOLD = 8_000; const FINALIZE_SETTLE_MS = 50; const FINALIZE_IDLE_TIMEOUT_MS = 1_000; @@ -453,6 +461,7 @@ export const Markdown = memo(function Markdown({ if (streaming || legacyMode) return committedView; + const MarkdownHistory = historyView ?? LazyMarkdownHistory; const historyFallback = wasStreamingRef.current ? committedView :
{text}
; diff --git a/desktop/frontend/src/components/MarkdownHistory.tsx b/desktop/frontend/src/components/MarkdownHistory.tsx index 126026ce28..e33f9393e9 100644 --- a/desktop/frontend/src/components/MarkdownHistory.tsx +++ b/desktop/frontend/src/components/MarkdownHistory.tsx @@ -14,6 +14,8 @@ import { Fragment, memo, startTransition, useCallback, useEffect, useLayoutEffec import { hastBlockToJsx } from "../lib/hastJsx"; import { estimateHastBytes, + parseMarkdown, + type MarkdownParseResult, markdownContentRevision, type MarkdownBlock, } from "../lib/markdownPipeline"; @@ -25,6 +27,7 @@ import { import { getTranscriptStore } from "../lib/transcriptStore"; import { createComponents } from "./markdownComponents"; import { VirtualMarkdownSourceTable } from "./MarkdownTable"; +import { useTranscriptPresentation } from "./TranscriptPresentationContext"; import { useTranscriptScrollOffsetWrite } from "./TranscriptLayoutIntentContext"; // A history surface opens at the newest transcript content. Keep the same @@ -33,6 +36,9 @@ import { useTranscriptScrollOffsetWrite } from "./TranscriptLayoutIntentContext" // The previous idle loop forced one React/layout commit per second until every block was in // the DOM, which could keep WebView2 busy for minutes after a session switch. const MARKDOWN_TAIL_BLOCKS = 24; +// Bound synchronous materialization by source size, not the number of AST +// blocks (one code/table block can contain an arbitrarily large document). +const SYNCHRONOUS_HISTORY_SOURCE_LIMIT = 8_000; const MARKDOWN_PREPEND_BLOCKS = 96; const MARKDOWN_WINDOW_BLOCKS = MARKDOWN_TAIL_BLOCKS + MARKDOWN_PREPEND_BLOCKS * 2; const MARKDOWN_SENTINEL_STYLE = { display: "block", height: 1 } as const; @@ -156,21 +162,49 @@ export const MarkdownHistory = memo(function MarkdownHistory({ onParsed?: () => void; onError?: () => void; }) { + const presentation = useTranscriptPresentation(); + const gestureRef = useRef(presentation.gestureActive); + gestureRef.current = presentation.gestureActive; + const pendingPresentation = useRef<(() => void) | null>(null); + useEffect(() => { + if (!presentation.gestureActive) pendingPresentation.current?.(); + }, [presentation.gestureActive]); const stableCacheKey = cacheKey ?? entryId; const revision = useMemo(() => markdownContentRevision(text), [text]); - // Parsed state is keyed by its source text: a text change renders the - // fallback (never stale blocks) until the new parse lands. - const [parsed, setParsed] = useState<{ text: string; blocks: MarkdownBlock[] } | undefined>(() => { + // A bounded answer is fully formatted when its DOM is first materialized. + // Publishing a short AST later is still a geometry change: tables/code can + // grow by many lines even when they fit within the Markdown block window. + const initial = useMemo(() => { const cached = cachedBlocks(stableCacheKey, revision, text); - return cached ? { text, blocks: cached } : undefined; - }); - const blocks = parsed && parsed.text === text ? parsed.blocks : undefined; + if (cached) return { text, blocks: cached, result: undefined as MarkdownParseResult | undefined }; + if (!presentation.windowed || text.length > SYNCHRONOUS_HISTORY_SOURCE_LIMIT) return undefined; + try { + const result = parseMarkdown(text); + // Large block-count documents retain their established worker/window + // handoff. Only a complete bounded document is first-paint material. + return result.blocks.length <= MARKDOWN_TAIL_BLOCKS ? { text, blocks: result.blocks, result } : undefined; + } catch { return undefined; } + }, [stableCacheKey, revision, text, presentation.windowed]); + const [parsed, setParsed] = useState<{ text: string; blocks: MarkdownBlock[] }>(); + const current = initial ?? parsed; + const blocks = current?.text === text ? current.blocks : undefined; const fallbackMarkerRef = useRef(null); + useLayoutEffect(() => { + // The Window measures the new DOM in this same prepaint React commit, + // rather than exposing a changed body above an old prefix for one frame. + presentation.geometryChanged(); + }, [blocks, presentation.geometryChanged]); useEffect(() => { - const cached = cachedBlocks(stableCacheKey, revision, text); - if (cached) { - setParsed({ text, blocks: cached }); + if (initial) { + if (stableCacheKey && initial.result) { + const result = initial.result; + getTranscriptStore().setMarkdown(stableCacheKey, revision, { + source: text, blocks: result.blocks, selectionText: result.selectionText, + selectionRevision: result.selectionRevision, + bytes: text.length * 2 + result.selectionText.length * 2 + estimateHastBytes(result.blocks), + }); + } onParsed?.(); return; } @@ -194,6 +228,7 @@ export const MarkdownHistory = memo(function MarkdownHistory({ if (cancelled) return; releaseDeferredCommit?.(); releaseDeferredCommit = undefined; + if (pendingPresentation.current === commit) pendingPresentation.current = null; setParsed(next); onParsed?.(); }; @@ -204,16 +239,12 @@ export const MarkdownHistory = memo(function MarkdownHistory({ commit(); return; } - // A reader who is not at the bottom does not have to lose the - // rendered view (#9570): the deferred handoff below is only needed - // when swapping the fallback for the bounded tail window would - // visibly remove content the reader is looking at. - // - // 1. Answers within one tail window (total <= MARKDOWN_TAIL_BLOCKS) - // render the full document in the block window — nothing is - // removed, so the swap cannot yank the scroller. + // A complete document that fits the block window must still render + // for a stationary reader (#9570), including sources above the inline + // parse budget. Only the active input lease delays that automatic swap. if (result.blocks.length <= MARKDOWN_TAIL_BLOCKS) { - commit(); + if (!gestureRef.current) commit(); + else pendingPresentation.current = commit; return; } // 2. Longer answers outside the transcript viewport swap safely: any height @@ -234,7 +265,7 @@ export const MarkdownHistory = memo(function MarkdownHistory({ // above, but keep the fallback until the reader deliberately returns // to the bottom; that handoff needs no competing scroll write. const handleScroll = () => { - if (isAtBottom()) commit(); + if (isAtBottom() || (scroller && !fallbackRowIntersectsTranscript(fallbackMarkerRef.current, scroller))) commit(); }; scroller?.addEventListener("scroll", handleScroll, { passive: true }); releaseDeferredCommit = () => scroller?.removeEventListener("scroll", handleScroll); @@ -244,13 +275,14 @@ export const MarkdownHistory = memo(function MarkdownHistory({ }); return () => { cancelled = true; + pendingPresentation.current = null; releaseDeferredCommit?.(); handle.cancel(); }; // onParsed/onError are stable caller callbacks; re-running per identity // change would re-request parses the cache already serves. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [text, stableCacheKey, revision]); + }, [text, stableCacheKey, revision, initial]); const components = useMemo(() => createComponents(plainStatusBlocks), [plainStatusBlocks]); const totalBlocks = blocks?.length ?? 0; diff --git a/desktop/frontend/src/components/TranscriptPresentationContext.tsx b/desktop/frontend/src/components/TranscriptPresentationContext.tsx new file mode 100644 index 0000000000..c5e9c54c7d --- /dev/null +++ b/desktop/frontend/src/components/TranscriptPresentationContext.tsx @@ -0,0 +1,11 @@ +import { createContext, useContext } from "react"; + +/** Automatic content replacement and its natural-size publication share the + * window's input owner and before-paint measurement boundary. */ +const TranscriptPresentationContext = createContext({ + gestureActive: false, + windowed: false, + geometryChanged: () => {}, +}); +export const TranscriptPresentationProvider = TranscriptPresentationContext.Provider; +export const useTranscriptPresentation = () => useContext(TranscriptPresentationContext); diff --git a/desktop/frontend/src/components/TranscriptViewport.tsx b/desktop/frontend/src/components/TranscriptViewport.tsx index 0304befbbb..d1b04fe7a3 100644 --- a/desktop/frontend/src/components/TranscriptViewport.tsx +++ b/desktop/frontend/src/components/TranscriptViewport.tsx @@ -1,15 +1,19 @@ import { Loader2, RotateCcw } from "lucide-react"; import { forwardRef, lazy, Suspense, useImperativeHandle, useLayoutEffect, useState, type ReactNode } from "react"; import { estimateTranscriptRowSize, type TranscriptRow } from "../lib/transcriptRows"; -import type { TranscriptKernel } from "../lib/transcriptKernel"; +import type { LogicalAnchor, TranscriptKernel } from "../lib/transcriptKernel"; import type { TimelineBlock, TimelineProjection, TranscriptRenderMode } from "../lib/transcriptTimeline"; import { useT } from "../lib/i18n"; import { TranscriptSelectionOverlay } from "./TranscriptSelectionOverlay"; import { TranscriptProjectionView } from "./TranscriptProjectionView"; +import { preloadMarkdownHistory } from "./Markdown"; import { ProcessBrainIcon } from "./ProcessCard"; import { useTick, workStatusLabel } from "../lib/workStatus"; -const TranscriptWindow = lazy(() => import("./TranscriptWindow")); +const TranscriptWindow = lazy(async () => { + const [window] = await Promise.all([import("./TranscriptWindow"), preloadMarkdownHistory()]); + return window; +}); function estimateBlock(block: TimelineBlock): number { return Math.max(64, block.rows.reduce((height, row) => height + estimateTranscriptRowSize(row), 0)); } @@ -31,7 +35,7 @@ export const TranscriptViewport = forwardRef void; - onGeometryWillChange: () => unknown; + onGeometryWillChange: (anchor?: LogicalAnchor) => unknown; onGeometryChange: (covered?: boolean, beforePaint?: boolean) => void; kernel: TranscriptKernel; protectedBlockKeys?: ReadonlySet; diff --git a/desktop/frontend/src/components/TranscriptWindow.tsx b/desktop/frontend/src/components/TranscriptWindow.tsx index 7d9fd877f2..fc7088d9b7 100644 --- a/desktop/frontend/src/components/TranscriptWindow.tsx +++ b/desktop/frontend/src/components/TranscriptWindow.tsx @@ -1,6 +1,7 @@ +import { TranscriptPresentationProvider } from "./TranscriptPresentationContext"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; -import type { TranscriptKernel } from "../lib/transcriptKernel"; +import type { LogicalAnchor, TranscriptKernel } from "../lib/transcriptKernel"; import type { ProjectionViewProps } from "./TranscriptProjectionView"; import { TranscriptMeasurementLedger } from "../lib/transcriptMeasurementLedger"; import type { TimelineBlock, TimelineProjection } from "../lib/transcriptTimeline"; @@ -64,7 +65,7 @@ export default function TranscriptWindow({ projection: TimelineProjection; scrollElement: HTMLDivElement | null; onGeometryChange: (covered?: boolean, beforePaint?: boolean) => void; - onGeometryWillChange: () => unknown; + onGeometryWillChange: (anchor?: LogicalAnchor) => unknown; protectedBlockKeys: ReadonlySet; kernel: Pick; pinnedJumpBlockKey?: string; @@ -133,9 +134,33 @@ export default function TranscriptWindow({ // Materialize TanStack's prefix-size ledger before reading either its // asynchronous candidate range or the synchronous recovery input. const totalSize = virtualizer.getTotalSize(); + // Newly materialized DOM is measured before its first paint. A single + // window origin preserves already-visible blocks when those new sizes + // refine the prefix above them, without writing during native input. + const materializedElements = useRef(new WeakSet()); + const windowOrigin = useRef(0); + const materializationAnchor = useRef<{ generation: number; key?: string; tailOffset?: number; top: number } | null>(null); + const materializationGeneration = useRef(kernel.generation); + if (materializationGeneration.current !== kernel.generation) { + materializationGeneration.current = kernel.generation; + materializedElements.current = new WeakSet(); + windowOrigin.current = 0; + materializationAnchor.current = null; + } + const pendingAnchor = materializationAnchor.current; + const anchorStart = pendingAnchor?.key + ? virtualizer.measurementsCache[coldIndexByKey.get(pendingAnchor.key) ?? -1]?.start + : pendingAnchor?.tailOffset != null ? scrollMargin + totalSize + pendingAnchor.tailOffset : undefined; + const refinedOrigin = pendingAnchor?.generation === kernel.generation && anchorStart != null + ? pendingAnchor.top - anchorStart : windowOrigin.current; + // Consume the temporary origin continuously as native travel approaches + // the leading edge. Clearing it only at zero would create a discontinuity; + // carrying it through zero would make the first content unreachable. + const origin = Math.max(-nativeViewport.scrollTop, Math.min(nativeViewport.scrollTop, refinedOrigin)); const candidateItems = virtualizer.getVirtualItems(); const committedGeometryRef = useRef | undefined>(undefined); const pendingMeasurementCommit = useRef(false); + const measurementNeedsRender = useRef(false); const structureRevision = `${split.cold.length}:${split.cold[0]?.key ?? ""}:${split.cold[split.cold.length - 1]?.key ?? ""}`; const geometry = commitTranscriptWindowGeometry({ candidate: candidateItems, @@ -145,7 +170,7 @@ export default function TranscriptWindow({ residentCount: split.resident.length, forceFull, structureRevision, - scrollTop: nativeViewport.scrollTop, + scrollTop: nativeViewport.scrollTop - origin, clientHeight: nativeViewport.clientHeight, scrollHeight: nativeViewport.scrollHeight, scrollMargin, @@ -161,14 +186,13 @@ export default function TranscriptWindow({ const logicalAnchorIndex = kernel.anchor.kind === "block" ? coldIndexByKey.get(kernel.anchor.blockKey) : undefined; - const rangeRevision = `${committedRange.scrollMargin}:${committedRange.totalSize}|${virtualItems.map((item) => `${String(item.key)}:${item.start}:${item.size}`).join("|")}`; + const rangeRevision = `${origin}:${committedRange.scrollMargin}:${committedRange.totalSize}|${virtualItems.map((item) => `${String(item.key)}:${item.start}:${item.size}`).join("|")}`; useLayoutEffect(() => { committedGeometryRef.current = geometry; - const beforePaint = geometry.measurementCommitted; - if (beforePaint) pendingMeasurementCommit.current = false; - onGeometryChange(geometry.covered, beforePaint); - }, [geometry, onGeometryChange]); + windowOrigin.current = origin; + materializationAnchor.current = null; + }, [geometry, origin]); useLayoutEffect(() => { if (!minimumResidentKey || currentResidentIndex >= 0) return; setResidentStartKey(minimumResidentKey); @@ -212,6 +236,9 @@ export default function TranscriptWindow({ if (rect.bottom >= viewport.top && rect.top <= viewport.bottom) onPinnedJumpVisible(); }, [onPinnedJumpVisible, pinnedJumpBlockKey, rangeRevision, scrollElement]); const [measurementRevision, setMeasurementRevision] = useState(0); + const presentationChanged = useCallback(() => setMeasurementRevision(value => value + 1), []); + const presentation = useMemo(() => ({ gestureActive: kernel.userGestureActive, windowed: true, geometryChanged: presentationChanged }), + [kernel.userGestureActive, presentationChanged]); useLayoutEffect(() => { const container = residentTailRef.current; if (!container || typeof ResizeObserver === "undefined") return; @@ -232,16 +259,36 @@ export default function TranscriptWindow({ }, [fullDOMFallback, kernel, rangeRevision]); const measuredItems = fullDOMFallback ? geometry.prefix.items : virtualItems; useLayoutEffect(() => { + measurementNeedsRender.current = false; const container = residentTailRef.current; const changes: Array<{ key: string; size: number }> = []; + const firstMeasurements = new Set(); const viewport = scrollElement?.getBoundingClientRect(); const observedTop = scrollElement?.scrollTop ?? nativeViewport.scrollTop; const clientHeight = scrollElement?.clientHeight ?? nativeViewport.clientHeight; const domItems: Array<{ index: number; top: number }> = []; + const blocks = Array.from(container?.querySelectorAll("[data-transcript-block-key]") ?? []); + const visible = blocks.filter(element => { + const rect = element.getBoundingClientRect(); + return viewport && rect.bottom > viewport.top + 0.5 && rect.top < viewport.top + clientHeight; + }); + const common = visible.find(element => materializedElements.current.has(element)); + const commonKey = common?.dataset.transcriptBlockKey; + const commonItem = commonKey ? measuredItems.find(item => String(item.key) === commonKey) : undefined; + // The committed prefix is independent of compositor progress. Rebuilding + // a cold content coordinate from separately sampled DOMRect/scrollTop + // would let a native advance between those reads become an origin error. + const commonTop = commonItem ? commonItem.start + origin + : common && viewport ? common.getBoundingClientRect().top - viewport.top + (scrollElement?.scrollTop ?? observedTop) : undefined; + const firstVisible = visible[0]; + const readerAnchor: LogicalAnchor | undefined = firstVisible && viewport + ? { kind: "block", blockKey: firstVisible.dataset.transcriptBlockKey!, offsetPx: viewport.top - firstVisible.getBoundingClientRect().top } + : undefined; if (container) { for (const item of measuredItems) { const element = container.querySelector(`.transcript__window-item[data-index="${item.index}"]`); if (!element) continue; + if (!materializedElements.current.has(element)) firstMeasurements.add(String(item.key)); const rect = element.getBoundingClientRect(); if (viewport) domItems.push({ index: item.index, top: rect.top - viewport.top }); const size = Math.max(64, rect.height || element.offsetHeight); @@ -254,18 +301,29 @@ export default function TranscriptWindow({ // Re-read native progress at publication; a render's snapshot can be older. const publicationTop = Math.max(observedTop, scrollElement?.scrollTop ?? observedTop); const measurementBoundaryIndex = findTranscriptMeasurementPublicationBoundary({ - paintedItems: measuredItems, domItems, scrollTop: publicationTop, clientHeight, + paintedItems: measuredItems.map(item => ({ ...item, start: item.start + origin })), domItems, scrollTop: publicationTop, clientHeight, anchorIndex: logicalAnchorIndex, }); const published = measurementLedger.publishStaged((key) => { const index = coldIndexByKey.get(key); - return kernel.intent === "reader" && index != null && ( + return index != null && (firstMeasurements.has(key) || (kernel.intent === "reader" && ( !kernel.userGestureActive || (measurementBoundaryIndex != null && index >= measurementBoundaryIndex) - ); + ))); }); - if (published.length > 0) { - if (!kernel.userGestureActive) onGeometryWillChange(); + blocks.forEach(element => materializedElements.current.add(element)); + const releaseOrigin = !kernel.userGestureActive && Math.abs(origin) > 0.5; + if (published.length > 0 || releaseOrigin) { + measurementNeedsRender.current = true; + if (!kernel.userGestureActive) { + onGeometryWillChange(readerAnchor); + windowOrigin.current = 0; + } else if (common && commonTop != null && published.some(change => firstMeasurements.has(change.key))) { + const key = common.dataset.transcriptBlockKey!; + materializationAnchor.current = coldIndexByKey.has(key) + ? { generation: kernel.generation, key, top: commonTop } + : { generation: kernel.generation, tailOffset: commonTop - (scrollMargin + totalSize + origin), top: commonTop }; + } pendingMeasurementCommit.current = true; // Feed only the atomically published batch into TanStack's keyed size // cache. `measure()` is intentionally forbidden here: it clears that @@ -283,7 +341,18 @@ export default function TranscriptWindow({ setMeasurementRevision(revision => revision + 1); return; } - }, [coldIndexByKey, fullDOMFallback, kernel.intent, kernel.userGestureActive, logicalAnchorIndex, measuredItems, measurementLedger, measurementRevision, nativeViewport.clientHeight, nativeViewport.scrollTop, onGeometryChange, onGeometryWillChange, projection.activeBlock?.measurementRevision, rangeRevision, scrollElement, split.resident, virtualItems, virtualizer]); + }, [coldIndexByKey, fullDOMFallback, kernel.intent, kernel.userGestureActive, logicalAnchorIndex, measuredItems, measurementLedger, measurementRevision, nativeViewport.clientHeight, nativeViewport.scrollTop, onGeometryChange, onGeometryWillChange, projection.activeBlock?.measurementRevision, rangeRevision, scrollElement, split.resident, virtualItems, virtualizer, origin, scrollMargin, totalSize]); + + useLayoutEffect(() => { + // Estimates are preparation, not trustworthy painted geometry. Only + // acknowledge after every first-materialization size has entered the + // same prefix; otherwise tail correction/health checks see an intermediate + // extent and can latch safety while this commit is still measuring it. + if (measurementNeedsRender.current) return; + const beforePaint = geometry.measurementCommitted; + if (beforePaint) pendingMeasurementCommit.current = false; + onGeometryChange(geometry.covered, beforePaint); + }, [geometry, onGeometryChange]); // Safety disables range eviction, not the last trustworthy prefix. Reflowing // every cold estimate into natural DOM would move a held reader without any @@ -293,10 +362,10 @@ export default function TranscriptWindow({ const mounted = fullDOMFallback ? projection.completedBlocks : [...virtualItems.map((item) => split.cold[item.index]), ...split.resident]; const placements = new Map(prefix.items.map((item) => [String(item.key), - { index: item.index, top: item.start - prefix.margin }])); - return renderProjection({ blocks: [...mounted, ...(projection.activeBlock ? [projection.activeBlock] : [])], - placements, extent: prefix.extent, + { index: item.index, top: item.start - prefix.margin + origin }])); + return {renderProjection({ blocks: [...mounted, ...(projection.activeBlock ? [projection.activeBlock] : [])], + placements, extent: Math.max(0, prefix.extent + origin), spacerRef: coldContainerRef, tailRef: residentTailRef, mode: fullDOMFallback ? "full" : "windowed", safety: fullDOMFallback, completedCount: projection.completedBlocks.length, - revision: `${fullDOMFallback}:${rangeRevision}:${projection.activeBlock?.measurementRevision}` }); + revision: `${fullDOMFallback}:${rangeRevision}:${projection.activeBlock?.measurementRevision}` })}; } diff --git a/desktop/frontend/src/lib/useTranscriptKernel.ts b/desktop/frontend/src/lib/useTranscriptKernel.ts index 82770d3709..f29968417e 100644 --- a/desktop/frontend/src/lib/useTranscriptKernel.ts +++ b/desktop/frontend/src/lib/useTranscriptKernel.ts @@ -4,6 +4,7 @@ import { TranscriptKernel, type TranscriptKernelClock, type ScrollTransactionKind, + type LogicalAnchor, type TranscriptScrollMode, type TranscriptScrollOwner, type TranscriptViewportSnapshot, @@ -149,9 +150,10 @@ export function useTranscriptKernel({ settleGeometry(beforePaint); }, [settleGeometry]); - const beginAnchorRestore = useCallback(() => { - if (kernel.userGestureActive || kernel.intent !== "reader" || kernel.anchor.kind !== "block") return null; - const transaction = kernel.activeTransaction ?? kernel.begin("restore", kernel.anchor); + const beginAnchorRestore = useCallback((anchor?: LogicalAnchor) => { + const restoreAnchor = anchor ?? kernel.anchor; + if (kernel.userGestureActive || kernel.intent !== "reader" || restoreAnchor.kind !== "block") return null; + const transaction = kernel.activeTransaction ?? kernel.begin("restore", restoreAnchor); if (transaction) refresh(); return transaction; }, [kernel, refresh]); diff --git a/docs/TRANSCRIPT_ARCHITECTURE.md b/docs/TRANSCRIPT_ARCHITECTURE.md index 44172bb7a6..ff436979f0 100644 --- a/docs/TRANSCRIPT_ARCHITECTURE.md +++ b/docs/TRANSCRIPT_ARCHITECTURE.md @@ -24,7 +24,15 @@ Up to 100 completed turns use full DOM. At 101 turns the adapter windows cold co The Window Adapter applies a range commit protocol instead of painting every asynchronous TanStack candidate. A committed range must cover the current native viewport. Native viewport geometry is consumed as an immutable external-store snapshot, allowing React to reject a concurrent render if the compositor offset advances before commit. The mounted items, total window extent, and scroll margin form one immutable adapter snapshot: retaining an old range while publishing a new extent is forbidden because that mixes measurement generations and can move or uncover content at an unchanged native `scrollTop`. Window items are positioned with absolute layout `top`, not transforms, so the item range and native scroll position cannot be split into independently committed WebView compositor transactions. The bounded adapter budget is directional: resident turns consume the shared 40-completed-block budget first, four cold blocks remain behind current motion as a reversal cushion when capacity permits, and the remaining cold capacity is mounted ahead. A stale candidate therefore cannot replace a previously covering range; a native jump that invalidates both ranges is reconstructed synchronously from TanStack's prefix-size ledger with the same directional budget, including every protected anchor, selection, focus, and jump block. If candidate, retained, and reconstructed ranges are all uncovered—or required protected/resident ownership cannot fit the window budget—the adapter fails closed through the shared full-DOM safety renderer before paint. It never exposes a blank range while waiting for the later anomaly probe. While native input owns an unchanged viewport, measurement-only notifications retain the complete painted geometry snapshot. The adapter records whether the range came from a candidate, retention, reconstruction, or an unavailable fail-closed state, but none of these paths may write scroll position. -DOM measurement uses the same commit boundary. The adapter owns an immutable, block-keyed Reasonix measurement ledger; TanStack's item ResizeObserver path is not connected. Measurements enter a staging ledger first. While native input owns reader intent, the whole painted viewport is immutable. Both the pre-measurement prefix range at the immutable native `scrollTop` and the mounted DOM must identify a block beyond the current publication frontier before any staged size may publish; the Kernel's logical anchor may only move that boundary later. After native ownership ends, the adapter publishes staged DOM sizes under the Kernel's logical-anchor restore transaction. Prefix layout and anchor correction complete in the same before-paint commit, which cancels queued older geometry work. The first reading anchor stays fixed while later blocks move to accommodate actual content growth; keeping every old top would overlap expanded content. Mounted absolute blocks have a generation-fenced ResizeObserver scheduled through the Kernel clock, because local reasoning folds and deferred Markdown do not resize the projection root. The measurement ledger owns sizes only; input leases remain exclusively in the Kernel. At publication the adapter re-reads physical scroll position and requires both painted prefix and measured DOM to identify a suffix beyond the viewport plus one viewport of runway. This is a geometric reserve, not a bound on compositor travel. Wheel deltas are never integrated into a pending-distance barrier: a temporary native backlog can exceed the entire mounted window, prevent all future measurement publication, and accumulate incorrect visible sizes even if native travel eventually catches up. The same geometry boundary applies to wheel, touch, selection, keyboard and native-thumb gestures; writer exclusion remains in the Kernel. Together these rules guard every divergence direction: a stale native listener, an underestimated prefix, an earlier lazy block growing into view, sequential visible blocks whose remeasurement would otherwise shift by an increasing amount, and compositor motion outrunning a React commit. TanStack's `scrollMargin` is measured in the native scroller's coordinate space, including Transcript padding and any prefix surface. During native ownership, measurements before or inside the frontier remain staged, while safe forward overscan is refined before the reader reaches it. Tail intent does not refine invisible cold history: its physical geometry comes from the exact resident tail, avoiding an unrelated prefix rebuild and extra tail write. Each publication first commits one immutable Reasonix ledger snapshot, then transfers that exact published batch into TanStack's keyed size cache synchronously in the same browser task. Calling TanStack `measure()` is forbidden because it clears the keyed cache and rebuilds the whole prefix, which can reintroduce older off-screen measurement deltas ahead of the reader. The full-DOM adapter follows the same will-change/commit handshake. This keeps rendering, prefix sums, and native scroll ownership on one ordered state transition instead of allowing asynchronous measurements or partially updated item sizes to move visible content behind the kernel. +DOM measurement distinguishes first materialization from later changes. A new native block host publishes its actual size with the complete prefix before its first paint, even during input. It cannot paint an estimated allocation that overlaps the next natural block and leave that discrepancy for input release. Geometry is acknowledged only after all measurements needed by that materialization have committed. The generation-bound host set also recognizes cache-backed remounts and safety mounts; a replacement surface starts a new set. + +When new blocks refine the prefix above an already-visible block during native input, the Window Adapter retains one coordinate origin for the entire window. DOM positions and extent include that origin; range lookup subtracts it from native scroll position, and publication-frontier checks use translated positions. Common visible blocks retain their positions while new adjacent blocks use real sizes. No native scroll write occurs. The origin is consumed continuously as native travel approaches the leading edge, so it cannot hide the first block or disappear abruptly at zero. When input ends, current DOM geometry supplies the restore anchor; clearing the origin and the Kernel correction form one prepaint commit. This is a coordinate mapping, not a second input lease or a queue of per-row size debts. + +Subsequent measurements enter the immutable, block-keyed staging ledger. TanStack's automatic measurement publication and native scroll correction remain disconnected. During input, both translated painted geometry and fresh DOM geometry must identify a suffix beyond the viewport plus one viewport of runway before a later size can publish. The Kernel anchor may only move that frontier later. Wheel deltas are never accumulated into a publication barrier. After input ends, later sizes publish under a fresh logical-anchor restore in the same prepaint transaction. Actual content growth or explicit disclosure can reposition following blocks; their old tops must not be frozen into overlaps. Mounted absolute blocks have generation-fenced ResizeObservers scheduled by the Kernel clock. + +Window materialization preloads its history presentation. Complete answers up to 8,000 source characters and 24 Markdown blocks format synchronously, so their first measured DOM is already formatted. Larger sources retain the worker and bounded block window. Ready output is cached separately from displayed output; a complete answer fitting the block window waits only for active input to end, not for a stationary reader to return to the bottom. Its layout effect asks the Window to measure before paint. Long block-window replacements retain their existing visible-source protection and can commit after leaving the viewport or returning to the tail. Full-DOM rendering keeps its existing worker path. Formatting a genuinely different content layout is not claimed to preserve every following block's old position. + +The ledger owns sizes only, and the Kernel owns all input leases and writes. First materialization establishes real mounted geometry in both reader and tail intent; later invisible cold-history changes do not refine the tail's prefix. Every approved batch first commits one immutable Reasonix snapshot, then transfers that exact batch into TanStack's keyed size cache synchronously. Calling TanStack `measure()` remains forbidden because it discards that cache and rebuilds the protected prefix. The full-DOM adapter continues to share the will-change/commit handshake and the same native writer. Development, test, preview, and canary builds may use the non-persistent `?transcriptRenderMode=full|windowed` diagnostic override. Stable builds ignore it. diff --git a/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md b/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md index bc860fc68d..174aebd75d 100644 --- a/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md +++ b/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md @@ -14,9 +14,15 @@ 挂载范围、完整前缀、总高度和滚动边距属于同一不可变快照。第三方惰性缓存必须先具体化;不同代际的范围和高度不能混用。 -DOM 测量先进入以块键索引的暂存账本。账本只拥有尺寸,输入租约统一由 Kernel 管理。发布时重新读取实际滚动位置,已绘制前缀和实测 DOM 都必须将安全边界放在当前视口之后,并保留一个视口的预备区域。该余量不是原生合成器行程的上限;不能把滚轮意图积分为禁止发布的距离,因为短暂的输入积压也可能超过整个挂载窗口,使未来尺寸冻结到释放时才集中挤压。滚轮、触摸、选择、键盘和原生滚动条输入使用相同的几何发布边界;Kernel 继续在输入租约期间禁止程序滚动抢权。整批尺寸、前缀和位置仍在绘制前共同提交。 +测量区分首次物化与后续变化。新挂载的原生块必须在首次绘制前,把真实尺寸与完整前缀一并提交;不能先按估算间距绘制重叠的自然高度内容,再等输入释放补算。所需首次测量全部提交后才能确认几何健康。挂载身份受代际限制,缓存命中后的重新挂载和安全展示使用同一规则,会话替换清空该身份集合。 -释放后,在同一次绘制前提交中发布前缀并恢复首个阅读锚点,取消旧几何任务。后续块按真实内容增长移动,不能冻结所有旧位置造成重叠。绝对定位的挂载块也拥有代际受限的 ResizeObserver,因为内部展开不一定改变投影根高度。不得重新开启 TanStack 自有的测量发布、调用 measure() 清空受保护前缀,或增加平台专用滚动补偿。 +输入期间,新块修正已有可见块之前的前缀时,窗口整体保留一个坐标原点偏移。DOM 位置与总高度包含它,范围查找从原生 scrollTop 减去它,发布边界使用平移后的前缀和新鲜 DOM 坐标。共同可见块保持位置,新相邻块按真实尺寸连续排列,不写原生滚动位置。原生行程接近顶部时连续消耗偏移,避免首块不可达或到零时骤然跳动。输入结束后从当前 DOM 捕获阅读锚点,在同一次绘制前提交中将偏移归零并由 Kernel 恢复位置。它不是第二个输入租约,也不是逐行累积的尺寸欠账。 + +后续尺寸先进入按块键索引的不可变暂存账本。输入期间,已平移的绘制前缀与实测 DOM 都必须将边界放在视口及额外一个视口的预备区域之后;逻辑锚点只能把边界后移。不能把 wheel 意图积分为禁止发布的距离。释放后按当前阅读锚点同帧发布后续尺寸。真实内容增长或主动展开可以移动后续块,不能把旧位置冻结成重叠。绝对定位块也由 Kernel 时钟管理带代际检查的 ResizeObserver。 + +窗口首次物化前预加载历史展示模块。不超过 8000 源字符且不超过 24 个 Markdown 块的完整回答,在首次测量前已完成格式化。较大文本继续使用 worker 与有界块窗口,准备好的缓存和正在显示的内容分开管理。能完整放入块窗口的回答只等待活动输入结束,停留阅读的用户不必滚到底部才能看到格式;展示变化的 layout effect 请求窗口在绘制前测量。大块窗口替换继续保留既有可见源码保护,可在离开视口或回到尾部后提交。普通全量 DOM 保留 worker 路径,不声称任意真实格式变化都能保持后续块原位置。 + +账本只拥有尺寸,输入租约与原生写入仍归 Kernel。首次物化在阅读和尾部意图下均建立真实挂载几何;后续不可见冷历史变化不重建尾部前缀。一次性发布 Reasonix 快照后,同步将同一批次交给 TanStack。不得重启 TanStack 自有发布、调用 measure() 清空缓存,或增加平台专用补偿。 ## 内核、导航与选择 From 6dfb647836856943af8da5d0cf4d7fb6b3df0f73 Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:21:57 +0800 Subject: [PATCH 6/7] test(desktop): select message text before safety fault injection Problem: the safety browser gate could fail with no selection before the fault transition, instead of testing whether a real selection survives. Root cause: its text walker included the Compress toolbar label, which is visible but not a native text-selection target. Fix: select only the existing transcript-selectable surface and exclude interactive controls. Require a real native selection and selection ownership before injecting the geometry fault. Retain every post-transition identity, selection, displacement, zero-write and memory assertion unchanged. Verification: the original replay captured Compress as its drag target and no selected text before the fault. The corrected full browser replay covers 24 safety cycles with real drag selection, pointer holds and tail ownership. --- desktop/frontend/bench/transcript-scroll-stability.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/desktop/frontend/bench/transcript-scroll-stability.mjs b/desktop/frontend/bench/transcript-scroll-stability.mjs index f33624f00e..035733991c 100644 --- a/desktop/frontend/bench/transcript-scroll-stability.mjs +++ b/desktop/frontend/bench/transcript-scroll-stability.mjs @@ -407,6 +407,10 @@ async function runSafetyFixture(page) { const element = document.querySelector(".transcript"), viewport = element.getBoundingClientRect(); const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); for (let node = walker.nextNode(); node; node = walker.nextNode()) { + // Toolbar labels are visible text, but native dragging does not + // select them. Exercise the same selectable surface as the product. + if (!node.parentElement.closest("[data-transcript-selectable]") + || node.parentElement.closest("button, input, textarea, [role=button]")) continue; if (node.textContent.trim().length < 8) continue; const range = document.createRange(); range.setStart(node, 0); range.setEnd(node, 8); const rect = range.getBoundingClientRect(); @@ -417,6 +421,9 @@ async function runSafetyFixture(page) { await page.mouse.move(point.x, point.y); await page.mouse.down(); await page.mouse.move(point.end, point.y, { steps: 4 }); await settleFrames(page, 2); + assert(await page.evaluate(() => Boolean(document.getSelection()?.toString()) + && document.querySelector(".transcript")?.dataset.scrollMode === "selection"), + "safety fixture establishes a real text selection before fault injection"); } const started = await page.evaluate((cycle) => { const element = document.querySelector(".transcript"); From 6a24344a7939f89e5ea9589dcbfb695728d16fda Mon Sep 17 00:00:00 2001 From: SivanCola <32437197+SivanCola@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:31:03 +0800 Subject: [PATCH 7/7] fix(desktop): preserve committed anchors across natural size changes Problem: enlarging an offscreen predecessor could move the held reader by its full growth (371px in the Windows browser replay). Root cause: measurement selected the first visible block from already changed DOM bounds. The enlarged predecessor became visible and replaced the real input-captured anchor before the new prefix was committed. Fix: ordinary size publication retains the Kernel anchor. Only window-origin removal supplies a coordinate-conversion anchor, selected from the previously committed prefix and its old sizes. An untrusted new DOM rectangle cannot claim reading ownership. Preserve existing transaction priorities and fallback to the Kernel anchor when no cold prefix candidate is available. Verification: deterministic predecessor-growth regressions fail before the repair and pass after it for both estimate directions. The model also covers mid-history origin release, leading-edge continuity and input write exclusion. Production build and repository lint pass; affected transcript and browser gates are rerun before publication. Architecture contracts document the owner. --- desktop/AGENTS.md | 4 +- .../transcript-materialization.test.tsx | 41 +++++++++++++++++++ .../src/components/TranscriptWindow.tsx | 13 ++++-- docs/TRANSCRIPT_ARCHITECTURE.md | 4 +- docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md | 4 +- 5 files changed, 56 insertions(+), 10 deletions(-) diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index a6466aedac..9b5574e2e4 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -74,8 +74,8 @@ contracts when touching anything that can move the transcript viewport. while new preceding blocks are measured. Translate positions, extent, range lookup and publication-frontier checks together; consume the origin continuously toward the native leading edge, never reset it abruptly at zero. - Clear it under a current-DOM anchor restore in one prepaint commit after - input ends. Acknowledge geometry only after materialization finishes. For + Clear it using the committed prefix anchor in one prepaint commit after + input ends; ordinary growth retains the input-captured Kernel anchor. Acknowledge geometry only after materialization finishes. For subsequent size changes, while native input owns reader intent, the entire painted viewport is immutable: both the pre-measurement prefix range and mounted DOM must place a block after the viewport before it becomes a publish boundary. The logical Kernel anchor may only move that diff --git a/desktop/frontend/src/__tests__/transcript-materialization.test.tsx b/desktop/frontend/src/__tests__/transcript-materialization.test.tsx index 583bf0cf7f..98edbb501f 100644 --- a/desktop/frontend/src/__tests__/transcript-materialization.test.tsx +++ b/desktop/frontend/src/__tests__/transcript-materialization.test.tsx @@ -101,6 +101,15 @@ async function verifyMaterialization(naturalHeight: number): Promise { check(drift <= 0.5, `reverse materialization preserves every common visible position (${drift}px)`); check(overlap <= 0.5, `reverse materialization has no inter-block gap or overlap (${overlap}px)`); check(writes.length === 0, "materialization does not write native scroll during input"); + const heldMidway = visible(); + await act(async () => { kernel.endUserGesture(); root.render(); }); + await harness.settle(); + check(heldMidway.every(before => { + const after = visible().find(block => block.key === before.key); + return after != null && Math.abs(after.top - before.top) <= 0.5; + }), "mid-history origin release preserves the previously painted prefix anchor"); + await act(async () => { kernel.beginUserGesture(snapshot()); root.render(); }); + // Reach the leading edge without releasing the input lease. Prefix-origin // calibration must be continuous, including the final one-pixel step. let maxEdgeExcess = 0; @@ -136,6 +145,38 @@ async function verifyMaterialization(naturalHeight: number): Promise { }); const leading = scroller.querySelector('[data-transcript-block-key="fixed-0"]'); check(!!leading && Math.abs(leading.getBoundingClientRect().top) <= 0.5, "native top exposes the complete first block"); + // A previously invisible block can grow across the viewport boundary. + // Its new DOM height must not replace the reader's committed anchor. + await act(async () => { + scroller.scrollTop = 6_000; + kernel.observeNativeScroll(snapshot()); + scroller.dispatchEvent(new Event("scroll")); + }); + await act(async () => { kernel.endUserGesture(); root.render(); }); + await harness.settle(); + await act(async () => { + kernel.beginUserGesture(snapshot()); + kernel.endUserGesture(); + root.render(); + }); + const beforeGrowth = visible()[0]; + const predecessor = Array.from(scroller.querySelectorAll(".transcript__window-item")) + .filter(element => element.getBoundingClientRect().bottom <= 0).at(-1); + check(!!predecessor && !!beforeGrowth, "offscreen growth fixture owns a visible anchor and predecessor"); + if (predecessor && beforeGrowth) { + await act(async () => { + for (let row = 0; row < 9; row++) { + const extra = document.createElement("div"); extra.className = "transcript__row"; predecessor.append(extra); + } + check(predecessor.getBoundingClientRect().bottom > 0, "natural growth enters viewport before its measured prefix commits"); + harness.observers.filter(observer => observer.target === predecessor).forEach(observer => observer.notify()); + }); + await harness.settle(); + const afterGrowth = visible().find(block => block.key === beforeGrowth.key); + check(afterGrowth != null && Math.abs(afterGrowth.top - beforeGrowth.top) <= 0.5, + "offscreen growth preserves the committed reader anchor instead of selecting the newly visible predecessor"); + } + } finally { await act(async () => root.unmount()); host.remove(); diff --git a/desktop/frontend/src/components/TranscriptWindow.tsx b/desktop/frontend/src/components/TranscriptWindow.tsx index fc7088d9b7..555ad11044 100644 --- a/desktop/frontend/src/components/TranscriptWindow.tsx +++ b/desktop/frontend/src/components/TranscriptWindow.tsx @@ -280,9 +280,12 @@ export default function TranscriptWindow({ // would let a native advance between those reads become an origin error. const commonTop = commonItem ? commonItem.start + origin : common && viewport ? common.getBoundingClientRect().top - viewport.top + (scrollElement?.scrollTop ?? observedTop) : undefined; - const firstVisible = visible[0]; - const readerAnchor: LogicalAnchor | undefined = firstVisible && viewport - ? { kind: "block", blockKey: firstVisible.dataset.transcriptBlockKey!, offsetPx: viewport.top - firstVisible.getBoundingClientRect().top } + // Origin removal converts the already-painted coordinate system. Choose + // from its committed prefix, never from DOM heights that have just changed. + const committedVisible = measuredItems.find(item => item.start + origin + item.size > observedTop + 0.5 + && item.start + origin < observedTop + clientHeight); + const originAnchor: LogicalAnchor | undefined = committedVisible + ? { kind: "block", blockKey: String(committedVisible.key), offsetPx: observedTop - (committedVisible.start + origin) } : undefined; if (container) { for (const item of measuredItems) { @@ -316,7 +319,9 @@ export default function TranscriptWindow({ if (published.length > 0 || releaseOrigin) { measurementNeedsRender.current = true; if (!kernel.userGestureActive) { - onGeometryWillChange(readerAnchor); + // Ordinary content growth belongs to the input-captured anchor; + // a newly enlarged preceding DOM block must not replace that owner. + onGeometryWillChange(releaseOrigin ? originAnchor : undefined); windowOrigin.current = 0; } else if (common && commonTop != null && published.some(change => firstMeasurements.has(change.key))) { const key = common.dataset.transcriptBlockKey!; diff --git a/docs/TRANSCRIPT_ARCHITECTURE.md b/docs/TRANSCRIPT_ARCHITECTURE.md index ff436979f0..32c80719d0 100644 --- a/docs/TRANSCRIPT_ARCHITECTURE.md +++ b/docs/TRANSCRIPT_ARCHITECTURE.md @@ -26,9 +26,9 @@ The Window Adapter applies a range commit protocol instead of painting every asy DOM measurement distinguishes first materialization from later changes. A new native block host publishes its actual size with the complete prefix before its first paint, even during input. It cannot paint an estimated allocation that overlaps the next natural block and leave that discrepancy for input release. Geometry is acknowledged only after all measurements needed by that materialization have committed. The generation-bound host set also recognizes cache-backed remounts and safety mounts; a replacement surface starts a new set. -When new blocks refine the prefix above an already-visible block during native input, the Window Adapter retains one coordinate origin for the entire window. DOM positions and extent include that origin; range lookup subtracts it from native scroll position, and publication-frontier checks use translated positions. Common visible blocks retain their positions while new adjacent blocks use real sizes. No native scroll write occurs. The origin is consumed continuously as native travel approaches the leading edge, so it cannot hide the first block or disappear abruptly at zero. When input ends, current DOM geometry supplies the restore anchor; clearing the origin and the Kernel correction form one prepaint commit. This is a coordinate mapping, not a second input lease or a queue of per-row size debts. +When new blocks refine the prefix above an already-visible block during native input, the Window Adapter retains one coordinate origin for the entire window. DOM positions and extent include that origin; range lookup subtracts it from native scroll position, and publication-frontier checks use translated positions. Common visible blocks retain their positions while new adjacent blocks use real sizes. No native scroll write occurs. The origin is consumed continuously as native travel approaches the leading edge, so it cannot hide the first block or disappear abruptly at zero. When input ends, the previously committed prefix supplies the coordinate-conversion anchor; clearing the origin and the Kernel correction form one prepaint commit. This is a coordinate mapping, not a second input lease or a queue of per-row size debts. -Subsequent measurements enter the immutable, block-keyed staging ledger. TanStack's automatic measurement publication and native scroll correction remain disconnected. During input, both translated painted geometry and fresh DOM geometry must identify a suffix beyond the viewport plus one viewport of runway before a later size can publish. The Kernel anchor may only move that frontier later. Wheel deltas are never accumulated into a publication barrier. After input ends, later sizes publish under a fresh logical-anchor restore in the same prepaint transaction. Actual content growth or explicit disclosure can reposition following blocks; their old tops must not be frozen into overlaps. Mounted absolute blocks have generation-fenced ResizeObservers scheduled by the Kernel clock. +Subsequent measurements enter the immutable, block-keyed staging ledger. TanStack's automatic measurement publication and native scroll correction remain disconnected. During input, both translated painted geometry and fresh DOM geometry must identify a suffix beyond the viewport plus one viewport of runway before a later size can publish. The Kernel anchor may only move that frontier later. Wheel deltas are never accumulated into a publication barrier. After input ends, later sizes publish under the input-captured Kernel anchor in the same prepaint transaction. A preceding block that grows into the viewport cannot replace that anchor through its newly changed DOM bounds. Actual content growth or explicit disclosure can reposition following blocks; their old tops must not be frozen into overlaps. Mounted absolute blocks have generation-fenced ResizeObservers scheduled by the Kernel clock. Window materialization preloads its history presentation. Complete answers up to 8,000 source characters and 24 Markdown blocks format synchronously, so their first measured DOM is already formatted. Larger sources retain the worker and bounded block window. Ready output is cached separately from displayed output; a complete answer fitting the block window waits only for active input to end, not for a stationary reader to return to the bottom. Its layout effect asks the Window to measure before paint. Long block-window replacements retain their existing visible-source protection and can commit after leaving the viewport or returning to the tail. Full-DOM rendering keeps its existing worker path. Formatting a genuinely different content layout is not claimed to preserve every following block's old position. diff --git a/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md b/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md index 174aebd75d..7633e329e9 100644 --- a/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md +++ b/docs/TRANSCRIPT_ARCHITECTURE.zh-CN.md @@ -16,9 +16,9 @@ 测量区分首次物化与后续变化。新挂载的原生块必须在首次绘制前,把真实尺寸与完整前缀一并提交;不能先按估算间距绘制重叠的自然高度内容,再等输入释放补算。所需首次测量全部提交后才能确认几何健康。挂载身份受代际限制,缓存命中后的重新挂载和安全展示使用同一规则,会话替换清空该身份集合。 -输入期间,新块修正已有可见块之前的前缀时,窗口整体保留一个坐标原点偏移。DOM 位置与总高度包含它,范围查找从原生 scrollTop 减去它,发布边界使用平移后的前缀和新鲜 DOM 坐标。共同可见块保持位置,新相邻块按真实尺寸连续排列,不写原生滚动位置。原生行程接近顶部时连续消耗偏移,避免首块不可达或到零时骤然跳动。输入结束后从当前 DOM 捕获阅读锚点,在同一次绘制前提交中将偏移归零并由 Kernel 恢复位置。它不是第二个输入租约,也不是逐行累积的尺寸欠账。 +输入期间,新块修正已有可见块之前的前缀时,窗口整体保留一个坐标原点偏移。DOM 位置与总高度包含它,范围查找从原生 scrollTop 减去它,发布边界使用平移后的前缀和新鲜 DOM 坐标。共同可见块保持位置,新相邻块按真实尺寸连续排列,不写原生滚动位置。原生行程接近顶部时连续消耗偏移,避免首块不可达或到零时骤然跳动。输入结束后从已提交的旧前缀确定坐标换算锚点,在同一次绘制前提交中将偏移归零并由 Kernel 恢复位置。它不是第二个输入租约,也不是逐行累积的尺寸欠账。 -后续尺寸先进入按块键索引的不可变暂存账本。输入期间,已平移的绘制前缀与实测 DOM 都必须将边界放在视口及额外一个视口的预备区域之后;逻辑锚点只能把边界后移。不能把 wheel 意图积分为禁止发布的距离。释放后按当前阅读锚点同帧发布后续尺寸。真实内容增长或主动展开可以移动后续块,不能把旧位置冻结成重叠。绝对定位块也由 Kernel 时钟管理带代际检查的 ResizeObserver。 +后续尺寸先进入按块键索引的不可变暂存账本。输入期间,已平移的绘制前缀与实测 DOM 都必须将边界放在视口及额外一个视口的预备区域之后;逻辑锚点只能把边界后移。不能把 wheel 意图积分为禁止发布的距离。释放后按输入时保存的 Kernel 阅读锚点同帧发布后续尺寸。前方块增高进入视口后,不能依据变化后的 DOM 边界替换原锚点。真实内容增长或主动展开可以移动后续块,不能把旧位置冻结成重叠。绝对定位块也由 Kernel 时钟管理带代际检查的 ResizeObserver。 窗口首次物化前预加载历史展示模块。不超过 8000 源字符且不超过 24 个 Markdown 块的完整回答,在首次测量前已完成格式化。较大文本继续使用 worker 与有界块窗口,准备好的缓存和正在显示的内容分开管理。能完整放入块窗口的回答只等待活动输入结束,停留阅读的用户不必滚到底部才能看到格式;展示变化的 layout effect 请求窗口在绘制前测量。大块窗口替换继续保留既有可见源码保护,可在离开视口或回到尾部后提交。普通全量 DOM 保留 worker 路径,不声称任意真实格式变化都能保持后续块原位置。