From defd85a9ebbee89eab2fdc90aef490c10e764ca6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:37:18 -0700 Subject: [PATCH] feat(floating-workspace): render the floating panel through the shared workspace surface (#22302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(floating-workspace): plan structured native chat in the floating panel * feat(floating-workspace): resolve the floating workspace so a structured session can run there * docs(floating-workspace): plan the unification onto the shared workspace surface * chore(floating-workspace): keep the unification plan out of the branch * test(floating-workspace): pin the structured route on capability, not workspace kind The host half removed the 'floating-workspace' blocker; this pin still asserted it and went red on the merge. Floating now routes like any workspace, so with capabilities un-negotiated the resolver answers 'runtime-capability-unknown'. * feat(floating-workspace): resolve the floating workspace's renderer row without a worktree record The renderer had no Worktree row for the floating id: markdown creation, tab-bar path entry, native-chat file links, attachments and model discovery all resolve a workspace through getKnownWorktreeById and silently fail for it. The host already answers with a synthetic row minted from the resolved floating directory; this gives the renderer the same single seam. - The repo slice stores the host-resolved floating directory (floatingWorkspacePath); the panel's existing cwd resolution now writes it there instead of panel-local state, so there is one copy. - findKnownWorktreeById mints the shared floatingWorkspaceToWorktree row from that path, local host only, cached while the directory is unchanged — mirroring the folder-workspace branch it sits beside. - No consumer changes needed: every row-dependent path already goes through this resolver. * feat(tabs): scope editor and browser activation to the owning workspace Shared editor/browser activation always wrote global selection (activeFileId, activeBrowserTabId, activeTabType). The floating panel is an overlay above the active workspace, and its active tab must never become global selection — the invariant documented in agent-auto-ack-targets.ts. Group-scoped activation is a prerequisite for rendering the floating workspace through the shared tab-group surface. - setActiveFile and setActiveBrowserTab take an optional target workspace, mirroring setActiveTabType: global selection moves only when the target IS the globally active workspace; per-workspace state always updates. Omitted keeps legacy behavior. - The shared tab-group activation/creation commands pass their workspace, which is a no-op for the main surface (hidden surfaces are inert, so commands only fire on the active workspace) and confines floating activation to floating state. - Terminal setActiveTab was already owner-scoped; now pinned by test. * refactor(tab-group): separate the workspace drag scope and group tree from main-window chrome Preparation for hosting the floating workspace on the shared surface, whose shell owns its own titlebar tab strip and cannot mount the main chrome: - WorkspaceTabDragLayer: the one dnd-kit scope for a workspace's strips and tree, extracted from TabGroupSplitLayout as a render-prop layer so a host can put shell-owned chrome (a titlebar strip) inside the same drag scope as the tree. - TabGroupSplitNodeTree: the chrome-free recursive group tree, with an explicit tabStrip composition point on TabGroupPanel ('attached' keeps today's per-group strip; 'external' hosts render no per-group strip at all — a hidden one would register duplicate drag sortables). - WorkspacePaneOverlayLayers: the retained pane hosts extracted from WorktreeSplitSurface so any tree host mounts the same pane lifecycle stack. - TerminalPaneOverlayLayer gains ownsNativeChatToggleShortcut, following the existing rule that only one live workspace registers the chord. TabGroupSplitLayout and WorktreeSplitSurface recompose these parts; the main surface's DOM is unchanged. * feat(floating-workspace): decide the panel's emptiness and layout atomically An empty floating workspace has no tab-group layout at all (the slice starts empty and hydration skips tab-less workspaces), while the shared group tree requires one. This model resolves the two together: the panel shows its empty state exactly until the first tab exists, and mounts the tree only with a layout plus a live focused group (stored focus, else the group with an active tab, else the first group — matching the shell chrome's focused-group policy). * feat(floating-workspace): render the floating panel through the shared workspace surface The panel previously projected exactly one tab group and hand-rendered its panes, so any tab that left that group — 'Move Tab to Split' creates a second group — stayed alive in state but vanished from the panel. The body now mounts the shared group tree and retained pane overlay stack for the floating workspace id, so every group renders, splits work, and structured chat finally has a floating surface. - One drag scope: WorkspaceTabDragLayer wraps the titlebar strip AND the body tree, so exactly one DndContext owns floating tab drag; strip drops reorder, body-edge drops split. The floating-only FloatingWorkspaceTabDragContext is deleted. - The titlebar keeps the panel's single TabBar, now fed by the shared per-group workspace model of the focused group (tabStrip='external' keeps per-group strips out of the shell), with workspace-scoped activation commands so global selection never moves. - Panes render through WorkspacePaneOverlayLayers: terminals gate on the resolved cwd and a settled viewport exactly as before; browser guests ride the shared retention layer; agent session tabs appear in the strip and render through the structured overlay. - The empty state renders exactly until the first resolvable tab exists (stale unified entries still show it), per the atomic layout/emptiness model. - Deleted: the single-group projection, the hand-written pane maps, FloatingBrowserSlot, and the floating pane-handle registry. Known behavior deltas, both narrowing floating onto main-workspace semantics: Cmd/Ctrl+W with panel (not terminal) focus closes the active tab rather than one pane of a multi-pane tab, and a pane-owned close of the last tab no longer arms the panel focus reclaim (strip and shortcut closes still do). * test: re-register moved pane-ownership sites with the extraction The retained overlay stack moved into WorkspacePaneOverlayLayers and the floating chrome model became a guest-paint consumer; the structured-pane layering pin and the browser-guest retention census both name files, so they follow the seam. * fix(floating-workspace): mount the shared group tree in a flex frame so panes own their height The tree's nodes size themselves as flex items, and the floating shell mounted them in a block container, so every group body — and every pane anchored to one via anchor-size() — measured 0px tall. Wrap the tree and overlay layers in the same 'absolute inset-0 flex' frame the worktree split surface uses, record the host contract on the tree, pin the frame in the panel's element-tree test, and add a real-renderer e2e spec that measures group-body and pane boxes for the single-group and split layouts (jsdom computes no layout, which is how the 0px regression passed 50k tests). * fix(runtime): import the floating worktree minting the structured create path calls floatingWorkspaceToResolvedWorktree called floatingWorkspaceToWorktree without importing it. The module is @ts-nocheck (mechanically split runtime), so tsc never saw the missing import and it only failed at call time — the structured createSupport probe for the floating workspace threw a ReferenceError. Add the import and a test that runs the real method, which fails on the missing import where the typechecker cannot. * fix(browser-pane): resolve floating and folder workspace paths in notebook navigation navigateBrowserPageToUrl looked the workspace up in allWorktrees(), which has no row for folder or floating workspaces, so their notebook navigations ran with an undefined workspace path. Use getKnownWorktreeById, the same resolver every other consumer was moved to. * fix(tabs): keep a workspace's tab selection out of the main window's global selection * refactor(floating-workspace): launch and close panes like every other workspace * refactor(tab-group): let the surface that mounts tab groups describe its own chrome * refactor(floating-workspace): render the floating panel through the shared workspace surface * fix(floating-workspace): resolve the floating directory once on the host, and keep the controls' inset * test(floating-workspace): pin the unified floating panel against the shared surface * fix(floating-workspace): read the floating directory's paths through the app environment The runtime now resolves the floating directory itself, and the headless runtime has no Electron app, so the Electron path lookup threw there. * fix(floating-workspace): keep the main window's frame and shortcuts out of the floating panel The shared layout drew the main window's 4px titlebar band and sidebar seam inside the floating panel, and the chat-view toggle fired in every workspace on screen, so one key press flipped the main window's tab and the panel's. * fix(floating-workspace): close floating tabs without taking over the main window An unsaved floating note's save prompt made the floating panel the main window's active worktree, and never appeared on a fresh launch because the workbench that owns the prompt was not mounted. The prompt now shows a floating note in the panel, and the workbench mounts while the panel holds tabs. Every close path now reads the same captured emptied-workspace reaction, so a floating pane closing itself, its shell exiting, a group close, and an unsaved note close all keep the panel's keyboard ownership, as a tab close already did. * test(tab-group): pin that a dirty editor close carries its emptied reaction * refactor(tabs): one rule for when a selection moves the main window's selection Opening a file kept a floating-only exception while every other writer checked whether the tab's workspace is the active one. Every writer now asks the same question, so opening a file in a background worktree no longer points the main window's editor selection into that worktree. * refactor(floating-workspace): drop what the shared surface left unused The terminal pane's imperative close handle lost its last consumer when the floating panel stopped keeping its own pane registry, and the floating and folder workspaces built the same lineage-free resolved worktree twice. * test(floating-workspace): type test fixtures without casts and drop the removed pane ref * fix(floating-workspace): keep panel focus when a close lands after the panel emptied Saving the last floating note closes it after an awaited write, so the panel could render empty before the close armed its keyboard reclaim, leaving the intent armed with nothing left to consume it. The panel now re-checks whenever the intent arms as well as when its tab count changes. A pinned tab's confirmation took focus before the close read panel ownership; the dispatcher now captures it before any prompt. * refactor(floating-workspace): finish what the shared surface left behind - The floating panel no longer accepts AI vault session drops: a session cannot resume into it, and its window-level drop handler fired beside the main window's. - The last global-selection writers that spelled the active-workspace check inline now use the shared rule. - Remove the save-dialog hook and item counter the old floating body used, and correct comments that said the workbench only mounts once a workspace exists. * fix(floating-workspace): keep a floating leaf detach from changing the main window's tab type * fix(floating-workspace): give floating chats their workspace identity and directory * fix(floating-workspace): read one visibility fact for chat attention and auto-ack * docs(floating-workspace): correct comments the shared surface made stale * fix(attention): one on-screen rule for every tab's unread marker, main window and floating panel * fix(agent-session): pin the folder a session launched in; floating chats resume there or refuse * fix(native-chat): resolve a floating chat's file links, images and skills in its pinned folder * fix(agent-session): open a floating chat's terminal handoff in its pinned folder * fix(agent-session): surface refusal text on chat open, require the launch-directory rule, and keep bookkeeping from gating a launch * fix(floating-workspace): wait for a floating chat's pinned folder and read panel visibility from the store * test(floating-workspace): assert the toggle event without a cast * fix(native-chat): show why a chat could not be opened instead of dropping the host's refusal * fix(floating-workspace): close chat tabs through shared commands * fix(i18n): remove obsolete floating editor loading key * fix(floating-workspace): preserve tab ownership on activation and browser close * fix(floating-workspace): keep terminal activation and focus reclaim in sync * fix(readme): point translations at tracked media * fix(floating-workspace): retain split surface with an empty focused group * docs(tabs): describe workspace-owned activation accurately * fix(floating-workspace): finish the main merge and name the pin a launch directory Port what the merge left on main's newer shapes: - The floating chat's "folder is gone" refusal becomes a typed reason, launchFolderMissing, worded like every other start refusal (copy, desktop words, five translations). Older clients drop the unknown reason and show the code's generic words. - The PR's launch-folder tests run on main's journal-database record store; the attach-level case is a row in the pre-spawn refusal table. - Tests main added since the PR's base learn the PR's close-queue settling, workspace-scoped notebook lookup and tab-stamped chat host. - Move the store's visible-tab-index reads into the tab table, so the record store stays under the line cap with the pin writer in it. - Drop a duplicate Worktree import the merge left in a @ts-nocheck file. Rename the persisted field, the status summary field and the renderer slice from workspacePath to launchDirectory: it records the folder a session first launched in, not its workspace's path, and it has not shipped yet. * Fix floating chat visibility, closes, and directory pinning * fix(i18n): drop the floating save dialog's strings with the dialog The floating panel now closes through the shared unsaved-changes prompt, so its own dialog's six strings had no caller and failed the runtime-catalog check. * fix(native-chat): pin floating founders and retain pinned editors * test: align auto-ack fixtures with unified tab visibility * test: give the forget-status attach fixture a location Attach now reads params.location to decide whether a new record is a floating founder, so the partial fixture needs the location real attach params always carry. * test: type the forget-status attach fixture instead of casting it The cast tripped the changed-code type-assertion gate. * fix(native-chat): read a floating chat's model defaults in the folder it was created in The model picker read the floating setting's current folder, so a chat pinned to another folder could show that folder's project default. * test(cross-version): give the stub host the record store the catalog read now checks * test(claude): give main's thinking-display launch stub the store shape the resolver takes --- .../agent-launch-executor.test.ts | 17 +- src/main/agent-launch/agent-launch-mode.ts | 7 +- .../claude-agent-sdk-contract-pins.test.ts | 3 +- src/main/claude/claude-at-rest-commands.ts | 6 +- .../claude-structured-auth-parity.test.ts | 7 +- ...laude-structured-launch-resolution.test.ts | 32 +- .../claude-structured-launch-resolution.ts | 5 +- ...codex-structured-launch-resolution.test.ts | 46 ++- .../codex-structured-launch-resolution.ts | 5 +- .../ipc/floating-workspace-directory.test.ts | 29 +- src/main/ipc/floating-workspace-directory.ts | 8 +- .../ipc/local-file-access-resolution.test.ts | 3 + .../structured-agent-session-adapter.ts | 5 +- ...ured-agent-session-attach-orchestration.ts | 26 +- .../structured-agent-session-attach.ts | 2 + .../structured-agent-session-failure-text.ts | 3 +- ...ctured-agent-session-forget-status.test.ts | 16 +- .../structured-agent-session-host-types.ts | 2 + .../structured-agent-session-host.test.ts | 22 + .../structured-agent-session-host.ts | 5 +- ...ent-session-pre-spawn-first-answer.test.ts | 6 + ...gent-session-reservation-directory.test.ts | 44 ++ ...ssion-status-feed-launch-directory.test.ts | 127 ++++++ ...ructured-agent-session-status-feed.test.ts | 7 +- .../structured-agent-session-status-feed.ts | 3 + ...d-agent-session-status-summary-equality.ts | 1 + .../agent-session-launch-directory.test.ts | 257 ++++++++++++ .../runtime/agent-session-launch-directory.ts | 66 +++ .../runtime/agent-session-record-founding.ts | 10 +- .../agent-session-record-launch-directory.ts | 27 ++ .../runtime/agent-session-record-store.ts | 31 +- ...gent-session-reservation-admission.test.ts | 18 + .../agent-session-reservation-admission.ts | 2 + src/main/runtime/agent-session-tab-table.ts | 20 + ...structured-agent-session-create-support.ts | 27 +- ...runtime-persist-headless-terminal-title.ts | 11 + ...ser-network-execution-host-for-worktree.ts | 61 ++- ...ctured-agent-session-create-intent.test.ts | 32 +- ...nal-creation-and-readiness-part-03.spec.ts | 12 +- .../agent-launch-floating-workspace.test.ts | 96 ++++- .../structured-agent-session-create.ts | 15 +- ...uctured-agent-session-options-read.test.ts | 40 +- .../structured-agent-session-options-read.ts | 8 + .../methods/structured-agent-session.test.ts | 76 ++++ .../structured-agent-session-runtime.ts | 1 + src/renderer/src/App.tsx | 6 +- .../src/app-shell/use-app-chrome-layout.ts | 11 +- .../src/app-shell/use-app-shell-services.ts | 4 +- .../app-shell/use-floating-workspace-panel.ts | 27 +- .../TerminalWorktreeSplitSurface.tsx | 29 +- .../components/WorkspacePaneOverlayLayers.tsx | 71 ++++ .../activity/ActivityPrototypePage.tsx | 2 + .../activity/activity-thread-actions.test.ts | 7 +- .../activity/activity-thread-actions.ts | 2 +- .../activity/use-agent-pane-threads.ts | 2 + .../BrowserPaneOverlayLayer.test.tsx | 21 + .../BrowserPaneOverlayLayer.tsx | 5 +- ...avigate-browser-page-notebook-link.test.ts | 2 +- .../navigate/navigate-browser-page-url.ts | 3 +- .../src/components/editor/editor-autosave.ts | 9 +- ...editor-untitled-dont-save-dialogs.test.tsx | 9 +- .../FloatingBrowserSlot.test.tsx | 109 ----- .../floating-terminal/FloatingBrowserSlot.tsx | 41 -- .../FloatingTerminalPanel.bounds.test.tsx | 5 + ...FloatingTerminalPanel.empty-state.test.tsx | 32 ++ .../FloatingTerminalPanel.focus.test.tsx | 93 ++++- ...tingTerminalPanel.markdown-editor.test.tsx | 42 +- .../FloatingTerminalPanel.shortcuts.test.tsx | 9 +- ...oatingTerminalPanel.tab-lifecycle.test.tsx | 257 +++++++----- .../FloatingTerminalPanelSurface.tsx | 387 ++++++++---------- .../FloatingTerminalSaveDialog.tsx | 92 ----- .../FloatingTerminalWindowControls.test.tsx | 30 +- .../FloatingTerminalWindowControls.tsx | 16 +- .../FloatingWorkspaceTabDragContext.tsx | 49 --- .../floating-terminal-panel-render-probe.ts | 16 +- .../floating-terminal-panel-test-fixtures.ts | 43 +- .../floating-terminal-panel-test-harness.ts | 44 +- ...oating-terminal-panel-test-module-mocks.ts | 2 + .../floating-workspace-surface-model.test.ts | 163 ++++++++ .../floating-workspace-surface-model.ts | 68 +++ .../terminal-pane-handle-registry.test.ts | 108 ----- .../terminal-pane-handle-registry.ts | 53 --- ...e-floating-terminal-close-actions.test.tsx | 145 +++++++ .../use-floating-terminal-close-actions.ts | 237 +++-------- ...e-floating-terminal-create-actions.test.ts | 84 ---- .../use-floating-terminal-create-actions.ts | 23 +- ...se-floating-terminal-editor-close-queue.ts | 120 ------ .../use-floating-terminal-focus-lifecycle.ts | 17 +- ...floating-terminal-initial-focus-effects.ts | 4 +- .../use-floating-terminal-panel-controller.ts | 12 +- ...e-floating-terminal-panel-focus-reclaim.ts | 42 +- .../use-floating-terminal-panel-geometry.ts | 11 +- .../use-floating-terminal-panel-items.ts | 225 ---------- ...use-floating-terminal-panel-local-state.ts | 15 - .../use-floating-terminal-panel-maximize.ts | 11 +- .../use-floating-terminal-panel-shortcuts.ts | 20 +- ...use-floating-terminal-panel-store-state.ts | 9 +- .../use-floating-workspace-chrome-model.ts | 116 ++++++ ...tructuredSession.launch-lifecycle.test.tsx | 13 + ...tiveChatStructuredSession.test-harness.tsx | 31 +- .../NativeChatStructuredSession.test.tsx | 1 + .../NativeChatStructuredSession.tsx | 11 +- ...atStructuredSessionDelivery.probe.test.tsx | 1 + ...tiveChatStructuredSessionDelivery.test.tsx | 4 + ...sionStatusBridge.launch-directory.test.tsx | 159 +++++++ .../StructuredAgentSessionStatusBridge.tsx | 15 + .../agent-session-failure-words-text.ts | 5 + .../native-chat-attachment-upload.ts | 2 +- .../native-chat/native-chat-file-link.test.ts | 59 +++ .../native-chat/native-chat-file-link.ts | 43 +- .../native-chat-image-runtime-context.test.ts | 47 +++ .../native-chat-image-runtime-context.ts | 60 ++- .../native-chat-skill-discovery-context.ts | 44 +- .../native-chat-tab-directory.test.ts | 121 ++++++ .../native-chat/native-chat-tab-directory.ts | 75 ++++ .../structured-attention-dispatch.test.ts | 40 +- .../structured-attention-surface.ts | 12 +- .../use-native-chat-skills.react.test.tsx | 35 ++ .../use-native-chat-skills.test.ts | 74 ++++ .../native-chat/use-native-chat-skills.ts | 18 +- .../native-chat/use-native-chat-tab-owner.ts | 7 + ...chat-toggle-shortcut.surface-owner.test.ts | 102 +++++ .../use-native-chat-toggle-shortcut.ts | 14 +- ...uctured-agent-session-provisional.test.tsx | 2 +- .../use-structured-agent-session.test.tsx | 5 +- .../settings/KeybindingsFileActions.tsx | 7 +- .../components/sidebar/SidebarAgentsList.tsx | 2 + .../components/tab-group/TabGroupPanel.tsx | 176 ++++---- .../tab-group/TabGroupSplitLayout.test.ts | 10 +- .../tab-group/TabGroupSplitLayout.tsx | 334 +-------------- .../tab-group/TabGroupSplitNodeTree.tsx | 323 +++++++++++++++ .../tab-group/WorkspaceTabDragLayer.tsx | 78 ++++ .../components/tab-group/tab-group-host.tsx | 74 ++++ .../useTabGroupActivationCommands.ts | 4 +- .../useTabGroupCloseScopeCommands.ts | 14 +- ...abCloseCommands.structured-session.test.ts | 15 +- .../useTabGroupTabCloseCommands.test.tsx | 17 +- .../tab-group/useTabGroupTabCloseCommands.ts | 16 +- .../tab-group/useTabGroupWorkspaceModel.ts | 8 +- .../tab-group/workspace-emptied-reaction.ts | 39 ++ ...ab-close-commands.floating-reclaim.test.ts | 131 ++++++ .../tab-group/workspace-tab-close-commands.ts | 40 +- ...minalOverlaySlot.emptied-reaction.test.tsx | 127 ++++++ .../terminal-pane/TerminalOverlaySlot.tsx | 9 +- .../components/terminal-pane/TerminalPane.tsx | 14 +- ...TerminalPaneOverlayLayer.react185.test.tsx | 2 +- .../TerminalPaneOverlayLayer.tsx | 27 +- .../notification-dispatch-test-harness.ts | 25 ++ .../pending-split-close-test-fixture.ts | 2 +- ...request-active-terminal-pane-close.test.ts | 35 ++ .../request-active-terminal-pane-close.ts | 34 ++ .../terminal-attention-surface.test.ts | 139 ++++++- .../terminal-attention-surface.ts | 22 +- .../terminal-keyboard-event-handlers.ts | 10 +- .../terminal-notification-pane-visibility.ts | 26 -- ...al-pane-store-subscription-budget.test.tsx | 6 +- .../terminal-pane/terminal-pane-types.ts | 4 - .../use-notification-dispatch.test.ts | 13 + .../use-terminal-pane-close-actions.ts | 26 +- .../use-terminal-pane-controller.ts | 9 +- .../use-terminal-pane-foundation.ts | 8 +- .../terminal-workspace-keydown.test.ts | 1 - .../components/terminal-workspace-keydown.ts | 9 +- .../terminal/terminal-tab-actions.ts | 2 +- ...se-terminal-editor-close-dialog-actions.ts | 27 +- .../use-terminal-editor-close-foundation.ts | 3 + ...erminal-editor-close-queue.reveal.test.tsx | 132 ++++++ .../use-terminal-editor-close-queue.ts | 26 +- .../window-close-request-coordinator.ts | 6 +- src/renderer/src/constants/terminal.ts | 6 + .../src/hooks/agent-auto-ack-targets.test.ts | 82 ++++ .../src/hooks/agent-auto-ack-targets.ts | 55 ++- ...ipc-events-terminal-create-test-harness.ts | 1 - .../src/hooks/ipc-events-test-harness.ts | 1 - .../os-markdown-file-open-bridge.test.ts | 19 +- .../os-markdown-file-open-bridge.ts | 9 +- .../hooks/useAutoAckViewedAgent.away.test.ts | 55 ++- .../useAutoAckViewedAgent.clock-skew.test.ts | 28 +- ...eAutoAckViewedAgent.floating-panel.test.ts | 74 +++- .../useAutoAckViewedAgent.focus-group.test.ts | 2 +- ...AutoAckViewedAgent.mixed-workspace.test.ts | 10 +- .../src/hooks/useAutoAckViewedAgent.test.ts | 49 +-- .../src/hooks/useAutoAckViewedAgent.ts | 34 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/fr.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../lib/agent-launch-caller-placement.test.ts | 8 +- .../lib/agent-launch-caller-routing.test.ts | 8 +- .../src/lib/agent-launch-route-input.test.ts | 5 +- .../src/lib/agent-launch-routing.test.ts | 13 +- .../lib/floating-workspace-focus-reclaim.ts | 39 +- .../floating-workspace-panel-reveal.test.ts | 58 +++ .../lib/floating-workspace-panel-reveal.ts | 23 +- .../lib/floating-workspace-shortcut-policy.ts | 4 +- .../lib/floating-workspace-tab-creation.ts | 6 +- ...loating-workspace-terminal-actions.test.ts | 108 ++--- .../floating-workspace-terminal-actions.ts | 38 +- .../launch-agent-in-new-tab-placement.test.ts | 19 +- .../src/lib/launch-agent-in-new-tab.ts | 6 +- src/renderer/src/lib/workspace-directory.ts | 52 +++ .../src/lib/workspace-tab-commands.ts | 24 +- .../floating-workspace-panel-selector.ts | 106 +++++ .../src/store/global-selection-owner.ts | 11 + src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/repos/repo-state.ts | 3 + src/renderer/src/store/selectors.test.ts | 15 +- src/renderer/src/store/selectors.ts | 80 +--- .../active-selection-owner-scoping.test.ts | 128 ++++++ .../browser/browser-page-focus-actions.ts | 1 - .../slices/browser/browser-slice-contract.ts | 4 +- .../slices/browser/browser-tab-actions.ts | 3 +- .../browser/browser-tab-focus-actions.ts | 11 +- .../editor/actions/open-file-mutations.ts | 13 +- .../slices/editor/actions/open-file-state.ts | 3 +- .../editor/tabs/editor-open-target-group.ts | 8 +- .../slices/editor/types/editor-files-slice.ts | 4 +- .../slices/floating-workspace-row.test.ts | 56 +++ .../src/store/slices/new-markdown.test.ts | 6 +- src/renderer/src/store/slices/repos.ts | 3 + .../src/store/slices/store-test-helpers.ts | 4 +- .../structured-session-launch-directories.ts | 77 ++++ .../store/slices/tabs/tabs-close-actions.ts | 3 +- .../store/slices/tabs/tabs-group-actions.ts | 3 +- .../ui/ui-slice-contract-preferences.ts | 6 + .../slices/ui/ui-slice-surface-actions.ts | 17 +- .../workspace-scoped-activation.test.ts | 149 +++++++ .../listing/detected-worktree-meta.ts | 24 +- .../store/terminals/terminal-tab-creation.ts | 4 +- .../terminals/terminal-tab-navigation.ts | 3 +- src/renderer/src/store/types.ts | 4 +- src/shared/agent-session-failure-copy.ts | 2 + src/shared/agent-session-failure-words.ts | 1 + src/shared/agent-session-failure.ts | 1 + src/shared/agent-session-record.ts | 5 + src/shared/agent-session-refusal-details.ts | 2 + .../agent-session-refusal-reason-words.ts | 1 + src/shared/agent-session-wire.ts | 3 + src/shared/floating-workspace-worktree.ts | 62 +++ ...ctured-agent-session-dispatch-rejection.ts | 2 + ...tructured-native-chat-launch-route.test.ts | 12 +- .../structured-native-chat-launch-route.ts | 4 - src/shared/workspace-launch-kind.ts | 6 +- .../structured-agent-session-host-fixture.ts | 3 +- .../floating-workspace-pane-height.spec.ts | 153 +++++++ ...native-chat-routing-authority.unit.test.ts | 1 - 249 files changed, 6252 insertions(+), 2883 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts create mode 100644 src/main/runtime/agent-session-launch-directory.test.ts create mode 100644 src/main/runtime/agent-session-launch-directory.ts create mode 100644 src/main/runtime/agent-session-record-launch-directory.ts create mode 100644 src/renderer/src/components/WorkspacePaneOverlayLayers.tsx delete mode 100644 src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx delete mode 100644 src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx delete mode 100644 src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx delete mode 100644 src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx create mode 100644 src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-workspace-surface-model.ts delete mode 100644 src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts delete mode 100644 src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx delete mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts delete mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts delete mode 100644 src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts create mode 100644 src/renderer/src/components/floating-terminal/use-floating-workspace-chrome-model.ts create mode 100644 src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-tab-directory.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts create mode 100644 src/renderer/src/components/tab-group/TabGroupSplitNodeTree.tsx create mode 100644 src/renderer/src/components/tab-group/WorkspaceTabDragLayer.tsx create mode 100644 src/renderer/src/components/tab-group/tab-group-host.tsx create mode 100644 src/renderer/src/components/tab-group/workspace-emptied-reaction.ts create mode 100644 src/renderer/src/components/tab-group/workspace-tab-close-commands.floating-reclaim.test.ts create mode 100644 src/renderer/src/components/terminal-pane/TerminalOverlaySlot.emptied-reaction.test.tsx create mode 100644 src/renderer/src/components/terminal-pane/request-active-terminal-pane-close.test.ts create mode 100644 src/renderer/src/components/terminal-pane/request-active-terminal-pane-close.ts create mode 100644 src/renderer/src/components/use-terminal-editor-close-queue.reveal.test.tsx create mode 100644 src/renderer/src/hooks/agent-auto-ack-targets.test.ts create mode 100644 src/renderer/src/lib/floating-workspace-panel-reveal.test.ts create mode 100644 src/renderer/src/lib/workspace-directory.ts create mode 100644 src/renderer/src/store/floating-workspace-panel-selector.ts create mode 100644 src/renderer/src/store/global-selection-owner.ts create mode 100644 src/renderer/src/store/slices/active-selection-owner-scoping.test.ts create mode 100644 src/renderer/src/store/slices/floating-workspace-row.test.ts create mode 100644 src/renderer/src/store/slices/structured-session-launch-directories.ts create mode 100644 src/renderer/src/store/slices/workspace-scoped-activation.test.ts create mode 100644 src/shared/floating-workspace-worktree.ts create mode 100644 tests/e2e/floating-workspace-pane-height.spec.ts diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index b2c50e82037..0e9c350c5ec 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -433,24 +433,21 @@ describe('delivering a launch prompt to a terminal agent', () => { }) /** - * The kind is read off the resolved workspace id, so a workspace with nowhere to keep a session is - * decided here rather than offered to a host probe that cannot answer for it. + * The kind is read off the resolved workspace id. Every kind, the floating workspace included, now + * has a directory a session can run in, so none downgrades a launch on its own. */ describe('a launch into an existing workspace, by workspace kind', () => { - it('runs the floating workspace as a terminal, never a structured session', async () => { + it('opens a structured session in the floating workspace', async () => { const h = harness({}) const result = await h.run({ agent: 'claude', target: { kind: 'existing', worktree: FLOATING_TERMINAL_WORKTREE_ID } }) - // The invariant, not the call order: the floating sentinel has no session store to open into. - expect(h.createStructuredSession).not.toHaveBeenCalled() - expect(result.outcome).toEqual({ kind: 'terminal', handle: 'term_1' }) - expect(result.receipt).toMatchObject({ - mode: 'terminal', - reason: 'structured_unsupported_on_host' - }) + // Why this changed: the floating workspace resolves to its configured directory, so a session + // has somewhere to run and be filed under. Kind alone no longer downgrades a launch. + expect(h.createStructuredSession).toHaveBeenCalled() + expect(result.outcome).toMatchObject({ kind: 'structured' }) }) it('still opens a structured session in a folder workspace', async () => { diff --git a/src/main/agent-launch/agent-launch-mode.ts b/src/main/agent-launch/agent-launch-mode.ts index f9a9eef7846..bd69aba3f1a 100644 --- a/src/main/agent-launch/agent-launch-mode.ts +++ b/src/main/agent-launch/agent-launch-mode.ts @@ -97,7 +97,6 @@ const BLOCKER_REASON: Record< > = { 'reused-terminal': 'reused_terminal', 'agent-without-structured-session': 'agent_without_structured_session', - 'floating-workspace': 'structured_unsupported_on_host', 'custom-start-directory': 'tui_launch_command', 'remote-execution-host': 'remote_execution_host', 'project-runtime': 'wsl_execution_runtime', @@ -147,10 +146,8 @@ export function decideAgentLaunchMode(args: { executionHostId: 'local', reusesTerminal: Boolean(placement.terminal), hostCapabilities: RUNTIME_CAPABILITIES, - // The floating workspace has nowhere to keep a session, so it is decided here rather than left - // to the host probe below, which cannot answer for a workspace with no record. WSL still is: - // the create-support probe reads the resolved workspace rather than guessing from a - // client-side project runtime. + // The host resolves the floating workspace to its configured directory; create-support still + // answers for the resolved workspace, including whether it uses WSL. ...(placement.workspaceKind ? { workspaceKind: placement.workspaceKind } : {}), // Mirrors the renderer's own route input (`agent-launch-route-input.ts`): a cwd is terminal-only // when it names somewhere other than the workspace root, by the same shared rule. diff --git a/src/main/claude/claude-agent-sdk-contract-pins.test.ts b/src/main/claude/claude-agent-sdk-contract-pins.test.ts index 341284f16e6..e16c1420ef1 100644 --- a/src/main/claude/claude-agent-sdk-contract-pins.test.ts +++ b/src/main/claude/claude-agent-sdk-contract-pins.test.ts @@ -14,7 +14,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { spawnProcess } from '../../shared/child-process/run-process' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { claudeQuerySettingsReader } from './claude-agent-sdk-control-requests' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' @@ -147,7 +146,7 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [ launchArgs } as unknown as AgentSessionRecord return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => '/repos/workspace-1', resolveCommand: () => FAKE_CLI, resolveAuthPolicy: () => ({ stripAuthEnv: true }), diff --git a/src/main/claude/claude-at-rest-commands.ts b/src/main/claude/claude-at-rest-commands.ts index 7eaa9c143ed..02bf2fc17a0 100644 --- a/src/main/claude/claude-at-rest-commands.ts +++ b/src/main/claude/claude-at-rest-commands.ts @@ -5,6 +5,7 @@ import { structuredSlashCommands } from '../../shared/structured-agent-session-c import { discoverSkills } from '../skills/discovery' import { scanClaudeCommandFolders } from './claude-command-folder-scan' import { supportsClaudeStructuredLocation } from './claude-structured-location-support' +import { agentSessionPinnedLaunchDirectory } from '../runtime/agent-session-record-launch-directory' /** How long one scan answers for a workspace and account before the next read scans again. */ export const CLAUDE_AT_REST_COMMANDS_TTL_MS = 10_000 @@ -110,7 +111,10 @@ export class ClaudeAtRestCommandCatalog { } private async scan(record: AgentSessionRecord): Promise { - const cwd = await this.deps.resolveWorkspacePath(record.location.workspaceId) + // The folder Claude will launch in: a floating chat's pin, not the floating setting's current one. + const cwd = + agentSessionPinnedLaunchDirectory(record) ?? + (await this.deps.resolveWorkspacePath(record.location.workspaceId)) const account = record.accountHome.path const [custom, discovered] = await Promise.all([ scanClaudeCommandFolders([join(cwd, '.claude', 'commands'), join(account, 'commands')]), diff --git a/src/main/claude/claude-structured-auth-parity.test.ts b/src/main/claude/claude-structured-auth-parity.test.ts index aa535a12a32..c445e4837f2 100644 --- a/src/main/claude/claude-structured-auth-parity.test.ts +++ b/src/main/claude/claude-structured-auth-parity.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { beginClaudeAuthSwitch, endClaudeAuthSwitch } from '../claude-accounts/live-pty-gate' @@ -6,7 +6,6 @@ import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE } from '../claude-accounts/environment' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { createClaudeStructuredLaunchResolver } from './claude-structured-launch-resolution' import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import { @@ -43,7 +42,7 @@ function resolverFor(options: { authSwitchSettleTimeoutMs?: number }): ReturnType { return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: options.stripAuthEnv }), @@ -68,7 +67,7 @@ function realResolverAdapter( } as unknown as AgentSessionRecord return new ClaudeStructuredSessionAdapter({ resolveLaunch: createClaudeStructuredLaunchResolver({ - store: { getRecord: () => resumable } as unknown as AgentSessionRecordStore, + store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index ef75a6ba2b9..c280553cfe5 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -4,7 +4,7 @@ import { delimiter, dirname, join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy' import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' @@ -65,7 +65,7 @@ function resolverFor( hasTranscript: () => Promise = async () => true ) { return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv }), @@ -110,6 +110,19 @@ const RESUMABLE = record({ }) describe('claude structured launch resolution', () => { + it('resumes a floating session in its pinned folder, not the current floating setting', async () => { + const pinned = mkdtempSync(join(tmpdir(), 'orca-claude-floating-')) + const floating = record({ + location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, + launchDirectory: pinned + }) + + const launch = await resolverFor(floating)({ identity: IDENTITY }) + + // resolverFor answers `/repos/` — the current setting — which a pinned resume must ignore. + expect(launch.cwd).toBe(pinned) + }) + it('pre-mints a stable provider id and pins interactive setting sources', async () => { const first = await resolverFor(record())({ identity: IDENTITY }) const second = await resolverFor(record())({ identity: IDENTITY }) @@ -354,7 +367,7 @@ describe('claude structured launch resolution', () => { it('builds on the supplied inherited env instead of Orca process env', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -366,7 +379,7 @@ describe('claude structured launch resolution', () => { it('drops an inherited CLAUDE_CONFIG_DIR so the record stays the only Claude home the pin sees', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -384,7 +397,7 @@ describe('claude structured launch resolution', () => { it('keeps a configured overlay CLAUDE_CONFIG_DIR over the dropped inherited one', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -397,7 +410,7 @@ describe('claude structured launch resolution', () => { it('still strips an inherited auth key under a managed account', async () => { const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', resolveAuthPolicy: () => ({ stripAuthEnv: true }), @@ -434,7 +447,7 @@ describe('claude structured launch resolution', () => { makeExecutable(nodeCommand) const launch = await createClaudeStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => claudeCommand, resolveAuthPolicy: () => ({ stripAuthEnv: false }), @@ -476,7 +489,7 @@ describe('claude structured launch resolution', () => { describe('managed-account gate on every acquisition', () => { function resolverWithGate(read: () => ClaudeManagedAccountGateSettings | null) { return createClaudeStructuredLaunchResolver({ - store: { getRecord: () => RESUMABLE } as unknown as AgentSessionRecordStore, + store: { getRecord: () => RESUMABLE, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => '/usr/local/bin/claude', // Derived, not a literal: the gate and the policy must read the SAME account state, so a @@ -531,8 +544,7 @@ describe('readable Claude thinking', () => { command = '/usr/local/bin/claude' ) => createClaudeStructuredLaunchResolver({ - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch resolution reads only getRecord. - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async (id) => `/repos/${id}`, resolveCommand: () => command, resolveAuthPolicy: () => ({ stripAuthEnv: false }), diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 5f1edeb017a..dc33d652e4d 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -35,6 +35,7 @@ import { resolveSessionFilePath } from '../native-chat/session-file-resolver' import { withoutInheritedClaudeConfigDir } from './claude-config-dir-pin' import type { ClaudeThinkingDisplaySupport } from './claude-thinking-display-support' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import { CLAUDE_STRUCTURED_AGENT } from './claude-structured-agent-definition' export const CLAUDE_DEFAULT_SETTING_SOURCES = ['user', 'project', 'local'] as const @@ -112,7 +113,7 @@ export type ClaudeStructuredLaunch = { } export type ClaudeStructuredLaunchResolverDeps = { - store: AgentSessionRecordStore + store: Pick resolveWorkspacePath: (workspaceId: string) => Promise resolveCommand?: () => string resolveEnv?: () => @@ -325,7 +326,7 @@ export function createClaudeStructuredLaunchResolver( ? head.nativeId : claudeSessionIdForOrcaSession(identity.sessionId) const continuesChain = head !== null - const cwd = await deps.resolveWorkspacePath(record.location.workspaceId) + const cwd = await resolveAgentSessionLaunchDirectory(deps, record) const sources = await resolveClaudeChildEnvSources(deps) // Asked as soon as the spawn's cwd and PATH are known, so it overlaps what is left to resolve. const thinkingDisplay = deps.thinkingDisplay?.argsFor({ diff --git a/src/main/codex/codex-structured-launch-resolution.test.ts b/src/main/codex/codex-structured-launch-resolution.test.ts index 1e6a6b4805e..9b62398ed87 100644 --- a/src/main/codex/codex-structured-launch-resolution.test.ts +++ b/src/main/codex/codex-structured-launch-resolution.test.ts @@ -1,8 +1,11 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' import { createCodexStructuredLaunchResolver } from './codex-structured-launch-resolution' import { codexStructuredPermissionPolicyForSettings } from './codex-structured-permission-policy' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' @@ -45,7 +48,7 @@ function resolverFor( agentDefaultArgs: Record = { codex: '' } ) { return createCodexStructuredLaunchResolver({ - store: { getRecord: () => value } as unknown as AgentSessionRecordStore, + store: { getRecord: () => value, pinLaunchDirectory: vi.fn() }, resolveWorkspacePath, resolveCommand: () => '/usr/local/bin/codex', resolveRollout, @@ -55,6 +58,41 @@ function resolverFor( } describe('codex structured launch resolution', () => { + it('resumes a floating session in its pinned folder, not the current floating setting', async () => { + const pinned = mkdtempSync(join(tmpdir(), 'orca-codex-floating-')) + const resolveWorkspacePath = vi.fn(async () => '/floating/current-setting') + const floating = record({ + location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, + launchDirectory: pinned + }) + + const launch = await resolverFor(floating, resolveWorkspacePath)({ identity: IDENTITY }) + + expect(launch.cwd).toBe(pinned) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + }) + + it('repairs the first launch directory of an unpinned legacy floating session', async () => { + const pinLaunchDirectory = vi.fn() + const resolveLaunch = createCodexStructuredLaunchResolver({ + store: { + getRecord: () => + record({ + location: { ...record().location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID } + }), + pinLaunchDirectory + }, + resolveWorkspacePath: async () => '/floating/start-folder', + resolveCommand: () => '/usr/local/bin/codex', + isWindowsProcessStartTimeAvailable: () => true + }) + + await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({ + cwd: '/floating/start-folder' + }) + expect(pinLaunchDirectory).toHaveBeenCalledExactlyOnceWith(SESSION_ID, '/floating/start-folder') + }) + it('launches the app server in the workspace and account home the record pinned', async () => { const launch = await resolverFor(record())({ identity: IDENTITY }) @@ -74,7 +112,7 @@ describe('codex structured launch resolution', () => { await withPlatform('win32', async () => { const resolveLaunch = createCodexStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`, resolveCommand: () => command, isWindowsProcessStartTimeAvailable: () => true @@ -91,7 +129,7 @@ describe('codex structured launch resolution', () => { await withPlatform('win32', async () => { const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`) const resolveLaunch = createCodexStructuredLaunchResolver({ - store: { getRecord: () => record() } as unknown as AgentSessionRecordStore, + store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() }, resolveWorkspacePath, isWindowsProcessStartTimeAvailable: () => false }) diff --git a/src/main/codex/codex-structured-launch-resolution.ts b/src/main/codex/codex-structured-launch-resolution.ts index 18db5def05d..0f5d21b41d4 100644 --- a/src/main/codex/codex-structured-launch-resolution.ts +++ b/src/main/codex/codex-structured-launch-resolution.ts @@ -11,6 +11,7 @@ import { agentSessionProviderHandleChainHead } from '../../shared/agent-session- import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { resolveCodexCommand } from '../codex-cli/command' import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { resolveAgentSessionLaunchDirectory } from '../runtime/agent-session-launch-directory' import type { CodexStructuredLaunch } from './codex-structured-session-adapter' import type { CodexStructuredPermissionPolicy } from './codex-structured-permission-policy' import { resolvePinnedCodexRolloutProof } from './codex-pinned-rollout-proof' @@ -18,7 +19,7 @@ import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-t import { CODEX_STRUCTURED_AGENT } from './codex-structured-agent-definition' export type CodexStructuredLaunchResolverDeps = { - store: AgentSessionRecordStore + store: Pick /** Absolute path of a workspace on this host. Rejects when the workspace no * longer resolves, which is the case a stale mobile client hits. */ resolveWorkspacePath: (workspaceId: string) => Promise @@ -102,7 +103,7 @@ export function createCodexStructuredLaunchResolver( return { command, args: ['app-server'], - cwd: await deps.resolveWorkspacePath(location.workspaceId), + cwd: await resolveAgentSessionLaunchDirectory(deps, record), codexHome: accountHome.path, ...(environment ? { env: { ...environment } as Record } : {}), // An empty chain is a session that has never proved a thread, so it diff --git a/src/main/ipc/floating-workspace-directory.test.ts b/src/main/ipc/floating-workspace-directory.test.ts index 55438efe587..f10f16efdd6 100644 --- a/src/main/ipc/floating-workspace-directory.test.ts +++ b/src/main/ipc/floating-workspace-directory.test.ts @@ -1,19 +1,10 @@ import { mkdtemp, mkdir, realpath, rm, symlink, unlink } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type { GlobalSettings } from '../../shared/global-settings-types' -const { appGetPathMock } = vi.hoisted(() => ({ - appGetPathMock: vi.fn() -})) - -vi.mock('electron', () => ({ - app: { - getPath: appGetPathMock - } -})) - import { ensureDefaultFloatingWorkspacePath, trustFloatingWorkspaceDirectory, @@ -53,14 +44,16 @@ describe('floating workspace directory', () => { homeDir = path.join(tempRoot, 'home') userDataDir = path.join(tempRoot, 'user-data') await mkdir(homeDir) - appGetPathMock.mockImplementation((name: string) => { - if (name === 'home') { - return homeDir + installFakeAppEnvironment({ + getPath: (name) => { + if (name === 'home') { + return homeDir + } + if (name === 'userData') { + return userDataDir + } + throw new Error(`unexpected app path: ${name}`) } - if (name === 'userData') { - return userDataDir - } - throw new Error(`unexpected app path: ${name}`) }) }) diff --git a/src/main/ipc/floating-workspace-directory.ts b/src/main/ipc/floating-workspace-directory.ts index 7c7a70cdd34..02142248117 100644 --- a/src/main/ipc/floating-workspace-directory.ts +++ b/src/main/ipc/floating-workspace-directory.ts @@ -1,7 +1,7 @@ import { constants as fsConstants } from 'node:fs' import { access, mkdir, realpath, stat } from 'node:fs/promises' import path from 'node:path' -import { app } from 'electron' +import { getAppEnvironment } from '../../shared/app-environment' import type { GlobalSettings } from '../../shared/global-settings-types' import type { FloatingTerminalCwdRequest } from '../../shared/ui-chrome-types' import type { Store } from '../persistence' @@ -22,7 +22,8 @@ function expandHomePath(input: string, home: string): string { } function resolveFloatingWorkspaceInput(input: string): string { - const home = app.getPath('home') + // Why the environment port: the headless runtime resolves this too, and it has no Electron app. + const home = getAppEnvironment().getPath('home') const expanded = expandHomePath(input, home) return path.isAbsolute(expanded) ? path.resolve(expanded) : path.resolve(home, expanded) } @@ -69,7 +70,8 @@ function isTrustedFloatingWorkspaceDirectory( /** The app-owned folder floating markdown documents are created in; a desktop-only root. */ export function getDefaultFloatingWorkspacePath(): string { - return path.join(app.getPath('userData'), FLOATING_WORKSPACE_DIRNAME) + // Why the environment port: the headless runtime resolves this too, and it has no Electron app. + return path.join(getAppEnvironment().getPath('userData'), FLOATING_WORKSPACE_DIRNAME) } export async function ensureDefaultFloatingWorkspacePath(): Promise { diff --git a/src/main/ipc/local-file-access-resolution.test.ts b/src/main/ipc/local-file-access-resolution.test.ts index e659a516c96..23f53aec7e3 100644 --- a/src/main/ipc/local-file-access-resolution.test.ts +++ b/src/main/ipc/local-file-access-resolution.test.ts @@ -2,6 +2,7 @@ import { mkdir, mkdtemp, realpath, rm, stat, symlink, writeFile } from 'node:fs/ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import type { Store } from '../persistence' import type * as RepoWorktrees from '../repo-worktrees' import { resolveAuthorizedPath } from './filesystem-auth' @@ -67,6 +68,8 @@ let project: string let outside: string beforeEach(async () => { + // The floating folder resolves through the app environment, as the headless runtime does too. + installFakeAppEnvironment({ getPath: () => userData.path }) invalidateAuthorizedRootsCache() base = await mkdtemp(join(await realpath(tmpdir()), 'orca-file-access-')) project = join(base, 'project') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index bf47e0f068a..62546e4a88e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -128,7 +128,10 @@ export type AgentSessionAcquisition = { /** A refusal before spawn that a person can act on; the site that refused names it. */ export type AgentSessionPreSpawnReason = Extract< AgentSessionRefusalReason<'agent_session_operation_invalid'>, - 'managedAccountEnvOverride' | 'accountSwitchInProgress' | 'managedAccountUnsupported' + | 'managedAccountEnvOverride' + | 'accountSwitchInProgress' + | 'managedAccountUnsupported' + | 'launchFolderMissing' > /** Acquisition failed with first-hand proof that no provider process existed. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 85dabccda7f..3a13e142420 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -8,6 +8,7 @@ import { recoverStructuredRewind } from './structured-rewind-recovery' // state; this owns the ordering between them. import { randomUUID } from 'node:crypto' +import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import type { AgentSessionAttachResult, AgentSessionMutationResult, @@ -43,6 +44,7 @@ import { } from '../../observability/agent-session-instrumentation' export type StructuredAgentSessionAttachOptions = { + hostLaunchDirectory?: string recordPhase?: AgentSessionCreatePhaseRecorder onAcquisitionFailed?: AttachFlowInput['onAcquisitionFailed'] /** The queued message a start is for; see `StructuredAgentSessionProviderChild.startedFor`. */ @@ -69,14 +71,17 @@ export function attachStructuredAgentSessionUnderSerialize( export function attachStructuredAgentSession( context: StructuredAgentSessionAttachContext, callerKey: string, - params: AgentSessionAttachParams + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions = {} ): Promise> { const sessionId = params.envelope.sessionId // Tracked from enqueue, not from its turn on the queue: a quit drains a queued attach before it // evicts, so no child is spawned behind the eviction and orphaned. const run = (recordPhase?: AgentSessionCreatePhaseRecorder) => context.tasks.trackAttach( - context.serialize(sessionId, () => runAttach(context, callerKey, params, { recordPhase })) + context.serialize(sessionId, () => + runAttach(context, callerKey, params, { ...options, recordPhase }) + ) ) if (params.envelope.expectedRuntimeFence !== null) { return run() @@ -119,14 +124,22 @@ async function runAttach( const probe = await withAgentSessionCreatePhase('probe_owner', recordPhase, () => context.runtimeState.probeOwner(sessionId) ) - // A child this attach spawns writes through a sink this attempt owns. Only a successful - // attach makes the child and its sink the session's; any other exit closes the sink with - // whatever the child queued, and leaves the conversation's child as it was. - const attemptSink = context.runtimeState.mintEventSink(sessionId) // Read before the reserve clears it: how the previous generation ended decides how whatever it // left running is settled. const priorRecord = context.deps.store.getRecord(sessionId) const priorDeathEvidence = priorRecord?.lease.deathEvidence ?? null + const launchDirectory = + !priorRecord && isFloatingWorkspaceId(params.location.workspaceId) + ? (options.hostLaunchDirectory ?? + (await context.deps.resolveWorkspacePath?.(params.location.workspaceId))) + : undefined + if (!priorRecord && isFloatingWorkspaceId(params.location.workspaceId) && !launchDirectory) { + throw new Error('floating_agent_session_launch_directory_unavailable') + } + // A child this attach spawns writes through a sink this attempt owns. Only a successful + // attach makes the child and its sink the session's; any other exit closes the sink with + // whatever the child queued, and leaves the conversation's child as it was. + const attemptSink = context.runtimeState.mintEventSink(sessionId) const attempt: { candidate: AttachCandidate | null; committed: boolean } = { candidate: null, committed: false @@ -146,6 +159,7 @@ async function runAttach( } }, authority: { + ...(launchDirectory ? { launchDirectory } : {}), spawnToken: () => context.deps.mintSpawnToken?.() ?? randomUUID(), claimKeyId: context.deps.claimKeyId, handoffOperationId: params.envelope.clientOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index d6780082433..a4e5c0cfdd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -87,6 +87,7 @@ export type AgentSessionAttachParams = { /** Host-supplied half of the reservation. */ export type AgentSessionAttachAuthority = { + launchDirectory?: string spawnToken: string | (() => string) claimKeyId: string handoffOperationId: string | null @@ -319,6 +320,7 @@ export function reserveRequestFor(input: { : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), + ...(authority.launchDirectory ? { launchDirectory: authority.launchDirectory } : {}), ...(params.adopt ? { // Fence 1 is a new record's first, and the owner probe requires the head link to carry diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts index c0c2172230d..6805ffd5ead 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failure-text.ts @@ -26,7 +26,8 @@ const TYPED_START_REFUSALS = [ 'historyTooLarge', 'managedAccountEnvOverride', 'accountSwitchInProgress', - 'managedAccountUnsupported' + 'managedAccountUnsupported', + 'launchFolderMissing' ] as const satisfies readonly (AgentSessionFailureKind & AgentSessionRefusalReason<'agent_session_operation_invalid'>)[] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index f91ab9b822b..93c86fc16df 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -201,9 +201,19 @@ function attachContext( } as unknown as StructuredAgentSessionAttachContext } -const attachParams = { - envelope: { sessionId: SESSION, clientOperationId: 'op-1' } -} as unknown as Parameters[2] +const attachParams: Parameters[2] = { + envelope: { + sessionId: SESSION, + clientOperationId: 'op-1', + expectedRuntimeFence: 1, + payloadFingerprint: 'fixture-payload' + }, + location: ownerRecord().location, + provider: 'codex', + agent: 'codex', + accountHome: ownerRecord().accountHome, + runtimeKind: 'native' +} describe('a session that leaves the host without an explicit close', () => { it('forgets the retained exact subject after the record and live session are deleted first', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 243e2d8bea2..51fd59cc8ed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -133,6 +133,8 @@ export type StructuredAgentSessionHostDeps = { resolveLaunchEnv?: ( provider: AgentSessionRecord['provider'] ) => Promise | undefined> | Record | undefined + /** Execution-host path for a newly founded floating session. */ + resolveWorkspacePath?: (workspaceId: string) => Promise now?: () => number /** The idle sweep's period and window. Tests drive these; production takes the defaults. */ idleSweep?: { intervalMs?: number; idleMs?: number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 0da2e682ec5..e92b33aac1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -30,6 +30,7 @@ import { import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' let root: string @@ -57,6 +58,27 @@ beforeEach(() => { }) describe('attach', () => { + it('founds a client-location floating chat with the host folder and keeps it on replay', async () => { + const resolveWorkspacePath = vi.fn(async () => '/host/original-folder') + host.deps.resolveWorkspacePath = resolveWorkspacePath + const params = attachParams({ + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + } + }) + + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.launchDirectory).toBe('/host/original-folder') + resolveWorkspacePath.mockResolvedValue('/host/changed-folder') + + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(store.getRecord(SESSION)?.launchDirectory).toBe('/host/original-folder') + expect(resolveWorkspacePath).toHaveBeenCalledTimes(1) + }) + it('reserves the lease, spawns through the adapter, and opens the journal', async () => { const result = await host.attach(CALLER, attachParams()) expect(result).toMatchObject({ ok: true, replayed: false }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index b82ece68435..863ab9edd68 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -252,9 +252,10 @@ export class StructuredAgentSessionHost { attach( caller: StructuredAgentSessionCaller, - params: AgentSessionAttachParams + params: AgentSessionAttachParams, + options?: Parameters[3] ): Promise> { - return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) + return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params, options) } /** Test barrier: every write has landed by its call's return, so no production path needs it. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index b69e90d3e77..104933eecb7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -130,6 +130,12 @@ describe('a create that fails before any process spawns', () => { 'managedAccountUnsupported', 'While a Claude account is added in WSL, Claude chats need a Windows Claude account. Choose or add one in Claude Accounts settings, then send your message again.' ], + [ + 'a floating chat whose launch folder is gone', + 'the folder this chat ran in no longer exists: /gone/floating', + 'launchFolderMissing', + 'The folder this chat ran in no longer exists. Restore it to continue this chat.' + ], [ "Orca's own reason", 'claude sessions pin CLAUDE_CONFIG_DIR, not CODEX_HOME', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts new file mode 100644 index 00000000000..e1fa90508f4 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reservation-directory.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { reserveRequestFor, type AgentSessionAttachParams } from './structured-agent-session-attach' + +const params: AgentSessionAttachParams = { + envelope: { + sessionId: 'chat-one', + clientOperationId: 'op-one', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + }, + provider: 'claude', + agent: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/account' }, + runtimeKind: 'native' +} + +describe('host-resolved directory in the attach reservation', () => { + it('carries the folder into the same required reservation as the record', () => { + const request = reserveRequestFor({ + sessionId: params.envelope.sessionId, + params, + authority: { + spawnToken: 'spawn-one', + claimKeyId: 'claim-one', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + launchDirectory: '/host/floating-folder' + }, + callerKey: 'client-one', + fingerprint: 'fingerprint', + now: 123 + }) + + expect(request.launchDirectory).toBe('/host/floating-folder') + expect(request.location.workspaceId).toBe(FLOATING_TERMINAL_WORKTREE_ID) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts new file mode 100644 index 00000000000..7e98972c55d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-launch-directory.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' + +const SESSION = 'session-alpha' +const NOW = 1_800_000_000_000 + +const FLOATING: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' +} +const WORKTREE: AgentSessionExecutionLocation = { + ...FLOATING, + workspaceId: 'repo-1::/repos/one', + workspaceKind: 'git-worktree' +} + +let root: string +let store: AgentSessionRecordStore +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-feed-workspace-path-')) + store = await openTestAgentSessionRecordStore(root) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function feedFor(location: AgentSessionExecutionLocation) { + await store.reserveOwner({ + sessionId: SESSION, + location, + provider: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'1'.padStart(32, '0')}`, + fingerprint: 'fp-1' + }, + now: NOW + }) + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: location.workspaceId, + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + stateDirectory: root + }) + const feed = new StructuredAgentSessionStatusFeed({ + logger: createStructuredAgentSessionLogger(), + sessions: new Map([[SESSION, { journal, params: { location, provider: 'codex' } }]]), + getRecord: (sessionId) => store.getRecord(sessionId), + now: () => NOW + }) + const events: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'renderer', emit: (event) => events.push(event) }) + return { feed, events } +} + +function lastSummary(events: readonly AgentSessionStatusEvent[]) { + const event = events.at(-1) + if (event?.type === 'status') { + return event.session + } + if (event?.type === 'snapshot') { + return event.sessions.find((session) => session.sessionId === SESSION) + } + throw new Error('no status publication') +} + +describe('status feed pinned launch directory', () => { + it('publishes a floating session its pinned folder once the launch pins it', async () => { + const { feed, events } = await feedFor(FLOATING) + expect(lastSummary(events)).toMatchObject({ sessionId: SESSION }) + expect(lastSummary(events)).not.toHaveProperty('launchDirectory') + + await store.pinLaunchDirectory(SESSION, '/home/me/original-floating') + feed.publish(SESSION) + + expect(events.at(-1)).toMatchObject({ + type: 'status', + session: { sessionId: SESSION, launchDirectory: '/home/me/original-floating' } + }) + }) + + it('carries the pin in the snapshot a later subscriber opens with, as after a restart', async () => { + const { feed } = await feedFor(FLOATING) + await store.pinLaunchDirectory(SESSION, '/home/me/original-floating') + + const late: AgentSessionStatusEvent[] = [] + feed.subscribe({ id: 'reloaded-renderer', emit: (event) => late.push(event) }) + + expect(lastSummary(late)).toMatchObject({ launchDirectory: '/home/me/original-floating' }) + }) + + it('omits the pin for a worktree session, whose id is where it launches', async () => { + const { feed, events } = await feedFor(WORKTREE) + await store.pinLaunchDirectory(SESSION, '/repos/one') + feed.publish(SESSION) + + expect(lastSummary(events)).toMatchObject({ sessionId: SESSION }) + expect(lastSummary(events)).not.toHaveProperty('launchDirectory') + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 828063da409..9fa62fd170e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -361,6 +361,7 @@ describe('StructuredAgentSessionStatusFeed', () => { it('carries the record model and the running tool line the sidebar row shows', async () => { const journal = await openJournal() const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), { + location: indexed({ journal }).params.location, options: { model: 'gpt-5-codex' }, providerHandleChain: [] }) @@ -674,7 +675,11 @@ describe('StructuredAgentSessionStatusFeed', () => { { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - const record = { options: { model: 'first-model' }, providerHandleChain: [] } + const record = { + location: indexed({ journal }).params.location, + options: { model: 'first-model' }, + providerHandleChain: [] + } const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), record) record.options.model = 'second-model' feed.publish(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 24581c8fca2..de27ebbe8c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -22,6 +22,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types' import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' +import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory' import { structuredStatusChildWork } from './structured-agent-session-status-child-work' import { StructuredAgentSessionJournalProjections, @@ -264,6 +265,7 @@ export class StructuredAgentSessionStatusFeed { // The journal has no model: the record's acknowledged options are where a mid-session // switch lands, so the row follows whichever is in force. const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) + const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined return { sessionId, workspaceId: session.params.location.workspaceId, @@ -281,6 +283,7 @@ export class StructuredAgentSessionStatusFeed { ...(model ? { model } : {}), ...this.childWorkFields(sessionId, session.params.provider), ...(providerSession ? { providerSession } : {}), + ...(launchDirectory ? { launchDirectory } : {}), updatedAt: journal.lastActivityAt() || this.deps.now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts index 129d6e104d4..68f8e06a40c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary-equality.ts @@ -33,6 +33,7 @@ export function structuredStatusSummariesEqual( a.toolName === b.toolName && a.toolInput === b.toolInput && a.lastAssistantMessage === b.lastAssistantMessage && + a.launchDirectory === b.launchDirectory && a.turnOutcome === b.turnOutcome && agentSessionBackgroundTasksEqual(a.backgroundTasks, b.backgroundTasks) && structuredStatusChildrenEqual(a.children, b.children) && diff --git a/src/main/runtime/agent-session-launch-directory.test.ts b/src/main/runtime/agent-session-launch-directory.test.ts new file mode 100644 index 00000000000..099ec84ba98 --- /dev/null +++ b/src/main/runtime/agent-session-launch-directory.test.ts @@ -0,0 +1,257 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { resolveAgentSessionLaunchDirectory } from './agent-session-launch-directory' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' + +const statFault = vi.hoisted(() => { + const fault: { error: Error | null } = { error: null } + return fault +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + stat: async (...args: Parameters) => { + if (statFault.error) { + throw statFault.error + } + return actual.stat(...args) + } + } +}) + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' + +const FLOATING: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' +} +const WORKTREE: AgentSessionExecutionLocation = { + ...FLOATING, + workspaceId: 'repo-1::/repos/one', + workspaceKind: 'git-worktree' +} +const FOLDER: AgentSessionExecutionLocation = { + ...FLOATING, + workspaceId: 'folder:folder-1', + workspaceKind: 'folder' +} + +let root: string +let store: AgentSessionRecordStore + +const LAUNCH_FOLDER_MISSING = { name: 'AgentSessionPreSpawnError', reason: 'launchFolderMissing' } + +function reserveRequest(location: AgentSessionExecutionLocation): AgentSessionReserveRequest { + return { + sessionId: SESSION, + location, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'1'.padStart(32, '0')}`, + fingerprint: 'fp-1' + }, + now: NOW + } +} + +/** A session reserved at `location`, pinned to `launchDirectory` when one is given. */ +async function reserve(location: AgentSessionExecutionLocation, launchDirectory?: string) { + const { record } = await store.reserveOwner({ + ...reserveRequest(location), + ...(launchDirectory ? { launchDirectory } : {}) + }) + return record +} + +async function directory(name: string): Promise { + const path = join(root, name) + await mkdir(path, { recursive: true }) + return path +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-launch-directory-')) + store = await openTestAgentSessionRecordStore(root) +}) + +afterEach(async () => { + statFault.error = null + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('agent session launch directory', () => { + it('pins a new floating session to the directory its first launch used', async () => { + const configured = await directory('floating-a') + const record = await reserve(FLOATING, configured) + const resolveWorkspacePath = vi.fn(() => directory('changed-setting')) + + const cwd = await resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + + expect(cwd).toBe(configured) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + const reopened = await openTestAgentSessionRecordStore(root) + expect(reopened.getRecord(SESSION)?.launchDirectory).toBe(configured) + }) + + it('resolves a worktree session by id without storing a second path', async () => { + const record = await reserve(WORKTREE) + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async (id) => `/resolved/${id}` }, + record + ) + ).resolves.toBe(`/resolved/${WORKTREE.workspaceId}`) + expect(store.getRecord(SESSION)?.launchDirectory).toBeUndefined() + }) + + it('resumes a floating session in its pinned folder after the floating setting changed', async () => { + const original = await directory('floating-a') + const changed = await directory('floating-b') + const record = await reserve(FLOATING, original) + const resolveWorkspacePath = vi.fn(async () => changed) + + await expect( + resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + ).resolves.toBe(original) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.launchDirectory).toBe(original) + }) + + it('refuses a floating resume whose pinned folder is gone instead of substituting one', async () => { + const gone = join(root, 'deleted-floating') + const record = await reserve(FLOATING, gone) + const fallback = await directory('app-owned-floating') + const resolveWorkspacePath = vi.fn(async () => fallback) + + const failure = resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + + await expect(failure).rejects.toMatchObject(LAUNCH_FOLDER_MISSING) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.launchDirectory).toBe(gone) + }) + + it('refuses a floating resume whose pinned path is now a file', async () => { + const file = join(root, 'not-a-folder') + await writeFile(file, '') + const record = await reserve(FLOATING, file) + + await expect( + resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath: async () => root }, record) + ).rejects.toMatchObject(LAUNCH_FOLDER_MISSING) + }) + + it('reports a pinned folder it cannot read as that failure, not as a missing folder', async () => { + const pinned = await directory('floating-locked') + const record = await reserve(FLOATING, pinned) + const denied = Object.assign(new Error(`EACCES: permission denied, stat '${pinned}'`), { + code: 'EACCES' + }) + statFault.error = denied + const resolveWorkspacePath = vi.fn(async () => root) + + const failure = resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath }, record) + + await expect(failure).rejects.toBe(denied) + expect(resolveWorkspacePath).not.toHaveBeenCalled() + }) + + it('launches in the resolved directory when writing its pin fails', async () => { + const configured = await directory('floating-a') + const record = await reserve(FLOATING) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const pinFailure = new Error('disk full') + const pinLaunchDirectory = vi.fn(async () => { + throw pinFailure + }) + + await expect( + resolveAgentSessionLaunchDirectory( + { store: { pinLaunchDirectory }, resolveWorkspacePath: async () => configured }, + record + ) + ).resolves.toBe(configured) + expect(pinLaunchDirectory).toHaveBeenCalledExactlyOnceWith(SESSION, configured) + expect(warn).toHaveBeenCalledWith( + '[agent-session] launch directory pin failed', + SESSION, + pinFailure + ) + }) + + it.each([ + ['git worktree', WORKTREE], + ['folder', FOLDER] + ])('keeps resolving a %s resume by id, not by its pin', async (_kind, location) => { + const record = await reserve(location, '/where/it/first/ran') + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async (id) => `/resolved/${id}` }, + record + ) + ).resolves.toBe(`/resolved/${location.workspaceId}`) + expect(store.getRecord(SESSION)?.launchDirectory).toBe('/where/it/first/ran') + }) + + it('founds a /clear replacement already pinned to the folder it inherits', async () => { + const inherited = await directory('floating-cleared') + const source = await reserve(FLOATING, inherited) + const replacement = foundAgentSessionRecord( + { ...source, sessionId: 'clear-replacement' }, + { claimKeyId: 'key-1', now: NOW } + ) + expect(replacement.launchDirectory).toBe(inherited) + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async () => '/floating/current-setting' }, + replacement + ) + ).resolves.toBe(inherited) + }) + + it('pins a legacy floating record once, so a later setting change cannot move it', async () => { + const current = await directory('floating-current') + const later = await directory('floating-later') + const legacy = await reserve(FLOATING) + expect(legacy.launchDirectory).toBeUndefined() + + await expect( + resolveAgentSessionLaunchDirectory( + { store, resolveWorkspacePath: async () => current }, + legacy + ) + ).resolves.toBe(current) + const pinned = store.getRecord(SESSION) + expect(pinned?.launchDirectory).toBe(current) + if (!pinned) { + throw new Error('the pinned record disappeared') + } + + await expect( + resolveAgentSessionLaunchDirectory({ store, resolveWorkspacePath: async () => later }, pinned) + ).resolves.toBe(current) + }) +}) diff --git a/src/main/runtime/agent-session-launch-directory.ts b/src/main/runtime/agent-session-launch-directory.ts new file mode 100644 index 00000000000..3616db4d68f --- /dev/null +++ b/src/main/runtime/agent-session-launch-directory.ts @@ -0,0 +1,66 @@ +import { stat } from 'node:fs/promises' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { AgentSessionPreSpawnError } from '../native-chat/agent-session-wire/structured-agent-session-adapter' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionPinnedLaunchDirectory } from './agent-session-record-launch-directory' +import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree' + +/** The floating folder a session ran in is gone; resuming anywhere else would be a different chat. */ +export function agentSessionLaunchFolderMissing( + launchDirectory: string +): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError( + new Error(`the folder this chat ran in no longer exists: ${launchDirectory}`), + { reason: 'launchFolderMissing' } + ) +} + +export type AgentSessionLaunchDirectoryDeps = { + store: Pick + /** Absolute path of a workspace on this host, by the workspace's current directory policy. */ + resolveWorkspacePath: (workspaceId: string) => Promise +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory() + } catch (error) { + // Only absence means the folder is gone; a permission or I/O failure is reported as itself. + if (isDefinitiveAbsence(error)) { + return false + } + throw error + } +} + +/** + * The one answer to "which directory does this acquisition launch the provider in". + * + * The floating workspace id names a setting, not a place, so a floating session resumes in the + * folder it was pinned to and refuses when that folder is gone. Worktree and folder ids name a + * durable place and keep resolving by id. Old floating records without a pin are repaired on + * launch; new floating records receive the directory in their founding reservation. + */ +export async function resolveAgentSessionLaunchDirectory( + deps: AgentSessionLaunchDirectoryDeps, + record: AgentSessionRecord +): Promise { + const pinned = agentSessionPinnedLaunchDirectory(record) + if (pinned !== undefined) { + if (!(await isDirectory(pinned))) { + throw agentSessionLaunchFolderMissing(pinned) + } + return pinned + } + const resolved = await deps.resolveWorkspacePath(record.location.workspaceId) + if (isFloatingWorkspaceId(record.location.workspaceId) && record.launchDirectory === undefined) { + try { + await deps.store.pinLaunchDirectory(record.sessionId, resolved) + } catch (error) { + // Bookkeeping must not gate the launch; an unpinned record is pinned by its next launch. + console.warn('[agent-session] launch directory pin failed', record.sessionId, error) + } + } + return resolved +} diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index d759a2461e0..464b144e5ce 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -6,7 +6,13 @@ import { /** Who a conversation's agent is and how it launches: the same whether it is created or founded. */ export type AgentSessionRecordIdentity = Pick< AgentSessionRecord, - 'sessionId' | 'location' | 'provider' | 'accountHome' | 'options' | 'launchArgs' + | 'sessionId' + | 'location' + | 'provider' + | 'accountHome' + | 'options' + | 'launchArgs' + | 'launchDirectory' > export function agentSessionRecordIdentityFields( @@ -21,6 +27,8 @@ export function agentSessionRecordIdentityFields( accountHome: identity.accountHome, ...(identity.options ? { options: { ...identity.options } } : {}), ...(identity.launchArgs ? { launchArgs: [...identity.launchArgs] } : {}), + // A /clear continues in the same tab, so it stays in the folder the chat ran in. + ...(identity.launchDirectory ? { launchDirectory: identity.launchDirectory } : {}), createdAt: now, updatedAt: now } diff --git a/src/main/runtime/agent-session-record-launch-directory.ts b/src/main/runtime/agent-session-record-launch-directory.ts new file mode 100644 index 00000000000..e00759d8377 --- /dev/null +++ b/src/main/runtime/agent-session-record-launch-directory.ts @@ -0,0 +1,27 @@ +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { isFloatingWorkspaceId } from '../../shared/floating-workspace-worktree' + +/** + * Pin the directory a session's provider was launched in. + * + * First writer wins: the pin records where the session ran, so no later launch may move it — a + * move would be an explicit act, not a side effect of resolving a workspace again. + */ +export function pinAgentSessionRecordLaunchDirectory( + record: AgentSessionRecord, + launchDirectory: string, + now: number +): AgentSessionRecord { + if (record.launchDirectory !== undefined) { + return record + } + return { ...record, launchDirectory, updatedAt: now } +} + +/** + * The pinned directory a launch is held to, or undefined when the workspace id decides. Only the + * floating id names a setting rather than a place, so only a floating session is held to its pin. + */ +export function agentSessionPinnedLaunchDirectory(record: AgentSessionRecord): string | undefined { + return isFloatingWorkspaceId(record.location.workspaceId) ? record.launchDirectory : undefined +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index eed772bcf30..bca7cf49cf1 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -8,7 +8,7 @@ import { type AgentSessionConversationClear } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' - +import { pinAgentSessionRecordLaunchDirectory } from './agent-session-record-launch-directory' import { agentSessionOperationKey, type AgentSessionOperationClaim, @@ -67,7 +67,12 @@ import { type AgentSessionReserveResult } from './agent-session-reservation-admission' import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' +import { + agentSessionVisibleTabIndex, + listVisibleAgentSessionIds, + setAgentSessionTabVisibility, + showAgentSessionTabs +} from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { loadAgentSessionStoreRows } from './agent-session-record-rows' @@ -114,20 +119,10 @@ export class AgentSessionRecordStore { /** Whether this host has recorded a chat, readable or not. Nothing removes a record row. */ holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0 - listVisibleSessionIds = (): string[] => - (this.state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => - this.state.records.has(sessionId) - ) + listVisibleSessionIds = (): string[] => listVisibleAgentSessionIds(this.state) - /** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ - getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ - present: this.state.sessionTabs !== null, - sessionIds: this.state.sessionTabs - ? this.listVisibleSessionIds() - : (this.state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => - this.state.records.has(sessionId) - ) - }) + getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => + agentSessionVisibleTabIndex(this.state) /** The id of the chat tab showing this conversation, if one does. */ getSessionTabId = (sessionId: string): string | null => @@ -173,6 +168,12 @@ export class AgentSessionRecordStore { setAgentSessionRecordConversationName(record, name, Date.now()) ) + /** Unfenced like the name: it records where a launch ran and never contends with the lease. */ + pinLaunchDirectory = (sessionId: string, launchDirectory: string): Promise => + this.mutate(sessionId, (record) => + pinAgentSessionRecordLaunchDirectory(record, launchDirectory, Date.now()) + ) + /** A record this build cannot validate: readable as present, never grantable as a writer. */ isSessionUnreadable(sessionId: string): boolean { return this.state.unreadableRecords.has(sessionId) diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 0729ebfc35b..7ae309560b0 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -99,6 +99,21 @@ describe('adopted handle chain seeding', () => { }) }) +describe('floating launch directory reservation', () => { + it('stores the host-selected folder when the record is first reserved', () => { + const request = reserveRequest({ launchDirectory: '/host/first-folder' }) + const { record, disposition } = applyAgentSessionReservation( + storeState(), + request, + LEASE_TTL_MS + ) + + expect(disposition).toBe('created') + expect(record.launchDirectory).toBe('/host/first-folder') + expect(record.lease.claimStatus).toBe('reserved') + }) +}) + describe('adopted conversation ownership', () => { it('refuses when another record already holds the same conversation root', () => { // The held link names a leaf; the adoption names none. Same root is the whole test: keying on @@ -149,6 +164,7 @@ describe('adopted conversation ownership', () => { ), location: LOCATION, accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + launchDirectory: '/original-folder', providerHandleChain: [link] } @@ -156,6 +172,7 @@ describe('adopted conversation ownership', () => { storeState([committed]), reserveRequest({ adoptedHandleLink: link, + launchDirectory: '/changed-folder', expectedFence: 1, handoffOperationId: 'handoff-1' }), @@ -164,6 +181,7 @@ describe('adopted conversation ownership', () => { expect(disposition).toBe('retry-reservation') expect(record.providerHandleChain).toEqual([link]) + expect(record.launchDirectory).toBe('/original-folder') }) it('refuses a Codex adoption another record already holds', () => { diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index d36d87b0e99..1344cc128d0 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -49,6 +49,8 @@ import type { AgentSessionStoreState } from './agent-session-record-store-file' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { + /** Host-resolved floating directory committed with the first owner reservation. */ + launchDirectory?: string sessionId: string location: AgentSessionExecutionLocation provider: StructuredAgentId diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 15ace0af761..e95e80d9d1e 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -114,6 +114,26 @@ function reopenedTabId(sessionId: string, held: (tabId: string) => boolean): str return tabId } +/** The sessions whose chat tab is shown, in tab order, skipping any without a record. */ +export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] { + return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId)) +} + +/** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ +export function agentSessionVisibleTabIndex(state: AgentSessionStoreState): { + present: boolean + sessionIds: string[] +} { + return { + present: state.sessionTabs !== null, + sessionIds: state.sessionTabs + ? listVisibleAgentSessionIds(state) + : (state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => + state.records.has(sessionId) + ) + } +} + export function setAgentSessionTabVisibility( state: AgentSessionStoreState, sessionId: string, diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index e51f2c26ae2..0fcdaae9532 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -17,6 +17,10 @@ import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentS import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' +import { + isFloatingWorkspaceId, + isFloatingWorkspaceSelector +} from '../../shared/floating-workspace-worktree' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { agentSessionAccountHome, @@ -53,7 +57,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR protected structuredAgentAccountHomePathResolver( agent: StructuredAgentId, worktree: string, - purpose: 'launch' | 'read' + purpose: 'launch' | 'read', + hostLaunchDirectory?: string ) { const registration = structuredAgentRuntimeRegistration(agent) if (!registration) { @@ -73,7 +78,9 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR purpose, workspacePath: purpose === 'launch' - ? async () => (await this.resolveRuntimeFileTarget(worktree)).worktree.path + ? async () => + hostLaunchDirectory ?? + (await this.resolveRuntimeFileTarget(worktree)).worktree.path : null }, services @@ -107,7 +114,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR const target = await this.resolveRuntimeFileTarget(worktreeSelector) const repo = this.store?.getRepo(target.worktree.repoId) const folderScope = parseWorkspaceKey(target.worktree.id) - const folderWorkspace = folderScope?.type === 'folder' + // The floating workspace is a plain directory with no repo git options, which is exactly what + // `folder` denotes here — it describes how Orca manages the place, not whether git is in it. + const folderWorkspace = + folderScope?.type === 'folder' || isFloatingWorkspaceId(target.worktree.id) // WSL routing describes *this* machine; no remote or runtime host may inherit // it. Both branches key on executionHostId: the target no longer carries a // connectionId, which used to spell remote, unresolved and local alike. @@ -146,18 +156,23 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR agent: StructuredAgentId callerKey?: string resumeFrom?: { providerSessionId: string } - }): Promise { + }): Promise { + const hostLaunchDirectory = isFloatingWorkspaceSelector(input.worktree) + ? (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path + : undefined const resolveAccountHomePath = this.structuredAgentAccountHomePathResolver( input.agent, input.worktree, - 'launch' + 'launch', + hostLaunchDirectory ) if (!resolveAccountHomePath) { throw agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostUnsupported' }) } - return this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + const resolved = await this.resolveStructuredAgentSessionIntent(input, resolveAccountHomePath) + return hostLaunchDirectory ? { ...resolved, hostLaunchDirectory } : resolved } /** diff --git a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts index 1869ac70819..932d6075cd5 100644 --- a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts +++ b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts @@ -15,6 +15,7 @@ import { } from '../../shared/execution-host' import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { resolveWorktreeHostRouting } from './worktree-launch-host-repo' +import { isFloatingWorkspaceSelector } from '../../shared/floating-workspace-worktree' export class OrcaRuntimeWithPersistHeadlessTerminalTitle extends OrcaRuntimeWithMoveHeadlessMobileSessionTab { // Persist a manual terminal rename so a headless rebuild keeps the title @@ -197,6 +198,16 @@ export class OrcaRuntimeWithPersistHeadlessTerminalTitle extends OrcaRuntimeWith worktree: ResolvedWorktree executionHostId: ExecutionHostId }> { + // The floating workspace has no row to resolve. Answering here rather than at each caller + // keeps one resolver authoritative for "where does this workspace live". + if (isFloatingWorkspaceSelector(worktreeSelector)) { + return { + worktree: this.floatingWorkspaceToResolvedWorktree( + await this.resolveFloatingWorkspacePath() + ), + executionHostId: LOCAL_EXECUTION_HOST_ID + } + } const folderScope = await this.resolveFolderWorkspaceLaunchScope(worktreeSelector) if (folderScope?.folderWorkspace) { // A folder workspace has no repo row to disagree with; its own inference already threw on an diff --git a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts index 370ed4da51b..b3a33f356f1 100644 --- a/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts +++ b/src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktree.ts @@ -1,6 +1,11 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithTransitionGraphReloadToTerminalState } from './orca-runtime-transition-graph-reload-to-terminal-state' import type { ExecutionHostId } from '../../shared/execution-host' +import { + floatingWorkspaceToWorktree, + isFloatingWorkspaceSelector +} from '../../shared/floating-workspace-worktree' +import { resolveFloatingTerminalCwd } from '../ipc/floating-workspace-directory' import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' import { LOCAL_EXECUTION_HOST_ID, @@ -14,6 +19,7 @@ import { resolveWorktreeLaunchHost } from './worktree-launch-host-repo' import { folderWorkspaceKey, parseWorkspaceKey } from '../../shared/workspace-scope' import type { FolderWorkspace } from '../../shared/folder-workspace-types' import type { ResolvedWorktree } from './runtime-worktree-path-identity' +import type { Worktree } from '../../shared/worktree/types' import { folderWorkspaceToWorktree } from '../../shared/folder-workspace-worktree' import type { TerminalWorkspaceLaunchScope } from './runtime-legacy-worker-terminal-recovery-types' import { resolveTerminalStartupCwd } from '../../shared/terminal-startup-cwd' @@ -26,9 +32,25 @@ import { getExplicitWorktreeIdSelector } from './runtime-worktree-selection' import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id' import { WorktreeIdRequiresFullPathError } from './runtime-worktree-lineage-resolution' import { triggerTerminalSpawnPushTargetMaterialization } from './runtime-terminal-spawn-push-target-materialization' -import type { Worktree } from '../../shared/worktree/types' import { resolveCreatedWorktreeTerminalTarget } from './runtime-created-worktree-terminal-target' +// Why: folder and floating workspaces have no worktree row, so no parent, children or lineage. +function withoutWorktreeLineage(worktree: Worktree): ResolvedWorktree { + return { + ...worktree, + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { + path: worktree.path, + head: worktree.head, + branch: worktree.branch, + isBare: worktree.isBare, + isMainWorktree: worktree.isMainWorktree + } + } +} + export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extends OrcaRuntimeWithTransitionGraphReloadToTerminalState { protected resolveBrowserNetworkExecutionHostForWorktree(worktree?: { id: string @@ -66,21 +88,21 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend : this.resolveEmulatorWorkspaceId(selector) } + // Why one resolver: host-launched floating terminals and floating chat sessions run in the same + // directory the panel's own terminals use — the user's floating-workspace setting. + protected async resolveFloatingWorkspacePath(): Promise { + const store = this.store + return store + ? resolveFloatingTerminalCwd(store, { path: store.getSettings().floatingTerminalCwd }) + : homedir() + } + + protected floatingWorkspaceToResolvedWorktree(path: string): ResolvedWorktree { + return withoutWorktreeLineage(floatingWorkspaceToWorktree(path)) + } + protected folderWorkspaceToResolvedWorktree(folderWorkspace: FolderWorkspace): ResolvedWorktree { - const worktree = folderWorkspaceToWorktree(folderWorkspace) - return { - ...worktree, - parentWorktreeId: null, - childWorktreeIds: [], - lineage: null, - git: { - path: worktree.path, - head: worktree.head, - branch: worktree.branch, - isBare: worktree.isBare, - isMainWorktree: worktree.isMainWorktree - } - } + return withoutWorktreeLineage(folderWorkspaceToWorktree(folderWorkspace)) } protected resolveWorkspaceTerminalStartupCwd( @@ -101,15 +123,12 @@ export class OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree extend selector: string, createdWorktree?: Worktree ): Promise { - const floatingTerminalSelector = - selector === FLOATING_TERMINAL_WORKTREE_ID || - selector === `id:${FLOATING_TERMINAL_WORKTREE_ID}` - if (floatingTerminalSelector) { - // Why: the floating sentinel is terminal-only — no backing repo/worktree record for other workspace APIs. + if (isFloatingWorkspaceSelector(selector)) { + // Why no managed worktree: the floating sentinel has no repo or worktree record behind it. return { scope: { id: FLOATING_TERMINAL_WORKTREE_ID, - path: homedir(), + path: await this.resolveFloatingWorkspacePath(), connectionId: null, repo: null, folderWorkspace: null diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index edff20ee99c..c6e75cd5a23 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -4,12 +4,18 @@ const applyAgentWorkspaceTrust = vi.hoisted(() => vi.fn(async () => ({}))) vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust })) import { OrcaRuntimeService } from './orca-runtime' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' beforeEach(() => { applyAgentWorkspaceTrust.mockClear() }) -function createCodexIntentRuntime(settings: Record) { +function createCodexIntentRuntime( + settings: Record, + workspaceId = 'workspace-1', + workspacePath = '/repos/workspace-1' +) { const prepareCodexStructuredLaunch = vi.fn(() => '/accounts/selected/home') const runtime = new OrcaRuntimeService( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store. @@ -24,21 +30,39 @@ function createCodexIntentRuntime(settings: Record) { resolveStructuredAgentSessionLocation: vi.fn(async () => ({ executionHostId: 'local', wslDistro: null, - workspaceId: 'workspace-1', + workspaceId, workspaceKind: 'git-worktree' as const })), - resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: '/repos/workspace-1' } })) + resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: workspacePath } })) }) const createIntent = () => runtime.resolveStructuredAgentSessionCreateIntent({ envelope: { sessionId: 'session-1', clientOperationId: 'operation-1' }, - worktree: 'id:workspace-1', + worktree: `id:${workspaceId}`, agent: 'codex' }) return { prepareCodexStructuredLaunch, createIntent } } describe('structured Codex folder trust', () => { + it('takes a floating chat directory from the host-resolved workspace', async () => { + const { createIntent } = createCodexIntentRuntime( + {}, + FLOATING_TERMINAL_WORKTREE_ID, + '/host/floating-folder' + ) + + const intent = await createIntent() + + expect(intent.hostLaunchDirectory).toBe('/host/floating-folder') + expect(applyAgentWorkspaceTrust).toHaveBeenCalledWith( + 'codex', + '/host/floating-folder', + expect.any(Object) + ) + expect(attachFingerprintFields(intent)).not.toHaveProperty('hostLaunchDirectory') + }) + it('pre-trusts the chat folder before launch preparation, as a Codex terminal launch does', async () => { const { prepareCodexStructuredLaunch, createIntent } = createCodexIntentRuntime({}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts index de21af9f013..d2b1c957cf9 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-03.spec.ts @@ -1,3 +1,4 @@ +import { realpath } from 'node:fs/promises' import { describe, expect, it, vi } from 'vitest' import { detectAgentCommandsOnHost } from '../../preflight/agent-detection' import { @@ -5,7 +6,6 @@ import { OrcaRuntimeService, SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV, electronMocks, - homedir, ipcMain, join, mkdtemp, @@ -366,7 +366,12 @@ describe('OrcaRuntimeService', () => { } ])('creates background terminal sessions for a $label', async ({ selector }) => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-floating' }) - const runtime = new OrcaRuntimeService(store) + const floatingDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-floating-'))) + const runtimeStore: typeof store = { + ...store, + getSettings: () => ({ ...store.getSettings(), floatingTerminalCwd: floatingDir }) + } + const runtime = new OrcaRuntimeService(runtimeStore) runtime.setPtyController({ spawn, write: () => true, @@ -393,8 +398,9 @@ describe('OrcaRuntimeService', () => { worktreeId?: string } | undefined + // Why the configured folder: host-launched floating terminals start where the panel's own do. expect(spawnCall).toMatchObject({ - cwd: homedir(), + cwd: floatingDir, connectionId: null, worktreeId: FLOATING_TERMINAL_WORKTREE_ID }) diff --git a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts index 1df2c1f7433..4802152fa62 100644 --- a/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-floating-workspace.test.ts @@ -2,9 +2,21 @@ import { homedir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { OrcaRuntimeService } from '../../orca-runtime' +import type { ResolvedWorktree } from '../../runtime-worktree-path-identity' import { AGENT_LAUNCH_METHODS } from './agent-launch' import { CAPABLE_CLIENT, methodNamed, STRUCTURED_PREFERENCE } from './agent-launch.test-fixture' +const createStructuredSession = vi.hoisted(() => + vi.fn(async (args: { envelope: { sessionId: string } }) => ({ + ok: true as const, + value: { sessionId: args.envelope.sessionId, fence: 1 } + })) +) + +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: createStructuredSession +})) + vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, webContents: { fromId: vi.fn(() => null) }, @@ -15,7 +27,34 @@ vi.mock('electron', () => ({ const launch = methodNamed(AGENT_LAUNCH_METHODS, 'agent.launch') const selectors = [FLOATING_TERMINAL_WORKTREE_ID, `id:${FLOATING_TERMINAL_WORKTREE_ID}`] -afterEach(() => vi.restoreAllMocks()) +afterEach(() => { + vi.restoreAllMocks() + createStructuredSession.mockClear() +}) + +describe('floating workspace resolved-worktree minting', () => { + // Regression: this mixin file is @ts-nocheck, so a missing module import there is invisible to + // `pnpm tc` and only fails at call time (shipped once as a ReferenceError from the structured + // createSupport path). Calling the real method pins the import wiring. + it('mints the synthetic floating row through the shared module', () => { + class FloatingResolverProbe extends OrcaRuntimeService { + mintFloatingResolvedWorktree(path: string): ResolvedWorktree { + return this.floatingWorkspaceToResolvedWorktree(path) + } + } + + const resolved = new FloatingResolverProbe().mintFloatingResolvedWorktree('/tmp/floating-qa') + + expect(resolved).toMatchObject({ + id: FLOATING_TERMINAL_WORKTREE_ID, + path: '/tmp/floating-qa', + parentWorktreeId: null, + childWorktreeIds: [], + lineage: null, + git: { path: '/tmp/floating-qa', head: '', branch: '', isBare: false, isMainWorktree: false } + }) + }) +}) describe('agent.launch with the real floating workspace resolver', () => { it.each(selectors)('resolves %s without a managed worktree record', async (selector) => { @@ -33,8 +72,20 @@ describe('agent.launch with the real floating workspace resolver', () => { }) }) + // Why here: the runtime resolvers this crosses are @ts-nocheck, so only a call that runs them + // proves the floating workspace resolves to a location a structured session can be filed under. + it.each(selectors)('supports a structured session for %s on the local host', async (selector) => { + const runtime = new OrcaRuntimeService() + + await expect( + runtime.getStructuredAgentSessionCreateSupport(selector, 'codex') + ).resolves.toEqual({ + supported: true + }) + }) + describe.each([true, false])('structured preference %s', (structuredPreference) => { - it.each(selectors)('launches a terminal through %s', async (selector) => { + it.each(selectors)('routes %s by preference, not by workspace kind', async (selector) => { const runtime = new OrcaRuntimeService() vi.spyOn(runtime, 'getClientSettings').mockReturnValue( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only these preferences and optional agentCmdOverrides; no other settings consumer runs because terminal creation is stubbed. @@ -55,25 +106,36 @@ describe('agent.launch with the real floating workspace resolver', () => { }) const result = await launch.handler( - launch.params.parse({ agent: 'claude', target: { kind: 'existing', worktree: selector } }), + launch.params.parse({ agent: 'codex', target: { kind: 'existing', worktree: selector } }), { runtime, ...CAPABLE_CLIENT } ) expect(scope).toHaveBeenCalledExactlyOnceWith(selector) - expect(createSupport).not.toHaveBeenCalled() - expect(structuredHost).not.toHaveBeenCalled() - expect(createTerminal).toHaveBeenCalledExactlyOnceWith( - `id:${FLOATING_TERMINAL_WORKTREE_ID}`, - { startupAgent: 'claude', onPtySpawnDispatched: expect.any(Function) } - ) - expect(result).toMatchObject({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - outcome: { kind: 'terminal', handle: 'term_floating' }, - receipt: { - mode: 'terminal', - reason: structuredPreference ? 'structured_unsupported_on_host' : 'user_default' - } - }) + if (structuredPreference) { + expect(createSupport).toHaveBeenCalledWith(`id:${FLOATING_TERMINAL_WORKTREE_ID}`, 'codex') + expect(createStructuredSession).toHaveBeenCalledWith( + expect.objectContaining({ worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }) + ) + expect(createTerminal).not.toHaveBeenCalled() + expect(result).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + outcome: { kind: 'structured', sessionId: expect.any(String) }, + receipt: { mode: 'structured' } + }) + } else { + expect(createStructuredSession).not.toHaveBeenCalled() + expect(createSupport).not.toHaveBeenCalled() + expect(structuredHost).not.toHaveBeenCalled() + expect(createTerminal).toHaveBeenCalledExactlyOnceWith( + `id:${FLOATING_TERMINAL_WORKTREE_ID}`, + { startupAgent: 'codex', onPtySpawnDispatched: expect.any(Function) } + ) + expect(result).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + outcome: { kind: 'terminal', handle: 'term_floating' }, + receipt: { mode: 'terminal', reason: 'user_default' } + }) + } }) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts index af82880543f..d1857652f2d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -36,6 +36,7 @@ import { export type PreparedStructuredAgentSessionCreate = { host: StructuredAgentSessionHost attachParams: AgentSessionAttachParams + hostLaunchDirectory?: string /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ tab: { workspaceId: string; agent: StructuredAgentId } | null } @@ -100,9 +101,15 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { fields: attachFingerprintFields({ ...resolved, envelope: args.envelope }) }) host ??= await args.ensureHost() - const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved + const { + agent: _resolvedAgent, + provider: _resolvedProvider, + hostLaunchDirectory, + ...resolvedAttach + } = resolved return { host, + ...(hostLaunchDirectory ? { hostLaunchDirectory } : {}), attachParams: { ...resolvedAttach, // After the fingerprint, deliberately: `attachFingerprintFields` excludes options because @@ -129,7 +136,11 @@ export async function commitStructuredAgentSessionCreate(args: { activate: boolean }): Promise> { const { prepared } = args - const result = await prepared.host.attach(args.caller, prepared.attachParams) + const result = prepared.hostLaunchDirectory + ? await prepared.host.attach(args.caller, prepared.attachParams, { + hostLaunchDirectory: prepared.hostLaunchDirectory + }) + : await prepared.host.attach(args.caller, prepared.attachParams) if (!result.ok || !prepared.tab) { return result } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts index 459ae5c75a4..d3bb69a6e00 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.test.ts @@ -2,6 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { AgentSessionRecord } from '../../../../shared/agent-session-record' +import { agentSessionRecordFixture } from '../../../../shared/agent-session-record.test-fixture' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { call, clearStructuredHostStub, @@ -16,10 +19,39 @@ afterEach(() => { describe('agentSession.modelCatalog', () => { const read = vi.fn(async () => ({ origin: 'unknown' as const })) + let record: AgentSessionRecord | null = null beforeEach(() => { read.mockClear() - setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } })) + record = null + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { modelCatalog: { read }, store: { getRecord: () => record } } + }) + ) + }) + + it("reads a floating chat's catalog in the folder it was created in", async () => { + const fixture = agentSessionRecordFixture() + record = { + ...fixture, + location: { ...fixture.location, workspaceId: FLOATING_TERMINAL_WORKTREE_ID }, + launchDirectory: '/home/me/floating-a' + } + // The floating setting has since moved to another folder. + const resolveStructuredAgentSessionLocalWorkspacePath = vi.fn(async () => '/home/me/floating-b') + await call( + 'agentSession.modelCatalog', + { agent: 'codex', sessionId: SESSION, worktree: `id:${FLOATING_TERMINAL_WORKTREE_ID}` }, + STRUCTURED_CLIENT, + { resolveStructuredAgentSessionLocalWorkspacePath } + ) + expect(resolveStructuredAgentSessionLocalWorkspacePath).not.toHaveBeenCalled() + expect(read).toHaveBeenCalledWith({ + agent: 'codex', + sessionId: SESSION, + workspacePath: '/home/me/floating-a' + }) }) it('reads the catalog for the directory the named worktree runs in on this host', async () => { @@ -74,7 +106,11 @@ describe('agentSession.modelCatalog before anything built the host', () => { fetchedAt: 1 })) const installHost = vi.fn(async () => { - setStructuredAgentSessionHost(Object.assign(hostStub(), { deps: { modelCatalog: { read } } })) + setStructuredAgentSessionHost( + Object.assign(hostStub(), { + deps: { modelCatalog: { read }, store: { getRecord: () => null } } + }) + ) }) beforeEach(() => { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts index 3cd86c876ad..98d94439b1f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-options-read.ts @@ -14,6 +14,7 @@ import { requireStructuredHost } from './structured-agent-session-gate' import { ModelCatalogParams, OptionsParams } from './structured-agent-session-schemas' +import { agentSessionPinnedLaunchDirectory } from '../../agent-session-record-launch-directory' export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ defineMethod({ @@ -36,6 +37,13 @@ export const STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS = [ if (!catalog) { return { origin: 'unknown' as const } } + // A floating chat runs in the folder it was created in, not the floating setting's current one. + const record = + params.sessionId === undefined ? null : host.deps.store.getRecord(params.sessionId) + const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined + if (launchDirectory) { + return catalog.read({ ...params, workspacePath: launchDirectory }) + } if (worktree === undefined) { return catalog.read(params) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 82437bb6d81..d7244bbd79b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -16,6 +16,7 @@ import { } from '../../../../shared/protocol-version' import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { ALL_RPC_METHODS } from './index' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { CLEANUP_METHODS } from './structured-agent-session-gate-classification.test-fixture' @@ -425,6 +426,46 @@ describe('method routing', () => { ) }) + it('keeps the host-resolved floating folder outside a worktree create payload', async () => { + const worktree = `id:${FLOATING_TERMINAL_WORKTREE_ID}` + const params = { + envelope: envelope({ + expectedRuntimeFence: null, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.create', + sessionId: SESSION, + fields: { worktree, agent: 'codex' } + }) + }), + worktree, + agent: 'codex' + } + + expect( + await call('agentSession.create', params, STRUCTURED_CLIENT, { + resolveStructuredAgentSessionCreateIntent: async () => ({ + envelope: params.envelope, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/host/account' }, + runtimeKind: 'native', + hostLaunchDirectory: '/host/floating-folder' + }) + }) + ).toMatchObject({ ok: true, result: { ok: true } }) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ hostLaunchDirectory: expect.anything() }), + { hostLaunchDirectory: '/host/floating-folder' } + ) + }) + it('refuses a create whose declared fingerprint omits the tab it reserved', async () => { // The tab id is part of the intent fingerprint, so a payload whose declared digest omits it // is refused rather than admitted as the blank create it looks like. @@ -590,6 +631,41 @@ describe('method routing', () => { expect(ensured).toMatchObject({ ok: true }) }) + it.each(['agentSession.create', 'agentSession.ensure'] as const)( + 'routes a new floating client location through host attach for %s', + async (method) => { + const params = attachParams({ + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + workspaceKind: 'folder' + } + }) + + expect(await call(method, params, STRUCTURED_CLIENT)).toMatchObject({ ok: true }) + expect(hostCalls.attach).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ location: params.location }) + ) + expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('launchDirectory') + } + ) + + it.each(['agentSession.create', 'agentSession.ensure'] as const)( + 'rejects a client-selected floating launch directory for %s', + async (method) => { + const response = await call( + method, + { ...attachParams(), launchDirectory: '/client/selected-folder' }, + STRUCTURED_CLIENT + ) + + expect(response).toMatchObject({ ok: false }) + expect(hostCalls.attach).not.toHaveBeenCalled() + } + ) + /** A client-supplied location skips the worktree-resolving support check, so both attach-shaped * entries must ask the executing host directly or a host that cannot fence a provider child * would create one anyway. */ diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index c347be27270..6913d3abc7c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -283,6 +283,7 @@ async function installOnJournal( claimKeyId: deps.claimKeyId, probeOwner: createStructuredAgentSessionOwnerProbe(deps.hostId), probeOwners: createStructuredAgentSessionOwnerProbes(deps.hostId), + resolveWorkspacePath: deps.resolveWorkspacePath, ...(deps.resolveLaunchArgs ? { resolveLaunchArgs: async (provider: AgentSessionRecord['provider']) => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b51cd836df2..892c445d100 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -39,11 +39,7 @@ function App(): React.JSX.Element { const onboardingGate = useOnboardingAndFeatureTips() const clearUnreadDockBadge = useUnreadDockBadge(floatingWorkspace.open) - // Why enabled && open: the overlay only renders while the feature is on, and its panel is - // aria-hidden while closed — so that pair is what "on screen" means for the floating workspace. - useAppShellServices({ - floatingPanelVisible: floatingWorkspace.enabled && floatingWorkspace.open - }) + useAppShellServices() // Why before the startup chain: its effect runs first, and no startup step can skip the load. useEffect(startNativeChatDraftLoad, []) useAppStartupHydration(onboardingGate.applyStartupOnboardingState) diff --git a/src/renderer/src/app-shell/use-app-chrome-layout.ts b/src/renderer/src/app-shell/use-app-chrome-layout.ts index f675ae7b79e..e49ecd66948 100644 --- a/src/renderer/src/app-shell/use-app-chrome-layout.ts +++ b/src/renderer/src/app-shell/use-app-chrome-layout.ts @@ -6,6 +6,7 @@ import { resolveLeftSidebarStyleVariables } from '@/lib/left-sidebar-appearance' import { resolveLeftTitlebarChromeLayout } from '@/lib/titlebar-left-chrome' import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface' import { useAppStore } from '../store' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { selectActiveTerminalChromeState } from '../store/active-terminal-chrome-selector' import { useSystemPrefersDark } from '../components/terminal-pane/use-system-prefers-dark' import { @@ -53,7 +54,13 @@ export function useAppChromeLayout() { [settings, systemPrefersDark] ) as React.CSSProperties | undefined - const canMountTerminalWorkbenchNow = activeWorktreeId !== null || backgroundTerminalMountRequested + // Why floating tabs count: the workbench owns every tab's unsaved-close and quit protection, and + // floating tabs exist without an active worktree. + const floatingWorkspaceHasTabs = useAppStore( + (s) => (s.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.length ?? 0) > 0 + ) + const canMountTerminalWorkbenchNow = + activeWorktreeId !== null || backgroundTerminalMountRequested || floatingWorkspaceHasTabs // Why a latch in state, not a ref: the write has to be visible to the next render, and a // render-phase ref write would also survive a render React discards. Setting state during // render is the supported way to derive it, and the `||` below keeps this render correct. @@ -61,7 +68,7 @@ export function useAppChromeLayout() { if (canMountTerminalWorkbenchNow && !hasMountedTerminalWorkbench) { setHasMountedTerminalWorkbench(true) } - // Why: skip the terminal bundle on the landing path, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null. + // Why: skip the terminal bundle on a landing path with no tabs, but once mounted keep hidden panes alive through sleep/shutdown when activeWorktreeId briefly goes null. const shouldMountTerminalWorkbench = canMountTerminalWorkbenchNow || hasMountedTerminalWorkbench // Why: visible worktree creation owns its faux tab strip start to finish; keep the previous workspace mounted for retention without real chrome. const creationLayoutActive = shouldShowWorktreeCreationSurface({ diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index f36401ff32e..3bb29d17afc 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -30,7 +30,7 @@ import { useVisibleHostedReviewRefresh } from './use-visible-hosted-review-refre * the component that consumes its result unmounts (right sidebar, explorer, terminal) or is * absent entirely on the landing path. */ -export function useAppShellServices(options: { floatingPanelVisible: boolean }): void { +export function useAppShellServices(): void { const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady) const persistedUIReady = useAppStore((s) => s.persistedUIReady) const primarySelectionMiddleClickPaste = useAppStore((s) => @@ -53,7 +53,7 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): // Why: wire file-change watching at App level so the editor keeps hearing FS changes when Explorer unmounts (right-sidebar switches to Source Control/Checks). useEditorExternalWatch() useGlobalFileDrop() - useAutoAckViewedAgent(options.floatingPanelVisible) + useAutoAckViewedAgent() useAppMenuPaste() useAppMenuSelectionActions() useLargeTextControlPaste() diff --git a/src/renderer/src/app-shell/use-floating-workspace-panel.ts b/src/renderer/src/app-shell/use-floating-workspace-panel.ts index 7cd3763e401..c2fb65c8bfc 100644 --- a/src/renderer/src/app-shell/use-floating-workspace-panel.ts +++ b/src/renderer/src/app-shell/use-floating-workspace-panel.ts @@ -1,35 +1,22 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, - type SetStateAction -} from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, type SetStateAction } from 'react' import { TOGGLE_FLOATING_TERMINAL_EVENT, requestFloatingTerminalOpenMaximized } from '@/lib/floating-terminal' import { createFloatingWorkspaceTourInteractionSnapshot } from '@/lib/floating-workspace-tour-interaction-snapshot' -import { - persistFloatingTerminalPanelOpen, - readPersistedFloatingTerminalPanelViewState -} from '../components/floating-terminal/floating-terminal-panel-view-state' +import { persistFloatingTerminalPanelOpen } from '../components/floating-terminal/floating-terminal-panel-view-state' import { useAppStore } from '../store' import { selectFloatingVisibleTabCount } from '../store/selectors' export type FloatingWorkspacePanelState = ReturnType /** - * Owns the floating workspace overlay's open state: persistence, the focus it hands back on - * close, and the toggle/disable paths that can flip it from outside React. + * Drives the floating workspace overlay's open state (held in the store): persistence, the focus + * it hands back on close, and the toggle/disable paths that can flip it from outside React. */ export function useFloatingWorkspacePanel() { - // Why restored: leaving the panel closed forces the user to reopen and re-maximize it, - // and that size jump reflows a live TUI's buffer (see floating-terminal-panel-view-state). - const [open, setOpen] = useState( - () => readPersistedFloatingTerminalPanelViewState()?.open === true - ) + const open = useAppStore((s) => s.floatingWorkspacePanelOpen) + const setOpen = useAppStore((s) => s.setFloatingWorkspacePanelOpen) const tourInteractionSnapshotRef = useRef<{ wasPreviouslyInteracted?: boolean persisted?: Promise @@ -110,7 +97,7 @@ export function useFloatingWorkspacePanel() { persistFloatingTerminalPanelOpen(resolvedOpen) } }, - [enabled, open, rememberReturnFocus, restoreReturnFocus] + [enabled, open, setOpen, rememberReturnFocus, restoreReturnFocus] ) const openMaximized = useCallback((): void => { diff --git a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx index e41b1d04091..03a3a472703 100644 --- a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx +++ b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx @@ -10,11 +10,7 @@ import { shouldMountRetainedBrowserOverlay } from './browser-pane/host-guest/browser-worktree-surface-paintability' import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' -import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' -import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' -import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' -import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' -import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' +import { WorkspacePaneOverlayLayers } from './WorkspacePaneOverlayLayers' export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreeId, @@ -66,34 +62,23 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId={focusedGroupId} isWorktreeActive={isVisible} /> - - - {isVisible || backgroundMountTabIds === null ? ( - - ) : null} - - ) }) diff --git a/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx b/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx new file mode 100644 index 00000000000..f8fa5236339 --- /dev/null +++ b/src/renderer/src/components/WorkspacePaneOverlayLayers.tsx @@ -0,0 +1,71 @@ +import type { ActivityTerminalPortalTarget } from './activity/activity-terminal-portal' +import TerminalPaneOverlayLayer from './terminal-pane/TerminalPaneOverlayLayer' +import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/BrowserPaneOverlayLayer' +import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer' +import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer' +import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer' + +/** + * The retained pane hosts for one workspace: terminal, browser, emulator and structured-chat + * panes anchored onto the group tree's pane bodies, plus the vault drop layer. Shared by every + * host of the group tree so pane lifecycle (retention, parking, guest paint) has one owner. + */ +export function WorkspacePaneOverlayLayers({ + worktreeId, + worktreePath, + isVisible, + shouldMeasureHiddenWorktree, + shouldColdParkTerminalPanes, + isForceParked, + activityTerminalPortals, + backgroundMountTabIds, + activationDeferredMountTabIds, + mountRetainedBrowserOverlay, + mountEmulatorOverlay, + ownsNativeChatToggleShortcut = true +}: { + worktreeId: string + worktreePath: string + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + shouldColdParkTerminalPanes: boolean + isForceParked: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] + backgroundMountTabIds: ReadonlySet | null + activationDeferredMountTabIds: ReadonlySet | null + mountRetainedBrowserOverlay: boolean + mountEmulatorOverlay: boolean + /** False for overlay hosts (the floating panel): the active workspace's listener already owns + * the chord, and two live listeners would both toggle. */ + ownsNativeChatToggleShortcut?: boolean +}): React.JSX.Element { + return ( + <> + + + {mountEmulatorOverlay ? ( + + ) : null} + + + + ) +} diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 90716732e62..4db73d25275 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -265,12 +265,14 @@ export default function ActivityPrototypePage(): React.JSX.Element { worktreesByRepo: storeData.worktreesByRepo, detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, folderWorkspaces: storeData.folderWorkspaces, + floatingWorkspacePath: storeData.floatingWorkspacePath, defaultHostId: storeData.defaultHostId }), [ storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces, + storeData.floatingWorkspacePath, storeData.defaultHostId ] ) diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index fd20d2ca18d..9f46fbd90f3 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -9,7 +9,6 @@ const mocks = vi.hoisted(() => ({ activateTabAndFocusPane: vi.fn(), activateStructuredAgentSessionTab: vi.fn(), activateAndRevealWorkspace: vi.fn(), - isFloatingWorkspacePanelVisible: vi.fn(), dispatchEvent: vi.fn() })) @@ -23,9 +22,6 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) -vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ - isFloatingWorkspacePanelVisible: mocks.isFloatingWorkspacePanelVisible -})) import { createActivityThreadActions, hasActivityThreadWorkspace } from './activity-thread-actions' @@ -70,7 +66,6 @@ describe('activity thread host routing', () => { beforeEach(() => { vi.clearAllMocks() vi.stubGlobal('window', { dispatchEvent: mocks.dispatchEvent }) - mocks.isFloatingWorkspacePanelVisible.mockReturnValue(false) mocks.activateStructuredAgentSessionTab.mockReturnValue(false) mocks.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) getKnownWorktreeById.mockReturnValue(thread.worktree) @@ -119,7 +114,7 @@ describe('activity thread host routing', () => { state.settings = { floatingTerminalEnabled: true } state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingThread.tab] } mocks.activateAndRevealWorkspace.mockReturnValue(false) - mocks.isFloatingWorkspacePanelVisible.mockReturnValue(open) + state.floatingWorkspacePanelOpen = open makeActions().selectThread(floatingThread) diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index 1193467fea0..1b35bf3f341 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -26,7 +26,7 @@ function getActivityThreadExecutionHostId( type ActivityThreadWorkspaceCatalog = Pick< AppState, - 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' + 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'floatingWorkspacePath' > & { defaultHostId: ExecutionHostId } function readActivityThreadWorkspaceCatalog(): ActivityThreadWorkspaceCatalog { diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 44fecebb5e6..5f3cb64d09f 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -40,6 +40,7 @@ export type AgentPaneThreadsStoreData = Pick< | 'repos' | 'worktreesByRepo' | 'folderWorkspaces' + | 'floatingWorkspacePath' | 'detectedWorktreesByRepo' | 'getKnownWorktreeById' | 'acknowledgedAgentsByPaneKey' @@ -125,6 +126,7 @@ export function useAgentPaneThreads(args: { repos: s.repos, worktreesByRepo: s.worktreesByRepo, folderWorkspaces: s.folderWorkspaces, + floatingWorkspacePath: s.floatingWorkspacePath, detectedWorktreesByRepo: s.detectedWorktreesByRepo, getKnownWorktreeById: s.getKnownWorktreeById, worktreeMap: getWorktreeMapFromState(s), diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx index 8ab52d952b8..c6fee18e367 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.test.tsx @@ -5,6 +5,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { BrowserTab as BrowserTabState } from '../../../../../shared/browser-workspace-types' import type { Tab, TabGroup } from '../../../../../shared/tab-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../shared/constants' type MockAppState = { browserTabsByWorktree: Record @@ -271,6 +272,26 @@ describe('BrowserPaneOverlayLayer', () => { expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(1000) }) + it('keeps a floating browser to chords from its own overlay even when its group holds focus', () => { + const floating = FLOATING_TERMINAL_WORKTREE_ID + const state = createState() + mocks.state = { + ...state, + browserTabsByWorktree: { [floating]: state.browserTabsByWorktree['wt-1'] }, + unifiedTabsByWorktree: { [floating]: state.unifiedTabsByWorktree['wt-1'] }, + groupsByWorktree: { [floating]: state.groupsByWorktree['wt-1'] }, + activeGroupIdByWorktree: { [floating]: 'group-1' } + } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain( + 'data-browser-pane-id="browser-a" data-browser-pane-active="true" data-browser-find-shortcut-scope="owned-target"' + ) + }) + it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => { mocks.state = createState() mocks.state.groupsByWorktree = { diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx index b15c8420ff0..b9c80d82eeb 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserPaneOverlayLayer.tsx @@ -8,6 +8,7 @@ import BrowserPane from './browser-workspace-pane' import { DeferredBrowserContent } from './DeferredBrowserContent' import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types' import { tabGroupBodyAnchorName } from '../../tab-group/tab-group-body-anchor' +import { isFloatingWorkspaceId } from '../../../../../shared/floating-workspace-worktree' import { useBrowserGuestPaintRetention } from '../host-guest/browser-guest-paint-retention' import { isClientHostedBrowserRowSelectionLive, @@ -135,6 +136,7 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ })) ) const focusGroup = useAppStore((state) => state.focusGroup) + const isFloatingWorkspace = isFloatingWorkspaceId(worktreeId) const knownFocusedGroupId = useMemo( () => focusedGroupId !== undefined && groups.some((group) => group.id === focusedGroupId) @@ -180,7 +182,8 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({ const isActive = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) const chromeShortcutScope: BrowserChromeShortcutScope = !isActive ? 'inactive' - : knownFocusedGroupId === undefined + : // Why: 'focused' means the main window's focused split, so a floating browser answers only its own chords. + isFloatingWorkspace || knownFocusedGroupId === undefined ? 'owned-target' : assignment?.groupId === knownFocusedGroupId ? 'focused' diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts index 38a0326840d..fd42af12c7c 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts @@ -8,7 +8,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => ({ settings: {}, - allWorktrees: () => [{ id: 'wt-1', path: '/repo' }], + getKnownWorktreeById: (id: string) => (id === 'wt-1' ? { id, path: '/repo' } : undefined), setActiveTabType: vi.fn(), ensureWorktreeRootGroup: () => 'group-1', openFile: mocks.openFile diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index 2f33e434982..a3ba85c5a35 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -97,7 +97,8 @@ export function navigateBrowserPageToUrl({ } try { - const activeWorktree = store.allWorktrees().find((w) => w.id === worktreeId) + // Why getKnownWorktreeById: folder and floating workspaces are absent from allWorktrees(). + const activeWorktree = store.getKnownWorktreeById(worktreeId) const fileContext: RuntimeFileOperationArgs = { settings: store.settings, worktreeId, diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index e2f7a304e4a..fb78c95df84 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -53,6 +53,8 @@ export type EditorFileSavedDetail = { export type EditorRequestFileCloseDetail = { fileId: string + /** Runs once the file actually closes — after save or discard, never on cancel. */ + onClosed?: () => void } export type EditorRequestCmdSaveDetail = { @@ -212,10 +214,13 @@ export async function requestEditorFileSave(target: EditorSaveFileTarget): Promi }) } -export function requestEditorFileClose(fileId: string): void { +export function requestEditorFileClose( + fileId: string, + options?: Pick +): void { window.dispatchEvent( new CustomEvent(ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT, { - detail: { fileId } + detail: { fileId, ...options } }) ) } diff --git a/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx index 4d55dda3d55..ece6409c8eb 100644 --- a/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx +++ b/src/renderer/src/components/editor/editor-untitled-dont-save-dialogs.test.tsx @@ -30,15 +30,22 @@ vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: vi.fn() })) const FILE_ID = '/repo/untitled.md' function mainWindowDialogController(fileId: string): TerminalEditorCloseDialogActionsInput { + const pendingEditorCloseQueueRef = { current: [fileId] } return { advanceEditorCloseQueue: vi.fn(), + closedReactionsRef: { current: new Map() }, inFlightSaveFileIdRef: { current: null }, isClosingRef: { current: false }, - pendingEditorCloseQueueRef: { current: [fileId] }, + pendingEditorCloseQueueRef, queueEditorCloseRequests: vi.fn(), releaseCloseDialogGuardAfterDebounce: vi.fn(), saveDialogFileId: fileId, setSaveDialogFileId: vi.fn(), + settleQueuedClose: vi.fn((settled: string) => { + pendingEditorCloseQueueRef.current = pendingEditorCloseQueueRef.current.filter( + (queued) => queued !== settled + ) + }), waitForFileClosed: vi.fn(async () => true), windowCloseAfterDirtyRef: { current: null } } diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx deleted file mode 100644 index 638a9b4b12c..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.test.tsx +++ /dev/null @@ -1,109 +0,0 @@ -// @vitest-environment happy-dom - -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { FloatingBrowserSlot } from './FloatingBrowserSlot' -import { getBrowserOverlaySlotViewport } from '@/components/browser-pane/host-guest/browser-page-viewport' -import type { BrowserChromeShortcutScope } from '@/components/browser-pane/BrowserPane' -import type { BrowserTab } from '../../../../shared/browser-workspace-types' - -const paneScopes = vi.hoisted((): (BrowserChromeShortcutScope | undefined)[] => []) - -// Why: BrowserPane mounts a real Electron we can't run in jsdom; stub -// it so the test isolates the slot-root registration that BrowserPane depends on. -vi.mock('@/components/browser-pane/BrowserPane', () => ({ - default: ({ chromeShortcutScope }: { chromeShortcutScope?: BrowserChromeShortcutScope }) => { - paneScopes.push(chromeShortcutScope) - return null - } -})) - -function makeBrowserTab(id: string): BrowserTab { - return { - id, - worktreeId: 'global-floating-terminal', - activePageId: id, - pageIds: [id], - url: 'https://google.com', - title: 'Google', - loading: true, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 0 - } -} - -describe('FloatingBrowserSlot', () => { - let container: HTMLDivElement | null = null - let root: Root | null = null - - afterEach(() => { - act(() => { - root?.unmount() - }) - container?.remove() - root = null - container = null - paneScopes.length = 0 - }) - - it('registers a browser overlay slot viewport keyed by the tab id so BrowserPane can mount its webview', () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - expect(getBrowserOverlaySlotViewport('floating-browser-1')).toBeNull() - - act(() => { - root!.render( - - ) - }) - - // Without this registration ensureBrowserPageViewport returns null, the - // webview is never created, and the page spins on "loading" forever. - expect(getBrowserOverlaySlotViewport('floating-browser-1')).not.toBeNull() - }) - - it('unregisters the slot viewport when the tab unmounts', () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root!.render( - - ) - }) - expect(getBrowserOverlaySlotViewport('floating-browser-2')).not.toBeNull() - - act(() => { - root!.unmount() - }) - root = null - - expect(getBrowserOverlaySlotViewport('floating-browser-2')).toBeNull() - }) - - it('answers chrome chords only from inside its own overlay, never as the focused split', () => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - act(() => { - root!.render() - }) - expect(paneScopes.at(-1)).toBe('owned-target') - expect(container.querySelector('[data-browser-overlay-tab-id="floating-3"]')).not.toBeNull() - - act(() => { - root!.render( - - ) - }) - expect(paneScopes.at(-1)).toBe('inactive') - }) -}) diff --git a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx b/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx deleted file mode 100644 index 85b883d6b86..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingBrowserSlot.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useCallback } from 'react' -import BrowserPane from '@/components/browser-pane/BrowserPane' -import { registerBrowserOverlaySlotViewport } from '@/components/browser-pane/host-guest/browser-page-viewport' -import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' - -// Why: BrowserPane mounts its persistent Electron into a slot viewport -// root keyed by the browser tab id. The main workspace registers that root via -// BrowserPaneOverlayLayer, but the floating panel renders BrowserPane directly -// and has no overlay layer — so without registering a root here, -// ensureBrowserPageViewport returns null, the webview is never created, and the -// page spins on "loading" forever. Mirror BrowserOverlaySlot's slot-root div so -// the guest mounts and survives tab switches (the root must not be reparented). -export function FloatingBrowserSlot({ - browserTab, - isActive -}: { - browserTab: BrowserTabState - isActive: boolean -}): React.JSX.Element { - const setSlotViewportRef = useCallback( - (node: HTMLDivElement | null): void => { - registerBrowserOverlaySlotViewport(browserTab.id, node) - }, - [browserTab.id] - ) - - return ( -
-
- -
- ) -} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx index 64f7a6bc56a..f17ff0a7f63 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.bounds.test.tsx @@ -23,6 +23,11 @@ import { setViewport } from './floating-terminal-panel-render-probe' +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx index 61b17584733..4c249d473f7 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.empty-state.test.tsx @@ -16,6 +16,11 @@ import { type ReactElementLike } from './floating-terminal-panel-render-probe' +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -273,6 +278,33 @@ describe('FloatingTerminalPanel close behavior', () => { ) }) + it('keeps the split surface mounted when its focused group has no tabs', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const state = storeBox.state as FloatingPanelStoreState + state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID].push({ + id: 'empty-group', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + activeTabId: null, + tabOrder: [] + }) + state.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = 'empty-group' + state.layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'floating-group' }, + second: { type: 'leaf', groupId: 'empty-group' } + } + + const element = await renderPanel(true) + + expect(() => findByTypeName(element, 'FloatingTerminalEmptyState')).toThrow( + 'FloatingTerminalEmptyState not found' + ) + expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.focusedGroupId).toBe( + 'empty-group' + ) + }) + it('minimizes the empty floating workspace from the empty state', async () => { const onOpenChange = vi.fn() const element = await renderPanel(true, onOpenChange) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx index c4dd87eecff..cd414c13804 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.focus.test.tsx @@ -1,16 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { consumeFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' +import { + armFloatingPanelReclaimIntent, + consumeFloatingPanelReclaimIntent +} from '@/lib/floating-workspace-focus-reclaim' +import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT } from '@/components/editor/editor-autosave' import { makeFile, makeTab, setFloatingEditorTabs, setFloatingTabs } from './floating-terminal-panel-test-fixtures' -import { - mocks, - saveDialogBox, - setupFloatingTerminalPanelTest -} from './floating-terminal-panel-test-harness' +import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' import { attachRef, findByProp, @@ -19,6 +19,30 @@ import { runEffects } from './floating-terminal-panel-render-probe' +function requestedEditorCloseReaction(): () => void { + const event = vi + .mocked(window.dispatchEvent) + .mock.calls.map(([candidate]) => candidate) + .find((candidate) => candidate.type === ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT) + if (!(event instanceof CustomEvent)) { + throw new Error('the editor close was not requested') + } + const detail: unknown = event.detail + if (!detail || typeof detail !== 'object' || !('onClosed' in detail)) { + throw new Error('the close request has no settlement reaction') + } + const onClosed = detail.onClosed + if (typeof onClosed !== 'function') { + throw new Error('the close reaction is not callable') + } + return () => onClosed() +} + +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -333,9 +357,9 @@ describe('FloatingTerminalPanel close behavior', () => { attachRef(findByProp(element, 'data-floating-terminal-panel').props.ref, panelElement) runEffects() - // The last-pane close authority (L3 → onCloseTab) closes the tab while the panel owns focus. - const terminalPane = findByTypeName(element, 'TerminalPane') - ;(terminalPane.props.onCloseTab as () => void)() + // The last-tab close authority (strip close → confirmed close) runs while the panel owns focus. + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') expect(mocks.closeTerminalTab).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ onClosed: expect.any(Function) }) @@ -370,8 +394,8 @@ describe('FloatingTerminalPanel close behavior', () => { attachRef(findByProp(element, 'data-floating-terminal-panel').props.ref, panelElement) runEffects() - const terminalPane = findByTypeName(element, 'TerminalPane') - ;(terminalPane.props.onCloseTab as () => void)() + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onClose as (tabId: string) => void)('tab-1') // Emptying schedules the reclaim frame (id 42, callback not yet run); unmounting cancels it. setFloatingTabs([]) @@ -445,9 +469,7 @@ describe('FloatingTerminalPanel close behavior', () => { expect(consumeFloatingPanelReclaimIntent()).toBe(false) }) - // A dirty editor's close is deferred to the save dialog, so its arm check must survive the queue - // and fire when the file actually leaves the panel — otherwise this one content type would empty - // the panel with no intent armed and the next Cmd/Ctrl+T would miss the floating panel. + // The central save queue runs the captured reaction only after a dirty file actually closes. it('reclaims panel keyboard focus after a deferred dirty-editor close empties the panel', async () => { setFloatingEditorTabs([makeFile({ id: 'file-a', isDirty: true })]) const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } @@ -466,24 +488,54 @@ describe('FloatingTerminalPanel close behavior', () => { ;(tabBar.props.onClose as (tabId: string) => void)('tab-file-a') // The close is parked on the save dialog: nothing closed yet, so nothing reclaims focus yet. - expect(saveDialogBox.fileId).toBe('file-a') + const onClosed = requestedEditorCloseReaction() expect(mocks.closeFile).not.toHaveBeenCalledWith('file-a') expect(window.requestAnimationFrame).not.toHaveBeenCalled() - // The dialog resolves (save or discard) and the file leaves the panel: the parked arm runs on + // The central queue resolves and the file leaves the panel. Its callback arms reclaim on // the now-empty count and the count→0 effect consumes it. The deferred reclaim frame is the // signal here — the empty panel's own open-focus effect focuses synchronously, without a frame. - saveDialogBox.fileId = null setFloatingEditorTabs([]) + onClosed() const emptyElement = await renderPanel(true) attachRef(findByProp(emptyElement, 'data-floating-terminal-panel').props.ref, panelElement) runEffects() + // The probe reads external-store snapshots on render; React re-renders on the arm notification. + const afterArm = await renderPanel(true) + attachRef(findByProp(afterArm, 'data-floating-terminal-panel').props.ref, panelElement) + runEffects() + expect(window.requestAnimationFrame).toHaveBeenCalled() expect(panelElement.focus).toHaveBeenCalledWith({ preventScroll: true }) }) - it('drops the deferred dirty-editor arm when the save dialog is cancelled', async () => { + it('reclaims focus when intent arms after the empty panel has rendered', async () => { + setFloatingTabs([]) + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const activeElement = { closest: vi.fn().mockReturnValue(panelElement) } + Object.setPrototypeOf(activeElement, HTMLElement.prototype) + vi.stubGlobal('document', { + activeElement, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) + + const emptyElement = await renderPanel(true) + attachRef(findByProp(emptyElement, 'data-floating-terminal-panel').props.ref, panelElement) + runEffects() + vi.mocked(window.requestAnimationFrame).mockClear() + + armFloatingPanelReclaimIntent() + const afterArm = await renderPanel(true) + attachRef(findByProp(afterArm, 'data-floating-terminal-panel').props.ref, panelElement) + runEffects() + + expect(window.requestAnimationFrame).toHaveBeenCalled() + expect(consumeFloatingPanelReclaimIntent()).toBe(false) + }) + + it('does not reclaim after a cancelled dirty-editor close', async () => { setFloatingEditorTabs([makeFile({ id: 'file-a', isDirty: true })]) const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } const activeElement = { closest: vi.fn().mockReturnValue(panelElement) } @@ -499,10 +551,9 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onClose as (tabId: string) => void)('tab-file-a') - ;(findByTypeName(element, 'Dialog').props.onOpenChange as (open: boolean) => void)(false) + requestedEditorCloseReaction() - // Cancel keeps the file open; a later close of that file (from some other path) must not - // resurrect this cancelled close's arm. + // Cancel never invokes onClosed; a later removal from another path must not reclaim focus. setFloatingEditorTabs([]) const emptyElement = await renderPanel(true) attachRef(findByProp(emptyElement, 'data-floating-terminal-panel').props.ref, panelElement) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx index 7f28f85285c..ad65720a025 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.markdown-editor.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { createUntitledMarkdownFileWithTemplateSelection } from '@/lib/create-untitled-markdown' +import { ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT } from '@/components/editor/editor-autosave' import { makeFile, makeTab, @@ -10,17 +11,30 @@ import { import { hookRuntime, mocks, - saveDialogBox, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' import { - findByProp, findByTypeName, flushAsyncWork, renderPanel, runEffects } from './floating-terminal-panel-render-probe' +function requestedEditorCloses(): string[] { + return vi + .mocked(window.dispatchEvent) + .mock.calls.flatMap(([event]) => + event.type === ORCA_EDITOR_REQUEST_FILE_CLOSE_EVENT && event instanceof CustomEvent + ? [event.detail.fileId] + : [] + ) +} + +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -224,23 +238,23 @@ describe('FloatingTerminalPanel close behavior', () => { setFloatingEditorTabs([makeFile({ id: 'notes' })]) const element = await renderPanel(true) - const editorPanel = findByProp(element, 'activeFileId') + // The editor renders inside the shared group tree; the panel's contract is the policy + // it passes down (scratch markdown exposes no agent annotations). + const tree = findByTypeName(element, 'TabGroupSplitNodeTree') - expect(editorPanel.props.markdownAnnotationsEnabled).toBe(false) - expect(editorPanel.props.activeFileId).toBe('notes') - expect(editorPanel.props.isVisible).toBe(true) + expect(tree.props.markdownAnnotationsEnabled).toBe(false) + expect(tree.props.isWorktreeActive).toBe(true) }) it('marks the retained floating editor hidden when the panel is closed', async () => { setFloatingEditorTabs([makeFile({ id: 'notes' })]) const element = await renderPanel(false) - const editorPanel = findByProp(element, 'activeFileId') - expect(editorPanel.props.isVisible).toBe(false) + expect(findByTypeName(element, 'TabGroupSplitNodeTree').props.isWorktreeActive).toBe(false) }) - it('queues dirty editor closes from close-all-files instead of overwriting the dialog id', async () => { + it('routes every dirty editor from close-all-files to the central save queue', async () => { setFloatingEditorTabs([ makeFile({ id: 'file-a', isDirty: true }), makeFile({ id: 'file-b', isDirty: true }) @@ -250,12 +264,12 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onCloseAllFiles as () => void)() - expect(saveDialogBox.fileId).toBe('file-a') + expect(requestedEditorCloses()).toEqual(['file-a', 'file-b']) expect(mocks.closeFile).not.toHaveBeenCalledWith('file-a') expect(mocks.closeFile).not.toHaveBeenCalledWith('file-b') }) - it('queues dirty editor closes from close-others and close-to-right one file at a time', async () => { + it('routes close-others and close-to-right through the central save queue', async () => { setFloatingEditorTabs([ makeFile({ id: 'file-a', isDirty: true }), makeFile({ id: 'file-b', isDirty: true }), @@ -265,15 +279,15 @@ describe('FloatingTerminalPanel close behavior', () => { const element = await renderPanel(true) const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onCloseOthers as (tabId: string) => void)('tab-file-b') - expect(saveDialogBox.fileId).toBe('file-a') + expect(requestedEditorCloses()).toEqual(['file-a', 'file-c']) - saveDialogBox.fileId = null + vi.mocked(window.dispatchEvent).mockClear() mocks.closeFile.mockClear() hookRuntime.values = [] const nextElement = await renderPanel(true) const nextTabBar = findByTypeName(nextElement, 'TabBar') ;(nextTabBar.props.onCloseToRight as (tabId: string) => void)('tab-file-a') - expect(saveDialogBox.fileId).toBe('file-b') + expect(requestedEditorCloses()).toEqual(['file-b', 'file-c']) expect(mocks.closeFile).not.toHaveBeenCalledWith('file-c') }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx index 708699286fb..79412ab9869 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx @@ -27,6 +27,11 @@ import { runEffects } from './floating-terminal-panel-render-probe' +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -204,7 +209,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() }) @@ -301,7 +305,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() }) @@ -549,6 +552,8 @@ describe('FloatingTerminalPanel close behavior', () => { } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: terminalTab.id } + // Real state keeps tabs and layout together; the surface mounts only with both. + state.layoutByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [ terminalUnifiedTab.id, diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx index 1d8624ba435..6a2fb7600c5 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tab-lifecycle.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { Tab } from '../../../../shared/tab-types' import { + attachFloatingBrowserUnifiedTab, makeFile, makeTab, setFloatingSimulatorTab, @@ -9,19 +10,27 @@ import { storeBox, type FloatingPanelStoreState } from './floating-terminal-panel-test-fixtures' +import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' import { - mocks, - parkingBox, - setupFloatingTerminalPanelTest -} from './floating-terminal-panel-test-harness' -import { - findAllByTypeName, + findByProp, findByTypeName, flushAsyncWork, renderPanel, runEffects } from './floating-terminal-panel-render-probe' +const closeWorkspaceBrowserTabMock = vi.hoisted(() => + vi.fn(() => ({ closesLocally: true, localCloseReason: 'user' })) +) +vi.mock('@/lib/workspace-browser-tab-close', () => ({ + closeWorkspaceBrowserTab: closeWorkspaceBrowserTabMock +})) + +vi.mock('zustand/react/shallow', () => ({ + // Why: zustand resolves the real react (unmocked in node_modules); the memo wrapper is inert here. + useShallow: (selector: unknown) => selector +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() const { createReactHookOverrides } = await import('./floating-terminal-panel-test-module-mocks') @@ -164,7 +173,7 @@ describe('FloatingTerminalPanel close behavior', () => { afterEach(() => { vi.unstubAllGlobals() }) - it('creates new floating terminal tabs active in the floating group', async () => { + it('creates new floating terminal tabs with workspace-owned activation', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(true) @@ -177,7 +186,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab') }) @@ -185,6 +193,7 @@ describe('FloatingTerminalPanel close behavior', () => { it('keeps floating browser create and duplicate local during active web runtime sessions', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) ;(storeBox.state as FloatingPanelStoreState).settings.activeRuntimeEnvironmentId = 'runtime-1' + attachFloatingBrowserUnifiedTab('browser-1') ;(storeBox.state as FloatingPanelStoreState).browserTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [ { @@ -241,37 +250,47 @@ describe('FloatingTerminalPanel close behavior', () => { ) }) - it('hides the active terminal pane from the renderer while the panel is closed', async () => { + it('keeps the shared pane overlay mounted but hidden while the panel is closed', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) - // Why: the closed panel stays mounted but CSS-hidden; gating isVisible on - // `open` routes the terminal through the standard hidden-terminal WebGL - // suspend/resume path so no live glyph atlas can corrupt while hidden. + // Why: the closed panel stays mounted but CSS-hidden; passing isVisible=open routes every + // retained pane through the standard hidden-terminal suspend/resume path (pinned on the + // shared TerminalOverlaySlot), so no live glyph atlas can corrupt while hidden. await renderPanel(false) runEffects() await Promise.resolve() const closedElement = await renderPanel(false) - const closedPane = findByTypeName(closedElement, 'TerminalPane') - expect(closedPane.props.isActive).toBe(true) - expect(closedPane.props.isVisible).toBe(false) + const closedLayers = findByTypeName(closedElement, 'WorkspacePaneOverlayLayers') + expect(closedLayers.props.isVisible).toBe(false) const openElement = await renderPanel(true) - const openPane = findByTypeName(openElement, 'TerminalPane') - expect(openPane.props.isVisible).toBe(true) + const openLayers = findByTypeName(openElement, 'WorkspacePaneOverlayLayers') + expect(openLayers.props.isVisible).toBe(true) + // Terminal panes mount against the host-resolved floating cwd once the viewport settles. + expect(openLayers.props.worktreePath).toBe('/tmp/orca') }) - it('does not mount floating terminal panes selected for cold parking', async () => { + it('wires the shared pane overlay with the floating parking and shortcut policy', async () => { setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) - parkingBox.parkedTabIds = new Set(['tab-2']) await renderPanel(true) runEffects() await Promise.resolve() const element = await renderPanel(true) - expect(findAllByTypeName(element, 'TerminalPane').map((pane) => pane.props.tabId)).toEqual([ - 'tab-1' - ]) + // Cold-park selection itself is owned by the shared overlay layer; the floating panel's + // contract is the policy it feeds in. + expect(findByTypeName(element, 'WorkspacePaneOverlayLayers').props).toEqual( + expect.objectContaining({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + shouldColdParkTerminalPanes: false, + isForceParked: false, + shouldMeasureHiddenWorktree: false, + backgroundMountTabIds: null, + activationDeferredMountTabIds: null, + ownsNativeChatToggleShortcut: false + }) + ) }) it('keeps the panel open when the explicit close action removes the last tab', async () => { @@ -309,68 +328,73 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) - it('keeps PTY exit separate from explicit terminal pane close', async () => { - const onOpenChange = vi.fn() - setFloatingTabs([makeTab({ id: 'tab-1' })]) - - await renderPanel(true, onOpenChange) - runEffects() - await Promise.resolve() - const element = await renderPanel(true, onOpenChange) - const terminalPane = findByTypeName(element, 'TerminalPane') - - ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('pty-1') - expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'pty-1') - expect(mocks.closeTerminalTab).toHaveBeenCalledWith('tab-1', { - lifecyclePtyId: 'pty-1', - reason: 'pty-exit' - }) - expect(mocks.closeTab).not.toHaveBeenCalled() - expect(onOpenChange).not.toHaveBeenCalled() - - mocks.closeTerminalTab.mockClear() - ;(terminalPane.props.onCloseTab as () => void)() - // Explicit pane close routes through the confirmed-close authority, not a raw pty-exit prune. - expect(mocks.closeTerminalTab).toHaveBeenCalledWith( - 'tab-1', - expect.objectContaining({ onClosed: expect.any(Function) }) - ) - expect(mocks.closeTab).not.toHaveBeenCalled() - expect(onOpenChange).not.toHaveBeenCalled() - }) - - it('preserves split siblings when a parked PTY exits during reveal', async () => { - setFloatingTabs([makeTab({ id: 'tab-1' })]) - mocks.shouldDeferParkedPtyExitTabClose.mockReturnValueOnce(true) - - await renderPanel(true) - runEffects() - await Promise.resolve() - const element = await renderPanel(true) - const terminalPane = findByTypeName(element, 'TerminalPane') - - ;(terminalPane.props.onPtyExit as (ptyId: string) => void)('split-pty') - - expect(mocks.shouldDeferParkedPtyExitTabClose).toHaveBeenCalledWith('tab-1', 'split-pty') - expect(mocks.closeTerminalTab).not.toHaveBeenCalled() - expect(mocks.closeTab).not.toHaveBeenCalled() - }) - it('renders and closes simulator tabs in the floating workspace', async () => { const tab = setFloatingSimulatorTab() const element = await renderPanel(true) const tabBar = findByTypeName(element, 'TabBar') - const emulatorPane = findByTypeName(element, 'EmulatorPane') ;(tabBar.props.onCloseFile as (tabId: string) => void)(tab.id) expect(tabBar.props.activeTabType).toBe('simulator') expect(tabBar.props.activeSimulatorTabId).toBe(tab.id) - expect(emulatorPane.props.tab).toBe(tab) + // The emulator pane itself renders through the shared overlay stack. + expect(findByTypeName(element, 'WorkspacePaneOverlayLayers').props.mountEmulatorOverlay).toBe( + true + ) expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(tab.id) expect(mocks.closeFile).not.toHaveBeenCalledWith(tab.id) }) + it('closes a structured chat through the shared workspace command', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: beforeEach installs the typed floating panel store fixture. + const state = storeBox.state as FloatingPanelStoreState + const baseTab = setFloatingSimulatorTab() + const tab: Tab = { ...baseTab, contentType: 'agent-session', entityId: 'session-1' } + state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] = [tab] + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + ;(tabBar.props.onCloseFile as (tabId: string) => void)(tab.id) + + expect(mocks.closeUnifiedTab).toHaveBeenCalledWith(tab.id) + expect(mocks.closeFile).not.toHaveBeenCalledWith(tab.entityId) + }) + + it('closes a browser through the shared host-aware command', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + attachFloatingBrowserUnifiedTab('browser-1') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: beforeEach installs the typed floating panel store fixture. + const state = storeBox.state as FloatingPanelStoreState + state.browserTabsByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { + id: 'browser-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + } + + const element = await renderPanel(true) + const tabBar = findByTypeName(element, 'TabBar') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rendered TabBar stub supplies this close callback with a tab id. + ;(tabBar.props.onCloseBrowserTab as (tabId: string) => void)('tab-browser-1') + + expect(closeWorkspaceBrowserTabMock).toHaveBeenCalledExactlyOnceWith( + FLOATING_TERMINAL_WORKTREE_ID, + 'browser-1', + 'tab-browser-1' + ) + expect(mocks.closeBrowserTab).not.toHaveBeenCalled() + }) + it('keeps simulator tabs open when closing all files', async () => { const state = storeBox.state as FloatingPanelStoreState const groupId = 'floating-group' @@ -415,6 +439,9 @@ describe('FloatingTerminalPanel close behavior', () => { ] } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.layoutByWorktree = { + [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } + } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [editorTab.id, simulatorTab.id] } @@ -445,7 +472,6 @@ describe('FloatingTerminalPanel close behavior', () => { 'floating-group', undefined ) - // Why: createTab itself activates the new tab within the floating group. expect(mocks.activateTab).not.toHaveBeenCalled() expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab') @@ -458,27 +484,47 @@ describe('FloatingTerminalPanel close behavior', () => { expect(onOpenChange).not.toHaveBeenCalled() }) - it('reads the current tab list for bulk close actions', async () => { + it('uses the current tab list for bulk close actions after rerender', async () => { setFloatingTabs([makeTab({ id: 'old-left' }), makeTab({ id: 'old-keep' })]) - const element = await renderPanel(true) - const tabBar = findByTypeName(element, 'TabBar') + await renderPanel(true) setFloatingTabs([ makeTab({ id: 'new-left', sortOrder: 0 }), makeTab({ id: 'new-keep', sortOrder: 1 }), makeTab({ id: 'new-right', sortOrder: 2 }) ]) + const refreshed = await renderPanel(true) + const tabBar = findByTypeName(refreshed, 'TabBar') ;(tabBar.props.onCloseOthers as (tabId: string) => void)('new-keep') - expect(mocks.closeTab).toHaveBeenCalledWith('new-left', { reason: 'cleanup' }) - expect(mocks.closeTab).toHaveBeenCalledWith('new-right', { reason: 'cleanup' }) - expect(mocks.closeTab).not.toHaveBeenCalledWith('old-left') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-left', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-right', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).not.toHaveBeenCalledWith('old-left', expect.anything()) - mocks.closeTab.mockClear() - ;(tabBar.props.onCloseToRight as (tabId: string) => void)('new-left') - expect(mocks.closeTab).toHaveBeenCalledWith('new-keep', { reason: 'cleanup' }) - expect(mocks.closeTab).toHaveBeenCalledWith('new-right', { reason: 'cleanup' }) - expect(mocks.closeTab).not.toHaveBeenCalledWith('old-keep') + mocks.closeTerminalTab.mockClear() + setFloatingTabs([ + makeTab({ id: 'new-left', sortOrder: 0 }), + makeTab({ id: 'new-keep', sortOrder: 1 }), + makeTab({ id: 'new-right', sortOrder: 2 }) + ]) + const next = await renderPanel(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the rendered TabBar stub supplies this close callback with a tab id. + ;(findByTypeName(next, 'TabBar').props.onCloseToRight as (tabId: string) => void)('new-left') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-keep', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'new-right', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).not.toHaveBeenCalledWith('old-keep', expect.anything()) }) it('closes tabs to the right using visible tab order', async () => { @@ -498,9 +544,15 @@ describe('FloatingTerminalPanel close behavior', () => { const tabBar = findByTypeName(element, 'TabBar') ;(tabBar.props.onCloseToRight as (tabId: string) => void)('tab-c') - expect(mocks.closeTab).toHaveBeenCalledWith('tab-a', { reason: 'cleanup' }) - expect(mocks.closeTab).toHaveBeenCalledWith('tab-b', { reason: 'cleanup' }) - expect(mocks.closeTab).not.toHaveBeenCalledWith('tab-c') + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'tab-a', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).toHaveBeenCalledWith( + 'tab-b', + expect.objectContaining({ skipRunningProcessConfirm: true }) + ) + expect(mocks.closeTerminalTab).not.toHaveBeenCalledWith('tab-c', expect.anything()) }) }) @@ -511,21 +563,42 @@ describe('FloatingTerminalPanel tab drag wiring', () => { vi.unstubAllGlobals() }) - it('hosts the tab strip in a drag context so floating tabs can be reordered', async () => { + it('hosts the tab strip and body tree in one shared drag scope', async () => { setFloatingTabs([makeTab({ id: 'tab-1' }), makeTab({ id: 'tab-2' })]) const element = await renderPanel(true) - const dragContext = findByTypeName(element, 'FloatingWorkspaceTabDragContext') + const dragLayer = findByTypeName(element, 'WorkspaceTabDragLayer') - expect(dragContext.props.enabled).toBe(true) - expect(findByTypeName(dragContext.props.children, 'TabBar')).toBeDefined() + expect(dragLayer.props.worktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID) + expect(dragLayer.props.enabled).toBe(true) + // The one strip and the group tree live inside the same scope, so a drag can + // reorder in the strip or split against the tree. + expect(findByTypeName(element, 'TabBar')).toBeDefined() + expect(findByTypeName(element, 'TabGroupSplitNodeTree')).toBeDefined() }) - it('leaves the drag context inactive while the closed panel stays mounted', async () => { + it('mounts the tree and pane overlays inside the absolute flex surface frame', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + const element = await renderPanel(true) + const frame = findByProp(element, 'data-floating-workspace-surface-frame') + + // Regression (0px panes): the tree's nodes size themselves as flex items, so their host + // must be a flex container that owns the body rect — TabGroupSplitNodeTree's host + // contract. Without it every group body, and every pane anchored to one, measures 0px + // tall. jsdom computes no layout, so pin the class contract; the e2e spec measures boxes. + expect(frame.props.className).toBe('absolute inset-0 flex') + expect(findByTypeName(frame, 'TabGroupSplitNodeTree')).toBeDefined() + // The overlays share the frame so anchor()/fallback geometry resolves in the same + // containing block as the group bodies they cover — as in WorktreeSplitSurface. + expect(findByTypeName(frame, 'WorkspacePaneOverlayLayers')).toBeDefined() + }) + + it('leaves the drag scope inactive while the closed panel stays mounted', async () => { setFloatingTabs([makeTab({ id: 'tab-1' })]) const element = await renderPanel(false) - expect(findByTypeName(element, 'FloatingWorkspaceTabDragContext').props.enabled).toBe(false) + expect(findByTypeName(element, 'WorkspaceTabDragLayer').props.enabled).toBe(false) }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx index bd2311e7ada..eca08e6d2ca 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanelSurface.tsx @@ -1,27 +1,18 @@ -import { Suspense } from 'react' -import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' -import EmulatorPane from '@/components/emulator-pane/EmulatorPane' import TabBar from '@/components/tab-bar/TabBar' -import TerminalPane from '@/components/terminal-pane/TerminalPane' -import { shouldDeferParkedPtyExitTabClose } from '@/components/terminal-pane/terminal-parked-tab-watchers' -import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { TabGroupSplitNodeTree } from '@/components/tab-group/TabGroupSplitNodeTree' +import { WorkspaceTabDragLayer } from '@/components/tab-group/WorkspaceTabDragLayer' +import { WorkspacePaneOverlayLayers } from '@/components/WorkspacePaneOverlayLayers' import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { isProvenProcessExit } from '../../../../shared/terminal-exit-cause' -import { FloatingBrowserSlot } from './FloatingBrowserSlot' import { FloatingTerminalEmptyState } from './FloatingTerminalEmptyState' import { renderFloatingTerminalOrchestrationCard } from './FloatingTerminalOrchestrationCard' import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog' import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles' -import { renderFloatingTerminalSaveDialog } from './FloatingTerminalSaveDialog' import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls' -import { FloatingWorkspaceTabDragContext } from './FloatingWorkspaceTabDragContext' import type { useFloatingTerminalPanelController } from './use-floating-terminal-panel-controller' -const EditorPanel = lazy(() => import('@/components/editor/EditorPanel')) +const NO_ACTIVITY_TERMINAL_PORTALS: [] = [] export function renderFloatingTerminalPanelSurface({ open, @@ -37,10 +28,16 @@ export function renderFloatingTerminalPanelSurface({ handleDragMove, handleDragEnd, handleTitlebarDoubleClick, + surface, + model, terminalItems, + activeGroup, activeTerminalId, + activeEditorUnifiedId, + activeBrowserId, + activeTab, + activeTabType, expandedPaneByTabId, - activateFloatingItem, closeFloatingItemConfirmed, closeOthers, closeToRight, @@ -52,29 +49,15 @@ export function renderFloatingTerminalPanelSurface({ setTabCustomTitle, setTabColor, setTabPaneExpanded, - editorItems, - browserItems, - activeEditorUnifiedId, - activeBrowserId, - activeTab, - activeTabType, - browserTabs, createBrowserTab, closeAllFiles, makePreviewFilePermanent, pinFile, - tabBarOrder, toggleMaximized, hasVisibleFloatingTabs, managedBrowserCreationEnabled, cwd, panelViewportSettled, - tabs, - parkedTerminalTabIds, - terminalPaneRegistry, - activeBrowserTab, - simulatorItems, - activeEditorFile, focusPanelForShortcuts, newTerminalShortcut, newBrowserShortcut, @@ -86,12 +69,7 @@ export function renderFloatingTerminalPanelSurface({ setOrchestrationDialogOpen, previewUserBounds, orchestrationDialogOpen, - refreshOrchestrationSetupVisibility, - saveDialogFileId, - saveDialogFile, - handleFloatingSaveDialogCancel, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogSave + refreshOrchestrationSetupVisibility }: ReturnType): React.JSX.Element { return ( // Why: sit above the z-40 notification cards so the floating workspace is @@ -134,191 +112,159 @@ export function renderFloatingTerminalPanelSurface({ }} onKeyDownCapture={handleShortcutSurfaceKeyDown} > -
-
- - createFloatingTerminalTab()} - onNewTerminalWithShell={createFloatingTerminalTab} - onNewBrowserTab={createFloatingBrowserTab} - onNewFileTab={createFloatingMarkdownTab} - onOpenFileTab={openFloatingMarkdownTab} - newTabMenuOrder="markdown-first" - onSetCustomTitle={setTabCustomTitle} - onSetTabColor={setTabColor} - onTogglePaneExpand={(tabId) => - setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true) - } - editorFiles={editorItems} - browserTabs={browserItems} - activeFileId={activeEditorUnifiedId} - activeBrowserTabId={activeBrowserId} - activeSimulatorTabId={activeTab?.contentType === 'simulator' ? activeTab.id : null} - activeTabType={activeTabType} - onActivateFile={activateFloatingItem} - onCloseFile={closeFloatingItemConfirmed} - onActivateBrowserTab={activateFloatingItem} - onCloseBrowserTab={closeFloatingItemConfirmed} - onDuplicateBrowserTab={(browserTabId, sourceUnifiedTabId) => { - const source = browserTabs.find((tab) => tab.id === browserTabId) - if (!source) { - return - } - createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, { - ...buildDuplicatedBrowserTabOptions(source), - afterTabId: sourceUnifiedTabId, - browserRuntimeEnvironmentId: null - }) - }} - onCloseAllFiles={closeAllFiles} - onMakePreviewFilePermanent={makePreviewFilePermanent} - onPinFile={pinFile} - tabBarOrder={tabBarOrder} - tabStripChrome="floating-panel" - /> - - onOpenChange(false)} - /> -
- -
- {/* Why also gated on a settled viewport: a restored-maximized panel derives its - rect from the live viewport, so mounting terminals before the window finishes - maximizing fits them to a grid it is about to leave, and the correcting fit - reflows the buffer under a live TUI. */} - {cwd && panelViewportSettled - ? tabs - .filter((tab) => !parkedTerminalTabIds.has(tab.id)) - .map((tab) => { - const isActive = tab.id === activeTerminalId - return ( -
- { - if (exitCode !== undefined && !isProvenProcessExit(exitCode)) { - useAppStore.getState().markUnverifiedPtyLoss(tab.id) - return - } - if (shouldDeferParkedPtyExitTabClose(tab.id, ptyId)) { - return - } - closeTerminalTab(tab.id, { - reason: 'pty-exit', - lifecyclePtyId: ptyId - }) - }} - onCloseTab={() => closeFloatingItemConfirmed(tab.id)} - /> -
- ) - }) - : null} - {browserTabs.map((tab) => { - const isActive = tab.id === activeBrowserTab?.id - return ( -
- -
- ) - })} - {simulatorItems.map((tab) => { - const isActive = tab.id === activeTab?.id - return ( -
- -
- ) - })} - {activeEditorFile ? ( -
- - {translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.d6b563ae24', - 'Loading editor...' - )} -
- } - > - {/* Why: floating workspace markdown is scratch/local context, - not a repo review surface that should expose agent notes. */} - + {({ isTabDragActive, hoveredTabInsertion, setDragRootNode }) => ( +
+
+
+ createFloatingTerminalTab()} + onNewTerminalWithShell={createFloatingTerminalTab} + onNewBrowserTab={createFloatingBrowserTab} + onNewFileTab={createFloatingMarkdownTab} + onOpenFileTab={openFloatingMarkdownTab} + newTabMenuOrder="markdown-first" + onSetCustomTitle={setTabCustomTitle} + onSetTabColor={setTabColor} + onTogglePaneExpand={(tabId) => + setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true) + } + editorFiles={model.editorItems} + browserTabs={model.browserItems} + agentSessionTabs={model.agentSessionItems} + groupActiveTabId={activeTab?.id ?? null} + activeFileId={activeEditorUnifiedId} + activeBrowserTabId={activeBrowserId} + activeSimulatorTabId={ + activeTab?.contentType === 'simulator' ? activeTab.id : null + } + activeTabType={activeTabType} + onActivateFile={model.commands.activateEditor} + onCloseFile={closeFloatingItemConfirmed} + onActivateBrowserTab={model.commands.activateBrowser} + onActivateAgentSession={model.commands.activateAgentSession} + onCloseBrowserTab={closeFloatingItemConfirmed} + onDuplicateBrowserTab={(browserTabId, sourceUnifiedTabId) => { + const source = model.browserItems.find((tab) => tab.id === browserTabId) + if (!source) { + return + } + createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, { + ...buildDuplicatedBrowserTabOptions(source), + afterTabId: sourceUnifiedTabId, + browserRuntimeEnvironmentId: null + }) + }} + onCloseAllFiles={closeAllFiles} + onMakePreviewFilePermanent={makePreviewFilePermanent} + onPinFile={pinFile} + tabBarOrder={model.tabBarOrder} + hoveredTabInsertion={hoveredTabInsertion} + tabStripChrome="floating-panel" /> - +
+ onOpenChange(false)} + />
- ) : null} - {!hasVisibleFloatingTabs ? ( - createFloatingTerminalTab()} - onNewMarkdown={createFloatingMarkdownTab} - onOpenMarkdown={openFloatingMarkdownTab} - onNewBrowser={createFloatingBrowserTab} - showNewBrowser={managedBrowserCreationEnabled} - onClose={() => onOpenChange(false)} - onFocusPanel={focusPanelForShortcuts} - newTerminalShortcut={newTerminalShortcut} - newBrowserShortcut={newBrowserShortcut} - newMarkdownShortcut={newMarkdownShortcut} - openMarkdownShortcut={openMarkdownShortcut} - closeShortcut={closeShortcut} - /> - ) : null} -
-
+ +
+ {/* Why also gated on resolvable items: stale unified tabs whose entities are gone + must show the empty state (with its CTAs), not a blank group body. */} + {surface.kind === 'workspace' && hasVisibleFloatingTabs ? ( + // Why the flex frame: the tree's nodes size themselves as flex items (flex-1), so + // the host must own their rect with a flex container — same contract as + // WorktreeSplitSurface. In a block parent every pane collapses to 0px. +
+ + {/* Why also gated on a settled viewport: a restored-maximized panel derives its + rect from the live viewport, so mounting terminals before the window finishes + maximizing fits them to a grid it is about to leave, and the correcting fit + reflows the buffer under a live TUI. An empty worktreePath mounts no + terminal panes; browser/editor/chat panes are not viewport-fitted. */} + +
+ ) : ( + createFloatingTerminalTab()} + onNewMarkdown={createFloatingMarkdownTab} + onOpenMarkdown={openFloatingMarkdownTab} + onNewBrowser={createFloatingBrowserTab} + showNewBrowser={managedBrowserCreationEnabled} + onClose={() => onOpenChange(false)} + onFocusPanel={focusPanelForShortcuts} + newTerminalShortcut={newTerminalShortcut} + newBrowserShortcut={newBrowserShortcut} + newMarkdownShortcut={newMarkdownShortcut} + openMarkdownShortcut={openMarkdownShortcut} + closeShortcut={closeShortcut} + /> + )} +
+
+ )} + {renderFloatingTerminalOrchestrationCard({ visible: showOrchestrationSetup && activeTabType === 'terminal', onDismiss: dismissOrchestrationSetup, @@ -336,13 +282,6 @@ export function renderFloatingTerminalPanelSurface({ onOpenChange={setOrchestrationDialogOpen} onSetupStateChange={() => void refreshOrchestrationSetupVisibility()} /> - {renderFloatingTerminalSaveDialog({ - saveDialogFileId, - saveDialogFile, - handleFloatingSaveDialogCancel, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogSave - })}
) } diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx deleted file mode 100644 index f285e6ce63f..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalSaveDialog.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { useFloatingTerminalPanelController } from './use-floating-terminal-panel-controller' - -type FloatingTerminalSaveDialogProps = Pick< - ReturnType, - | 'saveDialogFileId' - | 'saveDialogFile' - | 'handleFloatingSaveDialogCancel' - | 'handleFloatingSaveDialogDiscard' - | 'handleFloatingSaveDialogSave' -> - -export function renderFloatingTerminalSaveDialog({ - saveDialogFileId, - saveDialogFile, - handleFloatingSaveDialogCancel, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogSave -}: FloatingTerminalSaveDialogProps): React.JSX.Element { - return ( - { - if (!nextOpen) { - handleFloatingSaveDialogCancel() - } - }} - > - - - - {translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.690b6fb98a', - 'Unsaved Changes' - )} - - - {saveDialogFile - ? translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.5ddc688c52', - '"{{value0}}" has unsaved changes. Do you want to save before closing?', - { value0: saveDialogFile.relativePath.split('/').pop() } - ) - : translate( - 'auto.components.floating.terminal.FloatingTerminalPanel.b085fb58b5', - 'This file has unsaved changes.' - )} - - - - - - - - - - ) -} diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx index da9710b965d..ac4bb5eed80 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.test.tsx @@ -15,7 +15,9 @@ const storeBox = vi.hoisted(() => ({ })) const mocks = vi.hoisted(() => ({ + activateTab: vi.fn(), createTab: vi.fn(), + setActiveTabForWorktree: vi.fn(), setTabBarOrder: vi.fn(), queueTabStartupCommand: vi.fn(), focusTerminalTabSurface: vi.fn(), @@ -149,6 +151,8 @@ beforeEach(() => { agentDefaultEnv: {} }, createTab: mocks.createTab, + activateTab: mocks.activateTab, + setActiveTabForWorktree: mocks.setActiveTabForWorktree, setTabBarOrder: mocks.setTabBarOrder, queueTabStartupCommand: mocks.queueTabStartupCommand, tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [{ id: EXISTING_TAB_ID }] }, @@ -177,7 +181,8 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { requestId: expect.any(String), agent: 'claude', worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - launchSource: 'shortcut' + launchSource: 'shortcut', + activate: false }) // Why: the whole point of the migration. The shared launcher owns the startup plan and the // tab it lands in, so this button must not reach past it into the tab store. @@ -186,9 +191,18 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { expect(mocks.setTabBarOrder).not.toHaveBeenCalled() }) - it('focuses the launched terminal tab', () => { + it('activates the launched terminal tab so the floating panel selects and focuses it', () => { clickLaunch() + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId, which only activateTab writes. setActiveTabForWorktree updates + // the complementary legacy per-worktree map. Without activateTab the new agent + // tab would be appended but never selected/focused. + expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith( + FLOATING_TERMINAL_WORKTREE_ID, + NEW_AGENT_TAB_ID + ) + expect(mocks.activateTab).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith(NEW_AGENT_TAB_ID) }) @@ -198,14 +212,12 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { clickLaunch() expect(toast.error).toHaveBeenCalledWith('Could not build launch command for Claude.') - expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + expect(mocks.activateTab).not.toHaveBeenCalled() }) - // Why: a floating window has nowhere to keep a structured session, so the launch must resolve a - // terminal. Pinned against the shared resolver the launcher routes on, not a restatement here. - it('keeps the floating workspace off the structured route', () => { - // `claude` is a structured-session provider on a local host, so `floating-workspace` is the - // only blocker that can produce this result — any other answer means the kind stopped deciding. + // Why: workspace kind no longer refuses the structured route — the floating session files under + // its resolved directory like any workspace. Only capability negotiation can still say no here. + it('routes the floating workspace by capability, not by workspace kind', () => { expect( resolveStructuredNativeChatSupport({ agent: 'claude', @@ -213,6 +225,6 @@ describe('FloatingTerminalWindowControls default-agent launch', () => { hostCapabilities: null, workspaceKind: 'floating' }) - ).toEqual({ supported: false, blocker: 'floating-workspace' }) + ).toEqual({ supported: false, blocker: 'runtime-capability-unknown' }) }) }) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 28fa197c050..8536d4e5912 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -39,6 +39,8 @@ export function FloatingTerminalWindowControls({ onMinimize }: FloatingTerminalWindowControlsProps): React.JSX.Element { const defaultTuiAgent = useAppStore((s) => s.settings?.defaultTuiAgent ?? null) + const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree) + const activateTab = useAppStore((s) => s.activateTab) const maximizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.maximize') const minimizeShortcutLabel = useOptionalShortcutLabel('floatingWorkspace.minimize') @@ -71,7 +73,11 @@ export function FloatingTerminalWindowControls({ requestId: newAgentLaunchRequestId(), agent: defaultAgent, worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - launchSource: 'shortcut' + launchSource: 'shortcut', + // Why: `agent-auto-ack-targets` relies on the floating panel's active tab never becoming the + // global `activeTabId`; activating here would also flip the main view off an open editor. + // This selects within the floating group below instead. + activate: false }) if (!result) { toast.error( @@ -86,8 +92,14 @@ export function FloatingTerminalWindowControls({ if (result.surface.kind !== 'local-terminal') { return } + // Why: the floating panel renders its visible tab from the unified group's + // activeTabId. setActiveTabForWorktree only writes activeTabIdByWorktree, so + // the new agent tab would be appended but never selected/focused. activateTab + // selects it within the group, matching the empty-state tab creators. + setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, result.surface.tabId) + activateTab(result.surface.tabId) focusTerminalTabSurface(result.surface.tabId) - }, [defaultAgent, defaultAgentLabel]) + }, [activateTab, defaultAgent, defaultAgentLabel, setActiveTabForWorktree]) return (
diff --git a/src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx b/src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx deleted file mode 100644 index b331375d128..00000000000 --- a/src/renderer/src/components/floating-terminal/FloatingWorkspaceTabDragContext.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { cloneElement } from 'react' -import { DndContext, DragOverlay } from '@dnd-kit/core' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import TabDragPreview from '../tab-bar/TabDragPreview' -import { TabDragProvider } from '../tab-group/tab-drag-context' -import { useTabDragSplit, type HoveredTabInsertion } from '../tab-group/useTabDragSplit' - -/** dnd-kit host for the floating workspace tab strip, so its tabs reorder with - * the same gesture and insertion bar the main workspace tab bar uses. The panel - * renders one group and no split layout, so a drop only ever resolves to a - * same-group reorder — the pane-column split branches find no panel geometry. */ -export function FloatingWorkspaceTabDragContext({ - enabled, - children -}: { - /** False while the panel is hidden: a closed panel must not register sensors that compete with the workspace DndContext. */ - enabled: boolean - children: React.ReactElement<{ hoveredTabInsertion?: HoveredTabInsertion | null }> -}): React.JSX.Element { - const dragSplit = useTabDragSplit({ worktreeId: FLOATING_TERMINAL_WORKTREE_ID, enabled }) - - return ( - - -
- {/* Why cloneElement over a render prop: the tab bar stays a plain child element, which keeps the panel's tree shallow-walkable. */} - {cloneElement(children, { hoveredTabInsertion: dragSplit.hoveredTabInsertion })} -
- {/* Why: the source tab stays anchored in the overflow-hidden strip; this ghost is what follows the cursor. */} - - {dragSplit.activeDrag ? : null} - -
-
- ) -} diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts index c6cc9676e81..135e68c5ee7 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-render-probe.ts @@ -20,7 +20,21 @@ function visit(node: unknown, cb: (node: ReactElementLike) => void): void { return } cb(element) - visit(element.props.children, cb) + // Why: WorkspaceTabDragLayer takes a render prop; walk its children with an inert drag state + // so the titlebar strip and body tree stay reachable without invoking dnd-kit. + const children = element.props.children + if (typeof children === 'function') { + visit( + children({ + isTabDragActive: false, + hoveredTabInsertion: null, + setDragRootNode: () => {} + }), + cb + ) + return + } + visit(children, cb) } export function findByTypeName(node: unknown, typeName: string): ReactElementLike { diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts index 9fc535e1bbd..cd061b5ccf0 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts @@ -1,7 +1,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingOverrides, TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { BrowserTab } from '../../../../shared/browser-workspace-types' -import type { Tab, TabGroup } from '../../../../shared/tab-types' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { OpenFile } from '@/store/slices/editor' @@ -14,7 +14,17 @@ export type FloatingPanelStoreState = { openFiles: OpenFile[] activeGroupIdByWorktree: Record activeTabIdByWorktree: Record + layoutByWorktree: Record expandedPaneByTabId: Record + focusGroup: (worktreeId: string, groupId: string) => void + setActiveTabType: (type: string, worktreeId?: string) => void + setActiveFile: (fileId: string, worktreeId?: string) => void + setActiveBrowserTab: (tabId: string, worktreeId?: string) => void + setActiveWorktree: (worktreeId: string | null) => void + createEmptySplitGroup: (worktreeId: string, groupId: string, direction: string) => string | null + openNewBrowserTabInActiveWorkspace: (groupId?: string) => Promise + openNewMarkdownInActiveWorkspace: (groupId?: string) => Promise + openNewTerminalTabInActiveWorkspace: (groupId?: string) => Promise createTab: ( worktreeId: string, groupId?: string, @@ -50,6 +60,8 @@ export type FloatingPanelStoreState = { browserDefaultUrl: string keybindings?: KeybindingOverrides tabBarOrderByWorktree: Record + floatingWorkspacePath: string | null + setFloatingWorkspacePath: (path: string) => void settings: { activeRuntimeEnvironmentId?: string | null floatingTerminalCwd?: string @@ -131,6 +143,32 @@ export function setFloatingTabs(tabs: TerminalTab[]): void { state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) } + state.layoutByWorktree = + tabs.length > 0 ? { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } : {} +} + +/** Appends the unified strip entry a real createBrowserTab always writes beside the entity. */ +export function attachFloatingBrowserUnifiedTab(browserTabId: string): void { + const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const unified = state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + const tab: Tab = { + id: `tab-${browserTabId}`, + entityId: browserTabId, + groupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + contentType: 'browser', + label: browserTabId, + customLabel: null, + color: null, + sortOrder: unified.length, + createdAt: unified.length + } + state.unifiedTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [...unified, tab] } + const group = state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0] + if (group) { + group.tabOrder = [...group.tabOrder, tab.id] + } } export function setFloatingEditorTabs(files: OpenFile[]): void { @@ -162,6 +200,8 @@ export function setFloatingEditorTabs(files: OpenFile[]): void { ] } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } + state.layoutByWorktree = + files.length > 0 ? { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } : {} } export function setFloatingSimulatorTab(): Tab { @@ -193,5 +233,6 @@ export function setFloatingSimulatorTab(): Tab { } state.activeGroupIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: groupId } state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: [tab.id] } + state.layoutByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId } } return tab } diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts index d8bb0848c67..5de72fd51f9 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts @@ -60,6 +60,21 @@ export type FloatingTerminalPanelMocks = { pinFile: Mock setFloatingFocus: Mock<(state: { panelFocused: boolean; terminalFocused: boolean }) => void> setActiveTab: Mock + setActiveTabType: Mock + setActiveFile: Mock + setActiveBrowserTab: Mock + setActiveWorktree: Mock + focusGroup: Mock + createEmptySplitGroup: Mock + openNewBrowserTabInActiveWorkspace: Mock< + FloatingPanelStoreState['openNewBrowserTabInActiveWorkspace'] + > + openNewMarkdownInActiveWorkspace: Mock< + FloatingPanelStoreState['openNewMarkdownInActiveWorkspace'] + > + openNewTerminalTabInActiveWorkspace: Mock< + FloatingPanelStoreState['openNewTerminalTabInActiveWorkspace'] + > setTabColor: Mock setTabCustomTitle: Mock setTabPaneExpanded: Mock @@ -107,6 +122,15 @@ export const mocks: FloatingTerminalPanelMocks = { pinFile: vi.fn(), setFloatingFocus: vi.fn(), setActiveTab: vi.fn(), + setActiveTabType: vi.fn(), + setActiveFile: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn(), + focusGroup: vi.fn(), + createEmptySplitGroup: vi.fn(), + openNewBrowserTabInActiveWorkspace: vi.fn(), + openNewMarkdownInActiveWorkspace: vi.fn(), + openNewTerminalTabInActiveWorkspace: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -123,7 +147,7 @@ export const parkingBox = { } function resetStore(tabs: TerminalTab[] = []): void { - storeBox.state = { + const state: FloatingPanelStoreState = { tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs }, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, @@ -132,7 +156,17 @@ function resetStore(tabs: TerminalTab[] = []): void { openFiles: [], activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, + layoutByWorktree: {}, expandedPaneByTabId: {}, + focusGroup: mocks.focusGroup, + setActiveTabType: mocks.setActiveTabType, + setActiveFile: mocks.setActiveFile, + setActiveBrowserTab: mocks.setActiveBrowserTab, + setActiveWorktree: mocks.setActiveWorktree, + createEmptySplitGroup: mocks.createEmptySplitGroup, + openNewBrowserTabInActiveWorkspace: mocks.openNewBrowserTabInActiveWorkspace, + openNewMarkdownInActiveWorkspace: mocks.openNewMarkdownInActiveWorkspace, + openNewTerminalTabInActiveWorkspace: mocks.openNewTerminalTabInActiveWorkspace, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, clearEditorDraft: mocks.clearEditorDraft, @@ -152,8 +186,14 @@ function resetStore(tabs: TerminalTab[] = []): void { browserDefaultUrl: 'about:blank', keybindings: {}, tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) }, + floatingWorkspacePath: null, + // The real slice stores the host-resolved cwd; mirror the write so panes see it next render. + setFloatingWorkspacePath: (path: string) => { + state.floatingWorkspacePath = path + }, settings: { floatingTerminalCwd: '' } - } satisfies FloatingPanelStoreState + } + storeBox.state = state } // Why: the panel's per-test environment (hook runtime, store, stubbed globals and module diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts index 8ab51749d38..04c37ecfc9d 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-module-mocks.ts @@ -13,6 +13,8 @@ import { storeBox, type FloatingPanelStoreState } from './floating-terminal-pane export function createReactHookOverrides() { return { useCallback: (callback: T) => callback, + // Why: subscription hooks in the shared model read module stores; the probe only needs the snapshot. + useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => T) => getSnapshot(), useEffect: (effect: EffectCallback) => { hookRuntime.effects.push(effect) }, diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts new file mode 100644 index 00000000000..4b6d994b42f --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/tab-types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { resolveFloatingWorkspaceSurfaceModel } from './floating-workspace-surface-model' + +const FLOATING = FLOATING_TERMINAL_WORKTREE_ID + +function makeTab(id: string, groupId: string): Tab { + return { + id, + entityId: `entity-${id}`, + contentType: 'terminal', + worktreeId: FLOATING, + groupId, + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeGroup(id: string, overrides: Partial = {}): TabGroup { + return { id, worktreeId: FLOATING, activeTabId: null, tabOrder: [], ...overrides } +} + +const LEAF: TabGroupLayoutNode = { type: 'leaf', groupId: 'group-1' } + +function makeState(overrides: { + tabs?: Tab[] + groups?: TabGroup[] + layout?: TabGroupLayoutNode + focusedGroupId?: string + terminalTabs?: TerminalTab[] +}): Parameters[0] { + const unifiedTabsByWorktree: Record = {} + const groupsByWorktree: Record = {} + const layoutByWorktree: Record = {} + const activeGroupIdByWorktree: Record = {} + if (overrides.tabs) { + unifiedTabsByWorktree[FLOATING] = overrides.tabs + } + if (overrides.groups) { + groupsByWorktree[FLOATING] = overrides.groups + } + if (overrides.layout) { + layoutByWorktree[FLOATING] = overrides.layout + } + if (overrides.focusedGroupId) { + activeGroupIdByWorktree[FLOATING] = overrides.focusedGroupId + } + const terminalTabs = + overrides.terminalTabs ?? + (overrides.tabs ?? []) + .filter((tab) => tab.contentType === 'terminal') + .map((tab) => ({ + id: tab.entityId, + ptyId: null, + worktreeId: FLOATING, + title: tab.label, + customTitle: null, + color: null, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt + })) + return { + unifiedTabsByWorktree, + groupsByWorktree, + layoutByWorktree, + activeGroupIdByWorktree, + tabsByWorktree: { [FLOATING]: terminalTabs }, + browserTabsByWorktree: {}, + openFiles: [] + } +} + +describe('resolveFloatingWorkspaceSurfaceModel', () => { + it('is empty until the first tab exists', () => { + expect(resolveFloatingWorkspaceSurfaceModel(makeState({}))).toEqual({ kind: 'empty' }) + }) + + it('is empty when tabs exist but no layout does (defensive: creation writes both)', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ tabs: [makeTab('t1', 'group-1')], groups: [makeGroup('group-1')] }) + ) + ).toEqual({ kind: 'empty' }) + }) + + it('mounts the workspace with the stored focused group', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [makeGroup('group-1'), makeGroup('group-2')], + layout: LEAF, + focusedGroupId: 'group-2' + }) + ) + ).toEqual({ kind: 'workspace', layout: LEAF, focusedGroupId: 'group-2' }) + }) + + it('keeps the workspace mounted when the focused split is empty', () => { + const layout: TabGroupLayoutNode = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'group-1' }, + second: { type: 'leaf', groupId: 'group-2' } + } + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [ + makeGroup('group-1', { activeTabId: 't1', tabOrder: ['t1'] }), + makeGroup('group-2') + ], + layout, + focusedGroupId: 'group-2' + }) + ) + ).toEqual({ kind: 'workspace', layout, focusedGroupId: 'group-2' }) + }) + + it('shows the empty state when every unified tab has lost its backing entity', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [makeGroup('group-1', { activeTabId: 't1', tabOrder: ['t1'] })], + layout: LEAF, + terminalTabs: [] + }) + ) + ).toEqual({ kind: 'empty' }) + }) + + it('falls back from a stale focused id to the group with an active tab', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-2')], + groups: [makeGroup('group-1'), makeGroup('group-2', { activeTabId: 't1' })], + layout: LEAF, + focusedGroupId: 'group-gone' + }) + ) + ).toEqual({ kind: 'workspace', layout: LEAF, focusedGroupId: 'group-2' }) + }) + + it('falls back to the first group when none records an active tab', () => { + expect( + resolveFloatingWorkspaceSurfaceModel( + makeState({ + tabs: [makeTab('t1', 'group-1')], + groups: [makeGroup('group-1'), makeGroup('group-2')], + layout: LEAF + }) + ) + ).toEqual({ kind: 'workspace', layout: LEAF, focusedGroupId: 'group-1' }) + }) +}) diff --git a/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.ts b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.ts new file mode 100644 index 00000000000..fc595c09e2e --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-workspace-surface-model.ts @@ -0,0 +1,68 @@ +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { TabGroupLayoutNode } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' + +export type FloatingWorkspaceSurfaceModel = + | { kind: 'empty' } + | { kind: 'workspace'; layout: TabGroupLayoutNode; focusedGroupId: string } + +type FloatingWorkspaceSurfaceState = Pick< + AppState, + | 'unifiedTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'activeGroupIdByWorktree' + | 'tabsByWorktree' + | 'browserTabsByWorktree' + | 'openFiles' +> + +/** + * Decides emptiness and layout for the floating panel body atomically. + * + * An empty floating workspace has no layout at all: `layoutByWorktree` starts empty and + * hydration skips workspaces without tabs — while the shared group tree requires a layout. + * So the panel renders its empty state exactly until the first tab exists; tab creation and + * hydration both write a layout with the tab, which flips this to a mountable workspace. + */ +export function resolveFloatingWorkspaceSurfaceModel( + state: FloatingWorkspaceSurfaceState +): FloatingWorkspaceSurfaceModel { + const tabs = state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + const layout = state.layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + if (!tabs || tabs.length === 0 || !layout) { + return { kind: 'empty' } + } + const terminalIds = new Set( + (state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).map((tab) => tab.id) + ) + const browserIds = new Set( + (state.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).map((tab) => tab.id) + ) + const fileIds = new Set(state.openFiles.map((file) => file.id)) + const hasVisibleTab = tabs.some((tab) => { + if (tab.contentType === 'terminal') { + return terminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return browserIds.has(tab.entityId) + } + if (tab.contentType === 'agent-session' || tab.contentType === 'simulator') { + return true + } + return fileIds.has(tab.entityId) + }) + if (!hasVisibleTab) { + return { kind: 'empty' } + } + const groups = state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [] + const storedFocusId = state.activeGroupIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] + const focusedGroupId = + (storedFocusId && groups.some((group) => group.id === storedFocusId) ? storedFocusId : null) ?? + groups.find((group) => group.activeTabId != null)?.id ?? + groups[0]?.id + if (!focusedGroupId) { + return { kind: 'empty' } + } + return { kind: 'workspace', layout, focusedGroupId } +} diff --git a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts b/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts deleted file mode 100644 index 90b50de4fa1..00000000000 --- a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { createTerminalPaneHandleRegistry } from './terminal-pane-handle-registry' - -type FakeHandle = { closeActivePane: () => void } - -function createHandle(): FakeHandle { - return { closeActivePane: () => {} } -} - -describe('createTerminalPaneHandleRegistry', () => { - it('returns the same ref callback for a tab id across renders', () => { - const registry = createTerminalPaneHandleRegistry() - expect(registry.getRefCallback('tab-1')).toBe(registry.getRefCallback('tab-1')) - expect(registry.getRefCallback('tab-2')).not.toBe(registry.getRefCallback('tab-1')) - }) - - it('exposes the attached handle and clears it on detach', () => { - const registry = createTerminalPaneHandleRegistry() - const handle = createHandle() - const register = registry.getRefCallback('tab-1') - - register(handle) - expect(registry.getHandle('tab-1')).toBe(handle) - - register(null) - expect(registry.getHandle('tab-1')).toBeNull() - }) - - it('keeps callback identity stable through a same-id remount', () => { - const registry = createTerminalPaneHandleRegistry() - const first = registry.getRefCallback('tab-1') - first(createHandle()) - - // Generation bump: React detaches the old element, then attaches the new one. - first(null) - const remounted = registry.getRefCallback('tab-1') - const handle = createHandle() - remounted(handle) - - expect(remounted).toBe(first) - expect(registry.getRefCallback('tab-1')).toBe(first) - expect(registry.getHandle('tab-1')).toBe(handle) - }) - - it('does not re-attach on parent renders that follow a remount', () => { - const registry = createTerminalPaneHandleRegistry() - let attachedCallback: ((handle: FakeHandle | null) => void) | null = null - let handle: FakeHandle | null = null - let attachCount = 0 - - // One render + commit of the pane: React only touches the ref when its identity changed. - const commit = (): void => { - const next = registry.getRefCallback('tab-1') - if (next === attachedCallback) { - return - } - attachedCallback?.(null) - handle = createHandle() - next(handle) - attachedCallback = next - attachCount += 1 - } - - // Generation bump: the render reads the cache first, then the commit swaps the two elements. - const remount = (): void => { - const next = registry.getRefCallback('tab-1') - attachedCallback?.(null) - handle = createHandle() - next(handle) - attachedCallback = next - attachCount += 1 - } - - commit() - remount() - commit() - commit() - commit() - - expect(attachCount).toBe(2) - expect(registry.getHandle('tab-1')).toBe(handle) - }) - - it('re-arms the cache when a pruned tab attaches again', () => { - const registry = createTerminalPaneHandleRegistry() - const register = registry.getRefCallback('tab-1') - - registry.retainOnly([]) - // The pruned callback is still live in React's committed tree until it detaches. - register(createHandle()) - - expect(registry.getRefCallback('tab-1')).toBe(register) - }) - - it('prunes entries for tabs that left the panel', () => { - const registry = createTerminalPaneHandleRegistry() - registry.getRefCallback('tab-1')(createHandle()) - const survivor = registry.getRefCallback('tab-2') - survivor(createHandle()) - - registry.retainOnly(['tab-2']) - - expect(registry.getHandle('tab-1')).toBeNull() - expect(registry.getRefCallback('tab-1')).not.toBe(survivor) - expect(registry.getRefCallback('tab-2')).toBe(survivor) - expect(registry.getHandle('tab-2')).not.toBeNull() - }) -}) diff --git a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.ts b/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.ts deleted file mode 100644 index c0d69db2993..00000000000 --- a/src/renderer/src/components/floating-terminal/terminal-pane-handle-registry.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Per-tab imperative handles for the floating panel's terminal panes, plus the stable ref callback -// that fills them. Generic over the handle so this stays a plain unit-testable module instead of -// pulling in TerminalPane. -export type TerminalPaneHandleRegistry = { - getHandle: (tabId: string) => THandle | null - getRefCallback: (tabId: string) => (handle: THandle | null) => void - retainOnly: (liveTabIds: Iterable) => void -} - -export function createTerminalPaneHandleRegistry(): TerminalPaneHandleRegistry { - const handles = new Map() - const refCallbacks = new Map void>() - - const getRefCallback = (tabId: string): ((handle: THandle | null) => void) => { - const cached = refCallbacks.get(tabId) - if (cached) { - return cached - } - const register = (handle: THandle | null): void => { - if (handle) { - handles.set(tabId, handle) - // Re-arm: a remount (generation bump / StrictMode) can attach through a callback that - // retainOnly already pruned, and the next render must reuse it rather than mint a new one. - refCallbacks.set(tabId, register) - return - } - // Detach drops the handle only. Dropping the callback here would delete the entry the current - // render just wrote, so every later render would mint a fresh identity and force React to - // detach/re-attach the pane — the exact churn this cache exists to prevent. - handles.delete(tabId) - } - refCallbacks.set(tabId, register) - return register - } - - return { - getHandle: (tabId) => handles.get(tabId) ?? null, - getRefCallback, - retainOnly: (liveTabIds) => { - const live = new Set(liveTabIds) - for (const tabId of refCallbacks.keys()) { - if (!live.has(tabId)) { - refCallbacks.delete(tabId) - } - } - for (const tabId of handles.keys()) { - if (!live.has(tabId)) { - handles.delete(tabId) - } - } - } - } -} diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx new file mode 100644 index 00000000000..3907c88c020 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.test.tsx @@ -0,0 +1,145 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' +import { useAppStore } from '@/store' +import { makeOpenFile, makeTabGroup, makeUnifiedTab } from '@/store/slices/store-test-helpers' +import { useFloatingTerminalCloseActions } from './use-floating-terminal-close-actions' + +const requestEditorFileClose = vi.hoisted(() => vi.fn()) +vi.mock('@/components/editor/editor-autosave', () => ({ requestEditorFileClose })) + +const worktreeId = FLOATING_TERMINAL_WORKTREE_ID +const groupId = 'floating-group' +const sharedFileId = 'shared-file' +const editor = makeUnifiedTab({ + id: 'editor-one', + worktreeId, + groupId, + contentType: 'editor', + entityId: sharedFileId +}) +const secondReference = makeUnifiedTab({ + id: 'editor-two', + worktreeId, + groupId: 'other-group', + contentType: 'editor', + entityId: sharedFileId +}) +const chat = makeUnifiedTab({ + id: 'chat-one', + worktreeId, + groupId, + contentType: 'agent-session', + entityId: 'chat-session' +}) +const pinnedEditor = makeUnifiedTab({ + id: 'pinned-editor', + worktreeId, + groupId, + contentType: 'editor', + entityId: 'pinned-file', + isPinned: true +}) +const group = makeTabGroup({ + id: groupId, + worktreeId, + activeTabId: editor.id, + tabOrder: [editor.id, chat.id] +}) + +const closeFile = vi.fn['closeFile']>() +const closeUnifiedTab = vi.fn['closeUnifiedTab']>( + () => null +) +const requestPinnedTabCloseConfirm = vi.fn() + +beforeEach(() => { + vi.clearAllMocks() + useAppStore.setState({ + closeFile, + closeUnifiedTab, + requestPinnedTabCloseConfirm, + unifiedTabsByWorktree: { [worktreeId]: [editor, chat, secondReference] }, + groupsByWorktree: { [worktreeId]: [group] }, + activeGroupIdByWorktree: { [worktreeId]: groupId }, + openFiles: [makeOpenFile({ id: sharedFileId, worktreeId })] + }) +}) + +function actions(groupTabs = [editor, chat]) { + return renderHook(() => useFloatingTerminalCloseActions({ activeGroup: group, groupTabs })).result + .current +} + +function setPinnedConfirmation(confirmClosePinnedTab: boolean) { + const settings = useAppStore.getState().settings ?? getDefaultSettings(process.cwd()) + useAppStore.setState({ settings: { ...settings, confirmClosePinnedTab } }) +} + +describe('floating titlebar close actions', () => { + it('closes one editor reference while keeping its shared open file', () => { + actions().closeFloatingItemConfirmed(editor.id) + + expect(closeFile).not.toHaveBeenCalled() + expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id) + }) + + it('closes editor tabs without closing structured chats from Close All Editor Tabs', () => { + actions().closeAllFiles() + + expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id) + expect(closeUnifiedTab).not.toHaveBeenCalledWith(chat.id) + expect(closeFile).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'retains pinned editors without prompting during Close All Editor Tabs (confirmation %s)', + (confirmClosePinnedTab) => { + setPinnedConfirmation(confirmClosePinnedTab) + useAppStore.setState({ + unifiedTabsByWorktree: { [worktreeId]: [editor, pinnedEditor, chat] }, + openFiles: [ + makeOpenFile({ id: sharedFileId, worktreeId }), + makeOpenFile({ id: 'pinned-file', worktreeId }) + ] + }) + + actions([editor, pinnedEditor, chat]).closeAllFiles() + + expect(closeUnifiedTab).toHaveBeenCalledWith(editor.id) + expect(closeUnifiedTab).not.toHaveBeenCalledWith(pinnedEditor.id) + expect(closeUnifiedTab).not.toHaveBeenCalledWith(chat.id) + expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled() + } + ) + + it('keeps the confirmation policy for an explicit pinned editor close', () => { + setPinnedConfirmation(true) + useAppStore.setState({ + unifiedTabsByWorktree: { [worktreeId]: [pinnedEditor] }, + openFiles: [makeOpenFile({ id: 'pinned-file', worktreeId })] + }) + + actions([pinnedEditor]).closeFloatingItemConfirmed(pinnedEditor.id) + + expect(requestPinnedTabCloseConfirm).toHaveBeenCalledOnce() + expect(closeUnifiedTab).not.toHaveBeenCalled() + requestPinnedTabCloseConfirm.mock.calls[0]?.[0].onConfirm() + expect(closeUnifiedTab).toHaveBeenCalledWith(pinnedEditor.id) + }) + + it('routes a dirty last reference through the shared save confirmation', () => { + useAppStore.setState({ + unifiedTabsByWorktree: { [worktreeId]: [editor] }, + openFiles: [makeOpenFile({ id: sharedFileId, worktreeId, isDirty: true })] + }) + + actions().closeFloatingItemConfirmed(editor.id) + + expect(requestEditorFileClose).toHaveBeenCalledWith(sharedFileId, { + onClosed: expect.any(Function) + }) + expect(closeUnifiedTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts index ef08b9ee581..f3e3abc9149 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-close-actions.ts @@ -1,223 +1,82 @@ import { useCallback } from 'react' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' -import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' -import { - countVisibleFloatingWorkspaceItems, - isFloatingWorkspacePanelFocused -} from '@/lib/floating-workspace-terminal-actions' -import { armFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' -import { useAppStore } from '@/store' -import { destroyWorkspaceWebviews } from '@/store/slices/browser-webview-cleanup' -import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' -import type { Tab } from '../../../../shared/tab-types' +import { useTabGroupCloseScopeCommands } from '@/components/tab-group/useTabGroupCloseScopeCommands' +import { useTabGroupTabCloseCommands } from '@/components/tab-group/useTabGroupTabCloseCommands' +import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { FloatingTerminalEditorCloseQueue } from './use-floating-terminal-editor-close-queue' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' -import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' type FloatingTerminalCloseActionsInput = Pick< - FloatingTerminalPanelStoreState, - 'closeTab' | 'closeBrowserTab' | 'closeFile' | 'closeUnifiedTab' -> & - Pick & - Pick & - Pick + FloatingWorkspaceChromeModel, + 'activeGroup' | 'groupTabs' +> export function useFloatingTerminalCloseActions({ - closeTab, - closeBrowserTab, - closeFile, - closeUnifiedTab, activeGroup, - groupTabs, - pendingReclaimArmByFileIdRef, - queueEditorCloseRequests + groupTabs }: FloatingTerminalCloseActionsInput) { - const closeFloatingItems = useCallback( - (visibleIds: string[]) => { - const state = useAppStore.getState() - const currentGroupTabs = activeGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === activeGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - const items = visibleIds - .map((visibleId) => resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)) - .filter((item): item is Tab => item !== null && !item.isPinned) - if (items.length === 0) { - return - } - const dirtyEditorFileIds: string[] = [] - for (const item of items) { - if (item.contentType === 'terminal') { - closeTab(item.entityId, { reason: 'cleanup' }) - } else if (item.contentType === 'browser') { - destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId) - closeBrowserTab(item.entityId) - } else if (item.contentType === 'simulator') { - closeUnifiedTab(item.id) - } else { - const file = state.openFiles.find((candidate) => candidate.id === item.entityId) - if (file?.isDirty) { - dirtyEditorFileIds.push(item.entityId) - continue - } - closeFile(item.entityId) - } - } - if (dirtyEditorFileIds.length > 0) { - queueEditorCloseRequests(dirtyEditorFileIds) - } - }, - [activeGroup, closeBrowserTab, closeFile, closeTab, closeUnifiedTab, queueEditorCloseRequests] + const worktreeId = FLOATING_TERMINAL_WORKTREE_ID + const { closeItem, closeMany } = useTabGroupTabCloseCommands({ worktreeId }) + const scope = useTabGroupCloseScopeCommands({ + groupId: activeGroup?.id ?? '', + worktreeId, + group: activeGroup, + groupTabs, + closeItem, + closeMany + }) + const resolveItemId = useCallback( + (visibleId: string) => resolveGroupTabFromVisibleId(groupTabs, visibleId)?.id ?? null, + [groupTabs] ) const closeFloatingItemConfirmed = useCallback( (visibleId: string, options?: { guestOwned?: boolean }) => { - const item = resolveGroupTabFromVisibleId(groupTabs, visibleId) - if (!item) { - return + const tabId = resolveItemId(visibleId) + if (tabId) { + dispatchWorkspaceTabCommand({ + type: 'close', + target: { kind: 'tab', worktreeId, tabId }, + floatingPanelGuestOwned: options?.guestOwned === true + }) } - const panelOwnedNow = options?.guestOwned === true || isFloatingWorkspacePanelFocused() - const itemCountBeforeClose = countVisibleFloatingWorkspaceItems(useAppStore.getState()) - const armIfEmptying = (): void => { - if (!panelOwnedNow) { - return - } - const itemCountAfterClose = countVisibleFloatingWorkspaceItems(useAppStore.getState()) - if (itemCountAfterClose === 0 && itemCountAfterClose < itemCountBeforeClose) { - armFloatingPanelReclaimIntent() - } - } - if (item.contentType === 'terminal') { - closeTerminalTab(item.entityId, { onClosed: armIfEmptying }) - return - } - const state = useAppStore.getState() - guardPinnedTabClose({ - isPinned: item.isPinned === true, - tabLabel: resolvePinnedTabLabel(state, FLOATING_TERMINAL_WORKTREE_ID, visibleId), - onClose: () => { - const latest = useAppStore.getState() - if (item.contentType === 'browser') { - destroyWorkspaceWebviews(latest.browserPagesByWorkspace, item.entityId) - closeBrowserTab(item.entityId) - } else if (item.contentType === 'simulator') { - closeUnifiedTab(item.id) - } else { - const file = latest.openFiles.find((candidate) => candidate.id === item.entityId) - if (file?.isDirty) { - pendingReclaimArmByFileIdRef.current.set(item.entityId, armIfEmptying) - queueEditorCloseRequests([item.entityId]) - return - } - closeFile(item.entityId) - } - armIfEmptying() - } - }) }, - [ - closeBrowserTab, - closeFile, - closeUnifiedTab, - groupTabs, - pendingReclaimArmByFileIdRef, - queueEditorCloseRequests - ] + [resolveItemId, worktreeId] ) - const closeOthers = useCallback( (visibleId: string) => { - const state = useAppStore.getState() - const currentGroupTabs = activeGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === activeGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId) - if (!item) { - return + const tabId = resolveItemId(visibleId) + if (tabId) { + scope.closeOthers(tabId) } - closeFloatingItems( - currentGroupTabs.filter((tab) => tab.id !== item.id && !tab.isPinned).map((tab) => tab.id) - ) }, - [activeGroup, closeFloatingItems] + [resolveItemId, scope] ) - - const closeToSide = useCallback( - (visibleId: string, side: 'left' | 'right') => { - const state = useAppStore.getState() - const currentGroup = activeGroup - ? state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find( - (group) => group.id === activeGroup.id - ) - : null - const currentGroupTabs = currentGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === currentGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId) - if (!item || !currentGroup) { - return - } - const index = currentGroup.tabOrder.indexOf(item.id) - if (index === -1) { - return - } - const sideIds = - side === 'right' - ? currentGroup.tabOrder.slice(index + 1) - : currentGroup.tabOrder.slice(0, index) - const tabById = new Map(currentGroupTabs.map((tab) => [tab.id, tab])) - closeFloatingItems( - sideIds.filter((tabId) => { - const tab = tabById.get(tabId) - return tab ? !tab.isPinned : false - }) - ) - }, - [activeGroup, closeFloatingItems] - ) - const closeToRight = useCallback( - (visibleId: string) => closeToSide(visibleId, 'right'), - [closeToSide] + (visibleId: string) => { + const tabId = resolveItemId(visibleId) + if (tabId) { + scope.closeToRight(tabId) + } + }, + [resolveItemId, scope] ) const closeToLeft = useCallback( - (visibleId: string) => closeToSide(visibleId, 'left'), - [closeToSide] + (visibleId: string) => { + const tabId = resolveItemId(visibleId) + if (tabId) { + scope.closeToLeft(tabId) + } + }, + [resolveItemId, scope] ) - const closeAllFiles = useCallback(() => { - const state = useAppStore.getState() - const currentGroupTabs = activeGroup - ? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter( - (tab) => tab.groupId === activeGroup.id - ) - : (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []) - closeFloatingItems( - currentGroupTabs - .filter( - (tab) => - tab.contentType !== 'terminal' && - tab.contentType !== 'browser' && - tab.contentType !== 'simulator' && - !tab.isPinned - ) - .map((tab) => tab.id) - ) - }, [activeGroup, closeFloatingItems]) - return { closeFloatingItemConfirmed, closeOthers, closeToRight, closeToLeft, - closeAllFiles + closeAllFiles: scope.closeAllEditorTabsInGroup } } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts deleted file mode 100644 index 3a1d598d4cb..00000000000 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Real-store coverage: the floating New Terminal ("+" menu and Cmd+T) selects its tab in the floating panel only. - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' -import { createTestStore, makeWorktree, seedStore } from '../../store/slices/store-test-helpers' -import { createStoreCascadesMockApi } from '../../store/slices/store-cascades-test-harness' - -const storeBox = vi.hoisted(() => { - const box: { store: unknown } = { store: null } - return box -}) -const focusTerminalTabSurface = vi.hoisted(() => vi.fn()) - -vi.mock('react', async () => { - const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() - return { ...actual, useCallback: (callback: T) => callback } -}) - -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn(), message: vi.fn() } -})) - -vi.mock('@/store', () => ({ - get useAppStore() { - return storeBox.store - } -})) - -vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface })) - -createStoreCascadesMockApi() - -const MAIN_WORKTREE_ID = 'repo1::/path/wt1' - -describe('floating "+" New Terminal', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('selects the new tab in the floating panel without moving the main window', async () => { - const store = createTestStore() - storeBox.store = store - seedStore(store, { - settings: getDefaultSettings('/tmp'), - worktreesByRepo: { - repo1: [makeWorktree({ id: MAIN_WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] - }, - activeWorktreeId: MAIN_WORKTREE_ID - }) - const mainTerminal = store.getState().createTab(MAIN_WORKTREE_ID) - store.getState().setActiveTabType('editor', MAIN_WORKTREE_ID) - const previousFloatingTab = store.getState().createTab(FLOATING_TERMINAL_WORKTREE_ID) - const { useFloatingTerminalCreateActions } = - await import('./use-floating-terminal-create-actions') - const seeded = store.getState() - const floatingGroup = seeded.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0] - expect(floatingGroup?.activeTabId).toBe(previousFloatingTab.id) - - const { createFloatingTerminalTab } = useFloatingTerminalCreateActions({ - activateTab: seeded.activateTab, - setActiveTab: seeded.setActiveTab, - createBrowserTab: seeded.createBrowserTab, - browserDefaultUrl: seeded.browserDefaultUrl, - openFile: seeded.openFile, - activeGroup: floatingGroup, - groupTabs: seeded.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [], - markdownCwd: null - }) - createFloatingTerminalTab() - - const state = store.getState() - const newTab = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find( - (tab) => tab.id !== previousFloatingTab.id - ) - expect(newTab).toBeDefined() - expect(state.activeTabId).toBe(mainTerminal.id) - expect(state.activeTabType).toBe('editor') - expect(state.activeTabTypeByWorktree[MAIN_WORKTREE_ID]).toBe('editor') - expect(state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.[0]?.activeTabId).toBe(newTab?.id) - // Why: auto-acknowledge reads this map; it must follow the tab the floating panel shows. - expect(state.activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(newTab?.id) - expect(focusTerminalTabSurface).toHaveBeenCalledExactlyOnceWith(newTab?.id) - }) -}) diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts index 65b29800796..9a29a47e95e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-create-actions.ts @@ -7,11 +7,10 @@ import { ensureClientCreationActionAllowed } from '@/lib/client-creation-action- import { openDocumentInFloatingWorkspace } from '@/lib/open-document-in-floating-workspace' import { extractIpcErrorMessage } from '@/lib/ipc-error' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { createFloatingWorkspaceTerminalTab } from '@/lib/floating-workspace-tab-creation' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' @@ -19,14 +18,20 @@ const LOCAL_RUNTIME_SETTINGS = { activeRuntimeEnvironmentId: null } as const type FloatingTerminalCreateActionsInput = Pick< FloatingTerminalPanelStoreState, - 'activateTab' | 'setActiveTab' | 'createBrowserTab' | 'browserDefaultUrl' | 'openFile' + | 'activateTab' + | 'setActiveTab' + | 'createTab' + | 'createBrowserTab' + | 'browserDefaultUrl' + | 'openFile' > & - Pick & + Pick & Pick export function useFloatingTerminalCreateActions({ activateTab, setActiveTab, + createTab, createBrowserTab, browserDefaultUrl, openFile, @@ -58,9 +63,13 @@ export function useFloatingTerminalCreateActions({ [activateTab, groupTabs, setActiveTab] ) - const createFloatingTerminalTab = useCallback((shellOverride?: string) => { - void createFloatingWorkspaceTerminalTab(useAppStore.getState(), shellOverride) - }, []) + const createFloatingTerminalTab = useCallback( + (shellOverride?: string) => { + const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, shellOverride) + focusTerminalTabSurface(tab.id) + }, + [activeGroup, createTab] + ) const createFloatingBrowserTab = useCallback(() => { if (!ensureClientCreationActionAllowed(FLOATING_TERMINAL_WORKTREE_ID, 'managed-browser')) { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts deleted file mode 100644 index 8f82053348f..00000000000 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-editor-close-queue.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { useCallback, useEffect } from 'react' -import { useTerminalSaveDialog } from '@/components/terminal/useTerminalSaveDialog' -import { appendUniqueOpenFileIds } from '@/components/terminal/unsaved-close-queue' -import { useAppStore } from '@/store' -import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' - -type FloatingTerminalEditorCloseQueueInput = Pick< - FloatingTerminalPanelStoreState, - 'floatingFiles' | 'closeFile' -> & - Pick< - FloatingTerminalPanelLocalState, - 'pendingEditorCloseQueueRef' | 'pendingReclaimArmByFileIdRef' | 'saveDialogFileIdRef' - > - -export function useFloatingTerminalEditorCloseQueue({ - floatingFiles, - closeFile, - pendingEditorCloseQueueRef, - pendingReclaimArmByFileIdRef, - saveDialogFileIdRef -}: FloatingTerminalEditorCloseQueueInput) { - const { - saveDialogFileId, - saveDialogFile, - requestCloseFile, - handleSaveDialogSave, - handleSaveDialogDiscard, - handleSaveDialogCancel - } = useTerminalSaveDialog({ openFiles: floatingFiles, closeFile }) - - const getNextQueuedEditorClose = useCallback((): string | null => { - while (pendingEditorCloseQueueRef.current.length > 0) { - const fileId = pendingEditorCloseQueueRef.current[0] - const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === fileId) - if (!file) { - pendingEditorCloseQueueRef.current.shift() - continue - } - if (!file.isDirty) { - closeFile(fileId) - pendingEditorCloseQueueRef.current.shift() - continue - } - return fileId - } - return null - }, [closeFile, pendingEditorCloseQueueRef]) - - const advanceEditorCloseQueue = useCallback(() => { - if (saveDialogFileIdRef.current !== null) { - return - } - const nextFileId = getNextQueuedEditorClose() - if (!nextFileId) { - return - } - saveDialogFileIdRef.current = nextFileId - requestCloseFile(nextFileId) - }, [getNextQueuedEditorClose, requestCloseFile, saveDialogFileIdRef]) - - const queueEditorCloseRequests = useCallback( - (fileIds: string[]) => { - pendingEditorCloseQueueRef.current = appendUniqueOpenFileIds( - pendingEditorCloseQueueRef.current, - fileIds, - new Set(useAppStore.getState().openFiles.map((file) => file.id)) - ) - advanceEditorCloseQueue() - }, - [advanceEditorCloseQueue, pendingEditorCloseQueueRef] - ) - - useEffect(() => { - saveDialogFileIdRef.current = saveDialogFileId - if (saveDialogFileId === null) { - advanceEditorCloseQueue() - } - }, [advanceEditorCloseQueue, saveDialogFileId, saveDialogFileIdRef]) - - const handleFloatingSaveDialogSave = useCallback(() => { - const fileId = saveDialogFileIdRef.current - if (fileId) { - pendingEditorCloseQueueRef.current = pendingEditorCloseQueueRef.current.filter( - (queuedId) => queuedId !== fileId - ) - } - handleSaveDialogSave() - }, [handleSaveDialogSave, pendingEditorCloseQueueRef, saveDialogFileIdRef]) - - const handleFloatingSaveDialogDiscard = useCallback(() => { - const fileId = saveDialogFileIdRef.current - if (fileId) { - pendingEditorCloseQueueRef.current = pendingEditorCloseQueueRef.current.filter( - (queuedId) => queuedId !== fileId - ) - } - void Promise.resolve(handleSaveDialogDiscard()) - }, [handleSaveDialogDiscard, pendingEditorCloseQueueRef, saveDialogFileIdRef]) - - const handleFloatingSaveDialogCancel = useCallback(() => { - pendingEditorCloseQueueRef.current = [] - pendingReclaimArmByFileIdRef.current.clear() - handleSaveDialogCancel() - }, [handleSaveDialogCancel, pendingEditorCloseQueueRef, pendingReclaimArmByFileIdRef]) - - return { - saveDialogFileId, - saveDialogFile, - queueEditorCloseRequests, - handleFloatingSaveDialogSave, - handleFloatingSaveDialogDiscard, - handleFloatingSaveDialogCancel - } -} - -export type FloatingTerminalEditorCloseQueue = ReturnType< - typeof useFloatingTerminalEditorCloseQueue -> diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts index 4487d9fa187..b3091e66d6c 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-focus-lifecycle.ts @@ -3,35 +3,30 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { clearFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { reportFloatingFocus } from './floating-terminal-focus-reporting' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -type FloatingTerminalFocusLifecycleInput = Pick & - Pick< - FloatingTerminalPanelLocalState, - 'pendingReclaimArmByFileIdRef' | 'panelRef' | 'reclaimTerminalInputOnWindowFocusRef' - > & { open: boolean } +type FloatingTerminalFocusLifecycleInput = Pick & + Pick & { + open: boolean + } export function useFloatingTerminalFocusLifecycle({ activeTerminalId, - pendingReclaimArmByFileIdRef, panelRef, reclaimTerminalInputOnWindowFocusRef, open }: FloatingTerminalFocusLifecycleInput): void { useEffect(() => { - const pendingReclaimArms = pendingReclaimArmByFileIdRef.current if (!open) { reportFloatingFocus(null, true) clearFloatingPanelReclaimIntent() - pendingReclaimArms.clear() } return () => { reportFloatingFocus(null, true) clearFloatingPanelReclaimIntent() - pendingReclaimArms.clear() } - }, [open, pendingReclaimArmByFileIdRef]) + }, [open]) useEffect(() => { if (!open || typeof document === 'undefined') { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts index aa50e7fa2f1..9ac3490fc30 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-initial-focus-effects.ts @@ -1,11 +1,11 @@ import { useEffect } from 'react' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { reportFloatingFocus } from './floating-terminal-focus-reporting' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' type FloatingTerminalInitialFocusInput = Pick< - FloatingTerminalPanelItems, + FloatingWorkspaceChromeModel, 'activeTerminalId' | 'hasVisibleFloatingTabs' > & Pick & { open: boolean } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts index fb8bc43051d..19dea616132 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-controller.ts @@ -3,7 +3,6 @@ import { createFloatingTerminalPanelDragActions } from './floating-terminal-pane import type { FloatingTerminalPanelProps } from './floating-terminal-panel-types' import { useFloatingTerminalCloseActions } from './use-floating-terminal-close-actions' import { useFloatingTerminalCreateActions } from './use-floating-terminal-create-actions' -import { useFloatingTerminalEditorCloseQueue } from './use-floating-terminal-editor-close-queue' import { useFloatingTerminalFocusLifecycle } from './use-floating-terminal-focus-lifecycle' import { useFloatingTerminalGlobalShortcutListeners } from './use-floating-terminal-global-shortcut-listeners' import { useFloatingTerminalGuestBridge } from './use-floating-terminal-guest-bridge' @@ -12,7 +11,7 @@ import { useFloatingTerminalOrchestrationDismissal } from './use-floating-termin import { useFloatingTerminalOrchestrationVisibility } from './use-floating-terminal-orchestration-visibility' import { useFloatingTerminalPanelFocusReclaim } from './use-floating-terminal-panel-focus-reclaim' import { useFloatingTerminalPanelGeometry } from './use-floating-terminal-panel-geometry' -import { useFloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import { useFloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import { useFloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import { useFloatingTerminalPanelMaximize } from './use-floating-terminal-panel-maximize' import { useFloatingTerminalPanelShortcuts } from './use-floating-terminal-panel-shortcuts' @@ -27,7 +26,7 @@ export function useFloatingTerminalPanelController({ const storeState = useFloatingTerminalPanelStoreState() const shortcutDetails = useFloatingTerminalShortcutDetails() const localState = useFloatingTerminalPanelLocalState() - const items = useFloatingTerminalPanelItems({ ...storeState, open }) + const items = useFloatingWorkspaceChromeModel() useContextualTour('floating-workspace', open, 'floating_workspace_visible', { recordFeatureInteraction: tourInteractionSnapshot?.recordFeatureInteractionForTour ?? false, @@ -35,7 +34,6 @@ export function useFloatingTerminalPanelController({ wasFeaturePreviouslyInteracted: tourInteractionSnapshot?.wasPreviouslyInteracted }) - const editorCloseQueue = useFloatingTerminalEditorCloseQueue({ ...storeState, ...localState }) const geometry = useFloatingTerminalPanelGeometry({ ...storeState, ...localState }) useFloatingTerminalInitialFocusEffects({ ...items, ...localState, open }) const orchestrationVisibility = useFloatingTerminalOrchestrationVisibility({ @@ -48,10 +46,7 @@ export function useFloatingTerminalPanelController({ ...items }) const closeActions = useFloatingTerminalCloseActions({ - ...storeState, - ...localState, - ...items, - ...editorCloseQueue + ...items }) const focusReclaim = useFloatingTerminalPanelFocusReclaim({ ...storeState, @@ -86,7 +81,6 @@ export function useFloatingTerminalPanelController({ ...shortcutDetails, ...localState, ...items, - ...editorCloseQueue, ...geometry, ...orchestrationVisibility, ...createActions, diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts index 7a677794ac7..4680223b512 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-focus-reclaim.ts @@ -1,28 +1,31 @@ -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useSyncExternalStore } from 'react' import { clearFloatingPanelReclaimIntent, - consumeFloatingPanelReclaimIntent + consumeFloatingPanelReclaimIntent, + isFloatingPanelReclaimIntentArmed, + subscribeFloatingPanelReclaimIntent } from '@/lib/floating-workspace-focus-reclaim' import { reportFloatingFocus } from './floating-terminal-focus-reporting' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' type FloatingTerminalPanelFocusReclaimInput = Pick< FloatingTerminalPanelLocalState, - 'panelRef' | 'shortcutFocusFrameRef' | 'shortcutFocusTimeoutRef' | 'pendingReclaimArmByFileIdRef' + 'panelRef' | 'shortcutFocusFrameRef' | 'shortcutFocusTimeoutRef' > & - Pick & - Pick + Pick export function useFloatingTerminalPanelFocusReclaim({ panelRef, shortcutFocusFrameRef, shortcutFocusTimeoutRef, - pendingReclaimArmByFileIdRef, - visibleFloatingItemCount, - floatingFiles + hasVisibleFloatingTabs }: FloatingTerminalPanelFocusReclaimInput) { + const reclaimIntentArmed = useSyncExternalStore( + subscribeFloatingPanelReclaimIntent, + isFloatingPanelReclaimIntentArmed, + () => false + ) const focusPanelForShortcuts = useCallback( (preserveExistingPanelFocus = true) => { const active = document.activeElement @@ -86,27 +89,14 @@ export function useFloatingTerminalPanelFocusReclaim({ }, []) useEffect(() => { - const pending = pendingReclaimArmByFileIdRef.current - if (pending.size === 0) { - return - } - for (const [fileId, armIfEmptying] of pending) { - if (!floatingFiles.some((file) => file.id === fileId)) { - pending.delete(fileId) - armIfEmptying() - } - } - }, [floatingFiles, pendingReclaimArmByFileIdRef]) - - useEffect(() => { - if (visibleFloatingItemCount > 0) { + if (hasVisibleFloatingTabs) { clearFloatingPanelReclaimIntent() return } - if (consumeFloatingPanelReclaimIntent()) { + if (reclaimIntentArmed && consumeFloatingPanelReclaimIntent()) { focusPanelForShortcutsAfterClose() } - }, [focusPanelForShortcutsAfterClose, visibleFloatingItemCount]) + }, [focusPanelForShortcutsAfterClose, hasVisibleFloatingTabs, reclaimIntentArmed]) return { focusPanelForShortcuts, setPanelNode, reportFloatingFocusFromTarget } } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts index b24890503ca..d72077e5c7e 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-geometry.ts @@ -21,10 +21,9 @@ type FloatingTerminalPanelGeometryInput = Pick< | 'maximized' | 'stagedBoundsRef' | 'lastPersistedBoundsRef' - | 'setCwd' | 'setMarkdownCwd' > & - Pick + Pick export function useFloatingTerminalPanelGeometry({ boundsSourceRef, @@ -33,7 +32,7 @@ export function useFloatingTerminalPanelGeometry({ maximized, stagedBoundsRef, lastPersistedBoundsRef, - setCwd, + setFloatingWorkspacePath, setMarkdownCwd, floatingTerminalCwd }: FloatingTerminalPanelGeometryInput) { @@ -106,13 +105,15 @@ export function useFloatingTerminalPanelGeometry({ let cancelled = false void window.api.app.getFloatingTerminalCwd({ path: floatingTerminalCwd }).then((nextCwd) => { if (!cancelled) { - setCwd(nextCwd) + // Why the store: every renderer consumer that must answer "where does the floating + // workspace live" (workspace row minting included) reads this one resolved copy. + setFloatingWorkspacePath(nextCwd) } }) return () => { cancelled = true } - }, [floatingTerminalCwd, setCwd]) + }, [floatingTerminalCwd, setFloatingWorkspacePath]) useEffect(() => { let cancelled = false diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts deleted file mode 100644 index 392317f2d37..00000000000 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-items.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { useMemo } from 'react' -import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' -import { useTerminalTabColdParking } from '@/components/terminal-pane/use-terminal-tab-cold-parking' -import type { OpenFile } from '@/store/slices/editor' -import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types' -import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' - -const NO_ACTIVITY_TERMINAL_PORTALS = [] - -type FloatingTerminalPanelItemsInput = Pick< - FloatingTerminalPanelStoreState, - 'tabs' | 'browserTabs' | 'groups' | 'unifiedTabs' | 'floatingFiles' | 'generatedTabTitlesEnabled' -> & { open: boolean } - -export function useFloatingTerminalPanelItems({ - tabs, - browserTabs, - groups, - unifiedTabs, - floatingFiles, - generatedTabTitlesEnabled, - open -}: FloatingTerminalPanelItemsInput) { - const activeGroup = useMemo( - () => - groups.find((group) => group.activeTabId != null) ?? - (unifiedTabs[0] - ? (groups.find((group) => group.id === unifiedTabs[0].groupId) ?? null) - : null), - [groups, unifiedTabs] - ) - const groupTabs = useMemo( - () => (activeGroup ? unifiedTabs.filter((tab) => tab.groupId === activeGroup.id) : unifiedTabs), - [activeGroup, unifiedTabs] - ) - const activeTab = useMemo( - () => - (activeGroup?.activeTabId - ? groupTabs.find((tab) => tab.id === activeGroup.activeTabId) - : null) ?? - groupTabs[0] ?? - null, - [activeGroup, groupTabs] - ) - const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null - const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null - const activeEditorUnifiedId = - activeTab && - activeTab.contentType !== 'terminal' && - activeTab.contentType !== 'browser' && - activeTab.contentType !== 'simulator' - ? activeTab.id - : null - const activeEditorFileId = - activeTab && - activeTab.contentType !== 'terminal' && - activeTab.contentType !== 'browser' && - activeTab.contentType !== 'simulator' - ? activeTab.entityId - : null - const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs]) - const terminalAssignments = useMemo(() => { - const assignments = new Map() - for (const tab of unifiedTabs) { - if (tab.contentType === 'terminal') { - assignments.set(tab.entityId, { - groupId: tab.groupId, - isActiveInGroup: tab.entityId === activeTerminalId - }) - } - } - return assignments - }, [activeTerminalId, unifiedTabs]) - const parkedTerminalTabIds = useTerminalTabColdParking({ - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - terminalTabs: tabs, - assignments: terminalAssignments, - activeTerminalTabId: activeTerminalId, - isWorktreeActive: open, - coldParkTerminalPanes: false, - shouldMeasureHiddenWorktree: false, - activityTerminalPortals: NO_ACTIVITY_TERMINAL_PORTALS - }) - const terminalItems = useMemo<(TerminalTab & { unifiedTabId: string })[]>( - () => - groupTabs - .filter((tab) => tab.contentType === 'terminal') - .flatMap((tab): (TerminalTab & { unifiedTabId: string })[] => { - const terminalTab = terminalTabById.get(tab.entityId) - if (!terminalTab) { - return [] - } - return [ - { - ...terminalTab, - unifiedTabId: tab.id, - title: resolveUnifiedTabLabel( - { - ...tab, - quickCommandLabel: tab.quickCommandLabel ?? terminalTab.quickCommandLabel, - generatedLabel: tab.generatedLabel ?? terminalTab.generatedTitle - }, - generatedTabTitlesEnabled, - tab.label - ), - generatedTitle: terminalTab.generatedTitle ?? tab.generatedLabel ?? null, - quickCommandLabel: terminalTab.quickCommandLabel ?? tab.quickCommandLabel ?? null, - customTitle: tab.customLabel ?? terminalTab.customTitle, - color: tab.color ?? terminalTab.color - } - ] - }), - [generatedTabTitlesEnabled, groupTabs, terminalTabById] - ) - const browserItems = useMemo( - () => - groupTabs - .filter((tab) => tab.contentType === 'browser') - .map((tab) => { - const browserTab = browserTabs.find((candidate) => candidate.id === tab.entityId) - return browserTab ? { ...browserTab, tabId: tab.id } : null - }) - .filter((tab): tab is BrowserTabState & { tabId: string } => tab !== null), - [browserTabs, groupTabs] - ) - const editorItems = useMemo( - () => - groupTabs - .filter( - (tab) => - tab.contentType !== 'terminal' && - tab.contentType !== 'browser' && - tab.contentType !== 'simulator' - ) - .map((tab) => { - const file = floatingFiles.find((candidate) => candidate.id === tab.entityId) - return file ? { ...file, tabId: tab.id } : null - }) - .filter((file): file is OpenFile & { tabId: string } => file !== null), - [floatingFiles, groupTabs] - ) - const simulatorItems = useMemo( - () => groupTabs.filter((tab) => tab.contentType === 'simulator'), - [groupTabs] - ) - const hasVisibleFloatingTabs = - terminalItems.length > 0 || - browserItems.length > 0 || - editorItems.length > 0 || - simulatorItems.length > 0 - const visibleFloatingItemCount = - terminalItems.length + browserItems.length + editorItems.length + simulatorItems.length - const activeClosableTab = hasVisibleFloatingTabs ? activeTab : null - const tabBarOrder = useMemo( - () => - (activeGroup?.tabOrder ?? []).map((tabId) => { - const tab = groupTabs.find((candidate) => candidate.id === tabId) - return tab?.contentType === 'terminal' || tab?.contentType === 'browser' - ? tab.entityId - : tabId - }), - [activeGroup, groupTabs] - ) - const visibleFloatingTabOrder = useMemo( - () => - tabBarOrder.filter((visibleId) => { - const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) - if (!tab) { - return false - } - if (tab.contentType === 'terminal') { - return terminalItems.some((item) => item.unifiedTabId === tab.id) - } - if (tab.contentType === 'browser') { - return browserItems.some((item) => item.tabId === tab.id) - } - if (tab.contentType === 'simulator') { - return simulatorItems.some((item) => item.id === tab.id) - } - return editorItems.some((item) => item.tabId === tab.id) - }), - [browserItems, editorItems, groupTabs, simulatorItems, tabBarOrder, terminalItems] - ) - const activeBrowserTab = activeBrowserId - ? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null) - : null - const activeEditorFile = activeEditorFileId - ? (floatingFiles.find((file) => file.id === activeEditorFileId) ?? null) - : null - const activeTabType: 'browser' | 'terminal' | 'simulator' | 'editor' = - activeTab?.contentType === 'browser' - ? 'browser' - : activeTab?.contentType === 'terminal' - ? 'terminal' - : activeTab?.contentType === 'simulator' - ? 'simulator' - : 'editor' - - return { - activeGroup, - groupTabs, - activeTab, - activeTerminalId, - activeBrowserId, - activeEditorUnifiedId, - parkedTerminalTabIds, - terminalItems, - browserItems, - editorItems, - simulatorItems, - hasVisibleFloatingTabs, - visibleFloatingItemCount, - activeClosableTab, - tabBarOrder, - visibleFloatingTabOrder, - activeBrowserTab, - activeEditorFile, - activeTabType - } -} - -export type FloatingTerminalPanelItems = ReturnType diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts index f910b6432f2..237d4db1b77 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-local-state.ts @@ -1,5 +1,4 @@ import { useRef, useState } from 'react' -import type { TerminalPaneHandle } from '@/components/terminal-pane/TerminalPane' import { useMountedRef } from '@/hooks/useMountedRef' import { ModifierDoubleTapDetector } from '../../../../shared/modifier-double-tap-detector' import type { @@ -12,7 +11,6 @@ import { hasOrchestrationSetupMarker, isOrchestrationSetupDismissed } from '@/lib/orchestration-setup-state' -import { createTerminalPaneHandleRegistry } from './terminal-pane-handle-registry' import { readInitialPanelBounds, type FloatingTerminalPanelBoundsState @@ -20,7 +18,6 @@ import { import { useSettledPanelViewport } from './use-settled-panel-viewport' export function useFloatingTerminalPanelLocalState() { - const [cwd, setCwd] = useState(null) const [markdownCwd, setMarkdownCwd] = useState(null) const initialBoundsStateRef = useRef(null) if (initialBoundsStateRef.current === null) { @@ -45,13 +42,7 @@ export function useFloatingTerminalPanelLocalState() { const lastPersistedBoundsRef = useRef( initialBoundsState.source === 'user' ? initialBoundsState.committedBounds : null ) - const pendingEditorCloseQueueRef = useRef([]) - const pendingReclaimArmByFileIdRef = useRef void>>(new Map()) - const saveDialogFileIdRef = useRef(null) const panelRef = useRef(null) - const [terminalPaneRegistry] = useState(() => - createTerminalPaneHandleRegistry() - ) const doubleTapDetectorRef = useRef(null) if (!doubleTapDetectorRef.current) { // The detector must exist before event handlers are published. @@ -74,8 +65,6 @@ export function useFloatingTerminalPanelLocalState() { } | null>(null) return { - cwd, - setCwd, markdownCwd, setMarkdownCwd, boundsSourceRef, @@ -92,11 +81,7 @@ export function useFloatingTerminalPanelLocalState() { restoreBoundsRef, stagedBoundsRef, lastPersistedBoundsRef, - pendingEditorCloseQueueRef, - pendingReclaimArmByFileIdRef, - saveDialogFileIdRef, panelRef, - terminalPaneRegistry, doubleTapDetectorRef, shortcutFocusFrameRef, shortcutFocusTimeoutRef, diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts index 58463b46fb3..4349b3ced50 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-maximize.ts @@ -8,7 +8,6 @@ import { } from './floating-terminal-panel-bounds' import { persistFloatingTerminalPanelMaximized } from './floating-terminal-panel-view-state' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' -import type { FloatingTerminalPanelStoreState } from './use-floating-terminal-panel-store-state' type FloatingTerminalPanelMaximizeInput = Pick< FloatingTerminalPanelLocalState, @@ -20,9 +19,7 @@ type FloatingTerminalPanelMaximizeInput = Pick< | 'committedBoundsRef' | 'boundsSourceRef' | 'stagedBoundsRef' - | 'terminalPaneRegistry' -> & - Pick & { open: boolean } +> & { open: boolean } export function useFloatingTerminalPanelMaximize({ bounds, @@ -33,8 +30,6 @@ export function useFloatingTerminalPanelMaximize({ committedBoundsRef, boundsSourceRef, stagedBoundsRef, - terminalPaneRegistry, - tabs, open }: FloatingTerminalPanelMaximizeInput) { const toggleMaximized = useCallback(() => { @@ -106,10 +101,6 @@ export function useFloatingTerminalPanelMaximize({ } }, [open, maximizePanel]) - useEffect(() => { - terminalPaneRegistry.retainOnly(tabs.map((tab) => tab.id)) - }, [tabs, terminalPaneRegistry]) - return { toggleMaximized } } diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index ee1979451cf..52babee1750 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -18,17 +18,17 @@ import type { } from './floating-terminal-panel-types' import type { FloatingTerminalCloseActions } from './use-floating-terminal-close-actions' import type { FloatingTerminalCreateActions } from './use-floating-terminal-create-actions' -import type { FloatingTerminalPanelItems } from './use-floating-terminal-panel-items' +import type { FloatingWorkspaceChromeModel } from './use-floating-workspace-chrome-model' import type { FloatingTerminalPanelLocalState } from './use-floating-terminal-panel-local-state' import type { FloatingTerminalPanelMaximize } from './use-floating-terminal-panel-maximize' const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]' type FloatingTerminalPanelShortcutsInput = Pick< - FloatingTerminalPanelItems, - 'activeTab' | 'activeTerminalId' | 'activeClosableTab' | 'visibleFloatingTabOrder' + FloatingWorkspaceChromeModel, + 'activeTab' | 'activeClosableTab' | 'visibleFloatingTabOrder' > & - Pick & + Pick & Pick & FloatingTerminalCreateActions & FloatingTerminalPanelMaximize & { @@ -38,10 +38,8 @@ type FloatingTerminalPanelShortcutsInput = Pick< export function useFloatingTerminalPanelShortcuts({ activeTab, - activeTerminalId, activeClosableTab, visibleFloatingTabOrder, - terminalPaneRegistry, panelRef, closeFloatingItemConfirmed, activateFloatingItem, @@ -53,16 +51,14 @@ export function useFloatingTerminalPanelShortcuts({ open, onOpenChange }: FloatingTerminalPanelShortcutsInput) { + // Why the tab (not a pane): with an xterm focused the capture policy defers to the terminal's + // own dispatch, which closes the active pane; outside terminal focus the close targets the tab, + // matching the main workspace's strip-close semantics. const closeActiveFloatingTerminalPane = useCallback(() => { - const handle = activeTerminalId ? terminalPaneRegistry.getHandle(activeTerminalId) : null - if (handle) { - handle.closeActivePane() - return - } if (activeClosableTab) { closeFloatingItemConfirmed(activeClosableTab.id) } - }, [activeClosableTab, activeTerminalId, closeFloatingItemConfirmed, terminalPaneRegistry]) + }, [activeClosableTab, closeFloatingItemConfirmed]) const resolveFloatingPanelShortcut = useCallback( (input: FloatingPanelShortcutInput): FloatingPanelShortcutResolution | null => { diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts index 8dda34ef129..a167b0414fb 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-store-state.ts @@ -6,9 +6,9 @@ import { selectFloatingTerminalPanelInputs } from './floating-terminal-panel-inp export function useFloatingTerminalPanelStoreState() { const { tabs, browserTabs, groups, unifiedTabs, floatingFiles, expandedPaneByTabId } = useAppStore(selectFloatingTerminalPanelInputs) + const createTab = useAppStore((state) => state.createTab) const createBrowserTab = useAppStore((state) => state.createBrowserTab) const closeTab = useAppStore((state) => state.closeTab) - const closeBrowserTab = useAppStore((state) => state.closeBrowserTab) const closeFile = useAppStore((state) => state.closeFile) const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab) const activateTab = useAppStore((state) => state.activateTab) @@ -21,6 +21,9 @@ export function useFloatingTerminalPanelStoreState() { const openFile = useAppStore((state) => state.openFile) const browserDefaultUrl = useAppStore((state) => state.browserDefaultUrl) const floatingTerminalCwd = useAppStore((state) => state.settings?.floatingTerminalCwd ?? '') + // The host-resolved floating directory; the geometry hook keeps it fresh. + const cwd = useAppStore((state) => state.floatingWorkspacePath) + const setFloatingWorkspacePath = useAppStore((state) => state.setFloatingWorkspacePath) const generatedTabTitlesEnabled = useAppStore( (state) => state.settings?.tabAutoGenerateTitle === true ) @@ -37,9 +40,9 @@ export function useFloatingTerminalPanelStoreState() { unifiedTabs, floatingFiles, expandedPaneByTabId, + createTab, createBrowserTab, closeTab, - closeBrowserTab, closeFile, closeUnifiedTab, activateTab, @@ -52,6 +55,8 @@ export function useFloatingTerminalPanelStoreState() { openFile, browserDefaultUrl, floatingTerminalCwd, + cwd, + setFloatingWorkspacePath, generatedTabTitlesEnabled, managedBrowserCreationEnabled } diff --git a/src/renderer/src/components/floating-terminal/use-floating-workspace-chrome-model.ts b/src/renderer/src/components/floating-terminal/use-floating-workspace-chrome-model.ts new file mode 100644 index 00000000000..935d8796cb7 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/use-floating-workspace-chrome-model.ts @@ -0,0 +1,116 @@ +import { useMemo } from 'react' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '@/store' +import { useTabGroupWorkspaceModel } from '@/components/tab-group/useTabGroupWorkspaceModel' +import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { resolveFloatingWorkspaceSurfaceModel } from './floating-workspace-surface-model' + +const EMPTY_TERMINAL_TABS: readonly TerminalTab[] = [] + +/** + * Shell chrome model for the floating panel: the shared per-group workspace model of the + * FOCUSED floating group, plus the derived fields the panel's titlebar strip, shortcuts, + * close policy and focus lifecycle read. The panel body renders every group through the + * shared tree; only the shell's one titlebar strip needs a focused-group projection. + */ +export function useFloatingWorkspaceChromeModel() { + const surface = useAppStore(useShallow(resolveFloatingWorkspaceSurfaceModel)) + const focusedGroupId = surface.kind === 'workspace' ? surface.focusedGroupId : '' + const model = useTabGroupWorkspaceModel({ + groupId: focusedGroupId, + worktreeId: FLOATING_TERMINAL_WORKTREE_ID + }) + const { activeTab, group, groupTabs } = model + // Why filtered: the strip and the emptiness decision only count tabs whose terminal entity + // still exists — a stale unified entry must fall back to the empty state, not a blank pane. + const terminalEntities = useAppStore( + (state) => state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS + ) + const terminalItems = useMemo(() => { + const entityIds = new Set(terminalEntities.map((tab) => tab.id)) + return model.terminalTabs.filter((item) => entityIds.has(item.id)) + }, [model.terminalTabs, terminalEntities]) + + const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null + const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null + const activeEditorUnifiedId = + activeTab && + activeTab.contentType !== 'terminal' && + activeTab.contentType !== 'browser' && + activeTab.contentType !== 'simulator' && + activeTab.contentType !== 'agent-session' + ? activeTab.id + : null + const activeBrowserTab = activeBrowserId + ? (model.browserItems.find((item) => item.id === activeBrowserId) ?? null) + : null + const activeEditorFile = activeEditorUnifiedId + ? (model.editorItems.find((item) => item.tabId === activeEditorUnifiedId) ?? null) + : null + const activeTabType: 'browser' | 'terminal' | 'simulator' | 'editor' | 'agent-session' = + activeTab?.contentType === 'browser' + ? 'browser' + : activeTab?.contentType === 'terminal' + ? 'terminal' + : activeTab?.contentType === 'agent-session' + ? 'agent-session' + : activeTab?.contentType === 'simulator' + ? 'simulator' + : 'editor' + + const hasVisibleFloatingTabs = surface.kind === 'workspace' + + // Visible-id order for tab-cycling shortcuts: strip order restricted to entries whose entity + // still resolves, so a shortcut never lands on a tab the strip is not showing. + const visibleFloatingTabOrder = useMemo( + () => + model.tabBarOrder.filter((visibleId) => { + const tab = resolveGroupTabFromVisibleId(groupTabs, visibleId) + if (!tab) { + return false + } + if (tab.contentType === 'terminal') { + return terminalItems.some((item) => item.unifiedTabId === tab.id) + } + if (tab.contentType === 'browser') { + return model.browserItems.some((item) => item.tabId === tab.id) + } + if (tab.contentType === 'simulator' || tab.contentType === 'agent-session') { + return true + } + return model.editorItems.some((item) => item.tabId === tab.id) + }), + [groupTabs, model.browserItems, model.editorItems, model.tabBarOrder, terminalItems] + ) + const activeClosableTab = + activeTab && + visibleFloatingTabOrder.includes( + activeTab.contentType === 'terminal' || activeTab.contentType === 'browser' + ? activeTab.entityId + : activeTab.id + ) + ? activeTab + : null + + return { + surface, + model, + terminalItems, + activeGroup: group, + groupTabs, + activeTab, + activeTerminalId, + activeBrowserId, + activeEditorUnifiedId, + activeBrowserTab, + activeEditorFile, + activeTabType, + activeClosableTab, + hasVisibleFloatingTabs, + visibleFloatingTabOrder + } +} + +export type FloatingWorkspaceChromeModel = ReturnType diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx index 2b93f01e59b..c1369c966cd 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.launch-lifecycle.test.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' const { mocks, moduleFactories, resetStructuredSessionMocks } = await vi.hoisted(async () => (await import('./NativeChatStructuredSession.test-harness')).createStructuredSessionMocks() @@ -16,6 +17,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) @@ -107,6 +109,17 @@ describe('NativeChatStructuredSession launch lifecycle', () => { expect(mocks.retryLaunch).toHaveBeenCalledWith('wt-1', 'session-1') }) + it('keys a floating chat launch by its owner even before any path context exists', () => { + mocks.ownerWorktreeId = FLOATING_TERMINAL_WORKTREE_ID + mocks.fileLinkContext = null + mocks.launchLifecycle = 'failed' + render(sessionView()) + + expect(mocks.lifecycleLookup).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, 'session-1') + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(mocks.retryLaunch).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID, 'session-1') + }) + it('words why a failed launch failed beside Retry from the refusal, never its code', () => { mocks.launchLifecycle = 'failed' mocks.launchFailure = NOT_SIGNED_IN diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx index 8c7363c4115..cf990808189 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-harness.tsx @@ -10,6 +10,7 @@ import type { NativeChatApprovalCardProps } from './NativeChatApprovalCard' import type { NativeChatDeliveryNotice } from './NativeChatMessageRow' import type { NativeChatQuestionCardProps } from './NativeChatQuestionCard' import type { NativeChatLaunchSeed } from './native-chat-composer-types' +import type { NativeChatFileLinkContext } from './native-chat-file-link' import type { NativeChatOlderPageResult } from './native-chat-pagination' import type { StructuredAgentSessionThreadGoal } from './use-structured-agent-session-thread-goal' import type { StructuredAgentSessionLaunchLifecycle } from '@/lib/structured-agent-session-launch' @@ -24,6 +25,10 @@ function nullable(): T | null { return null } +function widened(value: T): T { + return value +} + function absent(): T | undefined { return undefined } @@ -99,6 +104,12 @@ type StructuredSessionMessageListProps = { deliveryNotices?: ReadonlyMap } +const DEFAULT_FILE_LINK_CONTEXT: NativeChatFileLinkContext = { + worktreeId: 'wt-1', + worktreePath: '/repo', + runtimeEnvironmentId: null +} + const initialMessageListProps: StructuredSessionMessageListProps | null = null const initialApprovalCardProps: NativeChatApprovalCardProps | null = null @@ -112,6 +123,9 @@ export function createStructuredSessionMocks() { call: vi.fn<(...args: never[]) => unknown>(), fileLinkClick: vi.fn<(...args: never[]) => unknown>(), launchLifecycle: nullable(), + ownerWorktreeId: widened('wt-1'), + fileLinkContext: widened(DEFAULT_FILE_LINK_CONTEXT), + lifecycleLookup: vi.fn<(worktreeId: string, sessionId: string) => void>(), launchFailure: nullable(), launchResumes: false, retryLaunch: vi.fn<(worktreeId: string, sessionId: string) => unknown>(), @@ -283,18 +297,20 @@ export function createStructuredSessionMocks() { getStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle, getStructuredAgentSessionLaunchResumes: () => mocks.launchResumes, useStructuredAgentSessionLaunchSelection: () => null, - useStructuredAgentSessionLaunchLifecycle: () => mocks.launchLifecycle, + useStructuredAgentSessionLaunchLifecycle: (worktreeId: string, sessionId: string) => { + mocks.lifecycleLookup(worktreeId, sessionId) + return mocks.launchLifecycle + }, useStructuredAgentSessionLaunchFailure: () => mocks.launchFailure }), useNativeChatFontSize: () => ({ useNativeChatFontSize: () => undefined }), useNativeChatFileLinkContext: () => ({ - useNativeChatFileLinkContext: () => ({ - worktreeId: 'wt-1', - worktreePath: '/repo', - runtimeEnvironmentId: null - }) + useNativeChatFileLinkContext: () => mocks.fileLinkContext + }), + useNativeChatTabOwner: () => ({ + useNativeChatTabOwnerWorktreeId: () => mocks.ownerWorktreeId }), useNativeChatFileLinkClick: () => ({ useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined) @@ -341,9 +357,12 @@ export function createStructuredSessionMocks() { const resetStructuredSessionMocks = (): void => { mocks.call.mockReset() mocks.launchLifecycle = null + mocks.ownerWorktreeId = 'wt-1' + mocks.fileLinkContext = DEFAULT_FILE_LINK_CONTEXT mocks.launchFailure = null mocks.launchResumes = false mocks.retryLaunch.mockReset() + mocks.lifecycleLookup.mockReset() mocks.controllerProps = null mocks.mode = 'static' mocks.status = 'ready' diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx index 4d1b94ba882..b4d8ee5e87c 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test.tsx @@ -20,6 +20,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index b3102c5639b..1c4108f4e61 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -21,6 +21,7 @@ import { useNativeChatFontSize } from './use-native-chat-font-size' import { LinkActionPopover } from '@/components/link-actions/LinkActionPopover' import { useNativeChatLinkActions } from './use-native-chat-link-actions' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' +import { useNativeChatTabOwnerWorktreeId } from './use-native-chat-tab-owner' import { useStructuredAgentSession } from './use-structured-agent-session' import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' @@ -43,11 +44,9 @@ import { pendingPromptsAllUnanswerableHere } from '../../../../shared/agent-sess export function NativeChatStructuredSession( props: Omit ): React.JSX.Element { + const ownerWorktreeId = useNativeChatTabOwnerWorktreeId(props.tabId) const fileLinkContext = useNativeChatFileLinkContext(props.tabId) - const provisionalLaunch = useNativeChatProvisionalLaunch( - fileLinkContext?.worktreeId, - props.sessionId - ) + const provisionalLaunch = useNativeChatProvisionalLaunch(ownerWorktreeId, props.sessionId) const { sendThroughRelaunch } = provisionalLaunch // The host's own word on whether the provider child has answered startup yet. const startupPhase = useStructuredAgentSessionHostExecutionPhase(props.sessionId, props.target) @@ -229,7 +228,7 @@ export function NativeChatStructuredSession( optionPickerRequest, sessionCommands: controller.sessionCommands, contextUsage: controller.contextUsage, - worktreeId: fileLinkContext?.worktreeId, + worktreeId: ownerWorktreeId ?? undefined, onError: setComposerError, runtime: (props.target.kind === 'local' ? 'local' : 'remote') as 'local' | 'remote', sessionId: props.sessionId, @@ -238,8 +237,8 @@ export function NativeChatStructuredSession( } }, [ controller, - fileLinkContext?.worktreeId, optionPickerRequest, + ownerWorktreeId, props.agent, props.sessionId, props.target, diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx index d3cd38b97ee..b0e51894dcf 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.probe.test.tsx @@ -17,6 +17,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => vi.mock('./use-structured-agent-session', () => moduleFactories.useStructuredAgentSession()) vi.mock('./use-native-chat-font-size', () => moduleFactories.useNativeChatFontSize()) vi.mock('./use-native-chat-file-link-context', () => moduleFactories.useNativeChatFileLinkContext()) +vi.mock('./use-native-chat-tab-owner', () => moduleFactories.useNativeChatTabOwner()) vi.mock('./use-native-chat-file-link-click', () => moduleFactories.useNativeChatFileLinkClick()) vi.mock('./NativeChatMessageList', () => moduleFactories.nativeChatMessageList()) vi.mock('./NativeChatComposer', () => moduleFactories.nativeChatComposer()) diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx index 5eea775bee7..d36cc874b53 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSessionDelivery.test.tsx @@ -143,6 +143,10 @@ vi.mock('./use-native-chat-file-link-context', () => ({ }) })) +vi.mock('./use-native-chat-tab-owner', () => ({ + useNativeChatTabOwnerWorktreeId: () => 'wt-1' +})) + vi.mock('./use-native-chat-file-link-click', () => ({ useNativeChatFileLinkClick: (context: unknown) => (context ? mocks.fileLinkClick : undefined) })) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx new file mode 100644 index 00000000000..0fae5234637 --- /dev/null +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.launch-directory.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../../shared/agent-session-wire' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId +} from '../../../../shared/execution-host' +import type { Tab } from '../../../../shared/tab-types' +import type { AppState } from '@/store/types' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' + +type TestStore = { + getState: () => AppState + setState: (state: Partial) => void +} + +const mocks = vi.hoisted(() => { + const hoisted: { + store: TestStore | null + subscribeStatus: ReturnType + supportsCapability: ReturnType + unsubscribe: ReturnType + } = { + store: null, + subscribeStatus: vi.fn(), + supportsCapability: vi.fn(), + unsubscribe: vi.fn() + } + return hoisted +}) + +vi.mock('@/store', async () => { + const { createTestStore } = await import('@/store/slices/store-test-helpers') + const useAppStore = createTestStore() + mocks.store = useAppStore + return { useAppStore } +}) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => ({ + ...(await importOriginal()), + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) + +import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' + +const floatingTab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex', + executionHostId: LOCAL_EXECUTION_HOST_ID +} satisfies Tab + +function summary(overrides: Partial = {}): AgentSessionStatusSummary { + return { + sessionId: 'session-1', + workspaceId: FLOATING_TERMINAL_WORKTREE_ID, + agent: 'codex', + status: 'idle', + latestPrompt: 'hello', + updatedAt: 1, + ...overrides + } +} + +function emit(event: AgentSessionStatusEvent): void { + const call = mocks.subscribeStatus.mock.calls.at(-1) + if (!call) { + throw new Error('status feed not subscribed') + } + const send: (event: AgentSessionStatusEvent) => void = call[1] + act(() => send(event)) +} + +function pins(): AppState['structuredSessionLaunchDirectoryByTabId'] { + return mocks.store?.getState().structuredSessionLaunchDirectoryByTabId ?? {} +} + +describe('StructuredAgentSessionStatusBridge pinned launch directory', () => { + beforeEach(() => { + vi.clearAllMocks() + resetStructuredAgentSessionStatusFeedsForTests() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + mocks.supportsCapability.mockResolvedValue(true) + mocks.store?.setState({ + structuredSessionLaunchDirectoryByTabId: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] } + }) + }) + + afterEach(() => { + cleanup() + resetStructuredAgentSessionStatusFeedsForTests() + }) + + it('keeps the host-published pinned folder for the tab and drops it when the tab goes', async () => { + const view = render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + emit({ type: 'snapshot', sessions: [summary({ launchDirectory: '/home/me/original' })] }) + + expect(pins()).toEqual({ + [floatingTab.id]: { sessionId: 'session-1', launchDirectory: '/home/me/original' } + }) + + act(() => mocks.store?.setState({ unifiedTabsByWorktree: {} })) + view.rerender() + expect(pins()).toEqual({}) + }) + + it('records nothing when an older host omits the field', async () => { + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + emit({ type: 'snapshot', sessions: [summary()] }) + + expect(pins()).toEqual({}) + }) + + it('never records a path published by a remote runtime', async () => { + // The tab records the paired host that owns the chat. + mocks.store?.setState({ + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [ + { ...floatingTab, executionHostId: toRuntimeExecutionHostId('env-1') } + ] + } + }) + render() + await waitFor(() => expect(mocks.subscribeStatus).toHaveBeenCalledOnce()) + + emit({ type: 'snapshot', sessions: [summary({ launchDirectory: '/srv/remote/floating' })] }) + + // The summary itself landed; only its path was withheld. + expect(Object.keys(mocks.store?.getState().agentStatusByPaneKey ?? {})).toHaveLength(1) + expect(pins()).toEqual({}) + }) +}) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 43e484c6f13..f711fff8044 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -289,6 +289,21 @@ function StructuredAgentSessionOwnedStatusProjection({ useEffect(() => { projectStatus(tab, summary, observation, launchFailedAt) }, [summary, observation, tab, launchFailedAt]) + const launchDirectory = summary?.launchDirectory + useEffect(() => { + // Why local only: a remote host's path is in its syntax, and floating chats only run locally. + useAppStore + .getState() + .setStructuredSessionLaunchDirectory( + tab.id, + tab.entityId, + target.kind === 'local' ? launchDirectory : undefined + ) + }, [launchDirectory, target.kind, tab.id, tab.entityId]) + useEffect( + () => () => useAppStore.getState().clearStructuredSessionLaunchDirectory(tab.id, tab.entityId), + [tab.entityId, tab.id] + ) useEffect( () => () => useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId)), diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 59c4947c8b4..3475db76333 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -78,6 +78,11 @@ const PIECES: Record + translate( + 'components.native-chat.failureWords.launchFolderMissing', + COPY.launchFolderMissing + ), chooseClaudeAccount: () => translate( 'components.native-chat.failureWords.chooseClaudeAccount', diff --git a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts index b7564ba7d9c..a77a0e207f8 100644 --- a/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts +++ b/src/renderer/src/components/native-chat/native-chat-attachment-upload.ts @@ -46,7 +46,7 @@ type NativeChatAttachmentOwnerState = Pick< | 'sshConnectionStates' | 'tabsByWorktree' | 'worktreesByRepo' -> +> & { floatingWorkspacePath?: AppState['floatingWorkspacePath'] } /** Resolve who owns the composer's backing worktree at attach time. Mirrors the * terminal drop resolver's order: runtime owner first, then SSH vs local. */ diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts index eaf63279fe4..8d911033097 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.test.ts @@ -3,7 +3,9 @@ import type { Tab } from '../../../../shared/tab-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { AppState } from '@/store/types' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { + findNativeChatTabOwnerWorktreeId, resolveNativeChatFileLink, resolveNativeChatFileLinkContext, type NativeChatFileLinkContext @@ -135,6 +137,63 @@ describe('resolveNativeChatFileLinkContext', () => { }) }) +describe('floating workspace native chat', () => { + const floatingTab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } satisfies Tab + + function floatingState(floatingWorkspacePath: string | null): AppState { + return state({ + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + getKnownWorktreeById: () => undefined, + worktreesByRepo: {}, + // Why a focused runtime: floating must stay local even when one is selected. + settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'env-1' }, + floatingWorkspacePath + }) + } + + it('finds the owning floating workspace before its directory is known', () => { + const pending = floatingState(null) + expect(findNativeChatTabOwnerWorktreeId(pending, floatingTab.id)).toBe( + FLOATING_TERMINAL_WORKTREE_ID + ) + expect(resolveNativeChatFileLinkContext(pending, floatingTab.id)).toBeNull() + }) + + it('resolves file links against the pinned folder after the floating setting moved', () => { + const pinned = { + ...floatingState('/home/me/changed-setting'), + structuredSessionLaunchDirectoryByTabId: { + [floatingTab.id]: { sessionId: floatingTab.entityId, launchDirectory: '/home/me/pinned' } + } + } + expect(resolveNativeChatFileLinkContext(pinned, floatingTab.id)).toEqual({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + worktreePath: '/home/me/pinned', + runtimeEnvironmentId: null + }) + }) + + it('resolves no file links until the pin arrives, instead of trusting the current setting', () => { + expect( + resolveNativeChatFileLinkContext(floatingState('/home/me/changed-setting'), floatingTab.id) + ).toBeNull() + }) +}) + describe('resolveNativeChatFileLink', () => { it('resolves repo-relative file links against the chat worktree', () => { expect(resolveNativeChatFileLink('docs/guide.md', context)).toEqual({ diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index 431dc9a556e..043bb9b78c6 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -1,12 +1,11 @@ import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' -import type { Worktree } from '../../../../shared/worktree/types' import { parseExplicitFileLinkTarget, resolveExplicitFileLinkTarget } from '@/lib/explicit-file-link-target' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import type { AppState } from '@/store/types' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { resolveNativeChatTabDirectory } from './native-chat-tab-directory' export type NativeChatFileLinkContext = { worktreeId: string @@ -31,6 +30,8 @@ type NativeChatFileLinkState = Pick< | 'worktreesByRepo' > & { unifiedTabsByWorktree?: AppState['unifiedTabsByWorktree'] + floatingWorkspacePath?: AppState['floatingWorkspacePath'] + structuredSessionLaunchDirectoryByTabId?: AppState['structuredSessionLaunchDirectoryByTabId'] } export function findTerminalTabWorktreeId( @@ -59,42 +60,26 @@ function findStructuredTabWorktreeId( return null } -function findWorktreeFallback( - worktreesByRepo: NativeChatFileLinkState['worktreesByRepo'], - worktreeId: string -): Pick | null { - for (const worktrees of Object.values(worktreesByRepo)) { - const worktree = worktrees.find((entry) => entry.id === worktreeId) - if (worktree) { - return worktree - } - } - return null +/** The workspace that owns a native chat tab, independent of whether its directory is known. */ +export function findNativeChatTabOwnerWorktreeId( + state: Pick, + tabId: string +): string | null { + return ( + findTerminalTabWorktreeId(state.tabsByWorktree, tabId) ?? + findStructuredTabWorktreeId(state.unifiedTabsByWorktree, tabId) + ) } export function resolveNativeChatFileLinkContext( state: NativeChatFileLinkState, terminalTabId: string ): NativeChatFileLinkContext | null { - const worktreeId = - findTerminalTabWorktreeId(state.tabsByWorktree, terminalTabId) ?? - findStructuredTabWorktreeId(state.unifiedTabsByWorktree, terminalTabId) + const worktreeId = findNativeChatTabOwnerWorktreeId(state, terminalTabId) if (!worktreeId) { return null } - - const knownWorktree = state.getKnownWorktreeById(worktreeId) - const worktree = knownWorktree?.path - ? knownWorktree - : findWorktreeFallback(state.worktreesByRepo, worktreeId) - const workspaceScope = parseWorkspaceKey(worktreeId) - const worktreePath = - worktree?.path ?? - (workspaceScope?.type === 'folder' - ? (state.folderWorkspaces.find( - (workspace) => workspace.id === workspaceScope.folderWorkspaceId - )?.folderPath ?? null) - : null) + const worktreePath = resolveNativeChatTabDirectory(state, terminalTabId, worktreeId) if (!worktreePath) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts index cfda20998fa..6264b437c03 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest' import { shallow } from 'zustand/shallow' import type { AppState } from '@/store/types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { Tab } from '../../../../shared/tab-types' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { resolveNativeChatImageRuntimeContext, selectNativeChatImageOwnerState @@ -93,4 +95,49 @@ describe('resolveNativeChatImageRuntimeContext', () => { settings: { activeRuntimeEnvironmentId: 'owner-a' } }) }) + + it('resolves a floating chat to its pinned folder on the local host, and nothing before the pin', () => { + const floatingTab: Tab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } + const floatingState: AppState = { + ...state(), + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + getKnownWorktreeById: () => undefined, + worktreesByRepo: {}, + // Why a focused runtime: floating must stay local even when one is selected. + settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'env-1' }, + floatingWorkspacePath: '/home/me/changed-setting' + } + + expect(resolveNativeChatImageRuntimeContext(floatingState, 'floating-chat-1')).toBeNull() + expect( + resolveNativeChatImageRuntimeContext( + { + ...floatingState, + structuredSessionLaunchDirectoryByTabId: { + 'floating-chat-1': { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + } + }, + 'floating-chat-1' + ) + ).toMatchObject({ + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + worktreePath: '/home/me/pinned', + expectedExecutionHostId: 'local', + settings: { activeRuntimeEnvironmentId: null } + }) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts index 600c5b63c1d..30ec92ac7b1 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-runtime-context.ts @@ -3,16 +3,14 @@ import type { AppState } from '@/store/types' import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' import { settingsForWorktreeOperationRoute, - resolveWorktreeOperationRouteResult + resolveWorktreeOperationRouteResult, + type WorktreeOperationRouteResolution } from '@/lib/worktree-operation-route' import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { captureDirectSshMutationExpectation } from '@/lib/ssh-mutation-expectation' -import { - parseExecutionHostId, - toRuntimeExecutionHostId, - type ExecutionHostId -} from '../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' +import { resolveNativeChatTabDirectory } from './native-chat-tab-directory' import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -26,6 +24,8 @@ type OwnerState = Pick< | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' + | 'floatingWorkspacePath' + | 'structuredSessionLaunchDirectoryByTabId' | 'projectGroups' | 'runtimeEnvironments' | 'runtimeEnvironmentCatalogHydrated' @@ -49,6 +49,8 @@ export function selectNativeChatImageOwnerState(state: AppState): OwnerState { worktreesByRepo: state.worktreesByRepo, detectedWorktreesByRepo: state.detectedWorktreesByRepo, folderWorkspaces: state.folderWorkspaces, + floatingWorkspacePath: state.floatingWorkspacePath, + structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, projectGroups: state.projectGroups, runtimeEnvironments: state.runtimeEnvironments, runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, @@ -64,6 +66,12 @@ export function selectNativeChatImageOwnerState(state: AppState): OwnerState { } } +// Why: floating has no catalog row for the route resolver to find, and it always runs locally. +const FLOATING_WORKSPACE_ROUTE: WorktreeOperationRouteResolution = { + kind: 'resolved', + route: { executionHostId: 'local', runtimeEnvironmentId: null } +} + // Route settings are cloned for the runtime operation contract. Reuse that // clone while the store's source settings and selected runtime are unchanged so // consumers do not treat an unrelated store update as a new image owner. @@ -98,33 +106,6 @@ function stableSettingsForRoute( return resolved } -function resolvePath( - state: OwnerState, - worktreeId: string, - hostId: ExecutionHostId | null -): string | null { - const known = state.getKnownWorktreeById(worktreeId, hostId ?? undefined) - if (known?.path) { - return known.path - } - const workspace = parseWorkspaceKey(worktreeId) - if (workspace?.type === 'folder') { - return ( - state.folderWorkspaces.find((entry) => entry.id === workspace.folderWorkspaceId) - ?.folderPath ?? null - ) - } - for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { - const match = worktrees.find( - (entry) => entry.id === worktreeId && (!hostId || entry.hostId === hostId) - ) - if (match?.path) { - return match.path - } - } - return null -} - export function resolveNativeChatImageRuntimeContext( state: OwnerState, tabId: string @@ -133,7 +114,9 @@ export function resolveNativeChatImageRuntimeContext( if (!linkContext) { return null } - const routeResolution = resolveWorktreeOperationRouteResult(state, linkContext.worktreeId) + const routeResolution = isFloatingWorkspaceId(linkContext.worktreeId) + ? FLOATING_WORKSPACE_ROUTE + : resolveWorktreeOperationRouteResult(state, linkContext.worktreeId) if (routeResolution.kind !== 'resolved') { return null } @@ -144,7 +127,12 @@ export function resolveNativeChatImageRuntimeContext( if (!executionHostId) { return null } - const worktreePath = resolvePath(state, linkContext.worktreeId, executionHostId) + const worktreePath = resolveNativeChatTabDirectory( + state, + tabId, + linkContext.worktreeId, + executionHostId + ) if (!worktreePath) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index b0f59a8a8a1..fc1e47ee3c1 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -7,18 +7,24 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { + resolveNativeChatTabDirectory, + resolveNativeChatTabDirectoryResolution, + type NativeChatTabDirectoryState +} from './native-chat-tab-directory' export type NativeChatSkillStateInputs = Pick< AppState, | 'activeRepoId' | 'activeWorktreeId' + | 'floatingWorkspacePath' | 'folderWorkspaces' | 'projectGroups' | 'projects' | 'repos' | 'restoredRuntimeHostIdByWorkspaceSessionKey' | 'settings' + | 'structuredSessionLaunchDirectoryByTabId' | 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'worktreesByRepo' @@ -26,10 +32,8 @@ export type NativeChatSkillStateInputs = Pick< type NativeChatSkillTab = { id: string; startupCwd?: string } -type NativeChatSkillWorktreeState = { +type NativeChatSkillWorktreeState = NativeChatTabDirectoryState & { tabsByWorktree: Record - unifiedTabsByWorktree?: Record - worktreesByRepo: Record } export type NativeChatSkillDiscoveryContext = { @@ -44,12 +48,14 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki return { activeRepoId: state.activeRepoId, activeWorktreeId: state.activeWorktreeId, + floatingWorkspacePath: state.floatingWorkspacePath, folderWorkspaces: state.folderWorkspaces, projectGroups: state.projectGroups, projects: state.projects, repos: state.repos, restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, + structuredSessionLaunchDirectoryByTabId: state.structuredSessionLaunchDirectoryByTabId, tabsByWorktree: state.tabsByWorktree, unifiedTabsByWorktree: state.unifiedTabsByWorktree, worktreesByRepo: state.worktreesByRepo @@ -70,13 +76,22 @@ export function resolveNativeChatSkillDiscoveryCwd( if (startupCwd) { return startupCwd } - for (const worktrees of Object.values(state.worktreesByRepo)) { - const worktree = worktrees.find((entry) => entry.id === found.worktreeId) - if (worktree) { - return worktree.path - } + return resolveNativeChatTabDirectory(state, terminalTabId, found.worktreeId) +} + +/** A missing context that is not a failure: the chat's folder is known soon, when its pin arrives. */ +export function isNativeChatSkillDiscoveryAwaitingDirectory( + state: NativeChatSkillWorktreeState, + terminalTabId: string +): boolean { + const found = findNativeChatTab(state, terminalTabId) + if (!found || found.tab.startupCwd?.trim()) { + return false } - return null + return ( + resolveNativeChatTabDirectoryResolution(state, terminalTabId, found.worktreeId).status === + 'awaiting-pin' + ) } export function resolveNativeChatSkillDiscoveryContext( @@ -87,14 +102,7 @@ export function resolveNativeChatSkillDiscoveryContext( if (!worktreeId) { return null } - const workspaceScope = parseWorkspaceKey(worktreeId) - const cwd = - resolveNativeChatSkillDiscoveryCwd(state, terminalTabId) ?? - (workspaceScope?.type === 'folder' - ? state.folderWorkspaces.find( - (workspace) => workspace.id === workspaceScope.folderWorkspaceId - )?.folderPath - : null) + const cwd = resolveNativeChatSkillDiscoveryCwd(state, terminalTabId) if (!cwd) { return null } diff --git a/src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts b/src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts new file mode 100644 index 00000000000..04b87261f26 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-tab-directory.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + resolveNativeChatTabDirectory, + resolveNativeChatTabDirectoryResolution, + type NativeChatTabDirectoryState +} from './native-chat-tab-directory' + +const FLOATING_TAB = { + id: 'floating-chat-1', + entityId: 'session-1', + contentType: 'agent-session', + agentSessionAgent: 'codex' +} +const FLOATING_TERMINAL_TAB = { + id: 'floating-terminal-1', + entityId: 'pty-1', + contentType: 'terminal' +} +const WORKTREE_TAB = { id: 'worktree-chat-1', entityId: 'session-2' } +const SSH_HOST = 'ssh:box-1' + +function state(overrides: Partial = {}): NativeChatTabDirectoryState { + return { + floatingWorkspacePath: '/home/me/changed-setting', + worktreesByRepo: { + repo: [ + { id: 'wt-1', path: '/repo/worktree', hostId: 'local' }, + { id: 'wt-ssh', path: '/srv/remote/worktree', hostId: SSH_HOST } + ] + }, + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [FLOATING_TAB, FLOATING_TERMINAL_TAB], + 'wt-1': [WORKTREE_TAB], + 'wt-ssh': [{ id: 'ssh-chat-1', entityId: 'session-3' }] + }, + structuredSessionLaunchDirectoryByTabId: { + [FLOATING_TAB.id]: { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + }, + ...overrides + } +} + +describe('resolveNativeChatTabDirectory', () => { + it('answers a floating chat with its pinned folder after the floating setting moved', () => { + expect( + resolveNativeChatTabDirectory(state(), FLOATING_TAB.id, FLOATING_TERMINAL_WORKTREE_ID) + ).toBe('/home/me/pinned') + expect( + resolveNativeChatTabDirectory( + state(), + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID, + 'local' + ) + ).toBe('/home/me/pinned') + }) + + it('has no directory until the pin arrives, instead of the current floating setting', () => { + const unpinned = state({ structuredSessionLaunchDirectoryByTabId: {} }) + expect( + resolveNativeChatTabDirectory(unpinned, FLOATING_TAB.id, FLOATING_TERMINAL_WORKTREE_ID) + ).toBeNull() + expect( + resolveNativeChatTabDirectoryResolution( + unpinned, + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID + ) + ).toEqual({ status: 'awaiting-pin' }) + }) + + it('ignores a pin left from a session the tab no longer shows', () => { + const rebound = state({ + unifiedTabsByWorktree: { + [FLOATING_TERMINAL_WORKTREE_ID]: [{ ...FLOATING_TAB, entityId: 'session-new' }] + } + }) + expect( + resolveNativeChatTabDirectoryResolution( + rebound, + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID + ) + ).toEqual({ status: 'awaiting-pin' }) + }) + + it('resolves a floating tab no session pin is published for by the floating setting', () => { + expect( + resolveNativeChatTabDirectory( + state(), + FLOATING_TERMINAL_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID + ) + ).toBe('/home/me/changed-setting') + }) + + it('never answers a floating chat off the local host, pinned or not', () => { + expect( + resolveNativeChatTabDirectory( + state(), + FLOATING_TAB.id, + FLOATING_TERMINAL_WORKTREE_ID, + SSH_HOST + ) + ).toBeNull() + }) + + it('resolves worktree chats by id even when a pin is recorded for the tab', () => { + const pinned = state({ + structuredSessionLaunchDirectoryByTabId: { + [WORKTREE_TAB.id]: { sessionId: 'session-2', launchDirectory: '/somewhere/else' }, + 'ssh-chat-1': { sessionId: 'session-3', launchDirectory: '/somewhere/else' } + } + }) + expect(resolveNativeChatTabDirectory(pinned, WORKTREE_TAB.id, 'wt-1')).toBe('/repo/worktree') + expect(resolveNativeChatTabDirectory(pinned, 'ssh-chat-1', 'wt-ssh', SSH_HOST)).toBe( + '/srv/remote/worktree' + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-tab-directory.ts b/src/renderer/src/components/native-chat/native-chat-tab-directory.ts new file mode 100644 index 00000000000..fb700e67840 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-tab-directory.ts @@ -0,0 +1,75 @@ +import type { ExecutionHostId } from '../../../../shared/execution-host' +import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' +import { isFloatingWorkspaceId } from '../../../../shared/floating-workspace-worktree' +import { resolveWorkspaceDirectory, type WorkspaceDirectoryState } from '@/lib/workspace-directory' +import type { StructuredSessionLaunchDirectory } from '@/store/slices/structured-session-launch-directories' + +type NativeChatDirectoryTab = { + id: string + entityId?: string + contentType?: string + agentSessionAgent?: unknown +} + +export type NativeChatTabDirectoryState = WorkspaceDirectoryState & { + unifiedTabsByWorktree?: Record + structuredSessionLaunchDirectoryByTabId?: Record +} + +export type NativeChatTabDirectoryResolution = + | { status: 'resolved'; directory: string } + /** A floating structured chat whose pinned folder the status feed has not delivered yet. */ + | { status: 'awaiting-pin' } + | { status: 'unavailable' } + +/** + * The directory a native chat tab's agent runs in. A floating structured chat is held to the folder + * its session was pinned to, because the floating setting can move after launch, so it has no + * directory until the host publishes that pin. Every other tab resolves by workspace id exactly as + * the host does. + */ +export function resolveNativeChatTabDirectoryResolution( + state: NativeChatTabDirectoryState, + tabId: string, + worktreeId: string, + executionHostId?: ExecutionHostId | null +): NativeChatTabDirectoryResolution { + const structuredTab = isFloatingWorkspaceId(worktreeId) + ? // Same predicate as the status bridge that mirrors pins, so no tab waits on one never sent. + state.unifiedTabsByWorktree?.[worktreeId]?.find( + (tab) => + tab.id === tabId && + tab.contentType === 'agent-session' && + isAgentSessionHandleProvider(tab.agentSessionAgent) + ) + : undefined + if (!structuredTab) { + const directory = resolveWorkspaceDirectory(state, worktreeId, executionHostId) + return directory ? { status: 'resolved', directory } : { status: 'unavailable' } + } + // Why: a floating chat only runs on the local host, so no other host holds its folder. + if (executionHostId && executionHostId !== 'local') { + return { status: 'unavailable' } + } + const pinned = state.structuredSessionLaunchDirectoryByTabId?.[tabId] + // Why no setting fallback: absence cannot tell "not delivered yet" from "none", and the setting + // may name a folder this session never ran in. + return pinned && pinned.sessionId === structuredTab.entityId + ? { status: 'resolved', directory: pinned.launchDirectory } + : { status: 'awaiting-pin' } +} + +export function resolveNativeChatTabDirectory( + state: NativeChatTabDirectoryState, + tabId: string, + worktreeId: string, + executionHostId?: ExecutionHostId | null +): string | null { + const resolution = resolveNativeChatTabDirectoryResolution( + state, + tabId, + worktreeId, + executionHostId + ) + return resolution.status === 'resolved' ? resolution.directory : null +} diff --git a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts index 5869e2eabfa..0203bbc28bb 100644 --- a/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts +++ b/src/renderer/src/components/native-chat/structured-attention-dispatch.test.ts @@ -21,6 +21,7 @@ import { } from '@/store/slices/store-test-helpers' import type { NotificationDispatchRequest } from '../../../../shared/notification-settings-types' import { isStructuredTab, type StructuredTab } from './structured-agent-session-tabs' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' vi.mock('@/store', () => ({ useAppStore: { getState: () => store.getState() } })) // The two client-side follow-ups of a delivery are leaf side effects with their own tests; this @@ -47,9 +48,12 @@ const CHAT_TAB = 'chat-tab' const SESSION = 'session-1' function settingsWith(groupAttention: boolean): GlobalSettings { - // The dispatcher reads exactly one settings field and GlobalSettings has no test factory. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only field read. - return { experimentalTerminalAttention: groupAttention } as GlobalSettings + // The dispatcher reads only these settings fields and GlobalSettings has no test factory. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only fields read. + return { + experimentalTerminalAttention: groupAttention, + floatingTerminalEnabled: true + } as GlobalSettings } function completion(overrides?: Partial): AgentSessionTurnCompletion { @@ -79,6 +83,7 @@ function seed(overrides?: { workspaceId?: string folderWorkspaces?: FolderWorkspace[] tabs?: boolean + floatingPanelOpen?: boolean }): void { const workspaceId = overrides?.workspaceId ?? WORKSPACE store.setState({ @@ -111,6 +116,7 @@ function seed(overrides?: { ] }, activeGroupIdByWorktree: { [workspaceId]: GROUP }, + activeView: 'terminal', // Default: the user is looking somewhere else, which is when unread is owed. activeWorktreeId: overrides?.activeWorktreeId === undefined ? 'other-workspace' : overrides.activeWorktreeId, @@ -118,6 +124,7 @@ function seed(overrides?: { unreadTerminalPanes: {}, unreadAgentCompletionPanes: {}, settings: settingsWith(overrides?.groupAttention ?? true), + floatingWorkspacePanelOpen: overrides?.floatingPanelOpen ?? false, // Persistence is not what this test is about; the folder path would otherwise call out to IPC. updateFolderWorkspace: async () => true }) @@ -254,6 +261,33 @@ describe('dispatchStructuredTurnCompletionAttention', () => { expect(indicators().paneDot).toBe('agent-completion') }) + it('earns no unread for a floating chat selected in the open floating panel', () => { + // The main window is on another workspace; the floating panel is what the user is reading. + seed({ workspaceId: FLOATING_TERMINAL_WORKTREE_ID, floatingPanelOpen: true }) + dispatchStructuredTurnCompletionAttention( + structuredTab(FLOATING_TERMINAL_WORKTREE_ID), + completion() + ) + expect(store.getState().unreadAgentCompletionPanes).toEqual({}) + expect(store.getState().unreadTerminalTabs).toEqual({}) + }) + + it('earns unread for a floating chat while the floating panel is closed', () => { + seed({ workspaceId: FLOATING_TERMINAL_WORKTREE_ID }) + dispatchStructuredTurnCompletionAttention( + structuredTab(FLOATING_TERMINAL_WORKTREE_ID), + completion() + ) + expect(indicators(FLOATING_TERMINAL_WORKTREE_ID).paneDot).toBe('agent-completion') + }) + + it('earns unread when the workspace chat is behind another top-level view', () => { + seed({ activeWorktreeId: WORKSPACE }) + store.setState({ activeView: 'tasks' }) + dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) + expect(indicators().paneDot).toBe('agent-completion') + }) + it('hands main the host verdict, which picks finished, failed or stopped', () => { dispatchStructuredTurnCompletionAttention(structuredTab(), completion()) // 'done' is the host's report that the turn settled, not a reading of the status row: main diff --git a/src/renderer/src/components/native-chat/structured-attention-surface.ts b/src/renderer/src/components/native-chat/structured-attention-surface.ts index 3abbff8b245..344b120d4e6 100644 --- a/src/renderer/src/components/native-chat/structured-attention-surface.ts +++ b/src/renderer/src/components/native-chat/structured-attention-surface.ts @@ -17,6 +17,7 @@ import type { import { parsePaneKey } from '../../../../shared/stable-pane-id' import { structuredAgentSessionPaneKey } from '../../../../shared/structured-agent-session-projection' import { isOrcaWindowForegroundFocused } from '../terminal-pane/terminal-notification-pane-visibility' +import { isTabOnVisibleSurface } from '@/hooks/agent-auto-ack-targets' import { isStructuredTab, type StructuredTab } from './structured-agent-session-tabs' type StoreSnapshot = ReturnType @@ -66,20 +67,15 @@ function admitStructuredSession( : { admitted: false, cause: 'superseded-surface' } } -/** The tab the workspace's focused group is currently showing, if it is this structured tab. */ function isViewedStructuredTab( state: StoreSnapshot, workspaceId: string, tab: StructuredTab ): boolean { - if (!isOrcaWindowForegroundFocused() || state.activeWorktreeId !== workspaceId) { - return false - } - const activeGroupId = state.activeGroupIdByWorktree[workspaceId] - const group = (state.groupsByWorktree[workspaceId] ?? []).find( - (candidate) => candidate.id === activeGroupId + return ( + isOrcaWindowForegroundFocused() && + isTabOnVisibleSurface(state, workspaceId, tab.id, 'structured') ) - return group?.activeTabId === tab.id } function collectStructuredAttentionRemainder( diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index e05cbb7a870..4e5e35d1cd8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -4,6 +4,7 @@ import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { NativeChatSkillDiscovery } from './use-native-chat-skills' import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent-profiles' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { isSkillPickerTriggered } from './native-chat-composer-state' const mocks = vi.hoisted(() => ({ @@ -176,6 +177,40 @@ describe('useNativeChatSkills', () => { expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) + it('reports a floating chat awaiting its pinned folder as loading, then scans the pin', () => { + const floatingTab = { + id: 'tab-1', + contentType: 'agent-session', + entityId: 'session-1', + agentSessionAgent: 'codex' + } + mocks.state = { + ...stateForHost('local'), + tabsByWorktree: {}, + floatingWorkspacePath: '/home/me/changed-setting', + structuredSessionLaunchDirectoryByTabId: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] } + } + const view = render() + expect(mocks.snapshots.at(-1)?.status).toBe('loading') + expect(mocks.snapshots.at(-1)?.error).toBeNull() + expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + + mocks.state = { + ...mocks.state, + structuredSessionLaunchDirectoryByTabId: { + 'tab-1': { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + } + } + view.rerender() + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'skills.discover', + { cwd: '/home/me/pinned', worktreeId: FLOATING_TERMINAL_WORKTREE_ID }, + { timeoutMs: 10_000 } + ) + }) + it('shares one in-flight request between sibling panes', async () => { render( <> diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 9baf747673a..bee675d0ffd 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest' import type { DiscoveredSkill, SkillDiscoveryResult } from '../../../../shared/skills' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' +import type { Tab } from '../../../../shared/tab-types' +import { + isNativeChatSkillDiscoveryAwaitingDirectory, + type NativeChatSkillStateInputs +} from './native-chat-skill-discovery-context' import { isNativeChatSkillForAgent, + resolveNativeChatSkillDiscoveryContext, resolveNativeChatSkillDiscoveryCwd } from './use-native-chat-skills' @@ -174,3 +181,70 @@ describe('resolveNativeChatSkillDiscoveryCwd', () => { ).toBe('/repo/worktree/packages/app') }) }) + +describe('floating workspace skill discovery', () => { + const floatingTab: Tab = { + id: 'floating-chat-1', + worktreeId: FLOATING_TERMINAL_WORKTREE_ID, + groupId: 'floating-group', + contentType: 'agent-session', + entityId: 'session-1', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0, + isPinned: false, + agentSessionAgent: 'codex' + } + const floatingInputs: NativeChatSkillStateInputs = { + activeRepoId: null, + activeWorktreeId: null, + floatingWorkspacePath: '/home/me/scratch', + folderWorkspaces: [], + projectGroups: [], + projects: [], + repos: [], + restoredRuntimeHostIdByWorkspaceSessionKey: {}, + // Why a focused runtime: floating must stay local even when one is selected. + settings: { ...getDefaultSettings('/home/me'), activeRuntimeEnvironmentId: 'env-1' }, + structuredSessionLaunchDirectoryByTabId: {}, + tabsByWorktree: {}, + unifiedTabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: [floatingTab] }, + worktreesByRepo: {} + } + + it('scans nothing and awaits the pin rather than scanning the current setting', () => { + expect(resolveNativeChatSkillDiscoveryCwd(floatingInputs, 'floating-chat-1')).toBeNull() + expect(resolveNativeChatSkillDiscoveryContext(floatingInputs, 'floating-chat-1')).toBeNull() + expect(isNativeChatSkillDiscoveryAwaitingDirectory(floatingInputs, 'floating-chat-1')).toBe( + true + ) + }) + + it('scans the pinned folder after the floating setting moved', () => { + const pinned: NativeChatSkillStateInputs = { + ...floatingInputs, + floatingWorkspacePath: '/home/me/changed-setting', + structuredSessionLaunchDirectoryByTabId: { + 'floating-chat-1': { sessionId: 'session-1', launchDirectory: '/home/me/pinned' } + } + } + expect(isNativeChatSkillDiscoveryAwaitingDirectory(pinned, 'floating-chat-1')).toBe(false) + expect(resolveNativeChatSkillDiscoveryContext(pinned, 'floating-chat-1')).toMatchObject({ + cwd: '/home/me/pinned', + executionHostKind: 'local', + runtimeTarget: { kind: 'local' }, + discoveryTarget: { cwd: '/home/me/pinned', worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + }) + }) + + it('stays not-ready until the floating directory resolves', () => { + expect( + resolveNativeChatSkillDiscoveryContext( + { ...floatingInputs, floatingWorkspacePath: null }, + 'floating-chat-1' + ) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.ts index 42adc911656..f045766178f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.ts @@ -7,6 +7,7 @@ import { getNativeChatAgentProfile } from '../../../../shared/native-chat-agent- import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { emitNativeChatSkillDiscovery } from '@/lib/native-chat-telemetry' import { + isNativeChatSkillDiscoveryAwaitingDirectory, resolveNativeChatSkillDiscoveryContext, selectNativeChatSkillStateInputs, type NativeChatSkillDiscoveryContext @@ -43,6 +44,13 @@ const IDLE_STATE: StoredDiscoveryState = { error: null, contextKey: null } +// No context yet, but one is on its way: reads as loading rather than as a failed scan. +const AWAITING_CONTEXT_STATE: StoredDiscoveryState = { + status: 'loading', + skills: [], + error: null, + contextKey: null +} const MISSING_CONTEXT_STATE: StoredDiscoveryState = { status: 'error', skills: [], @@ -87,6 +95,10 @@ export function useNativeChatSkills( () => resolveNativeChatSkillDiscoveryContext(inputs, terminalTabId), [inputs, terminalTabId] ) + const awaitingContext = useMemo( + () => !context && isNativeChatSkillDiscoveryAwaitingDirectory(inputs, terminalTabId), + [context, inputs, terminalTabId] + ) const [state, setState] = useState(IDLE_STATE) const [retryGeneration, setRetryGeneration] = useState(0) const paneDiscoveryCache = useRef(new Map()) @@ -185,11 +197,13 @@ export function useNativeChatSkills( !profile || !enabled ? IDLE_STATE : !context - ? MISSING_CONTEXT_STATE + ? awaitingContext + ? AWAITING_CONTEXT_STATE + : MISSING_CONTEXT_STATE : state.contextKey === context.key ? state : { status: 'loading' as const, skills: [], error: null, contextKey: context.key }, - [context, enabled, profile, state] + [awaitingContext, context, enabled, profile, state] ) const visibleSkills = useMemo(() => { if (!profile || effectiveState.status !== 'ready') { diff --git a/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts b/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts new file mode 100644 index 00000000000..8ec04deac07 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-tab-owner.ts @@ -0,0 +1,7 @@ +import { useAppStore } from '@/store' +import { findNativeChatTabOwnerWorktreeId } from './native-chat-file-link' + +/** Owning workspace of a chat tab; launch identity must not wait on its directory resolving. */ +export function useNativeChatTabOwnerWorktreeId(tabId: string): string | null { + return useAppStore((state) => findNativeChatTabOwnerWorktreeId(state, tabId)) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts new file mode 100644 index 00000000000..7132afa51d4 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.surface-owner.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom + +import { cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' + +const toggleTabViewMode = vi.hoisted(() => vi.fn()) + +function workspaceWithTerminal(worktreeId: string) { + const tabId = `${worktreeId}-tab` + return { + groups: [{ id: `${worktreeId}-group`, activeTabId: tabId }], + tabs: [{ id: tabId, entityId: `${worktreeId}-terminal`, contentType: 'terminal' }], + terminals: [{ id: `${worktreeId}-terminal`, title: 'zsh' }] + } +} + +const state = vi.hoisted(() => ({ activeWorktreeId: 'wt-main' })) +vi.mock('../../store', () => { + const main = workspaceWithTerminal('wt-main') + const floating = workspaceWithTerminal(FLOATING_TERMINAL_WORKTREE_ID) + return { + useAppStore: { + getState: () => ({ + activeWorktreeId: state.activeWorktreeId, + activeGroupIdByWorktree: { + 'wt-main': main.groups[0].id, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.groups[0].id + }, + groupsByWorktree: { + 'wt-main': main.groups, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.groups + }, + unifiedTabsByWorktree: { + 'wt-main': main.tabs, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.tabs + }, + tabsByWorktree: { + 'wt-main': main.terminals, + [FLOATING_TERMINAL_WORKTREE_ID]: floating.terminals + }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {}, + settings: { experimentalNativeChat: true }, + toggleTabViewMode + }) + } + } +}) +vi.mock('./native-chat-shortcut', () => ({ + isMacPlatform: () => true, + matchesNativeChatToggleShortcut: () => true +})) +vi.mock('./native-chat-availability', () => ({ canToggleNativeChat: () => true })) +vi.mock('@/lib/native-chat-transcript-readability', () => ({ + isNativeChatTranscriptLocalReadable: () => true +})) +vi.mock('@/lib/connection-context', () => ({ getConnectionIdFromState: () => null })) + +import { useNativeChatToggleShortcut } from './use-native-chat-toggle-shortcut' + +function pressToggleFrom(target: HTMLElement): void { + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'j', bubbles: true })) +} + +// The floating panel and the main window mount the same surface side by side; one key press +// must toggle only the surface it came from. +describe('native chat toggle with the floating panel on screen', () => { + afterEach(() => { + cleanup() + toggleTabViewMode.mockClear() + document.body.replaceChildren() + }) + + function mountBothSurfaces(): { panelButton: HTMLElement; mainButton: HTMLElement } { + renderHook(() => useNativeChatToggleShortcut('wt-main', true)) + renderHook(() => useNativeChatToggleShortcut(FLOATING_TERMINAL_WORKTREE_ID, true)) + const panel = document.createElement('div') + panel.setAttribute('data-floating-terminal-panel', '') + const panelButton = document.createElement('button') + panel.append(panelButton) + const mainButton = document.createElement('button') + document.body.append(panel, mainButton) + return { panelButton, mainButton } + } + + it('toggles only the floating tab for a key press inside the panel', () => { + const { panelButton } = mountBothSurfaces() + + pressToggleFrom(panelButton) + + expect(toggleTabViewMode.mock.calls).toEqual([[`${FLOATING_TERMINAL_WORKTREE_ID}-tab`]]) + }) + + it('toggles only the main window tab for a key press outside the panel', () => { + const { mainButton } = mountBothSurfaces() + + pressToggleFrom(mainButton) + + expect(toggleTabViewMode.mock.calls).toEqual([['wt-main-tab']]) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 085bb240ef6..6cc94609361 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -7,6 +7,7 @@ import { canToggleNativeChat } from './native-chat-availability' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' import { getConnectionIdFromState } from '@/lib/connection-context' +import { resolveKeyboardWorkspaceId } from '@/lib/floating-workspace-terminal-actions' import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId @@ -35,10 +36,10 @@ export function resolveNativeChatToggleShortcutDetectedAgent({ ) } -/** Toggles the active worktree's focused agent-terminal tab between the terminal - * and native chat views via the keyboard. Gated to the active worktree so only - * one listener acts at a time, and to agent terminals so the chord is inert on - * plain shells / non-terminal surfaces. */ +/** Toggles a workspace's focused agent-terminal tab between the terminal and native + * chat views via the keyboard. Gated to the surface the key press came from (main + * window or floating panel) so only one listener acts, and to agent terminals so + * the chord is inert on plain shells / non-terminal surfaces. */ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive: boolean): void { useEffect(() => { if (!isWorktreeActive) { @@ -50,6 +51,11 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive return } const state = useAppStore.getState() + // Why: the floating panel and the main window can both be on screen; only the one the key + // press came from toggles. + if (resolveKeyboardWorkspaceId(e.target, state.activeWorktreeId) !== worktreeId) { + return + } const activeGroupId = state.activeGroupIdByWorktree[worktreeId] const group = (state.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === activeGroupId) if (!group?.activeTabId) { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx index fd4445b68b6..257ca019eef 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-provisional.test.tsx @@ -6,7 +6,7 @@ import type { StructuredAgentSessionState } from '../../../../shared/structured- const mocks = vi.hoisted(() => ({ call: vi.fn<(target: unknown, method: string, params: unknown) => Promise>(), - hold: vi.fn<(args: { enabled?: boolean }) => void>(), + hold: vi.fn((_args: { enabled?: boolean }) => ({ error: null })), read: vi.fn<(args: { isVisible?: boolean }) => void>(), outbox: vi.fn<(args: { fence: number | null; submissions: readonly unknown[] }) => void>(), send: vi.fn<(text: string) => boolean>(), diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx index d3f8fd9dc0f..d9956651626 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session.test.tsx @@ -393,8 +393,11 @@ describe('useStructuredAgentSession options', () => { const pending = new Promise((_resolve, rejectPromise) => { reject = rejectPromise }) + // Only the option write fails; a refused hold would surface on `error` for its own reason. mocks.call.mockImplementation((_target, method) => - method === 'agentSession.options' ? Promise.resolve(OPTIONS) : pending + method === 'agentSession.options' || method === 'agentSession.hold' + ? Promise.resolve(method === 'agentSession.options' ? OPTIONS : null) + : pending ) const { result, rerender } = renderHook(() => useStructuredAgentSession({ diff --git a/src/renderer/src/components/settings/KeybindingsFileActions.tsx b/src/renderer/src/components/settings/KeybindingsFileActions.tsx index f9b1288a73e..e19464fc45f 100644 --- a/src/renderer/src/components/settings/KeybindingsFileActions.tsx +++ b/src/renderer/src/components/settings/KeybindingsFileActions.tsx @@ -3,8 +3,7 @@ import { ChevronDown, Code2, ExternalLink, FileText, FolderOpen, RefreshCw } fro import { toast } from 'sonner' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { useAppStore } from '../../store' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '../../lib/floating-terminal' -import { isFloatingWorkspacePanelVisible } from '../../lib/floating-workspace-terminal-actions' +import { revealFloatingWorkspacePanel } from '../../lib/floating-workspace-panel-reveal' import { detectLanguage } from '../../lib/language-detect' import { Button } from '../ui/button' import { @@ -104,9 +103,7 @@ export function KeybindingsFileActions(): React.JSX.Element { cancelFloatingTerminalToggleFrame() floatingTerminalToggleFrameRef.current = requestAnimationFrame(() => { floatingTerminalToggleFrameRef.current = null - if (!isFloatingWorkspacePanelVisible()) { - window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT)) - } + revealFloatingWorkspacePanel(useAppStore.getState()) }) } catch (error) { toast.error( diff --git a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx index eab6379936c..22114f5b150 100644 --- a/src/renderer/src/components/sidebar/SidebarAgentsList.tsx +++ b/src/renderer/src/components/sidebar/SidebarAgentsList.tsx @@ -102,12 +102,14 @@ export default function SidebarAgentsList({ worktreesByRepo: storeData.worktreesByRepo, detectedWorktreesByRepo: storeData.detectedWorktreesByRepo, folderWorkspaces: storeData.folderWorkspaces, + floatingWorkspacePath: storeData.floatingWorkspacePath, defaultHostId: storeData.defaultHostId }), [ storeData.worktreesByRepo, storeData.detectedWorktreesByRepo, storeData.folderWorkspaces, + storeData.floatingWorkspacePath, storeData.defaultHostId ] ) diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index f6ad4b04d89..b7476c499ab 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -29,12 +29,18 @@ const EditorPanel = lazy(() => import('../editor/EditorPanel')) const EMPTY_GROUPS: readonly TabGroup[] = [] const EMPTY_CLIENT_HOSTED_ROWS: readonly ClientHostedBrowserRow[] = [] +/** Who renders this group's tab strip: the panel itself ('attached', the main surface), or the + * host shell ('external', the floating panel's titlebar strip). */ +export type TabGroupPanelTabStrip = 'attached' | 'external' + export default function TabGroupPanel({ groupId, worktreeId, isVisible, isFocused, hasSplitGroups, + tabStrip = 'attached', + markdownAnnotationsEnabled = true, touchesRightEdge, touchesLeftEdge, touchesBottomEdge = false, @@ -51,6 +57,9 @@ export default function TabGroupPanel({ isVisible: boolean isFocused: boolean hasSplitGroups: boolean + tabStrip?: TabGroupPanelTabStrip + /** Off for scratch workspaces (the floating panel) whose markdown is not a review surface. */ + markdownAnnotationsEnabled?: boolean touchesRightEdge: boolean touchesLeftEdge: boolean touchesBottomEdge?: boolean @@ -260,93 +269,97 @@ export default function TabGroupPanel({ > {/* Why: each split group needs its own tab row because multiple groups can show at once but the titlebar has only one shared center slot. */} {/* Why: macOS hiddenInset titleBarStyle makes -webkit-app-region: drag the only way to move the window from this tab row. */} -
-
- {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the collapsed left-sidebar's floating toggle clickable. */} - {reserveCollapsedSidebarHeaderSpace && !sidebarOpen ? ( + {/* Why absent (not hidden) for an 'external' host: the floating panel's titlebar owns the one + strip, and a mounted second strip would register duplicate sortables in the drag scope. */} + {tabStrip !== 'attached' ? null : ( +
+
+ {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the collapsed left-sidebar's floating toggle clickable. */} + {reserveCollapsedSidebarHeaderSpace && !sidebarOpen ? ( +
+ ) : null} +
{tabBar}
- ) : null} -
{tabBar}
-
-
- {isFocused ? ( - - ) : null} - {isFocused && hasSplitGroups ? ( - - - - - + + + + { + commands.closeGroup() }} - className={menuButtonClassName} > - - - - - - { - commands.closeGroup() - }} - > - - {translate( - 'auto.components.tab.group.TabGroupPanel.closePaneColumn', - 'Close split pane' - )} - - - - - {translate( - 'auto.components.tab.group.TabGroupPanel.9acaf92093', - 'Pane Actions' - )} - - - ) : null} + + {translate( + 'auto.components.tab.group.TabGroupPanel.closePaneColumn', + 'Close split pane' + )} + + + + + {translate( + 'auto.components.tab.group.TabGroupPanel.9acaf92093', + 'Pane Actions' + )} + + + ) : null} +
+ {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the floating right-sidebar toggle + window controls clickable. */} + {reserveClosedExplorerToggleSpace && !rightSidebarOpen ? ( +
+ ) : null}
- {/* Why: Electron drag hit-test respects no-drag only on DOM descendants, not z-index siblings, so this no-drag spacer keeps the floating right-sidebar toggle + window controls clickable. */} - {reserveClosedExplorerToggleSpace && !rightSidebarOpen ? ( -
- ) : null}
-
+ )}
diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts index b0cadf8c4b0..22b04485b64 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.test.ts @@ -73,7 +73,10 @@ describe('TabGroupSplitLayout', () => { }) function getLayoutWrapper(element: ReturnType) { - const dndContext = asElement(element.props.children) + // The layout composes WorkspaceTabDragLayer (a render prop) around the chrome wrapper: + // invoke it to reach TabDragProvider > DndContext > [wrapper, DragOverlay, split overlay]. + const providerElement = asElement(invokeComponent(asElement(element))) + const dndContext = asElement(providerElement.props.children) return React.Children.toArray(dndContext.props.children as React.ReactNode)[0] } @@ -82,9 +85,12 @@ describe('TabGroupSplitLayout', () => { asElement(getLayoutWrapper(element)).props.children as React.ReactNode ) const splitBody = layoutWrapperChildren[1] - const splitNodeElement = React.Children.only( + const treeElement = React.Children.only( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wrapper's split body renders exactly the tree element; props are untyped in this shallow-walk rig. asElement(splitBody).props.children as React.ReactNode ) + // TabGroupSplitNodeTree renders the recursive SplitNode; invoke both to reach the leaf/split. + const splitNodeElement = invokeComponent(asElement(treeElement)) return invokeComponent(asElement(splitNodeElement)) } diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx index 77f04fb3023..2f0a182de25 100644 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx @@ -1,267 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { DndContext, DragOverlay } from '@dnd-kit/core' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' -import { useAppStore } from '../../store' -import TabGroupPanel from './TabGroupPanel' -import TabDragPreview from '../tab-bar/TabDragPreview' -import { TabDragProvider } from './tab-drag-context' -import TabPaneColumnSplitDragOverlay from './TabPaneColumnSplitDragOverlay' -import { type HoveredTabInsertion, useTabDragSplit } from './useTabDragSplit' - -const MIN_RATIO = 0.15 -const MAX_RATIO = 0.85 - -function ResizeHandle({ - direction, - onResizeStart, - onRatioChange -}: { - direction: 'horizontal' | 'vertical' - onResizeStart: () => void - onRatioChange: (ratio: number) => void -}): React.JSX.Element { - const isHorizontal = direction === 'horizontal' - const [dragging, setDragging] = useState(false) - const activeResizeCleanupRef = useRef<((updateDragging?: boolean) => void) | null>(null) - - useEffect( - () => () => { - activeResizeCleanupRef.current?.(false) - }, - [] - ) - - const onPointerDown = useCallback( - (event: React.PointerEvent) => { - event.preventDefault() - // Why: a second pointer must not steal or finalize the active gesture. - if (activeResizeCleanupRef.current) { - return - } - const handle = event.currentTarget - const container = handle.parentElement - if (!container) { - return - } - const firstPane = handle.previousElementSibling as HTMLElement | null - const secondPane = handle.nextElementSibling as HTMLElement | null - if (!firstPane || !secondPane) { - return - } - onResizeStart() - setDragging(true) - handle.setPointerCapture(event.pointerId) - // Why: measure outside pointermove so pane writes never force a readback. - let rect = container.getBoundingClientRect() - const resizeObserver = new ResizeObserver(() => { - rect = container.getBoundingClientRect() - }) - resizeObserver.observe(container) - let draggedRatio: number | null = null - - const onPointerMove = (moveEvent: PointerEvent): void => { - if (moveEvent.pointerId !== event.pointerId || !handle.hasPointerCapture(event.pointerId)) { - return - } - const ratio = isHorizontal - ? (moveEvent.clientX - rect.left) / rect.width - : (moveEvent.clientY - rect.top) / rect.height - const clamped = Math.min(MAX_RATIO, Math.max(MIN_RATIO, ratio)) - draggedRatio = clamped - // Why: direct style writes keep the drag off the store — a commit per - // pointermove published 60-120 global store updates/s against every - // subscriber (STA-3328). React re-applies identical flex on commit. - firstPane.style.flex = `${clamped} 1 0%` - secondPane.style.flex = `${1 - clamped} 1 0%` - } - - let cleaned = false - const cleanup = (updateDragging = true): void => { - if (cleaned) { - return - } - cleaned = true - resizeObserver.disconnect() - if (draggedRatio !== null) { - onRatioChange(draggedRatio) - } - if (updateDragging) { - setDragging(false) - } - try { - if (handle.hasPointerCapture(event.pointerId)) { - handle.releasePointerCapture(event.pointerId) - } - } catch { - // Best effort: unmount cleanup can run after Chromium has already dropped capture. - } - handle.removeEventListener('pointermove', onPointerMove) - handle.removeEventListener('pointerup', onPointerUp) - handle.removeEventListener('pointercancel', onPointerCancel) - handle.removeEventListener('lostpointercapture', onLostPointerCapture) - if (activeResizeCleanupRef.current === cleanup) { - activeResizeCleanupRef.current = null - } - } - - const onPointerUp = (upEvent: PointerEvent): void => { - if (upEvent.pointerId === event.pointerId) { - cleanup() - } - } - - const onPointerCancel = (cancelEvent: PointerEvent): void => { - if (cancelEvent.pointerId === event.pointerId) { - cleanup() - } - } - - const onLostPointerCapture = (lostEvent: PointerEvent): void => { - if (lostEvent.pointerId === event.pointerId) { - cleanup() - } - } - - handle.addEventListener('pointermove', onPointerMove) - handle.addEventListener('pointerup', onPointerUp) - handle.addEventListener('pointercancel', onPointerCancel) - handle.addEventListener('lostpointercapture', onLostPointerCapture) - activeResizeCleanupRef.current = cleanup - }, - [isHorizontal, onRatioChange, onResizeStart] - ) - - return ( -
- ) -} - -function SplitNode({ - node, - nodePath, - worktreeId, - focusedGroupId, - isWorktreeActive, - hasSplitGroups, - touchesTopEdge, - touchesRightEdge, - touchesLeftEdge, - touchesBottomEdge, - suppressLeftBorder, - suppressRightBorder, - suppressBottomBorder, - isTabDragActive, - hoveredTabInsertion -}: { - node: TabGroupLayoutNode - nodePath: string - worktreeId: string - focusedGroupId?: string - isWorktreeActive: boolean - hasSplitGroups: boolean - touchesTopEdge: boolean - touchesRightEdge: boolean - touchesLeftEdge: boolean - touchesBottomEdge: boolean - suppressLeftBorder: boolean - suppressRightBorder: boolean - suppressBottomBorder: boolean - isTabDragActive: boolean - hoveredTabInsertion: HoveredTabInsertion | null -}): React.JSX.Element { - const setTabGroupSplitRatio = useAppStore((state) => state.setTabGroupSplitRatio) - const recordFeatureInteraction = useAppStore((state) => state.recordFeatureInteraction) - - if (node.type === 'leaf') { - return ( -