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 (
-
-
-
- {connection.platform === "qq" ? "Q" : connection.platform === "weixin" ? "微" : connection.platform === "lark" ? "L" : "飞"}
-
-
-
{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")}
-
- }>
-
{
- setTerminalPanelOpen(false);
- saveTerminalPanelOpen(false);
- }}
- onAddOutput={(sessionId) => void addTerminalOutputToComposer(sessionId)}
- onAddToChat={addTerminalSelectionToComposer}
- />
-
- )}
-
- {
- setSavedTerminalHeight(TERMINAL_DEFAULT_HEIGHT);
+ 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,
}}
/>
- >
- {!sidebarImDetailConnection && (
- void app.DisconnectRemoteHost(hostId).catch(() => {})}
- onManageRemote={() => setSettingsTarget("remote")}
- onOpenRemote={requestRemoteExplorer}
- onOpenRemoteWorkspace={openRemoteWorkspaceFromStatus}
- remoteHosts={remoteHosts}
- remoteStatuses={remoteStatuses}
- />
- )}
-
-
- {histView !== null && (
-
- 0 ? { height: footerHeight, minHeight: footerHeight, boxSizing: "border-box" } : undefined}
+ todo={footerTodo}
+ undo={footerUndo}
+ decision={decisionFooterSurface}
+ composer={buildComposerSurface({
+ view: {
+ hidden: composerSurfaceHidden,
+ inert: runtimeTransitioning,
+ hero: session.transcript.creationEmptyHero,
+ headline: t("welcome.creation.title"),
+ remote: core.remoteSurfaceActive,
+ rewindCommitting: session.sessionUndo.rewindCommitting,
+ messageActionPending: state.messageAction != null,
+ decisionActive: Boolean(decisionSurface),
+ runtimeTransitioning,
+ controllerReady,
+ showContextWindowRing: sidebarCreation,
+ },
+ base: conversationView.composer,
+ tab: activeTab,
+ tabId: activeTabId,
+ profile: session.profileProjection,
+ router: session.routerCommands,
+ modes: session.modeActions,
+ goals: session.goalCommands,
+ remoteGoal: session.remoteGoalActions,
+ modelSwitch: session.controllerProfileCommands,
+ inserts: session.insertCommands,
+ control: session.controlCommands,
+ remoteComposer: {
+ send: session.remoteComposerSend,
+ cancel: core.remoteCancel,
+ ready: core.remoteComposerReady,
+ profileReady: session.profileProjection.remoteComposerProfileReady,
+ liveStore: core.remoteSession.liveStore,
+ },
+ localLiveStore: core.liveStore,
+ onInvocationMetadataChange: session.invocation.handleInvocationMetadataChange,
+ onCycleMode: session.cycleMode,
+ transientDismissSignal: shell.transientOverlayDismissSignal,
+ sessionKey: session.composerSessionKey,
+ workspaceScopeKey: session.workspaceScopeKey,
+ fileRefRefreshKey: local.composerFileRefRefreshKey,
+ guidance: session.transcript.latestGuidanceConsumed,
+ guidanceQueuePreviewItems: navigation.guidanceQueueMockItems,
+ })}
/>
-
- )}
-
-
-
-
+
- {visitedTrash && }
- {visitedAutomation && }
+ 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,
+ },
+ })} />
+
- {settingsTarget !== null && { setSettingsFocus(null); returnToWorkspace(); },
- onChanged: (settings?: SettingsView | null) => {
- void refreshMeta();
- void refreshProviderSetupState().catch(() => {});
- if (settings) {
- applyDesktopPreferences(settings);
- void refreshSidebarImConnectionsFromSettings(settings).catch((e) => console.warn("bot sidebar refresh failed", e));
- return;
- }
- void reloadSidebarImConnections().catch((e) => console.warn("bot sidebar refresh failed", e));
- void app.DesktopStartupSettings().then(applyDesktopPreferences).catch((e) => console.warn("desktop preferences refresh failed", e));
+ }
-
-
-
-
- setPaletteOpen(false)}
- items={paletteItems}
- placeholder={t("palette.placeholder")}
- emptyText={t("palette.empty")}
- />
-
- setShortcutsOpen(false)}
- t={t}
- />
-
- {startupSplashVisible && (
- setStartupSplashVisible(false)} />
- )}
-
- {needsOnboarding && (
- {
- setProviderSetupNeeded(false);
- setNeedsOnboarding(false);
- }}
- onChooseProvider={() => {
- setNeedsOnboarding(false);
- setSettingsFocus({ target: "model-access" });
- setSettingsTarget("models");
- }}
- onSkip={() => {
- dismissOnboarding();
- setNeedsOnboarding(false);
- }}
- />
- )}
-
-
-
-
- {worktreeMergeTabId && (
-
- setWorktreeMergeTabId(null)}
- onMerged={async (res) => {
- const tabToClose = worktreeMergeTabId;
- if (!tabToClose || !res.sourceRoot || !res.worktreeRoot || !res.targetBranch || !res.mergedCommit || !res.worktreeBranch || !res.worktreeHead) {
- throw new Error(res.error || t("worktree.mergeReceiptInvalid"));
- }
- const navigationIntentSeq = noteNavigationIntent();
- try {
- const navigationIntentToken = await registeredNavigationIntent(navigationIntentSeq);
- if (!navigationIntentToken || !isNavigationIntentCurrent(navigationIntentSeq)) {
- showToast(t("worktree.navigationChangedPreserved"), "error", { durationMs: 9000 });
- return;
- }
- const lifecycle = await runWorktreeMergeLifecycle(res, tabToClose, navigationIntentToken, {
- ensureSource: (sourceRoot) => singleSurfaceLayout
- ? ensureBlankSurface("project", sourceRoot, navigationIntentSeq)
- : ensureBlankTab("project", sourceRoot, navigationIntentSeq),
- isNavigationCurrent: () => isNavigationIntentCurrent(navigationIntentSeq),
- seedSource: seedActiveTabMeta,
- listTabs: () => app.ListTabs(),
- closeWorktree: (request) => app.CloseMergedWorktreeTab(request),
- finalize: (request) => app.FinalizeWorktreeMerge(request),
- onNavigationPreserved: () => showToast(t("worktree.navigationChangedPreserved"), "error", { durationMs: 9000 }),
- onCloseBlocked: () => showToast(t("worktree.cleanupViewBlocked"), "error", { durationMs: 8000 }),
- });
- if (lifecycle.phase !== "finalized") return;
- showWorktreeCleanupNotice(lifecycle.cleanup, t, showToast);
- } catch (caught: unknown) {
- showToast(`${t("worktree.mergeDoneCleanupFailed")} ${caught instanceof Error ? caught.message : String(caught)}`, "error", { durationMs: 9000 });
- }
- }}
- />
-
- )}
+ setSettingsTarget: shell.setSettingsTarget,
+ })} />
{windowsFramelessChrome && (
)}
+
);
-}
+}
\ No newline at end of file
diff --git a/desktop/frontend/src/__tests__/activate-topic-stale.test.tsx b/desktop/frontend/src/__tests__/activate-topic-stale.test.tsx
index 118b0af3f0..171ff7e5e8 100644
--- a/desktop/frontend/src/__tests__/activate-topic-stale.test.tsx
+++ b/desktop/frontend/src/__tests__/activate-topic-stale.test.tsx
@@ -7,7 +7,6 @@
// single-surface prune removes every other tab state, blanking the visible
// transcript).
-import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
@@ -389,26 +388,6 @@ eq(controller?.activeTabId, tabA.id, "late X activation cannot replace A");
eq(backendActiveId, tabA.id, "late X activation reasserts A as backend owner");
eq(controller?.state.ask?.id, "pending-tab-a", "late X completion cannot clear A's ask");
-// Wiring lock: App.enqueueNavigation must invalidate in-flight activations at
-// enqueue time — the queue-based scenario above only proves the mechanism.
-const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
-ok(
- /const enqueueNavigation = useCallback\(\(input: DesktopNavigationIntent\)[\s\S]{0,900}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,900}?enqueueNavigationWithIntent\(input, navigationIntentSeq\)/.test(appSource),
- "App.enqueueNavigation captures a shared navigation intent before handing the request to the queue",
-);
-ok(
- /const enqueueNavigationWithIntent = useCallback\([\s\S]{0,900}?enqueueNavigationRequest\([\s\S]{0,900}?\{ \.\.\.input, navigationIntentSeq \}/.test(appSource),
- "App.enqueueNavigationWithIntent forwards the captured intent into enqueueNavigationRequest",
-);
-ok(
- /const enqueueTabSwitch = useCallback\([\s\S]{0,1400}?const navigationIntentSeq = noteNavigationIntent\(\);[\s\S]{0,1400}?switchTab\(request\.tabId, request\.optimisticTab, request\.navigationIntentSeq\)/.test(appSource),
- "App.enqueueTabSwitch invalidates older navigation at enqueue time and forwards the shared intent",
-);
-ok(
- /const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\)/.test(appSource),
- "App navigation results require both queue ownership and the shared navigation intent",
-);
-
// useController owns periodic runtime metadata refreshes. Unmount explicitly
// so the suite verifies their cleanup and does not keep the discovery runner
// alive after all assertions have passed.
diff --git a/desktop/frontend/src/__tests__/active-tab-mirror.test.tsx b/desktop/frontend/src/__tests__/active-tab-mirror.test.tsx
new file mode 100644
index 0000000000..85d75cb6ac
--- /dev/null
+++ b/desktop/frontend/src/__tests__/active-tab-mirror.test.tsx
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { JSDOM } from "jsdom";
+import { activeTabMirror, useActiveTabMirrorCommit } from "../app-runtime/activeTabMirror";
+
+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 Probe({ activeTabId }: { activeTabId?: string }) {
+ useActiveTabMirrorCommit(activeTabId);
+ return null;
+}
+
+try {
+ await act(async () => root.render());
+ assert.equal(activeTabMirror().current, "A", "the mirror follows the committed active tab");
+
+ const reads: (string | undefined)[] = [];
+ const deferredRead = new Promise((resolve) => {
+ setTimeout(() => {
+ reads.push(activeTabMirror().current);
+ resolve();
+ }, 0);
+ });
+ await act(async () => root.render());
+ await deferredRead;
+ assert.deepEqual(reads, ["B"], "an async continuation reads the replacement tab, never a stale render capture");
+
+ await act(async () => root.render());
+ assert.equal(activeTabMirror().current, undefined, "a committed empty selection clears the mirror");
+
+ await act(async () => root.render());
+ assert.equal(activeTabMirror().current, "B", "returning to a tab commits its identity again");
+
+ await act(async () => root.unmount());
+ assert.equal(activeTabMirror().current, undefined, "unmounting the host releases the mirror");
+
+ console.log("active tab mirror: commit-following writes, async reads and unmount release passed");
+} finally { dom.window.close(); }
diff --git a/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts b/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts
index 8aa55463ca..4b5e0e4563 100644
--- a/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts
+++ b/desktop/frontend/src/__tests__/app-chrome-tabs.test.ts
@@ -12,6 +12,14 @@ const appChromeSource = readFileSync(resolve(testDir, "../components/AppChrome.t
const commandPaletteSource = readFileSync(resolve(testDir, "../components/CommandPalette.tsx"), "utf8");
const projectTreeSource = readFileSync(resolve(testDir, "../components/ProjectTree.tsx"), "utf8");
const topicShortcutsSource = readFileSync(resolve(testDir, "../lib/topicShortcuts.ts"), "utf8");
+const topicShortcutOwnerSource = readFileSync(resolve(testDir, "../app-runtime/useTopicNavigationShortcuts.ts"), "utf8");
+const runtimeHandlersSource = readFileSync(resolve(testDir, "../app-runtime/useRuntimeEventHandlers.ts"), "utf8");
+const sessionNavigationSource = readFileSync(resolve(testDir, "../app-runtime/useSessionNavigationCommands.ts"), "utf8");
+const chromeCommandsSource = readFileSync(resolve(testDir, "../app-runtime/useAppChromeCommands.ts"), "utf8");
+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 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");
@@ -226,21 +234,13 @@ ok(!shouldRefreshTabMetaForEvent("text_delta"), "stream deltas do not trigger ta
}
ok(
- !appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") && appSource.includes('import("./lib/workspaceRefreshStore")') &&
+ !appSource.includes("setInterval(() => void refreshTabMetas(), 2000)") && runtimeHandlersSource.includes('import("../lib/workspaceRefreshStore")') &&
workspaceFocusSource.includes('document.addEventListener("visibilitychange", onVisibilityChange)') &&
- appSource.includes("createBoundedRefreshCoordinator(TAB_META_MAX_IN_FLIGHT)") &&
+ runtimeHandlersSource.includes("createBoundedRefreshCoordinator(TAB_META_MAX_IN_FLIGHT)") &&
/void refreshTabMetas\(\);\s+schedule\(\);/.test(workspaceFocusSource),
"tab metadata refresh is event-driven with a visibility-aware fallback",
);
-ok(
- appSource.includes("refreshTabMetas(undefined, { afterMutation: true })") &&
- appSource.includes("{ afterMutation: true }") &&
- appSource.includes("if (shouldRefreshTabMetaForEvent(e.kind)) {") &&
- appSource.includes("void refreshTabMetas(undefined, { afterMutation: true });") &&
- /await refreshTabMetas\(\s*\(\) => isNavigationIntentCurrent\(request\.navigationIntentSeq\),\s*\{\s*afterMutation:\s*true\s*\},?\s*\)/.test(appSource),
- "tab lifecycle events and explicit mutations force a post-mutation trailing metadata refresh",
-);
ok(
/import \{ TabBar \} from "\.\/TabBar";/.test(appChromeSource),
@@ -368,32 +368,27 @@ ok(
);
ok(
- /workbenchChromeHidden\s*=\s*sidebarWorkbench/.test(appSource),
+ /workbenchChromeHidden\s*=\s*sidebarWorkbench/.test(appViewSource),
"workbench chrome is hidden for every desktop platform",
);
ok(
- /\{!appChromeHidden && \(/.test(appSource),
+ /\{!appChromeHidden && \(/.test(appViewSource),
"workbench skips rendering the top AppChrome row",
);
ok(
- /topicbar__chrome-btn/.test(appSource),
+ /topicbar__chrome-btn/.test(dockToggleSource),
"workbench keeps chrome controls in the topic bar",
);
ok(
/const \[transcriptRevealSignal, setTranscriptRevealSignal\] = useState\(0\);/.test(appSource) &&
- /revealActiveSignal=\{tabRevealSignal\}/.test(appSource) &&
- /revealSignal=\{transcriptRevealSignal\}/.test(appSource),
+ /revealActiveSignal={local.tabRevealSignal}/.test(appViewSource) &&
+ /revealSignal=\{transcript\.revealSignal\}/.test(chatPaneSource),
"transcript bottom reveal is decoupled from tab-strip reveal",
);
-const tabsReorderBlock = appSource.match(/const handleTabsReorder = useCallback\([\s\S]*?\n \}, \[refreshTabMetas, reorderTabs\]\);/)?.[0] ?? "";
-ok(
- /setTabRevealSignal/.test(tabsReorderBlock) && !/setTranscriptRevealSignal/.test(tabsReorderBlock),
- "tab reordering refreshes the tab strip without snapping the transcript",
-);
ok(
/aria-label=\{t\("transcript\.jumpToBottom"\)\}/.test(transcriptSource) &&
@@ -407,8 +402,8 @@ ok(
);
ok(
- /topicShortcutIndexFromEvent\(event, desktopPlatform\)/.test(appSource) &&
- /useTopicShortcuts\(!sidebarCollapsed && !managementActive, desktopPlatform\)/.test(appSource),
+ /topicShortcutIndexFromEvent\(event, input\.platform\)/.test(topicShortcutOwnerSource) &&
+ /useTopicShortcuts\(input\.enabled, input\.platform\)/.test(topicShortcutOwnerSource),
"topic shortcuts use the resolved desktop platform",
);
@@ -424,56 +419,20 @@ ok(
"topic shortcut badge state is cleared when disabled, interrupted, or cleaned up",
);
-ok(
- /const \[rewindStatesByTab, setRewindStatesByTab\] = useState>\(\{\}\);/.test(appSource) &&
- /setRewindStateForTab\(sourceTabId, null\);/.test(appSource) &&
- /setRewindCommittingForTab\(sourceTabId, true\);/.test(appSource),
- "committing optimistic rewind clears only the source tab before awaiting the backend",
-);
+// session-submission-lifecycle.test.tsx verifies source-only undo invalidation
+// before send, and zero invalidation for stale/read-only/disposed submissions.
-ok(
- /if \(scope === "code"\) \{[\s\S]*?rewindForTabDetailed\(sourceTabId, turn, scope\)[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
- "code-only rewind retains the committed transaction id for real undo",
-);
+// session-undo-lifecycle.test.tsx drives the production useSessionUndo owner:
+// code-only rewind retains the committed transaction id, full rewinds fill the
+// composer only after success, failures leave the banner untouched, and the
+// edit prompt honors the undo banner gate.
-ok(
- /onSessionRevertCommitted\?\.\(workspaceTabId, result\)/.test(workspacePanelSource) &&
- /onSessionRevertCommitted=\{handleSessionRevertCommitted\}/.test(appSource) &&
- /handleSessionRevertCommitted[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
- "single-file session revert publishes its transaction id to the app undo state",
-);
-ok(
- /const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
- /if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
- /onPrompt=\{handleTranscriptPrompt\}/.test(appSource) &&
- /submitDisabled=\{remoteSurfaceActive \? !remoteComposerReady \|\| !remoteComposerProfileReady : !controllerReady\}/.test(appSource),
- "welcome prompts and composer submit share the controller readiness gate",
-);
-ok(
- /pendingPlanRevisionsByTab\[activeTabId\]/.test(appSource) &&
- /commitThenSendRef\.current\(activeTabId, text\)/.test(appSource) &&
- !/const \[pendingPlanRevision, setPendingPlanRevision\]/.test(appSource),
- "queued plan revisions stay scoped to their source tab",
-);
+// pending-plan-revision-lifecycle.test.tsx drives running/idle, tab changes,
+// replacement sessions, identical queued text, old finally and disposal.
-ok(
- /commitThenSendRef\.current\(sourceTabId, trimmed, submitText\.trim\(\), structured\)/.test(appSource) &&
- /sendToTab\(sourceTabId, displayText, submitText, undefined, structured, initialGoal\)/.test(appSource) &&
- /onSteer=\{handleSteer\}/.test(appSource) &&
- /composerInsertRequestsByTab\[activeTabId\]/.test(appSource) &&
- /consumedInsertIdByDraftRef\.current\[draftKey\]/.test(composerSource),
- "composer sends and steers carry an explicit source tab through async preparation",
-);
-ok(
- appSource.includes('key={`${activeTabId ?? ""}:${state.approval.id}`}') &&
- appSource.includes('key={`${activeTabId ?? ""}:${state.ask.id}`}') &&
- /planRevisionInsertRequest\.tabId === activeTabId/.test(appSource) &&
- /planRevisionInsertRequest\.approvalId === state\.approval\?\.id/.test(appSource),
- "approval and ask local state is scoped by tab plus prompt identity",
-);
ok(
/app\.NewSessionForTab\(tabId\)/.test(controllerSource) &&
@@ -500,21 +459,12 @@ ok(
"rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation",
);
-ok(/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
- /hydrating=\{transcriptHydrating \|\| \(runtimeTransitioning && !navigationTargetDataReady\)\}/.test(appSource) &&
- /surfaceCommitToken=\{surfaceCommitToken\}/.test(appSource) && /onSurfacePaintReady=\{handleSurfacePaintReady\}/.test(appSource),
+ok(/const transcriptHydrating = input\.hydrating && !input\.hydrateHistoryLoaded;/.test(transcriptSurfaceSource) &&
+ /hydrating=\{transcript\.transcriptHydrating \|\| \(transitioning && !transcript\.navigationDataReady\)\}/.test(chatPaneSource) &&
+ /surfaceCommitToken=\{transcript\.surfaceCommitToken\}/.test(chatPaneSource) && /onSurfacePaintReady=\{commands\.onSurfacePaintReady\}/.test(chatPaneSource),
"Welcome stays suppressed through target data commit and navigation settles only after paint readiness",
);
-ok(
- /const creationEmptyHero =/.test(appSource) &&
- /!sidebarImDetailConnection/.test(appSource) &&
- /!transcriptHydrating/.test(appSource) &&
- /!hydratePlaceholderActive/.test(appSource) && /!state\.hydrateError/.test(appSource) &&
- /chat-pane\$\{creationEmptyHero \? " chat-pane--creation-empty" : ""\}/.test(appSource) &&
- /heroMode=\{creationEmptyHero\}/.test(appSource),
- "Creation empty hero waits for hydration and skips IM/Bot detail panels",
-);
ok(
/if \(heroMode\) \{[\s\S]*?const maxHeight = composerHeroInputMaxHeight\(\);[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
@@ -522,66 +472,24 @@ ok(
"Creation hero composer auto-grows multi-line drafts instead of clipping at 20px",
);
-ok(
- /const \[workspaceControllerEpoch, setWorkspaceControllerEpoch\] = useState\(0\);/.test(appSource) &&
- /const workspaceScopeKey = \[/.test(appSource) &&
- /activeTab\?\.sessionPath/.test(appSource) &&
- /state\.meta\?\.sessionPath/.test(appSource) &&
- /state\.meta\?\.cwd/.test(appSource) &&
- /state\.sessionGen/.test(appSource) &&
- /workspaceControllerEpoch/.test(appSource) &&
- Array.from(appSource.matchAll(/workspaceScopeKey=\{workspaceScopeKey\}/g)).length === 3,
- "workspace file consumers receive a session and controller scoped identity",
-);
-ok(
- /const unsubReady = onReady\(\(readyTabId\) => \{[\s\S]*?setWorkspaceControllerEpoch[\s\S]*?\n \}\);/.test(appSource) &&
- /const unsubRebuilt = onRuntimeRebuilt\(\(rebuiltTabId\) => \{[\s\S]*?setWorkspaceControllerEpoch[\s\S]*?\n \}\);/.test(appSource),
- "controller ready and rebuilt events invalidate active workspace file scopes",
-);
const navigationBlock = appSource.match(/const runNavigationRequest = useCallback\([\s\S]*?\n \}, \[[^\]]*singleSurfaceLayout[^\]]*\]\);/)?.[0] ?? "";
+
ok(
- /const navigationRunningRef = useRef\(false\);/.test(appSource) &&
- /const navigationPendingRef = useRef\(null\);/.test(appSource) &&
- /const runNavigationRequest = useCallback\(async \(request: PendingDesktopNavigationRequest\)/.test(appSource) &&
- /const latest = \(\) => request\.seq === navigationSeqRef\.current && isNavigationIntentCurrent\(request\.navigationIntentSeq\);/.test(appSource) &&
- /return activateTopic\(scope, workspaceRoot, topicId, sessionPath \|\| "", request\.navigationIntentSeq\)/.test(appSource) &&
- /return openTopicSession\(scope, workspaceRoot, topicId, sessionPath, request\.navigationIntentSeq\)/.test(appSource) &&
- /return openGlobalTab\(topicId, request\.navigationIntentSeq\)/.test(appSource) &&
- /return openProjectTab\(workspaceRoot, topicId, request\.navigationIntentSeq\)/.test(appSource) &&
- /enqueueNavigationRequest\([\s\S]*runningRef: navigationRunningRef, pendingRef: navigationPendingRef/.test(appSource) &&
- !/openTopicQueueRef\.current\.catch\(\(\) => \{\}\)\.then/.test(appSource) &&
- /const refreshLatestTabMetas = async \(\): Promise => \{[\s\S]*if \(latest\(\)\) setTabMetas\(tabs\);/.test(navigationBlock) &&
- /if \(!latest\(\)\) return;[\s\S]*seedActiveTabMeta\(openedTab\);[\s\S]*void refreshLatestTabMetas\(\);/.test(navigationBlock),
- "desktop navigation coalesces pending requests, ignores stale results, and seeds active tab metadata before background refresh",
-);
-
-ok(
- /return enqueueNavigation\(\{ kind: "topic", scope, workspaceRoot, topicId, sessionPath \}\);/.test(appSource) &&
- /enqueueNavigation\(\{ kind: "blank", scope, workspaceRoot: scope === "project" \? workspaceRoot : "" \}\)/.test(appSource) &&
- /return enqueueNavigation\(\{ kind: "sidebar-im", connection \}\);/.test(appSource) &&
- /return enqueueNavigation\(\{ kind: "resume-session", session \}\);/.test(appSource),
+ /return navigation\.enqueueNavigation\(\{ kind: "topic", scope, workspaceRoot, topicId, sessionPath \}\);/.test(sessionNavigationSource) &&
+ /enqueueNavigation\(\{ kind: "blank", scope, workspaceRoot: scope === "project" \? workspaceRoot : "" \}\)/.test(sessionNavigationSource) &&
+ /return navigation\.enqueueNavigation\(\{ kind: "sidebar-im", connection \}\);/.test(sessionNavigationSource) &&
+ /return navigation\.enqueueNavigation\(\{ kind: "resume-session", session \}\);/.test(sessionNavigationSource),
"topic, blank, IM, and history navigation all use the shared coalescing path",
);
-ok(
- /const enterChatViewForTabNavigation = useCallback\(\(\) => \{\s*enterConversation\(\);/.test(appSource) &&
- /const enqueueTabSwitch = useCallback\([\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?enqueueNavigationRequest/.test(appSource) &&
- /const revealBackgroundRuntime = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealBackgroundRuntime/.test(appSource) &&
- /const revealWorkspaceWriter = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealWorkspaceWriterForTab/.test(appSource),
- "every direct tab activation returns overlay pages to the chat view",
-);
ok(
!/await resumeSession\(session\.path, targetTab\.id\);/.test(navigationBlock),
"history navigation does not re-resume a session that OpenTopicSession already pinned",
);
-ok(
- /onOpenTopic: openAutomationTopic/.test(appSource) && /const openAutomationTopic = useCallback[\s\S]*enqueueNavigationWithIntent\(\{ kind: "topic", scope, workspaceRoot, topicId \}, intent\)/.test(appSource),
- "heartbeat topic navigation uses the guarded open-topic path",
-);
for (const selector of [
".app--darwin .app-chrome--tabs",
@@ -808,20 +716,13 @@ ok(
// click on a drag region never reaches the OS: both title-bar-hiding platforms
// have to zoom from here or not at all.
ok(
- /chromeDoubleClickZooms\s*=\s*windowsFramelessChrome\s*\|\|\s*desktopPlatform === "darwin"/.test(appSource),
+ /chromeDoubleClickZooms\s*=\s*input\.windowsFrameless\s*\|\|\s*input\.platform === "darwin"/.test(chromeCommandsSource),
"title-bar double click zooms on macOS as well as frameless Windows",
);
ok(
- /handleChromeTitlebarDoubleClick[\s\S]{0,700}?closest\("button, input, textarea, select, a, \[role='button'\], \[role='tab'\], \.windows-window-controls"\)/.test(appSource),
+ /handleChromeTitlebarDoubleClick[\s\S]{0,700}?closest\("button, input, textarea, select, a, \[role='button'\], \[role='tab'\], \.windows-window-controls"\)/.test(chromeCommandsSource),
"title-bar double click still ignores interactive controls",
);
-ok(
- /function isMacOSWorkbenchSidebarTitlebar[\s\S]{0,500}?closest\("\.sidebar--workbench"\)[\s\S]{0,500}?MACOS_WORKBENCH_TITLEBAR_HEIGHT/.test(appSource) &&
- /handleChromeTitlebarDoubleClick[\s\S]{0,400}?isMacOSWorkbenchSidebarTitlebar\(target, event\.clientY, desktopPlatform\)/.test(appSource) &&
- !appSource.includes("window.runtime?.WindowToggleMaximise") &&
- !bridgeSource.includes("WindowToggleMaximise?(): void;"),
- "macOS workbench sidebar titlebar reuses the centralized zoom path",
-);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
diff --git a/desktop/frontend/src/__tests__/app-lifecycle-probe.test.ts b/desktop/frontend/src/__tests__/app-lifecycle-probe.test.ts
new file mode 100644
index 0000000000..a9bd1c9192
--- /dev/null
+++ b/desktop/frontend/src/__tests__/app-lifecycle-probe.test.ts
@@ -0,0 +1,27 @@
+import assert from "node:assert/strict";
+import { JSDOM } from "jsdom";
+import { createAppRenderToken, commitAppRenderToken, trackAppOperation, trackAppSubscription } from "../app-runtime/appLifecycleProbe";
+
+const dom = new JSDOM("", { url: "https://example.invalid/?app-lifecycle-probe=1" });
+Object.assign(globalThis, { window: dom.window });
+const retained = Array.from({ length: 4096 }, () => createAppRenderToken()!);
+try {
+ // Keeping this cohort alive is deliberate: the probe must report the leak.
+ for (const token of retained) commitAppRenderToken(token);
+ const first = window.__reasonixAppLifecycle!.snapshot();
+ assert.equal(first.liveRenderTokens, retained.length, "the oldest live references must not be evicted");
+ commitAppRenderToken(retained[0]);
+ assert.equal(window.__reasonixAppLifecycle!.snapshot().liveRenderTokens, retained.length,
+ "StrictMode commit replay must not duplicate a presentation identity");
+ trackAppOperation(1);
+ trackAppOperation(-1);
+ trackAppOperation(-1);
+ assert.equal(window.__reasonixAppLifecycle!.snapshot().activeOperations, -1, "double cleanup must remain observable");
+ trackAppSubscription(1);
+ trackAppSubscription(-1);
+ trackAppSubscription(-1);
+ assert.equal(window.__reasonixAppLifecycle!.snapshot().activeSubscriptions, -1);
+ console.log("PASS lifecycle probe exposes retained cohorts and duplicate cleanup");
+} finally {
+ dom.window.close();
+}
diff --git a/desktop/frontend/src/__tests__/app-lifecycle.test.tsx b/desktop/frontend/src/__tests__/app-lifecycle.test.tsx
new file mode 100644
index 0000000000..5b837fc3b0
--- /dev/null
+++ b/desktop/frontend/src/__tests__/app-lifecycle.test.tsx
@@ -0,0 +1,148 @@
+import React, { StrictMode, Suspense, startTransition } from "react";
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { JSDOM } from "jsdom";
+import assert from "node:assert/strict";
+import {
+ createOperationOwner,
+ operationTargetsEqual,
+ type OperationIdentity,
+ type OperationTarget,
+} from "../app-runtime/operationOwner";
+import { useCommittedCommand } from "../lib/useCommittedCommand";
+import { useCommittedAsyncCommand } from "../lib/useCommittedAsyncCommand";
+import { createSessionSurfaceFence } from "../app-runtime/sessionTarget";
+
+const dom = new JSDOM("");
+globalThis.window = dom.window as unknown as Window & typeof globalThis;
+globalThis.document = dom.window.document;
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const root = createRoot(document.getElementById("root")!);
+const never = new Promise(() => undefined);
+let command!: (value: number) => number | undefined;
+let asyncCommand!: (value: number) => Promise<{ status: string; value?: number; reason?: string }>;
+let releaseAsync!: (value: number) => void;
+let asyncGate = new Promise((resolve) => { releaseAsync = resolve; });
+
+function CommandProbe({ revision, suspend = false }: { revision: number; suspend?: boolean }) {
+ command = useCommittedCommand((value: number) => revision + value);
+ if (suspend) throw never;
+ return null;
+}
+
+async function executeAddition(input: { base: number; gate: Promise }) {
+ return input.base + await input.gate;
+}
+function AsyncCommandProbe({ revision }: { revision: number }) {
+ asyncCommand = useCommittedAsyncCommand((value: number) => ({ base: revision + value, gate: asyncGate }), executeAddition);
+ return null;
+}
+
+const session = (tabId: string, sessionKey: string): OperationTarget => ({
+ kind: "session",
+ tabId,
+ sessionKey,
+});
+
+try {
+ await act(async () => root.render(
+ ,
+ ));
+ const retainedCommand = command;
+ assert.equal(retainedCommand(4), 5);
+
+ for (let revision = 2; revision <= 512; revision += 1) {
+ await act(async () => root.render(
+ ,
+ ));
+ assert.equal(command, retainedCommand, "the entry point is stable across presentation commits");
+ assert.equal(retainedCommand(4), revision + 4, "only committed input owns command dispatch");
+ }
+
+ await act(async () => startTransition(() => root.render(
+ ,
+ )));
+ assert.equal(retainedCommand(4), 516, "abandoned render input never becomes authoritative");
+
+ await act(async () => root.render());
+ assert.equal(retainedCommand(4), undefined, "a hidden Suspense subtree has no layout-owned command authority");
+ await act(async () => root.render());
+ assert.equal(command, retainedCommand, "revealing a suspended surface preserves the stable entry");
+ assert.equal(retainedCommand(4), 516, "revealing publishes the current committed input in a fresh lifecycle");
+
+ await act(async () => root.unmount());
+ assert.equal(retainedCommand(4), undefined, "a retained command is inert after its owner unmounts");
+
+ const asyncHost = document.createElement("div");
+ document.body.append(asyncHost);
+ const asyncRoot = createRoot(asyncHost);
+ await act(async () => asyncRoot.render());
+ const retainedAsyncCommand = asyncCommand;
+ const superseded = retainedAsyncCommand(2);
+ const releaseSuperseded = releaseAsync;
+ asyncGate = new Promise((resolve) => { releaseAsync = resolve; });
+ await act(async () => asyncRoot.render());
+ const current = retainedAsyncCommand(3);
+ releaseSuperseded(4);
+ releaseAsync(4);
+ assert.deepEqual(await superseded, { status: "cancelled", reason: "superseded" });
+ assert.deepEqual(await current, { status: "completed", value: 17 });
+ await act(async () => asyncRoot.unmount());
+ assert.deepEqual(await retainedAsyncCommand(1), { status: "cancelled", reason: "disposed" });
+ asyncHost.remove();
+
+ const owner = createOperationOwner();
+ const ownerEpoch = owner.mount();
+ const a = session("tab-a", "session-a:1");
+ const b = session("tab-b", "session-b:1");
+
+ const firstA = owner.begin(a, 10);
+ assert.equal(owner.owns(firstA), true);
+ const firstB = owner.begin(b, 11);
+ assert.equal(owner.owns(firstA), false, "new navigation supersedes the prior UI operation");
+ assert.equal(owner.owns(firstB), true);
+
+ const secondA = owner.begin(a, 12);
+ assert.equal(owner.owns(firstB), false);
+ assert.equal(owner.owns(firstA), false, "A → B → A does not revive the first A operation");
+ assert.equal(owner.owns(secondA), true);
+
+ const thirdA = owner.begin(a, 13);
+ assert.equal(owner.finish(secondA), false, "an old finally cannot clear the replacement request");
+ assert.equal(owner.owns(thirdA), true);
+ assert.equal(owner.finish(thirdA), true);
+ assert.equal(owner.activeCount, 0);
+
+ const pending = owner.begin(a, 14);
+ owner.unmount(ownerEpoch);
+ assert.equal(owner.owns(pending), false, "disposed owner rejects stale async continuations");
+ assert.equal(owner.activeCount, 0, "disposed owner releases every operation input");
+
+ const remountedEpoch = owner.mount();
+ const remounted = owner.begin(a, 15);
+ assert.notEqual(remounted.ownerEpoch, pending.ownerEpoch, "StrictMode remount receives a new epoch");
+ assert.equal(owner.owns(remounted), true);
+ owner.unmount(remountedEpoch);
+
+ const sameTarget: OperationTarget = { kind: "session", tabId: "tab-a", sessionKey: "session-a:1" };
+ assert.equal(operationTargetsEqual(a, sameTarget), true);
+ assert.equal(operationTargetsEqual(a, session("tab-a", "session-a:2")), false);
+ assert.equal(operationTargetsEqual(a, b), false);
+
+ const surfaceFence = createSessionSurfaceFence();
+ const surfaceA1 = surfaceFence.commit("tab-a", "session-a:1")!;
+ surfaceFence.commit("tab-b", "session-b:1");
+ const surfaceA2 = surfaceFence.commit("tab-a", "session-a:1")!;
+ assert.equal(surfaceFence.owns(surfaceA1), false, "A → B → A cannot reacquire old UI ownership");
+ assert.equal(surfaceFence.owns(surfaceA2), true, "the latest committed A surface owns UI continuation");
+ surfaceFence.dispose();
+ assert.equal(surfaceFence.owns(surfaceA2), false, "surface disposal invalidates every retained operation");
+
+ const identities = new Set([firstA, firstB, secondA, thirdA, pending, remounted]);
+ assert.equal(identities.size, 6, "every operation has a distinct identity object");
+ console.log("PASS App committed commands and source-bound operation ownership are lifecycle safe");
+} finally {
+ if (document.getElementById("root")?.hasChildNodes()) await act(async () => root.unmount());
+ dom.window.close();
+}
diff --git a/desktop/frontend/src/__tests__/automation-navigation-lifecycle.test.tsx b/desktop/frontend/src/__tests__/automation-navigation-lifecycle.test.tsx
new file mode 100644
index 0000000000..8ea1d3171b
--- /dev/null
+++ b/desktop/frontend/src/__tests__/automation-navigation-lifecycle.test.tsx
@@ -0,0 +1,55 @@
+import assert from "node:assert/strict";
+import React, { act } from "react";
+import { createRoot } from "react-dom/client";
+import { JSDOM } from "jsdom";
+import { useAutomationNavigation } from "../app-runtime/useAutomationNavigation";
+import { useAppNavigationStore as navigation } from "../store/appNavigation";
+import type { DesktopNavigationIntent } from "../app-runtime/desktopNavigationOwner";
+
+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 seq = 0;
+const queued: { request: DesktopNavigationIntent; intent: number; resolve(): void }[] = [];
+let commands!: ReturnType;
+function Probe() {
+ commands = useAutomationNavigation({ noteIntent: () => ++seq,
+ enqueue: (request, intent) => new Promise(resolve => queued.push({ request, intent, resolve })) });
+ return null;
+}
+try {
+ await act(async () => root.render());
+ navigation.getState().openPage({ kind: "automation" });
+ const a = commands.openAutomationTopic("project", "fixture", "A");
+ assert.deepEqual(queued[0].request, { kind: "topic", scope: "project", workspaceRoot: "fixture", topicId: "A" });
+ assert.equal(navigation.getState().page.kind, "automation", "keep the management page until the target is accepted");
+ commands.topicAccepted(queued[0].intent);
+ assert.equal(navigation.getState().page.kind, "workspace");
+ assert.equal(navigation.getState().automationReturn, true);
+ navigation.getState().openPage({ kind: "automation" });
+ const b = commands.openAutomationTopic("project", "fixture", "B");
+ queued[0].resolve(); await a;
+ commands.topicAccepted(queued[1].intent);
+ assert.equal(navigation.getState().page.kind, "workspace", "old finally cannot retire the newer link");
+ queued[1].resolve(); await b;
+ navigation.getState().openPage({ kind: "automation" });
+ const c = commands.openAutomationTopic("project", "fixture", "C");
+ const original = queued[2].intent;
+ navigation.getState().openPage({ kind: "settings", tab: "general" });
+ navigation.getState().openPage({ kind: "automation" });
+ commands.topicAccepted(original);
+ assert.equal(navigation.getState().page.kind, "automation", "ABA page replacement cannot regain navigation rights");
+ assert.ok(seq > original, "page replacement revokes the controller's navigation intent");
+ queued[2].resolve(); await c;
+ const d = commands.openAutomationTopic("project", "fixture", "D");
+ const last = queued[3].intent;
+ queued[3].resolve(); await d;
+ commands.topicAccepted(last);
+ assert.equal(navigation.getState().page.kind, "automation", "an unaccepted terminal request leaves no live link");
+ await act(async () => root.unmount());
+ const before = seq;
+ commands.openAutomationTopic("project", "fixture", "disposed");
+ navigation.getState().returnToWorkspace();
+ assert.equal(seq, before, "unmount releases both command and page subscription");
+ console.log("automation navigation: accepted-target return, page ABA, exact finally and disposal passed");
+} finally { dom.window.close(); }
diff --git a/desktop/frontend/src/__tests__/automation-regions.test.tsx b/desktop/frontend/src/__tests__/automation-regions.test.tsx
new file mode 100644
index 0000000000..2fbf5c8d1c
--- /dev/null
+++ b/desktop/frontend/src/__tests__/automation-regions.test.tsx
@@ -0,0 +1,41 @@
+import assert from "node:assert/strict";
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { JSDOM } from "jsdom";
+import { AppBottomRegions } from "../app-shell/AppBottomRegions";
+import { register } from "node:module";
+import type { Translator } from "../lib/i18n";
+
+const noop = () => {};
+register(new URL("../../scripts/svg-loader.mjs", import.meta.url));
+const { SidebarRegion } = await import("../app-shell/SidebarRegion");
+const t = ((key: string) => key) as Translator;
+for (const layout of ["classic", "workbench", "creation"]) {
+ for (const automation of [false, true]) {
+ const markup = renderToStaticMarkup(<>
+
+
+ >);
+ const dom = new JSDOM(markup);
+ const doc = dom.window.document;
+ assert.equal(doc.querySelectorAll(".terminal-drawer").length, 1, "terminal host survives page projection");
+ assert.equal(doc.querySelector(".terminal-drawer")?.hasAttribute("inert"), automation);
+ assert.equal(doc.querySelectorAll(".terminal-drawer-resizer").length, automation ? 0 : 1);
+ assert.equal(doc.querySelectorAll(".sidebar-collapse-toggle").length, layout === "creation" && !automation ? 1 : 0);
+ const automationButtons = [...doc.querySelectorAll("button")].filter(button => button.querySelector(".lucide-alarm-clock"));
+ assert.equal(automationButtons.length, 1, `${layout} keeps exactly one Automation entry`);
+ if (layout !== "workbench") assert.equal(automationButtons[0].getAttribute("aria-current"), automation ? "page" : null);
+ dom.window.close();
+ }
+}
+console.log("automation regions: shared three-layout page projection passed");
diff --git a/desktop/frontend/src/__tests__/automation-surface-layout.test.ts b/desktop/frontend/src/__tests__/automation-surface-layout.test.ts
index ebcd89e834..914eb2e10a 100644
--- a/desktop/frontend/src/__tests__/automation-surface-layout.test.ts
+++ b/desktop/frontend/src/__tests__/automation-surface-layout.test.ts
@@ -9,32 +9,36 @@ 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 chromeCommands = read("../app-runtime/useAppChromeCommands.ts");
+const palette = read("../app-runtime/usePaletteCommands.tsx");
// The shared full-window shell replaces the old chat-pane projection. Background
// geometry and component identity survive while all workspace input is inert.
-assert.match(app, /useManagementWorkspace\(layoutRef, managementActive\)/);
+assert.match(sessionComposition, /useManagementWorkspace\(layoutRef, managementActive\)/);
assert.match(isolation, /workspace\.inert = true/);
assert.match(isolation, /workspace\.inert = false/);
assert.doesNotMatch(app, /mainView === "automation"/);
-assert.match(app, /inert=\{managementActive\}/);
+assert.match(appView, /inert=\{managementActive\}/);
assert.match(css, /\.management-screen \{[^}]*position: fixed;[^}]*inset: 0;/);
assert.match(shell, /hidden=\{!active\} inert=\{!active\}/);
-assert.match(app, /if \(managementActive\) returnToWorkspace\(\)/);
+assert.match(palette, /if \(managementActive\) ports\.returnToWorkspace\(\)/);
assert.match(heartbeat, /Back`);
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 decision; }
+const paint = () => act(async () => root.render(todo} undo={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 (
<>
- run("stop goal")}>stop
- run("switch mode")}>mode
+ stop
+ setCollaborationModeFromUi("plan")}>mode
run("background goal resync")}>resync
>
);
@@ -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(
+ Fixture action
+));
+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