From 99d18f7a2f3bc2354052e356280ac19af394bb27 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:32:07 -0400 Subject: [PATCH] Browser, chat and simulator tabs main-first: the window asks main for each change and draws only main's update (dark, layout-owner build only) (#31516) * The window shows each layout records frame record by record, without reading its session back (dark, layout-owner build only) A records frame no longer re-serializes the window's whole session and runs the Loader. Panes' terminal bindings and live titles apply alone, to the tabs the frame names; any other record builds only its own workspace's slices, with the view and facts read from the store. Only a workspace the store does not show from frames yet (startup, a reopened stream, a first visit, one sent whole) takes the full build. Tests install an apply check that, after every flush, compares the store with the full build for the same cache and pre-flush store; the shared layout-apply transport now sends main's records frames and fails a flush that differs. * A frame of only tab rows shows without reading the window's session back or running the Loader (test, layout-owner build only) * The account-restart test's fake binding runtime carries per-pane binding generations, so it passes with the switch on (test only) * The account-restart test types its store writes, so its five old e2e type errors are gone (test only) * A workspace build reads the editor selection and groups through the save's own writers, as the full build does (dark, layout-owner build only) * A tab released from the window's hold shows the binding main wrote meanwhile (test, layout-owner build only) * The frame-apply check still checks a flush's other workspaces when it builds one in full, and the shared transport keeps a test's own check (test only) * The frame-apply check is proven to catch a mismatch beside a full build, and the shared transport restores only the check it installed (test only) * wip(d3a-2): a windowless host lists a file main opens on session.tabs at once * wip(d3a-2): the record key's fields are declared where the key is made * wip(d3a-2): main publishes an editor tab's view, and a window rebuilds the open file it lacks from it Main's in-memory editor view now carries the file facts an open file needs; it rides the tab row (PublishedTab.editorView) and a frame build rebuilds a diff, conflict, preview or check-details tab. Inert with the switch off: views are set only by layout.apply (switch builds) and read only in frame-sync. * wip(d3a-2): a switch-on window sends editor opens, closes and promotions to main and draws the tabs from its frames Layout-owner build only; the switch-off path is unchanged. An open proposes the tab id and sends openEditorTab with the edit file's record or the tab's view (a reopen also its index); the open file is written at once and the tab is selected once its frame draws it. Callers that act on the tab (focus request, link reveal, group focus) wait for that frame through layout-owner-pending-tabs. A close sends closeTabs; the tab goes with the frame and the file's content after it, and a refusal keeps both. A promotion sends promotePreviewTab and the frame clears the flag. Deleted: the drawn editor senders, their before/after captures, the editor drawn-tab holds and the window-record keep rule (a transient tab is kept by its frame view). * wip(d3a-2): a switch-on close keeps its selection and a reopen, a link names its group, and main stamps an open's time Layout-owner build only. After main's frame drops a closed editor tab, the window selects the group's previous tab and its surface through activateTab and focusGroup, after closeFile's content cleanup rather than before it; a reopen sent while the close was in flight keeps the file. openFileForLink's proposed view names the group the open asked for. The window's openEditorTab sends no createdAt: main stamps the tab's time (ledger L186). Preview promotion writes no tab itself: the window ends a preview only where a paired server owns the layout. * wip(d3a-2): editor tests assert switch-on tabs through main's command and frame (test only) Each store and hook test that assumed an immediate editor-tab draw now runs with main's model through the T1 transport in apply mode and its frames (tabs-drawn-from-main.test-fixture), and asserts the tab, its selection and its content after main's frame. The fixture fills the frame slices a partial editor-and-tabs store lacks. * wip(d3a-2): main takes a restored move as a command, renames a view tab's path, and keeps one tab per file per group Layout-owner build only (the switch gates every change; shipped builds unchanged). moveTabToWorkspace no longer skips a tab "the window's save got there first" (L160): window saves no longer carry the move, so a repeat is tab_not_found; tab_exists and invalid_params stay. A move or rename may carry a non-plain tab's new view (its file under the new path or owner), which replaces editorViews[tab] (L180). Main refuses a moveTab or moveTabToWorkspace that would put a second tab for a file into a group (tab_exists), and its loader folds a saved session's duplicate into the first, as the window's restore does (dedupeEditorTabsWithinGroups): the window's frame dedupe hid the second, which no close could reach. * wip(d3a-2): a switch-on restored move and disk rename are main's commands, and the frame that shows them rekeys the content Layout-owner build only; the switch-off path is unchanged. A restored editor tab re-owned by its file's workspace (L157, L158) sends moveTabToWorkspace (a re-own in place sends renameEditorFiles) under the ids and group the window proposes, with each view tab's new view; a disk rename (L180) sends renameEditorFiles per workspace and draws nothing first. The window's content rekey (open file records, drafts, buffers, the editorBuffers echo gate, pendingLiveDiskVerification and pendingSelfMoveEcho) waits in layout-owner-content-rekeys and the frame flush that first shows main's change applies it, in the same store update as the frame (a flush now writes one patch). A change no tab shows is rekeyed on main's answer; a refusal drops it and changes nothing. The move coordinator and the restored-owner loader wait for that outcome; a refused rename rolls the disk move back. A rename picks a runtime-owned or floating file's owned id, as main's frames restore it. Deleted: recordRestoredEditorMoveInLayoutOwner, recordRenamedEditorFilesInLayoutOwner, the reparent's before/after capture and the drawn-tab holds for moves and renames. Tests that assumed the window moves or renames first now run through main's T1 transport in apply mode and its frames. * wip(d3a-2): the static gate's casts and layout-writer ratchet hold for the switch-on move, rename and gap fixes Layout-owner build only. The content rekey's pure store change stays in rekey-open-files-action (its writer count drops 4 -> 2, pruned); a twin tab main routes to a paired server ends its preview through patchTab, the existing tab writer, instead of a new one; a frame-only editor tab takes its label through the frame build's tab-label facts, read from its view or file record; the moved file's activation names the moved tab, writing no surface field. Test casts are typed or carry a SAFETY note. * wip(d3a-2): the switch-on rename takes the window's pure rekey as an argument, so the two modules do not import each other Layout-owner build only; no behaviour change. Clears the static gate's no-cycle finding. * wip(d3b): a switch-on window sends browser, chat and simulator opens, closes and page changes to main and draws them from its frames Main places the chats its host lists (twin h), a local cancel keeps no tombstone (L155), and the listing no longer writes chat tab structure where frames draw it (L153). Co-Authored-By: Claude * wip(d3b): a switch-on browser, chat or simulator close waits for main's frame, lands an emptied workspace as today, and its tests read main's frames * wip(d3b): the stale-listing and cross-version chat tests check a switch-on local cancel through main, with a presence precondition and per-test resets * wip(d3b): main places a tab whose group it lacks in the workspace's first group, not beside the window's anchor (L150) * wip(d3b): tests for main's chat tab commands from a host's chat changes * wip(d3b): main's windowless chat tab and chat fork tests apply a switch-on host's chat tab through main's layout * wip(d3b): the switch-on close and open keep their layout writes in the files the writer ratchet counts them in, and a drawn browser close carries its position instead of a rebuilt layout * Editor tabs main-first: the window sends each editor action to main and draws editor tabs only from main's frames (dark, layout-owner build only) With ORCA_LAYOUT_OWNER_IS_MAIN on, editor open (preview replace, open to the side), reopen at its position, close, close-all, promote, the resolved conflict placeholder, a restored-editor move and a disk rename are layout commands the window sends before it draws anything. Window content (open file records, drafts, buffers, the autosave gate) is written at once for an open, and rekeyed in the same store update as the frame for a move or rename. Main publishes each non-edit tab's view, keeps views in memory across applies and partition changes, stamps the tab's time, folds same-file duplicates, and republishes a windowless host's session.tabs after its own file open. The switch-off path is unchanged. * wip(d3b): a switch-on close main answers workspace_not_found drops the tab's content as tab_not_found does, and placement reads a missing active-group map safely * wip(d3b): switch-on browser and chat tests open and close through main's command and draw from its frame (test only) * wip(d3b): a switch-on chat listing writes no tab structure of a workspace main's frames draw; a host chat title changes only the drawn tab's label * wip(d3b): main's phone rows show a diff tab and a markdown preview as the window's do, with the preview's source named by the window's open-file id * wip(d3b): the cleanup close's page-read guard counts its named linear passes, so a per-entry rescan still fails switch-on (test only) * wip(d3b): the static gate's compat entry, focus-read ratchet and formatting hold for the switch-on test conversions and phone rows * test(d3a-2): the buffer view index follows a rename main answers * wip(d3b): placement reads the active-group map the real store always holds; the browser test store seeds it * wip(d3b): a switch-on background browser open leaves the worktree's remembered browser tab unset, as with the switch off * wip(d3b): a switch-on chat open selects the chat in its group without moving the focused group, as the window's own open does; its test counts the chats main draws * wip(d3b): the converted simulator, launch-cancel, cleanup-close and chat-listing tests assert what the window's draws asserted, through main's command and frame (test only) * wip(d3b): main fences a chat's create on its close even when the close fails for want of the chat host, so a cancelled launch never shows * wip(d3b): a frame keeps the window's hidden chat launch slot after its neighbour, so a resumed chat keeps its saved place through main's frames * wip(d3b): a declined chat's terminal is placed after the chat and pinned as it is in one main move, and shown before main closes the chat * wip(d3b): a switch-on chat close the host refuses asks main to open the chat again, as the window's re-read shows it with the switch off * wip(d3b): a paired server's workspace, its row naming the server, opens and closes browser and chat tabs without main's layout (test only) * wip(d3b): a refused chat close comes back only while this machine's host still lists the chat, and a close main answers workspace_not_found removes the window-only tab * wip(d3b): the twin chat close test names what each build stops: no host off, main's host on (test only) * wip(d3b): the refused-close reopen takes only a chat agent it can launch * wip(d3b): a frame's browser selection keeps an unchosen workspace's unset through the restore projection's own rule, not a second write * wip(d3b): the simulator and paired-server tests type their mock stores without assertions (test only) * wip(d3b): the switch-off declined-chat placement runs as before, with no wait; only the switch-on move waits for main * chore(d3b): drop the frame-binding helper the main merge left without a caller * chore(d3b): drop the import the deleted helper used * wip(d3b): a chat this machine's window creates is placed only by the window's own open, so a reserved chat slot keeps its place (switch-on) * wip(d3b): main reads a chat tab change against its layout when its queue reaches it, through the one rule the switch-off save uses, so a close right after an open closes it * wip(d3b): a declined chat's terminal moves to its index among the tabs main draws, not past a hidden launch slot, so it still replaces the chat (switch-on) * wip(d3b): main's phone-row comments say which rows the window's own row wins, and a test shows a window-listed diff keeps its dirty state (comments and test only) * wip(d3b): a switch-on chat open returns only the tab's id and chat, the fields main's frame keeps; no caller reads more * chore(d3b): the unit-routing census registers the browser suites' drawn-from-main store fixture as a support bridge (test only) * chore(d3b): the chat tab rule's comment wraps and names its two callers (comment only) * wip(d3b): main's chat tab queue asks a worktree's host only once it has a saved session, and one failed change no longer stops the ones after it (switch-on) * wip(d3b): a chat tab batch whose changes cannot be read is logged, and the batches after it still land (switch-on) * test(d3b): a CLI-chat resume's tab is opened once, by the window through main, after the tab before its hidden slot (test only) * test(d3b): an SSH worktree has no host to run a chat on, so none of the chat open, close or fix paths reach it (test only) * chore(d3b): the content tab placement reads folder workspaces through main's workspace-record selector, as the new ratchet requires * fix(merge): put back main's f47f632 files that the merge took from a later main by mistake (no D3b change) --------- Co-authored-by: E2E Test Co-authored-by: Claude --- .../scripts/pr-unit-support-routing.test.mjs | 4 +- .../agent-session-tab-layout-command.test.ts | 88 +++ .../agent-session-tab-layout-queue.test.ts | 57 ++ .../runtime/agent-session-tab-layout-queue.ts | 64 ++ src/main/runtime/agent-session-tab-layout.ts | 73 ++- ...-agent-session-close-before-create.test.ts | 9 + ...structured-agent-session-create-handler.ts | 3 +- ...nt-session-create-window-placement.test.ts | 68 +++ .../structured-agent-session-create.ts | 5 + .../rpc/methods/structured-agent-session.ts | 6 +- src/main/runtime/runtime-layout-apply.ts | 2 +- .../session-layout-apply.test-fixture.ts | 28 + .../main-session-tab-rows.test.ts | 14 +- .../session-tabs/main-session-tab-rows.ts | 17 +- .../structured-session-tab-close.ts | 42 +- .../structured-session-tab-restoration.ts | 8 +- ...ent-session-windowless-layout-host.test.ts | 18 +- ...ed-agent-session-windowless-layout.test.ts | 104 +++- .../launch/mobile-terminal-chat-fork.test.ts | 7 + .../browser-page-annotation-document.test.ts | 44 +- ...n-status-bridge-unconfirmed-start.test.tsx | 11 +- ...iVaultSessionVirtualList.keyboard.test.tsx | 43 +- .../tab-bar/cli-chat-resume-run.test.ts | 65 +- .../initial-terminal-admission-wait.test.tsx | 61 +- ...e-jump-palette-browser-navigation.test.tsx | 22 + .../lib/ensure-simulator-tab-behavior.test.ts | 266 +++++++- src/renderer/src/lib/ensure-simulator-tab.ts | 120 ++-- ...-agent-session-launch-cancellation.test.ts | 567 ++++++++++++------ ...nt-hosted-browser-row-ephemerality.test.ts | 42 +- .../lib/simulator-tab-layout-owner.test.ts | 150 +++-- .../src/lib/simulator-tab-layout-owner.ts | 80 ++- .../structured-agent-session-action-launch.ts | 78 ++- ...red-agent-session-launch-admission.test.ts | 57 +- ...ed-agent-session-launch-close-race.test.ts | 36 +- ...tructured-agent-session-launch-registry.ts | 15 +- ...structured-agent-session-launch-surface.ts | 72 ++- ...tured-agent-session-local-launch-cancel.ts | 30 +- ...-agent-session-new-chat-own-launch.test.ts | 185 ++++-- ...ured-agent-session-provisional-tab.test.ts | 184 +++++- ...tructured-agent-session-provisional-tab.ts | 4 +- ...t-session-tab-open-through-layout-owner.ts | 68 +++ .../agent-session-tab-layout-owner.test.ts | 339 ----------- .../runtime/agent-session-tab-layout-owner.ts | 101 ---- src/renderer/src/runtime/layout-apply-send.ts | 27 +- .../runtime/layout-owner-content-tab-open.ts | 63 ++ .../layout-owner-drawn-tab-placement.test.ts | 50 -- .../layout-owner-drawn-tab-placement.ts | 30 - .../runtime/layout-owner-pending-tab-facts.ts | 68 +++ .../src/runtime/layout-owner-pending-tabs.ts | 4 + .../frame-drawn-chat-tabs.test.ts | 139 +++++ .../frame-drawn-chat-tabs.ts | 31 + .../inventory-refresh.ts | 8 +- .../snapshot-apply.ts | 39 +- ...ession-tab-rows-main-window-parity.test.ts | 204 +++++++ ...structured-agent-session-tab-retirement.ts | 30 +- ...uctured-launch-cancel-layout-owner.test.ts | 19 +- ...c-runtime-graph-conversion-publish.test.ts | 47 +- .../workspace-layout-frame-bindings.ts | 19 - .../runtime/workspace-layout-frame-builds.ts | 4 +- .../workspace-layout-frame-held-tabs.test.ts | 58 +- .../active-selection-owner-scoping.test.ts | 19 +- .../src/store/slices/agent-status.test.ts | 45 +- .../browser-annotation-retention.test.ts | 108 ++-- .../slices/browser-cleanup-close.test.ts | 130 +++- .../browser-layout-owner-commands.test.ts | 131 ++-- .../browser-mount-admission-removal.test.ts | 68 ++- ...rowser-page-close-intent-recording.test.ts | 77 ++- .../slices/browser-page-conversion.test.ts | 150 +++-- .../store/slices/browser-page-conversion.ts | 11 +- .../browser-remote-page-lifecycle.test.ts | 276 ++++----- .../browser-remote-tab-creation.test.ts | 77 ++- ...wser-slice-drawn-from-main.test-fixture.ts | 52 ++ .../slices/browser-slice-test-harness.ts | 2 + .../browser-tab-creation-placement.test.ts | 153 +++-- .../browser-workspace-doc-location.test.ts | 140 +++-- .../browser-workspace-host-ownership.test.ts | 50 +- src/renderer/src/store/slices/browser.test.ts | 214 +++++-- .../slices/browser/browser-close-actions.ts | 425 +++++++------ .../browser/browser-hydration-actions.ts | 60 +- .../slices/browser/browser-hydration-state.ts | 8 +- .../browser/browser-layout-owner-commands.ts | 96 ++- .../browser-page-conversion-actions.ts | 208 ++++--- .../browser/browser-page-create-actions.ts | 359 +++++------ .../slices/browser/browser-tab-actions.ts | 218 ++++--- .../browser-tab-close-through-layout-owner.ts | 90 +++ .../browser/browser-tab-focus-actions.ts | 61 +- .../browser-tab-open-through-layout-owner.ts | 75 +++ .../content-tabs-from-main-frames.test.ts | 384 ++++++++++++ ...ntent-tabs-paired-server-workspace.test.ts | 183 ++++++ .../slices/editor-tab-layout-owner.test.ts | 23 + .../editor-tab-close-through-layout-owner.ts | 91 +-- .../native-chat-launch-draft-teardown.test.ts | 27 +- .../store/slices/recently-closed-tabs.test.ts | 7 + ...-active-worktree-removal-successor.test.ts | 18 + ...store-active-worktree-split-groups.test.ts | 25 +- .../store-active-worktree-tab-close.test.ts | 78 ++- .../store-open-to-side-layout-owner.test.ts | 37 +- .../tabs-drawn-from-main.test-fixture.ts | 2 + .../content-tab-close-through-layout-owner.ts | 158 +++++ .../slices/tabs/content-tab-open-placement.ts | 62 ++ .../tabs/structured-chat-close-owner.test.ts | 37 +- .../store/slices/tabs/tabs-close-actions.ts | 166 +++-- .../attention-removal-follow-up.ts | 11 +- ...ce-unread-removal-characterization.test.ts | 21 +- ...le-listing-purges-created-worktree.test.ts | 23 +- .../store/workspace-layout-frame-held-tabs.ts | 26 +- .../store/workspace-layout-frame-slices.ts | 4 +- .../rpc-contract/workspace-layout-params.ts | 3 +- .../old-client-session-editor-rows.ts | 142 +++++ .../old-client/old-client-session-row-ids.ts | 22 + .../old-client/old-client-session-tab-rows.ts | 20 +- .../old-client-session-tabs-projection.ts | 55 +- .../workspace-layout-browser-commands.test.ts | 6 +- .../workspace-layout-command-types.ts | 9 +- .../workspace-layout-group-commands.ts | 29 +- .../workspace-layout-move-tab-pin.test.ts | 76 +++ .../workspace-layout-tab-commands.ts | 7 +- ...uctured-agent-session-surface-execution.ts | 3 + ...session-tab-close-regressions.unit.test.ts | 100 ++- 119 files changed, 6547 insertions(+), 2708 deletions(-) create mode 100644 src/main/runtime/agent-session-tab-layout-command.test.ts create mode 100644 src/main/runtime/agent-session-tab-layout-queue.test.ts create mode 100644 src/main/runtime/agent-session-tab-layout-queue.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-create-window-placement.test.ts create mode 100644 src/main/runtime/session-layout-apply.test-fixture.ts create mode 100644 src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts delete mode 100644 src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts delete mode 100644 src/renderer/src/runtime/agent-session-tab-layout-owner.ts create mode 100644 src/renderer/src/runtime/layout-owner-content-tab-open.ts delete mode 100644 src/renderer/src/runtime/layout-owner-drawn-tab-placement.test.ts delete mode 100644 src/renderer/src/runtime/layout-owner-drawn-tab-placement.ts create mode 100644 src/renderer/src/runtime/layout-owner-pending-tab-facts.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.test.ts create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.ts create mode 100644 src/renderer/src/runtime/session-tab-rows-main-window-parity.test.ts create mode 100644 src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts create mode 100644 src/renderer/src/store/slices/browser/browser-tab-close-through-layout-owner.ts create mode 100644 src/renderer/src/store/slices/browser/browser-tab-open-through-layout-owner.ts create mode 100644 src/renderer/src/store/slices/content-tabs-from-main-frames.test.ts create mode 100644 src/renderer/src/store/slices/content-tabs-paired-server-workspace.test.ts create mode 100644 src/renderer/src/store/slices/tabs/content-tab-close-through-layout-owner.ts create mode 100644 src/renderer/src/store/slices/tabs/content-tab-open-placement.ts create mode 100644 src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts create mode 100644 src/shared/workspace-layout/old-client/old-client-session-row-ids.ts create mode 100644 src/shared/workspace-layout/workspace-layout-move-tab-pin.test.ts diff --git a/config/scripts/pr-unit-support-routing.test.mjs b/config/scripts/pr-unit-support-routing.test.mjs index 238a55114e5..58874ff7a4d 100644 --- a/config/scripts/pr-unit-support-routing.test.mjs +++ b/config/scripts/pr-unit-support-routing.test.mjs @@ -25,7 +25,9 @@ const supportBridges = new Set([ 'src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts', 'src/renderer/src/components/editor/editor-model-lifetime-fixture.ts', // Autosave suites' store rig; it takes the editor harness's tab-layout maps. - 'src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts' + 'src/renderer/src/components/editor/editor-autosave-controller-test-fixture.ts', + // Browser suites' store with main drawing their tabs; it takes the browser harness's store. + 'src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts' ]) function productConsumers(file, graph, unitFiles) { diff --git a/src/main/runtime/agent-session-tab-layout-command.test.ts b/src/main/runtime/agent-session-tab-layout-command.test.ts new file mode 100644 index 00000000000..e13028fee71 --- /dev/null +++ b/src/main/runtime/agent-session-tab-layout-command.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import type { Tab } from '../../shared/tab-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { chatTabLayoutCommand, loadChatTabLayout } from './agent-session-tab-layout' + +const WT = 'repo-1::/tmp/worktree-a' + +function chatTab(sessionId: string, id: string): Tab { + return { + id, + entityId: sessionId, + groupId: 'group-1', + worktreeId: WT, + contentType: 'agent-session', + label: 'Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function session(tabs: Tab[], activeGroupId?: string): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + activeWorktreeId: WT, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { [WT]: tabs }, + tabGroups: { + [WT]: [{ id: 'group-1', worktreeId: WT, activeTabId: null, tabOrder: tabs.map((t) => t.id) }] + }, + ...(activeGroupId ? { activeGroupIdByWorktree: { [WT]: activeGroupId } } : {}) + } +} + +/** The command for `change` against `stored` as main loads it. */ +function commandOn( + stored: WorkspaceSessionState, + change: Parameters[3] +) { + const { loaded, activeGroupId } = loadChatTabLayout('local', stored, WT) + return chatTabLayoutCommand(loaded.layout.workspaces[WT], activeGroupId, WT, change) +} + +const open = (sessionId: string) => ({ + open: { sessionId, agent: 'codex' as const, tabId: `agent-session:${sessionId}` } +}) + +describe("a host's chat tab change as main's layout command", () => { + it("opens a listed chat in the workspace's remembered active group", () => { + expect(commandOn(session([chatTab('a', 'saved-tab')], 'group-1'), open('b'))).toEqual({ + type: 'openAgentSessionTab', + workspace: WT, + tabId: 'agent-session:b', + sessionId: 'b', + agent: 'codex', + groupId: 'group-1' + }) + }) + + it('names no group the workspace no longer holds, nor one with no layout loaded', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')], 'gone'), open('b'))).toEqual( + expect.not.objectContaining({ groupId: expect.anything() }) + ) + expect(chatTabLayoutCommand(undefined, 'group-1', WT, open('b'))).toEqual( + expect.not.objectContaining({ groupId: expect.anything() }) + ) + }) + + it('opens nothing for a chat a tab already shows', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')], 'group-1'), open('a'))).toBeNull() + }) + + it('closes the tab that shows the chat, under its saved id, forced', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')]), { close: 'a' })).toEqual({ + type: 'closeTabs', + workspace: WT, + tabIds: ['saved-tab'], + force: true + }) + }) + + it('closes nothing for a chat no tab shows', () => { + expect(commandOn(session([chatTab('a', 'saved-tab')]), { close: 'b' })).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-tab-layout-queue.test.ts b/src/main/runtime/agent-session-tab-layout-queue.test.ts new file mode 100644 index 00000000000..56d975d1cd4 --- /dev/null +++ b/src/main/runtime/agent-session-tab-layout-queue.test.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from 'vitest' +import { createChatTabLayoutQueue } from './agent-session-tab-layout-queue' +import type { ChatTabLayoutCommand } from './agent-session-tab-layout' + +const open = (sessionId: string) => ({ + worktreeId: 'wt', + change: { open: { sessionId, agent: 'codex' as const, tabId: `tab-${sessionId}` } } +}) + +it('asks no host of a worktree with no saved session, and goes on past a change that fails', async () => { + const applied: string[] = [] + const hostIdOf = vi.fn((): never => { + throw new Error('folder_workspace_not_found') + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const queue = createChatTabLayoutQueue({ + sessionOf: () => null, + hostIdOf, + apply: async (command: ChatTabLayoutCommand) => { + if (command.type === 'openAgentSessionTab' && command.sessionId === 'a') { + throw new Error('layout_store_unavailable') + } + applied.push(command.type === 'openAgentSessionTab' ? command.sessionId : 'close') + return { ok: true, result: {} } + } + }) + + queue(() => [open('a')]) + queue(() => [open('b')]) + await vi.waitFor(() => expect(applied).toEqual(['b'])) + + expect(hostIdOf).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() +}) + +it('goes on past a batch whose changes cannot be read', async () => { + const applied: string[] = [] + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const queue = createChatTabLayoutQueue({ + sessionOf: () => null, + hostIdOf: () => 'local', + apply: async (command: ChatTabLayoutCommand) => { + applied.push(command.type === 'openAgentSessionTab' ? command.sessionId : 'close') + return { ok: true, result: {} } + } + }) + + queue(() => { + throw new Error('session store unreadable') + }) + queue(() => [open('b')]) + await vi.waitFor(() => expect(applied).toEqual(['b'])) + + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() +}) diff --git a/src/main/runtime/agent-session-tab-layout-queue.ts b/src/main/runtime/agent-session-tab-layout-queue.ts new file mode 100644 index 00000000000..add84705c23 --- /dev/null +++ b/src/main/runtime/agent-session-tab-layout-queue.ts @@ -0,0 +1,64 @@ +// A layout-owner build's chat tab changes from this host (a chat it publishes, retires or +// restores): main's own layout commands, with or without a window. + +import type { ExecutionHostId } from '../../shared/execution-host' +import type { LayoutApplyReply } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import { + chatTabLayoutCommand, + loadChatTabLayout, + type ChatTabChange, + type ChatTabLayoutCommand +} from './agent-session-tab-layout' + +export type QueuedChatTabChange = { worktreeId: string; change: ChatTabChange } + +/** + * Runs each batch in order, every change read against the layout as it stands when the queue + * reaches it, so a close follows the open it undoes. A refusal is logged, never thrown. + */ +export function createChatTabLayoutQueue(deps: { + sessionOf: (worktreeId: string) => WorkspaceSessionState | null + /** Read only for a worktree with a saved session; it throws for one the host cannot place. */ + hostIdOf: (worktreeId: string) => ExecutionHostId + apply: (command: ChatTabLayoutCommand) => Promise +}): (changesNow: () => readonly QueuedChatTabChange[]) => void { + let queue: Promise = Promise.resolve() + const commandNow = ({ worktreeId, change }: QueuedChatTabChange) => { + const session = deps.sessionOf(worktreeId) + if (!session) { + return chatTabLayoutCommand(undefined, undefined, worktreeId, change) + } + const hostId = deps.hostIdOf(worktreeId) + const { loaded, activeGroupId } = loadChatTabLayout(hostId, session, worktreeId) + return chatTabLayoutCommand( + loaded.layout.workspaces[worktreeId], + activeGroupId, + worktreeId, + change + ) + } + return (changesNow) => { + queue = queue.then(async () => { + // A batch or change that fails is logged and the queue goes on: a later one must still land. + let changes: readonly QueuedChatTabChange[] + try { + changes = changesNow() + } catch (error) { + console.warn('[agent-session-tabs] reading chat tab changes failed:', error) + return + } + for (const queued of changes) { + try { + const command = commandNow(queued) + const reply = command ? await deps.apply(command) : null + if (command && reply && !reply.ok) { + console.warn(`[agent-session-tabs] ${command.type} refused:`, reply.code) + } + } catch (error) { + console.warn('[agent-session-tabs] a chat tab change failed:', error) + } + } + }) + } +} diff --git a/src/main/runtime/agent-session-tab-layout.ts b/src/main/runtime/agent-session-tab-layout.ts index 404a801b522..6cd7a23a72d 100644 --- a/src/main/runtime/agent-session-tab-layout.ts +++ b/src/main/runtime/agent-session-tab-layout.ts @@ -6,7 +6,8 @@ import type { ExecutionHostId } from '../../shared/execution-host' import { applyLayoutCommand } from '../../shared/workspace-layout/workspace-layout-commands' import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' -import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { CommandOf } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-model' import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { randomUUID } from 'node:crypto' @@ -17,36 +18,66 @@ export type ChatTabChange = | { open: { sessionId: string; agent: AgentType; tabId: string } } | { close: string } +/** A chat tab change as main's layout command: a window's, which routes by the chat's tab. */ +export type ChatTabLayoutCommand = + | (CommandOf<'openAgentSessionTab'> & { tabId: string }) + | (CommandOf<'closeTabs'> & { tabIds: [string] }) + /** - * The partition with the worktree's chat tabs changed in one layout load: a chat opens in the active - * group unless a saved tab shows it, and a close drops the chat's tab. Null when nothing changed. + * One change against the workspace's layout as loaded now: a chat opens in the active group (while + * the workspace still holds it) unless a tab shows it, and a close drops the chat's tab. Null when + * nothing changes. Main's queue and the windowless save both decide through it. */ +export function chatTabLayoutCommand( + workspace: WorkspaceLayout | undefined, + activeGroupId: string | undefined, + worktreeId: string, + change: ChatTabChange +): ChatTabLayoutCommand | null { + // A remembered group the workspace no longer holds names nothing: the open mints its own. + const groupId = workspace?.groups.some((group) => group.id === activeGroupId) + ? activeGroupId + : undefined + const sessionId = 'close' in change ? change.close : change.open.sessionId + const tabId = + workspace?.tabs.find((tab) => tab.kind === 'agent-session' && tab.entityId === sessionId)?.id ?? + null + if ('close' in change) { + return tabId ? { type: 'closeTabs', workspace: worktreeId, tabIds: [tabId], force: true } : null + } + if (tabId) { + return null + } + const open = { type: 'openAgentSessionTab', workspace: worktreeId, ...change.open } as const + return groupId ? { ...open, groupId } : open +} + +/** The layout loaded from a partition, with the worktree's remembered active group. */ +export function loadChatTabLayout( + hostId: ExecutionHostId, + session: WorkspaceSessionState, + worktreeId: string +) { + const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) + return { loaded, activeGroupId: loaded.desktopView.activeGroupIdByWorktree?.[worktreeId] } +} + +/** The partition with the worktree's chat tabs changed in one layout load; null when unchanged. */ export function withChatTabChanges( hostId: ExecutionHostId, session: WorkspaceSessionState, worktreeId: string, changes: readonly ChatTabChange[] ): WorkspaceSessionState | null { - const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext()) - const activeGroupId = loaded.desktopView.activeGroupIdByWorktree?.[worktreeId] + const { loaded, activeGroupId } = loadChatTabLayout(hostId, session, worktreeId) let layout = loaded.layout for (const change of changes) { - const workspace = layout.workspaces[worktreeId] - // A remembered group the workspace no longer holds names nothing: the open mints its own. - const groupId = workspace?.groups.some((group) => group.id === activeGroupId) - ? activeGroupId - : undefined - const sessionId = 'close' in change ? change.close : change.open.sessionId - const tabId = - workspace?.tabs.find((tab) => tab.kind === 'agent-session' && tab.entityId === sessionId) - ?.id ?? null - let command: LayoutCommand | null = null - if ('close' in change && tabId) { - command = { type: 'closeTabs', workspace: worktreeId, tabIds: [tabId], force: true } - } else if ('open' in change && !tabId) { - const open = { type: 'openAgentSessionTab', workspace: worktreeId, ...change.open } as const - command = groupId ? { ...open, groupId } : open - } + const command = chatTabLayoutCommand( + layout.workspaces[worktreeId], + activeGroupId, + worktreeId, + change + ) const applied = command ? applyLayoutCommand(layout, command, { mintId: randomUUID, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts index bd7b14b4401..4a477ad60ac 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-close-before-create.test.ts @@ -157,6 +157,15 @@ describe('a close that reaches main before its create', () => { expect(rig.host.hasSession(SESSION)).toBe(false) }) + it('a close main fails for want of its chat host still refuses the create that follows', async () => { + setStructuredAgentSessionHost(null) + expect(await close()).toMatchObject({ ok: false }) + setStructuredAgentSessionHost(rig.host) + + expect(await create()).toMatchObject(refusedAsClosed) + expect(runtimeCalls.publishStructuredAgentSessionTab).not.toHaveBeenCalled() + }) + it('switch off: a close before the create is not remembered', async () => { mocks.layoutOwnerIsMain = false await close() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts index 351feef5d32..1c15a9f26ba 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-handler.ts @@ -118,6 +118,7 @@ export async function handleStructuredAgentSessionCreate( runtime: ctx.runtime, caller: callerFor(ctx), prepared, - activate: true + activate: true, + placedByWindow: ctx.caller?.kind === 'desktop' }) } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-window-placement.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-window-placement.test.ts new file mode 100644 index 00000000000..125efcd8adc --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-window-placement.test.ts @@ -0,0 +1,68 @@ +// A chat this machine's window creates is placed by the window's own open, where it reserved the +// chat's place; main's publish of it places nothing. Any other caller's chat main places itself. +import '../unused-default-rpc-methods.test-fixture' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../../shared/agent-session-create-capabilities' +import { structuredAgentSessionCreateParams } from '../../../../shared/structured-agent-session-create' +import { + createRestTestRig, + type RestTestRig +} from '../../../native-chat/agent-session-wire/structured-agent-session-rest-test-rig' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_NOW as NOW +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { DESKTOP_RPC_CALLER, type RpcCallerIdentity } from '../rpc-caller-identity' +import { call, runtimeCalls } from './structured-agent-session-rpc.test-fixture' + +const CLIENT = { + clientId: 'device-1', + clientKind: 'runtime' as const, + clientCapabilities: [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY + ] +} + +let rig: RestTestRig + +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + setStructuredAgentSessionHost(rig.host) +}) +afterEach(async () => { + setStructuredAgentSessionHost(null) + await rig.dispose() +}) + +// The transport's caller stamp, which the fixture forwards with the rest of the client. +const create = (client: typeof CLIENT & { caller?: RpcCallerIdentity }) => + call( + 'agentSession.create', + structuredAgentSessionCreateParams({ + sessionId: SESSION, + worktree: 'id:workspace-1', + agent: 'codex', + randomUuid: () => '00000000-0000-0000-0000-0000000000fb', + now: NOW + }), + client + ) + +it.each([ + ["this machine's window", { ...CLIENT, caller: DESKTOP_RPC_CALLER }, true], + ['a paired device', { ...CLIENT, caller: { kind: 'paired-device', deviceId: 'phone-1' } }, false], + ['a caller the transport did not name', CLIENT, false] +] as const)( + 'a create from %s leaves its chat to the window only when the window made it', + async (_, client, byWindow) => { + expect(await create(client)).toMatchObject({ ok: true, result: { ok: true } }) + + expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledOnce() + const published = runtimeCalls.publishStructuredAgentSessionTab?.mock.calls[0]?.[0] + expect(published).toMatchObject({ sessionId: SESSION, activate: true }) + expect(published?.placedByWindow).toBe(byWindow ? true : undefined) + } +) 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 d5eba180f3c..7c7cd24a2c5 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -195,9 +195,12 @@ export async function commitStructuredAgentSessionCreate(args: { caller: StructuredAgentSessionCaller prepared: PreparedStructuredAgentSessionCreate activate: boolean + /** This machine's window made the create and opens the chat's tab where it reserved it. */ + placedByWindow?: boolean }): Promise> { const { prepared } = args const tab = prepared.tab + const placedByWindow = args.placedByWindow ? { placedByWindow: true } : {} if (prepared.atRest) { return prepared.host.create(args.caller, prepared.attachParams, { ...prepared.atRest, @@ -217,6 +220,7 @@ export async function commitStructuredAgentSessionCreate(args: { sessionId: prepared.attachParams.envelope.sessionId, agent: tab.agent, activate: args.activate, + ...placedByWindow, ...(prepared.attachParams.surfaceTabId ? { tabId: prepared.attachParams.surfaceTabId } : {}) @@ -246,6 +250,7 @@ export async function commitStructuredAgentSessionCreate(args: { sessionId: result.value.sessionId, agent: prepared.tab.agent, activate: args.activate, + ...placedByWindow, ...(surfaceTabId ? { tabId: surfaceTabId } : {}) }) } catch (error) { diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index f1982ab6dd1..3e6c736f976 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -153,13 +153,15 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ permission: 'workspace', params: OptionsParams, handler: async (params, ctx) => { - const host = requireStructuredCleanupHost(ctx) + requireStructuredCapability(ctx) + // Noted before the host is required: a close that fails still refuses the create it raced. if (ORCA_LAYOUT_OWNER_IS_MAIN) { noteStructuredAgentSessionClose( params.sessionId, - host.deps.store.getRecord(params.sessionId) !== null + (getStructuredAgentSessionHost()?.deps.store.getRecord(params.sessionId) ?? null) !== null ) } + const host = requireStructuredCleanupHost(ctx) // Terminal-disposal closes use this RPC without the session-tabs retirement RPC. if (typeof host.setSessionTabVisibility === 'function') { await host.setSessionTabVisibility(params.sessionId, false) diff --git a/src/main/runtime/runtime-layout-apply.ts b/src/main/runtime/runtime-layout-apply.ts index 6894a8f5513..c9bba8572f8 100644 --- a/src/main/runtime/runtime-layout-apply.ts +++ b/src/main/runtime/runtime-layout-apply.ts @@ -101,7 +101,7 @@ export async function applyWindowLayoutCommand( ) { markAgentLaunchesClosedByUser(command.workspace, { kind: 'tab', tabId: terminalTabId }) } - // Why: a workspace main lacks has no partition record to write; the window already removed the tab. + // Why: a workspace main lacks has no tab to close; the window drops the tab as for tab_not_found. return reply.ok && reply.result.alreadyClosed ? { ok: false, code: 'workspace_not_found' } : reply diff --git a/src/main/runtime/session-layout-apply.test-fixture.ts b/src/main/runtime/session-layout-apply.test-fixture.ts new file mode 100644 index 00000000000..145a1775e57 --- /dev/null +++ b/src/main/runtime/session-layout-apply.test-fixture.ts @@ -0,0 +1,28 @@ +// Main's `layout.apply` on a stored partition, as the Store applies it, for runtime tests whose +// store is a plain session map (a layout-owner build saves a chat's tab through it). +import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { LayoutCommand } from '../../shared/workspace-layout/workspace-layout-command-types' +import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission' +import { resolveHostId } from '../persistence/loading-store/session-host-partitions' + +export function layoutApplyOn( + get: (hostId: string) => WorkspaceSessionState | null, + set: (next: WorkspaceSessionState, hostId: string) => void +) { + return async (command: LayoutCommand, hostIds?: readonly string[]) => { + const hostId = resolveHostId(hostIds?.[0]) + const session = get(hostId) + if (!session) { + return { ok: false as const, code: 'workspace_not_found' as const } + } + const change = applyCommandToSession(hostId, session, command) + if (change.session) { + set(change.session, hostId) + } + return change.value + } +} + +/** Main queues a host's chat tab commands; this lets them land. */ +export const chatTabCommandsSettled = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)) diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.test.ts b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts index cc4ed43bb0a..2625f7d9899 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.test.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.test.ts @@ -84,7 +84,7 @@ function mainProjection( return rest } -describe('session.tabs rows from main (layout-owner build, before D3b)', () => { +describe('session.tabs rows from main (layout-owner build)', () => { it("takes main's terminal rows and name, with the window's theme, status and draft", () => { const theme = { mode: 'dark' as const, theme: {} } const agentStatus = null @@ -125,6 +125,18 @@ describe('session.tabs rows from main (layout-owner build, before D3b)', () => { ]) }) + // Main's diff row is never dirty (its drafts are keyed by path); the window's edit is. + it("shows a diff the window lists with the window's dirty state, over main's row", () => { + const edited = { ...file('d1', 'diff'), isDirty: true } + const merged = withMainSessionTabRows( + windowSnapshot([edited], ['d1'], 'd1'), + mainProjection([file('d1', 'diff')], ['d1'], 'd1'), + NONE_EXITED + ) + + expect(merged.tabs).toEqual([{ ...edited, isActive: true }]) + }) + it('keeps the rows main does not hold (browser, diff) in the window’s place', () => { const merged = withMainSessionTabRows( windowSnapshot( diff --git a/src/main/runtime/session-tabs/main-session-tab-rows.ts b/src/main/runtime/session-tabs/main-session-tab-rows.ts index a90aa8d7060..6957346686e 100644 --- a/src/main/runtime/session-tabs/main-session-tab-rows.ts +++ b/src/main/runtime/session-tabs/main-session-tab-rows.ts @@ -1,10 +1,8 @@ -// Layout-owner builds, before main holds browser and chat records (D3b): a workspace main owns -// publishes its terminal and editor rows, tab order and groups from main's layout -// (projectSessionTabs). The window's live publication supplies what the user sees: selection, the -// focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced -// save), the rows main's model does not hold (browser, chat, diff and other view-only editor rows) -// and the facts only the window has (terminal theme, agent status, launch drafts, a markdown -// view's mode, file id and draft version). +// Layout-owner builds: a workspace main owns publishes its rows, tab order and groups from main's +// layout (projectSessionTabs). The window's live publication supplies what the user sees: selection, +// the focused pane, dirty state and live terminal titles (main's copy of the view lags a debounced +// save), the rows main's layout does not list yet, and the facts only the window has (terminal +// theme, agent status, launch drafts, a markdown view's mode, file id and draft version). import type { RuntimeMobileSessionSnapshotTab } from '../../../shared/runtime-mobile-session-tab-contracts' import { orderIdOf, placeInMainGroups, withStrayIn } from './main-session-tab-group-placement' @@ -22,8 +20,9 @@ export type MainSessionTabRows = Omit< > /** - * Rows main's layout answers for: terminals and edit-mode files (the model holds no diff or - * preview-only tab). Any other row is the window's until D3b. + * Rows main's layout answers for, with the window's facts: terminals and edit-mode files. Main also + * lists browser, chat, diff and markdown-preview rows, but the window's row of the same id stands + * whole while the window lists it (its draft is what makes a diff dirty). */ function isMainRow(row: Row): boolean { return ( diff --git a/src/main/runtime/session-tabs/structured-session-tab-close.ts b/src/main/runtime/session-tabs/structured-session-tab-close.ts index 445eba16238..03de169f2a5 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-close.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-close.ts @@ -18,6 +18,10 @@ import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-w import type { StructuredAgentSessionCloseCause } from '../../native-chat/agent-session-wire/structured-agent-session-host-lifetime' import { retireStructuredAgentSessionTabFrom } from '../structured-agent-session-tab-retirement' import { chatTabId, withChatTabChanges, type ChatTabChange } from '../agent-session-tab-layout' +import { createChatTabLayoutQueue } from '../agent-session-tab-layout-queue' +import type { WindowLayoutCommand } from '../runtime-layout-apply' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' import { savedSessionId } from '../saved-structured-agent-session-restoration' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { structuredSessionTabRetired } from '../structured-worker-custody' @@ -33,6 +37,8 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca ...args: Parameters ): ReturnType + abstract applyLayoutCommand(command: WindowLayoutCommand): Promise + protected abstract persistHeadlessTabGroups( ...args: Parameters ): ReturnType @@ -286,16 +292,36 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca if (!host || !structuredSessionTabRetired(host, sessionId)) { return } - const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {} - for (const [worktreeId, tabs] of Object.entries(saved)) { - if (tabs.some((tab) => savedSessionId(tab) === sessionId)) { - this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }]) - } + // Switch point: main reads which worktrees show the chat when its queue reaches the close, so + // an open still queued ahead of it is closed too. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + this.queueChatTabChanges(() => + this.worktreesShowingSavedChat(sessionId).map((worktreeId) => ({ + worktreeId, + change: { close: sessionId } + })) + ) + return } + for (const worktreeId of this.worktreesShowingSavedChat(sessionId)) { + this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }]) + } + } + + private worktreesShowingSavedChat(sessionId: string): string[] { + const saved = this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {} + return Object.entries(saved).flatMap(([worktreeId, tabs]) => + tabs.some((tab) => savedSessionId(tab) === sessionId) ? [worktreeId] : [] + ) } // A window saves its own layout whole, chat tabs included; only a host with none saves them here. protected changeWindowlessChatTabs(worktreeId: string, changes: readonly ChatTabChange[]): void { + // Switch point: main places a chat's tab in its own layout, with or without a window. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + this.queueChatTabChanges(() => changes.map((change) => ({ worktreeId, change }))) + return + } const session = this.getAvailableAuthoritativeWindow() ? null : this.getWorkspaceSessionForWorktree(worktreeId) @@ -311,4 +337,10 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca this.setWorkspaceSessionForWorktree(worktreeId, next) } } + + private readonly queueChatTabChanges = createChatTabLayoutQueue({ + sessionOf: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId), + hostIdOf: (worktreeId) => this.getWorkspaceSessionHostIdForWorktree(worktreeId), + apply: (command) => this.applyLayoutCommand(command) + }) } diff --git a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts index 6cfc57fb71e..4829bfceff0 100644 --- a/src/main/runtime/session-tabs/structured-session-tab-restoration.ts +++ b/src/main/runtime/session-tabs/structured-session-tab-restoration.ts @@ -6,6 +6,7 @@ import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { seedStructuredAgentSessionTabIndex } from '../structured-agent-session-tab-index-seed' import { restoredChatTabChanges } from './restored-chat-tab-changes' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionTabsSnapshot, @@ -102,6 +103,8 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten notify?: boolean /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string + /** This machine's window made the create; its own open places the tab. */ + placedByWindow?: boolean }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { @@ -117,7 +120,10 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten }) }) } - this.saveWindowlessAgentSessionTab(input.workspaceId, input) + // Switch point: main leaves a window's chat to the window's open, the one placement decision. + if (!(ORCA_LAYOUT_OWNER_IS_MAIN && input.placedByWindow)) { + this.saveWindowlessAgentSessionTab(input.workspaceId, input) + } this.projectStructuredAgentSessionTab(input) } diff --git a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts index 6780344c216..a6c94c61249 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout-host.test.ts @@ -15,6 +15,10 @@ import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-sess import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { OrcaRuntimeService } from './orca-runtime' import { withChatTabChanges } from './agent-session-tab-layout' +import { + chatTabCommandsSettled as settled, + layoutApplyOn +} from './session-layout-apply.test-fixture' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -85,7 +89,11 @@ async function runtimeWithSavedChats( } else { partitions.set(hostId, next) } - } + }, + applyLayoutCommand: layoutApplyOn( + (hostId) => (hostId === 'local' ? state.session : (partitions.get(hostId) ?? null)), + (next, hostId) => store.setWorkspaceSession(next, hostId) + ) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths. const runtime = new OrcaRuntimeService(store as never) @@ -97,6 +105,7 @@ async function runtimeWithSavedChats( activate: false }) } + await settled() return { runtime, state } } @@ -152,6 +161,7 @@ describe('a windowless chat tab against the chat host', () => { expect(host.deps.store.getRecord(CHAT_A)).not.toBeNull() await host.setSessionTabVisibility(CHAT_A, false) + await settled() expect(savedChatIds(state)).toEqual([CHAT_B]) }) @@ -166,6 +176,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_B]) }) @@ -189,6 +200,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_B]) }) @@ -203,6 +215,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual(['short', CHAT_B]) }) @@ -225,6 +238,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_B, CHAT_SSH]) }) @@ -252,6 +266,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(partitions.get('runtime:env-y')?.unifiedTabs?.[WT]?.map((tab) => tab.entityId)).toEqual([ CHAT_REMOTE @@ -264,6 +279,7 @@ describe('a windowless chat tab against the chat host', () => { restoringRuntime(runtime) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual([CHAT_GONE, CHAT_B]) }) diff --git a/src/main/runtime/structured-agent-session-windowless-layout.test.ts b/src/main/runtime/structured-agent-session-windowless-layout.test.ts index c9837363ad5..0faf0d3ca8c 100644 --- a/src/main/runtime/structured-agent-session-windowless-layout.test.ts +++ b/src/main/runtime/structured-agent-session-windowless-layout.test.ts @@ -6,6 +6,8 @@ import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { OrcaRuntimeService } from './orca-runtime' import { withChatTabChanges } from './agent-session-tab-layout' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { chatTabCommandsSettled, layoutApplyOn } from './session-layout-apply.test-fixture' const WT = 'repo-1::/tmp/worktree-a' @@ -34,10 +36,18 @@ function runtimeStore(state: { session: WorkspaceSessionState }) { getWorkspaceSessionHostIds: () => ['local' as const], setWorkspaceSession: (next: WorkspaceSessionState) => { state.session = next - } + }, + applyLayoutCommand: layoutApplyOn( + () => state.session, + (next) => { + state.session = next + } + ) } } +const settled = chatTabCommandsSettled + /** A chat host with only the members a test's path reads. */ function useChatHost(fake: Record): void { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each test supplies every host member its path reads. @@ -72,6 +82,7 @@ describe('a chat tab on a host with no window', () => { await runtime.publishStructuredAgentSessionTab({ ...chat, activate: true }) await runtime.publishStructuredAgentSessionTab({ ...chat, activate: true }) + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) }) @@ -84,6 +95,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() useChatHost({ getSessionTabId: (sessionId: string) => `reserved-${sessionId}` }) await runtime.publishStructuredAgentSessionTab({ workspaceId: WT, @@ -91,6 +103,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() expect(state.session.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual([ 'structured-agent-session-chat-1', @@ -106,6 +119,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() // A window opening the same chat reuses the tab with this id, content type and entity. expect(state.session.unifiedTabs?.[WT]).toEqual([ @@ -125,6 +139,7 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) // A fresh runtime lists no chats, as when the close RPC or a worker stop runs before a restore. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths. @@ -132,11 +147,12 @@ describe('a chat tab on a host with no window', () => { useChatHost({ getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) }) fresh.onStructuredSessionTabHidden('chat-1') + await settled() expect(savedChatIds(state)).toEqual([]) }) - it("closes this host's chat tab in the local partition and leaves every other partition alone", () => { + it("closes this host's chat tab in the local partition and leaves every other partition alone", async () => { const empty: WorkspaceSessionState = { activeRepoId: 'repo-1', activeWorktreeId: WT, @@ -169,7 +185,11 @@ describe('a chat tab on a host with no window', () => { getWorkspaceSession: (hostId = 'local') => sessions.get(hostId) ?? null, setWorkspaceSession: (next: WorkspaceSessionState, hostId = 'local') => { sessions.set(hostId, next) - } + }, + applyLayoutCommand: layoutApplyOn( + (hostId) => sessions.get(hostId) ?? null, + (next, hostId) => sessions.set(hostId, next) + ) } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only these store methods on these paths. const runtime = new OrcaRuntimeService(store as never) @@ -179,6 +199,7 @@ describe('a chat tab on a host with no window', () => { runtime.onStructuredSessionTabHidden('chat-x') runtime.onStructuredSessionTabHidden('chat-ssh') runtime.onStructuredSessionTabHidden('chat-y') + await settled() expect(sessions.get('local')?.unifiedTabs?.[WT] ?? []).toEqual([]) expect(sessions.get('runtime:env-x')).toBe(remote) @@ -198,7 +219,9 @@ describe('a chat tab on a host with no window', () => { getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['chat-1'] }) }) + await settled() runtime.onStructuredSessionTabHidden('chat-1') + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) }) @@ -222,11 +245,13 @@ describe('a chat tab on a host with no window', () => { }) await runtime.restoreStructuredAgentSessionTabs() + await settled() expect(savedChatIds(state)).toEqual(['chat-1']) }) - it('is left to the window when one saves the layout', async () => { + // A layout-owner build's main places the chat's tab itself, window or not. + it('is left to the window when one saves the layout, unless main owns the layout', async () => { const { runtime, state } = createRuntime() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected member; any non-null value is an attached window. const withWindow = runtime as unknown as { getAvailableAuthoritativeWindow(): unknown } @@ -238,6 +263,77 @@ describe('a chat tab on a host with no window', () => { agent: 'codex', activate: true }) + await settled() + + expect(savedChatIds(state)).toEqual(ORCA_LAYOUT_OWNER_IS_MAIN ? ['chat-1'] : []) + }) + + // Switch-on, a window's own launch reserves the chat's place and opens it there once the create + // answers; main's publish leaves the placing to that open, in either order. + it.each(['publish first', 'window open first'])( + "puts a window's chat where the window opens it: %s", + async (order) => { + const { runtime, state } = createRuntime() + for (const sessionId of ['chat-a', 'chat-b']) { + await runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId, + agent: 'codex', + activate: false + }) + } + await settled() + const publish = () => + runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId: 'chat-w', + agent: 'codex', + activate: true, + placedByWindow: true + }) + const windowOpen = () => + runtime.applyLayoutCommand({ + type: 'openAgentSessionTab', + workspace: WT, + tabId: structuredAgentSessionTabId('chat-w'), + sessionId: 'chat-w', + agent: 'codex', + afterTabId: structuredAgentSessionTabId('chat-a') + }) + + if (order === 'publish first') { + await publish() + await settled() + await windowOpen() + } else { + await windowOpen() + await publish() + } + await settled() + + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return + } + expect(state.session.tabGroups?.[WT]?.map((group) => group.tabOrder)).toEqual([ + ['chat-a', 'chat-w', 'chat-b'].map(structuredAgentSessionTabId) + ]) + } + ) + + it('closes a chat whose open is still queued when its close arrives', async () => { + const { runtime, state } = createRuntime() + useChatHost({ getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }) }) + + // Not awaited between: the close reaches main while the open is still in its queue. + void runtime.publishStructuredAgentSessionTab({ + workspaceId: WT, + sessionId: 'chat-1', + agent: 'codex', + activate: true + }) + runtime.onStructuredSessionTabHidden('chat-1') + await settled() + await settled() expect(savedChatIds(state)).toEqual([]) }) diff --git a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts index a409ff071b6..7fdede736fe 100644 --- a/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts +++ b/src/main/runtime/terminal/launch/mobile-terminal-chat-fork.test.ts @@ -5,6 +5,8 @@ import { prepareStructuredChatCliFork } from '../../../ai-vault/structured-chat- import { OrchestrationError } from '../../orchestration/orchestration-error' import { PROJECT_RUNTIME_REPAIR_REQUIRED_CODE } from '../../../../shared/project-runtime-repair-message' import '../../orca-runtime-test-lifecycle.spec' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' vi.mock('../../../ai-vault/structured-chat-cli-fork', () => ({ prepareStructuredChatCliFork: vi.fn(), isStructuredChatCliForkSourceCurrent: vi.fn(() => true) @@ -200,6 +202,11 @@ it.each([true, false])( expect(fixture.getSession().unifiedTabs?.[wt]).toEqual( expect.arrayContaining([expect.objectContaining({ id: canonical, entityId: source })]) ) + } else if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main placed the published chat's tab itself, so the fork opens beside it. + expect(admit.mock.calls[0]?.[0]).toMatchObject({ + afterTabId: structuredAgentSessionTabId(source) + }) } else { expect(admit.mock.calls[0]?.[0]).not.toHaveProperty('afterTabId') expect(reveal.mock.calls[0]?.[1]).not.toHaveProperty('afterTabId') diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts index db1d09f809a..7788b422300 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts @@ -1,15 +1,29 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' import { BROWSER_ANNOTATION_VIEWPORT_MESSAGE_PREFIX } from '../../../../../shared/browser-annotation-viewport-bridge' -import { createBrowserMockApi, createTestStore } from '@/store/slices/browser-slice-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createBrowserMockApi } from '@/store/slices/browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from '@/store/slices/browser-slice-drawn-from-main.test-fixture' import { makeAnnotation } from '@/store/slices/browser-annotation-test-fixture' import { syncGuestAnnotationViewportBridge } from '../annotate/guest-annotation-viewport-bridge' import { createBrowserPageWebviewLoadingHandlers } from './browser-page-webview-loading-handlers' import { createBrowserPageWebviewNavigationHandlers } from './browser-page-webview-navigation-handlers' +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + afterEach(() => vi.unstubAllGlobals()) -function createHarness() { +async function createHarness() { const setAnnotationViewportBridge = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('window', { api: { @@ -17,11 +31,19 @@ function createHarness() { browser: { ...createBrowserMockApi(vi.fn()).browser, setAnnotationViewportBridge } } }) - const store = createTestStore() + const { store, main } = await createTestStoreDrawnFromMain() const pageId = store.getState().createBrowserTab('wt-1', 'https://example.com').activePageId + await main.settle() if (!pageId) { throw new Error('Expected browser page') } + // A layout-owner build's main opened the page's tab, and its frame drew the page. + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [expect.objectContaining({ pageId })] : [] + ) + expect(Object.values(store.getState().browserPagesByWorkspace).flat()).toEqual([ + expect.objectContaining({ id: pageId }) + ]) store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) const ref = (current: T) => ({ current }) const cancelCapture = vi.fn() @@ -106,8 +128,8 @@ function navigationEvent( } describe('browser annotation document boundaries', () => { - it('still accepts token-authenticated live target reports', () => { - const h = createHarness() + it('still accepts token-authenticated live target reports', async () => { + const h = await createHarness() const report = JSON.stringify({ scrollX: 0, scrollY: 0, @@ -134,8 +156,8 @@ describe('browser annotation document boundaries', () => { }) }) - it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', () => { - const h = createHarness() + it('retains the exact saved notes on actual same-URL loading-start, including untracked loads', async () => { + const h = await createHarness() const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] h.loading.handleDidStartLoading() expect(h.store.getState().browserAnnotationsByPageId[h.pageId]).toBe(saved) @@ -147,8 +169,8 @@ describe('browser annotation document boundaries', () => { ) }) - it('retires geometry for full and same-document main-frame navigation but not subframes', () => { - const h = createHarness() + it('retires geometry for full and same-document main-frame navigation but not subframes', async () => { + const h = await createHarness() h.navigation.handleDidStartNavigation( navigationEvent('https://example.com/frame', false, false) ) @@ -162,8 +184,8 @@ describe('browser annotation document boundaries', () => { expect(h.store.getState().browserAnnotationMarkerIdsByPageId[h.pageId]).toBeUndefined() }) - it('keeps geometry retired after redirects and away/back commits, including missed start events', () => { - const h = createHarness() + it('keeps geometry retired after redirects and away/back commits, including missed start events', async () => { + const h = await createHarness() const saved = h.store.getState().browserAnnotationsByPageId[h.pageId] h.loading.handleDidStartLoading() h.navigation.handleDidRedirectNavigation(navigationEvent('https://example.org/redirect')) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx index ad011173bb2..0b7d0745882 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -79,6 +79,7 @@ vi.mock('@/lib/launch-structured-agent-session', () => { }) import { StructuredAgentSessionStatusBridge } from './StructuredAgentSessionStatusBridge' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' import { StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch' import { @@ -361,7 +362,15 @@ describe('a chat whose start was never confirmed', () => { hostIsReachableAgain() await flush() expect(recreated(CLAUDE_SESSION)).toBe(0) - expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Switch-on, main's host is told to close the chat instead of the window keeping a tombstone. + expect(mocks.history).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.close', { + sessionId: CLAUDE_SESSION + }) + expect(lifecycle(CLAUDE_SESSION)).toBeNull() + } else { + expect(lifecycle(CLAUDE_SESSION)).toBe('cancelled') + } }) it('re-checks once per return of its own host: one still unknown waits for the next', async () => { diff --git a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx index 91fe76c5fbd..3f56d21fe71 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultSessionVirtualList.keyboard.test.tsx @@ -8,6 +8,8 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau import type { AgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { structuredAgentSessionPaneKey, structuredAgentSessionTabId @@ -78,7 +80,22 @@ vi.mock('@/lib/activate-ai-vault-structured-session', async (importOriginal) => structuredSession: { sessionId: string; workspaceId: string } }) => { const tabs = useAppStore.getState().unifiedTabsByWorktree[s.workspaceId] ?? [] - if (!tabs.some((tab) => tab.entityId === s.sessionId)) { + if (tabs.some((tab) => tab.entityId === s.sessionId)) { + return true + } + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Switch-on, the host's republished chat reaches the window as main's frame. + await layoutApplyTestTransport.call({ + command: { + type: 'openAgentSessionTab', + workspace: s.workspaceId, + tabId: s.sessionId, + sessionId: s.sessionId, + agent: 'claude' + } + }) + await layoutApplyTestTransport.settle() + } else { useAppStore.getState().createUnifiedTab(s.workspaceId, 'agent-session', { id: s.sessionId, entityId: s.sessionId, @@ -91,7 +108,23 @@ vi.mock('@/lib/activate-ai-vault-structured-session', async (importOriginal) => ) })) +// Switch-on, main opens and closes the chat tabs and its frames draw them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + import { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' class NoopResizeObserver { observe(): void {} @@ -222,7 +255,11 @@ beforeEach(() => { }) }) +let main: TabsDrawnFromMain | undefined + afterEach(() => { + main?.close() + main = undefined cleanup() vi.unstubAllGlobals() vi.restoreAllMocks() @@ -270,6 +307,7 @@ describe('Session History keyboard selection', () => { agentSessionAgent: 'claude' }) } + main = await drawTabsFromMain(useAppStore) const order = () => store().groupsByWorktree[workspaceId]?.[0]?.tabOrder const resumed = structuredAgentSessionTabId('new') const confirm = vi.fn<(options: unknown) => Promise>() @@ -278,6 +316,7 @@ describe('Session History keyboard selection', () => { const settle = async () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) + await main?.settle() }) const pick = async (settlement: StructuredAgentLaunchSettlement, enter = press) => { launch.settlement = settlement @@ -339,6 +378,7 @@ describe('Session History keyboard selection', () => { agentSessionAgent: 'claude' }) } + main = await drawTabsFromMain(useAppStore) const order = () => useAppStore.getState().groupsByWorktree[workspaceId]?.[0]?.tabOrder const dismiss = vi.fn() const { press } = renderList({ @@ -357,6 +397,7 @@ describe('Session History keyboard selection', () => { press('Enter') await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) + await main?.settle() }) } await refusedBy('old') diff --git a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts index 4b6e04dd2d8..1a59f359ccc 100644 --- a/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts +++ b/src/renderer/src/components/tab-bar/cli-chat-resume-run.test.ts @@ -36,6 +36,17 @@ vi.mock('sonner', () => ({ toast: { error: mocks.toast } })) vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ activateAiVaultStructuredSession: mocks.activate })) +// Switch-on, main opens the chat's tab and its frame draws it. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) import { adoptAgentSessionLaunchVerdict } from '@/lib/agent-session-launch-plan' import { newAgentLaunchRequestId } from '@/lib/agent-launch-request-id' @@ -62,6 +73,8 @@ import { buildHydratedTabState } from '@/store/slices/tabs-hydration' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { useAppStore } from '@/store' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { runCliChatResume } from './cli-chat-resume-run' import { resetStructuredAgentLaunchRegistryForTests, @@ -75,6 +88,10 @@ import { } from '@/runtime/web-session-focus-intent' import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' import { registerSessionTabIpcBridge } from '@/hooks/ipc-events/session-tab-ipc-bridge' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' const SOURCE: CliChatResumeSource = { tabId: 'tab-1', @@ -85,7 +102,12 @@ const SOURCE: CliChatResumeSource = { executionHostId: 'local' } const tabs = () => useAppStore.getState().unifiedTabsByWorktree['wt-1'] ?? [] -afterEach(() => vi.unstubAllGlobals()) +let main: TabsDrawnFromMain | undefined +afterEach(() => { + vi.unstubAllGlobals() + main?.close() + main = undefined +}) function deferred() { let resolve: (value: T) => void = () => {} const promise = new Promise((done) => { @@ -102,7 +124,7 @@ function refuse(reason: 'transcriptNotFound' | 'transcriptUnreadable'): AgentSes } } -beforeEach(() => { +beforeEach(async () => { vi.clearAllMocks() resetLocalStructuredSessionVersionForTests() resetWebSessionTabsSnapshotFreshnessForTests() @@ -122,6 +144,7 @@ beforeEach(() => { return { ok: true, value: { sessionId: params.envelope.sessionId, fence: 1 } } } ) + main = await drawTabsFromMain(useAppStore) }) function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult { @@ -147,10 +170,18 @@ function snapshot(sessionId?: string): RuntimeMobileSessionTabsResult { } } -function seedTabs() { - return ['E', 'S', 'B'].map((id) => +/** Main takes the store's current layout, as after a restart; seeding after it is invisible to main. */ +async function redrawFromMain(): Promise { + main?.close() + main = await drawTabsFromMain(useAppStore) +} + +async function seedTabs() { + const seeded = ['E', 'S', 'B'].map((id) => useAppStore.getState().createUnifiedTab('wt-1', 'editor', { id, entityId: id }) ) + await redrawFromMain() + return seeded } describe('CLI identity resume through the existing launch owner', () => { @@ -325,7 +356,7 @@ describe('CLI identity resume through the existing launch owner', () => { ])( 'keeps canonical membership through registered IPC (%s first, move %s to %s)', async (first, mover, requested, expected) => { - const [e, source, browser] = seedTabs() + const [e, source, browser] = await seedTabs() let move: Parameters[0] = () => {} vi.stubGlobal('api', { ...window.api, @@ -352,6 +383,7 @@ describe('CLI identity resume through the existing launch owner', () => { const later = useAppStore .getState() .createUnifiedTab('wt-1', 'editor', { id: 'R', entityId: 'R' }) + await redrawFromMain() const beforeNoop = useAppStore.getState() move({ kind: 'reorder', @@ -379,6 +411,7 @@ describe('CLI identity resume through the existing launch owner', () => { entityId: 'other', split: { splitDirection: 'right', sourceGroupId: e.groupId } }) + await redrawFromMain() clearWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), 'wt-1') if (first === 'inventory') { applyStructuredSessionTabSnapshots([snapshot(sessionId)]) @@ -398,10 +431,26 @@ describe('CLI identity resume through the existing launch owner', () => { } ) + // Main's publish of a window's create places nothing switch-on, so the resume's own open must. + it('opens a resumed chat once, through main, after the tab before its hidden slot', async () => { + const [, , browser] = await seedTabs() + await runCliChatResume(SOURCE) + await main?.settle() + const sessionId: string = mocks.call.mock.calls[0][2].envelope.sessionId + const slotId = `structured-agent-session-${sessionId}` + expect(tabs().find((tab) => tab.entityId === sessionId)?.id).toBe(slotId) + expect(useAppStore.getState().groupsByWorktree['wt-1'][0].tabOrder.at(-1)).toBe(slotId) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(layoutApplyTestTransport.sentOfType('openAgentSessionTab')).toEqual([ + expect.objectContaining({ tabId: slotId, sessionId, afterTabId: browser.id }) + ]) + } + }) + it.each([false, true])( 'keeps overlapping requests in their canonical order (second finishes first: %s)', async (reverse) => { - const initial = seedTabs() + const initial = await seedTabs() const replies = [deferred(), deferred()] mocks.call .mockImplementationOnce(() => replies[0].promise) @@ -424,7 +473,7 @@ describe('CLI identity resume through the existing launch owner', () => { } ) - it('restores a pending-only group as unknown without sending create or restoring focus', () => { + it('restores a pending-only group as unknown without sending create or restoring focus', async () => { const record = { sessionId: 'reload-pending', executionHostId: 'local' as const, @@ -461,9 +510,11 @@ describe('CLI identity resume through the existing launch owner', () => { ...buildHydratedTabState(session, new Set(['wt-1'])), worktreesByRepo: { repo: [makeWorktree({ id: 'wt-1', repoId: 'repo', hostId: 'local' })] } }) + await redrawFromMain() restoreStructuredLaunchSurfaces() restoreStructuredLaunchSurfaces() applyStructuredSessionTabSnapshots([snapshot()]) + await main?.settle() expect(mocks.call).not.toHaveBeenCalled() expect( peekWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), 'wt-1') diff --git a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx index 7486a21de42..a6d66b27756 100644 --- a/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx +++ b/src/renderer/src/components/terminal/initial-terminal-admission-wait.test.tsx @@ -21,7 +21,11 @@ vi.mock('@/store', () => ({ tabsByWorktree: {}, closedTerminalTabTombstonesByTabId: {}, unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, activeGroupIdByWorktree: {}, + // A switch-on chat open places the tab in the workspace's root group. + ensureWorktreeRootGroup: () => 'group-1', createUnifiedTab: mocks.createUnifiedTab, setActiveTabType: vi.fn() }) @@ -47,11 +51,20 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ : new Promise(() => undefined) })) +// Main answers the window's chat-tab open (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) + import { useTerminalWatcherEffects } from '../use-terminal-watcher-effects' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' import { isEmptyWorkspaceDefaultSurfacePending } from '@/lib/empty-workspace-default-surface-claims' import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import type { Tab } from '../../../../shared/tab-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) let root: Root | undefined @@ -61,8 +74,40 @@ afterEach(async () => { root = undefined mocks.answers.length = 0 vi.clearAllMocks() + layoutApplyTestTransport.reset() }) +/** No shell created here or (switch-on) asked of main. */ +function expectNoShell(): void { + expect(mocks.createTab).not.toHaveBeenCalled() + expect(layoutApplyTestTransport.sentOfType('createTerminalTab')).toEqual([]) +} + +/** The watcher's shell create: here, or (switch-on) sent to main, whose frame draws it. */ +async function expectShellCreatedOnce(): Promise { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await layoutApplyTestTransport.settle() + expect(layoutApplyTestTransport.sentOfType('createTerminalTab')).toEqual([ + expect.objectContaining({ workspace: 'wt-1' }) + ]) + return + } + expect(mocks.createTab).toHaveBeenCalledOnce() +} + +/** The chat opened: drawn here, or (switch-on) its open sent to main, whose frame draws it. */ +async function expectChatOpenedOnce(): Promise { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await layoutApplyTestTransport.settle() + expect(layoutApplyTestTransport.sentOfType('openAgentSessionTab')).toEqual([ + expect.objectContaining({ workspace: 'wt-1', sessionId: 'claude_1' }) + ]) + expect(mocks.createUnifiedTab).not.toHaveBeenCalled() + return + } + expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() +} + function Watcher(): null { useTerminalWatcherEffects({ activeWorktreeId: 'wt-1', @@ -142,12 +187,12 @@ describe('a local chat launch waiting on its host', () => { mocks.gate.mockResolvedValueOnce('blocked').mockResolvedValueOnce('empty') root = createRoot(document.createElement('div')) await act(async () => root?.render(createElement(Watcher))) - expect(mocks.createTab).not.toHaveBeenCalled() + expectNoShell() await act(async () => root?.unmount()) root = createRoot(document.createElement('div')) await act(async () => root?.render(createElement(Watcher))) - expect(mocks.createTab).toHaveBeenCalledOnce() + await expectShellCreatedOnce() }) it('keeps a watched create free of a stray shell until its chat opens', async () => { @@ -163,11 +208,11 @@ describe('a local chat launch waiting on its host', () => { root = createRoot(document.createElement('div')) await act(async () => root?.render(createElement(Watcher))) await act(async () => finish()) - expect(mocks.createTab).not.toHaveBeenCalled() + expectNoShell() await act(async () => mocks.answers[0]?.({ supported: true })) - expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() - expect(mocks.createTab).not.toHaveBeenCalled() + await expectChatOpenedOnce() + expectNoShell() }) it("keeps an empty workspace's default chat free of a shell seeded by the shared gate", async () => { @@ -181,11 +226,11 @@ describe('a local chat launch waiting on its host', () => { await act(async () => root?.render(createElement(Watcher))) await act(async () => finish()) expect(mocks.begin).not.toHaveBeenCalled() - expect(mocks.createTab).not.toHaveBeenCalled() + expectNoShell() await act(async () => mocks.answers[0]?.({ supported: true })) - expect(mocks.createUnifiedTab).toHaveBeenCalledOnce() - expect(mocks.createTab).not.toHaveBeenCalled() + await expectChatOpenedOnce() + expectNoShell() }) it('stops owning the surface once the host has answered and its surface opened', async () => { diff --git a/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx b/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx index 24b267bf8a8..c3a94e8d442 100644 --- a/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx +++ b/src/renderer/src/components/worktree-jump-palette-browser-navigation.test.tsx @@ -21,11 +21,27 @@ import { makeWorktree } from './worktree-jump-palette-test-fixtures' import type { QuickActionPaletteItem } from './worktree-jump-palette-model' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' vi.mock('@/hooks/useSettingsNavigationMetadata', () => ({ useSettingsNavigationMetadata: () => [] })) +// Main answers the window's layout commands; its frames draw the browser tab. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const { openWorkspaceBrowserTab } = vi.hoisted(() => ({ openWorkspaceBrowserTab: vi.fn() })) vi.mock(import('@/lib/workspace-browser-tab-open'), async (importOriginal) => ({ ...(await importOriginal()), @@ -111,6 +127,8 @@ function usePalette() { } describe('Cmd+J website selection', () => { + let main: TabsDrawnFromMain | undefined + beforeEach(() => { openWorkspaceBrowserTab.mockReset().mockResolvedValue(undefined) useAppStore.setState( @@ -130,6 +148,8 @@ describe('Cmd+J website selection', () => { afterEach(() => { cleanup() + main?.close() + main = undefined vi.restoreAllMocks() useAppStore.setState(initialAppState, true) }) @@ -220,9 +240,11 @@ describe('Cmd+J website selection', () => { } }) } else { + main = await drawTabsFromMain(useAppStore) useAppStore.getState().createBrowserTab('wt-beta', 'https://example.com/', { title: name }) + await main.settle() } const { result } = renderHook(usePalette) diff --git a/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts b/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts index 5ca8c137b1b..821bbe3ef21 100644 --- a/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts +++ b/src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts @@ -1,12 +1,177 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { Tab, TabGroup } from '../../../shared/tab-types' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { AppState } from '@/store/types' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createTestStore, makeWorktree } from '@/store/slices/store-test-helpers' +import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness' + +// Switch on, the open goes to main as `layout.apply` and main's frame draws the tab. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +type DrawnStore = { getState: () => AppState } +const drawn = vi.hoisted((): { store: DrawnStore | null; main: TabsDrawnFromMain | null } => ({ + store: null, + main: null +})) + +/** Switch on: a real store seeded like the mock (whose tabs it keeps), drawn from main's frames. */ +async function drawFromMain(): Promise<{ + store: DrawnStore + sent: () => readonly { type: string }[] +}> { + createStoreCascadesMockApi() + const store = createTestStore() + const unifiedTabsByWorktree = Object.fromEntries( + Object.entries(mockStoreState.unifiedTabsByWorktree).map(([worktreeId, tabs]) => [ + worktreeId, + tabs.map((tab): Tab => ({ + entityId: tab.id, + label: 'Mobile Emulator', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + worktreeId, + ...tab, + contentType: 'simulator' + })) + ]) + ) + const groupsByWorktree = Object.fromEntries( + Object.entries(mockStoreState.groupsByWorktree).map(([worktreeId, groups]) => [ + worktreeId, + groups.map(({ id }): TabGroup => ({ + id, + worktreeId, + activeTabId: null, + tabOrder: (unifiedTabsByWorktree[worktreeId] ?? []) + .filter((tab) => tab.groupId === id) + .map((tab) => tab.id) + })) + ]) + ) + store.setState({ + activeWorktreeId: mockStoreState.activeWorktreeId, + activeWorkspaceExecutionHostId: mockStoreState.activeWorkspaceExecutionHostId, + // One repo per row, so same-id rows on two hosts stay apart. + worktreesByRepo: Object.fromEntries( + mockStoreState + .allWorktrees() + .map((row, index) => [ + `repo-${index}`, + [makeWorktree({ ...row, repoId: `repo-${index}`, path: `/path/${index}` })] + ]) + ), + folderWorkspaces: [], + groupsByWorktree, + layoutByWorktree: Object.fromEntries( + Object.entries(groupsByWorktree).map(([worktreeId, [first, second]]) => [ + worktreeId, + second + ? { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: first!.id }, + second: { type: 'leaf', groupId: second.id } + } + : { type: 'leaf', groupId: first!.id } + ]) + ), + activeGroupIdByWorktree: { ...mockStoreState.activeGroupIdByWorktree }, + unifiedTabsByWorktree + }) + drawn.main = await drawTabsFromMain(store) + drawn.store = store + return { store, sent: () => layoutApplyTestTransport.sent } +} + +/** Switch on: opens through main, draws main's answer, and returns the drawn tab and the open + * main got (over this machine's `layout.apply`, the only target the transport answers). */ +async function openThroughMain(open: () => string | null): Promise<{ + tabId: string | null + tab: Tab | undefined + sent: { type: string; groupId?: string; split?: unknown }[] + store: DrawnStore +}> { + const { store, sent } = await drawFromMain() + const tabId = open() + await drawn.main!.settle() + return { + tabId, + tab: store.getState().unifiedTabsByWorktree['wt-1']?.find((entry) => entry.id === tabId), + sent: sent().filter((command) => command.type === 'openSimulatorTab'), + store + } +} + +/** + * A background open for this machine's host, in group-1, drawn local and never selected. + * `viaMain` false: a paired server owns the active twin, so the window draws it as today. + */ +async function expectBackgroundLocalOpen(viaMain: boolean): Promise { + const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + const { tabId, tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' }) + ) + expect(sent).toEqual(viaMain ? [expect.objectContaining({ tabId, groupId: 'group-1' })] : []) + expect(tab).toMatchObject({ + contentType: 'simulator', + label: 'Mobile Emulator', + groupId: 'group-1', + executionHostId: 'local' + }) + expectNotSurfaced(store, tab!) +} + +/** The window selected the tab, its group and the simulator view, as a surfaced open does. */ +function expectSurfaced(store: DrawnStore, tab: Tab): void { + const state = store.getState() + const group = state.groupsByWorktree['wt-1']?.find((entry) => entry.id === tab.groupId) + expect(group?.activeTabId).toBe(tab.id) + expect(group?.recentTabIds).toContain(tab.id) + expect(state.activeGroupIdByWorktree['wt-1']).toBe(tab.groupId) + expect(state.activeTabType).toBe('simulator') +} + +/** No focus: not in the group's recents, no focus time, the view type unchanged. */ +function expectNotSurfaced(store: DrawnStore, tab: Tab): void { + const state = store.getState() + const group = state.groupsByWorktree['wt-1']?.find((entry) => entry.id === tab.groupId) + expect(group?.recentTabIds ?? []).not.toContain(tab.id) + expect(tab.lastFocusedAt).toBeUndefined() + expect(state.activeTabType).not.toBe('simulator') +} + +type MockWorktreeRow = { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +type MockTabRow = { + id: string + groupId: string + worktreeId?: string + executionHostId?: ExecutionHostId + contentType: string + label?: string +} const mockStoreState = vi.hoisted(() => ({ activeGroupIdByWorktree: {} as Record, - activeWorkspaceExecutionHostId: null as string | null, + activeWorkspaceExecutionHostId: ((): ExecutionHostId | null => null)(), activeWorktreeId: 'wt-1', - allWorktrees: vi.fn( - () => [] as { id: string; hostId?: string; runtimeOwnerEnvironmentId?: string }[] - ), + allWorktrees: vi.fn((): MockWorktreeRow[] => []), activateTab: vi.fn(), createEmptySplitGroup: vi.fn(), createUnifiedTab: vi.fn(), @@ -30,17 +195,7 @@ const mockStoreState = vi.hoisted(() => ({ settings: { mobileEmulatorEnabled: true }, setActiveTab: vi.fn(), setActiveTabType: vi.fn(), - unifiedTabsByWorktree: {} as Record< - string, - { - id: string - groupId: string - worktreeId?: string - executionHostId?: string - contentType: string - label?: string - }[] - > + unifiedTabsByWorktree: ((): Record => ({}))() })) function stubCreatedSimulatorTab(groupId: string): void { @@ -60,7 +215,7 @@ function createdSimulatorTabId(): string { vi.mock('@/store', () => ({ useAppStore: { - getState: () => mockStoreState + getState: () => drawn.store?.getState() ?? mockStoreState } })) @@ -89,7 +244,16 @@ describe('ensureSimulatorTab', () => { mockStoreState.focusGroup.mockReset() mockStoreState.setActiveTab.mockReset() mockStoreState.setActiveTabType.mockReset() - vi.resetModules() + // Switch on, main's transport and frames live in modules a reset would orphan. + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + vi.resetModules() + } + }) + + afterEach(() => { + drawn.main?.close() + drawn.main = null + drawn.store = null }) it('activates an existing simulator tab through unified tab state', async () => { @@ -123,6 +287,18 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The active twin is host-b's, a paired server's: the window draws it as today. + const { tabId, tab, sent } = await openThroughMain(() => ensureSimulatorTab('wt-1')) + expect(tabId).not.toBe('sim-local') + expect(sent).toEqual([]) + expect(tab).toMatchObject({ + contentType: 'simulator', + groupId: 'group-1', + executionHostId: 'runtime:host-b' + }) + return + } expect(ensureSimulatorTab('wt-1')).toBe(createdSimulatorTabId()) expect(mockStoreState.createUnifiedTab).toHaveBeenCalled() }) @@ -232,6 +408,19 @@ describe('ensureSimulatorTab', () => { }) const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const { tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit' }) + ) + expect(sent).toEqual([ + expect.objectContaining({ split: { besideGroupId: 'group-1', direction: 'right' } }) + ]) + expect(tab).toMatchObject({ contentType: 'simulator', label: 'Mobile Emulator' }) + expect(tab!.groupId).not.toBe('group-1') + expect(sent[0]).toMatchObject({ groupId: tab!.groupId }) + expectSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe('sim-2') expect(mockStoreState.createEmptySplitGroup).not.toHaveBeenCalled() @@ -268,6 +457,16 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-2') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const { tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit' }) + ) + expect(sent).toEqual([expect.objectContaining({ groupId: 'group-2' })]) + expect(sent[0]).not.toHaveProperty('split') + expect(tab).toMatchObject({ label: 'Mobile Emulator', groupId: 'group-2' }) + expectSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe(createdSimulatorTabId()) expect(mockStoreState.createUnifiedTabInSplit).not.toHaveBeenCalled() @@ -288,6 +487,18 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main makes the split with the tab in one apply: no half-made split to fall back from. + const { tabId, tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit' }) + ) + expect(sent).toEqual([ + expect.objectContaining({ tabId, split: { besideGroupId: 'group-1', direction: 'right' } }) + ]) + expect(tab).toMatchObject({ contentType: 'simulator', label: 'Mobile Emulator' }) + expectSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit' })).toBe(createdSimulatorTabId()) expect(mockStoreState.createUnifiedTabInSplit).toHaveBeenCalledWith( @@ -317,6 +528,16 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + const { tabId, tab, sent, store } = await openThroughMain(() => + ensureSimulatorTab('wt-1', { placement: 'rightSplit', surfacePane: false }) + ) + expect(sent).toEqual([expect.objectContaining({ tabId, groupId: 'group-1' })]) + expect(sent[0]).not.toHaveProperty('split') + expect(tab).toMatchObject({ label: 'Mobile Emulator', groupId: 'group-1' }) + expectNotSurfaced(store, tab!) + return + } expect(ensureSimulatorTab('wt-1', { placement: 'rightSplit', surfacePane: false })).toBe( createdSimulatorTabId() ) @@ -345,6 +566,11 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // This machine's twin goes to main; its frame names the tab's host. + await expectBackgroundLocalOpen(true) + return + } expect(ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' })).toBe( createdSimulatorTabId() ) @@ -374,6 +600,10 @@ describe('ensureSimulatorTab', () => { stubCreatedSimulatorTab('group-1') const { ensureSimulatorTab } = await import('./ensure-simulator-tab') + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await expectBackgroundLocalOpen(false) + return + } expect(ensureSimulatorTab('wt-1', { surfacePane: false, executionHostId: 'local' })).toBe( createdSimulatorTabId() ) diff --git a/src/renderer/src/lib/ensure-simulator-tab.ts b/src/renderer/src/lib/ensure-simulator-tab.ts index b728c8bfcd9..055ba8fc45d 100644 --- a/src/renderer/src/lib/ensure-simulator-tab.ts +++ b/src/renderer/src/lib/ensure-simulator-tab.ts @@ -13,7 +13,10 @@ import { import { isExecutionHostAliasForWorktree } from './worktree-execution-host-alias' import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { recordOpenedSimulatorTabInLayoutOwner } from './simulator-tab-layout-owner' +import { + openingSimulatorTabId, + openSimulatorTabThroughLayoutOwner +} from './simulator-tab-layout-owner' import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' type EnsureSimulatorTabOptions = { @@ -102,7 +105,9 @@ export function ensureSimulatorTab( const sourceGroupId = options?.targetGroupId ?? store.activeGroupIdByWorktree[worktreeId] ?? - store.groupsByWorktree[worktreeId]?.[0]?.id + store.groupsByWorktree[worktreeId]?.[0]?.id ?? + // Main's frames draw no empty group: a layout-owner build opens into the workspace's root one. + (ORCA_LAYOUT_OWNER_IS_MAIN ? store.ensureWorktreeRootGroup(worktreeId) : undefined) if (!sourceGroupId) { return null } @@ -124,74 +129,69 @@ export function ensureSimulatorTab( return existing.id } - if (options?.placement === 'rightSplit' && shouldSurface) { - const reusableRightGroupId = findReusableRightSplitGroupId( - store.layoutByWorktree[worktreeId], - sourceGroupId - ) - if (reusableRightGroupId) { - const tabId = createBrowserUuid() - store.createUnifiedTab(worktreeId, 'simulator', { - id: tabId, - label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'), - targetGroupId: reusableRightGroupId, - activate: true, - ...(executionHostId ? { executionHostId } : {}) - }) - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedSimulatorTab(tabId) - } - surfaceCreatedSimulatorTab(worktreeId, tabId) - return tabId - } - + const label = translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator') + const reusableRightGroupId = + options?.placement === 'rightSplit' && shouldSurface + ? findReusableRightSplitGroupId(store.layoutByWorktree[worktreeId], sourceGroupId) + : null + const rightSplit = options?.placement === 'rightSplit' && shouldSurface && !reusableRightGroupId + const groupId = reusableRightGroupId ?? sourceGroupId + const drawHere = (tabId?: string): string | null => { // Why: publish the simulator directly in its split group. A two-step // create-then-move can persist the midpoint during dev reload/HMR. - const splitTab = store.createUnifiedTabInSplit( - worktreeId, - 'simulator', - { - sourceGroupId, - splitDirection: 'right' - }, - { - label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'), - activate: true, - ...(executionHostId ? { executionHostId } : {}) - } - ) + const splitTab = rightSplit + ? store.createUnifiedTabInSplit( + worktreeId, + 'simulator', + { sourceGroupId, splitDirection: 'right' }, + { ...(tabId ? { id: tabId } : {}), label, activate: true, ...hostOf(executionHostId) } + ) + : null if (splitTab) { - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedSimulatorTab(splitTab.id) - } return splitTab.id } + const id = tabId ?? createBrowserUuid() + store.createUnifiedTab(worktreeId, 'simulator', { + id, + label, + targetGroupId: groupId, + activate: reusableRightGroupId ? true : shouldSurface, + ...hostOf(executionHostId) + }) + if (reusableRightGroupId || shouldSurface) { + surfaceCreatedSimulatorTab(worktreeId, id) + } + return id } - - const tabId = createBrowserUuid() - store.createUnifiedTab(worktreeId, 'simulator', { - id: tabId, - label: translate('auto.lib.ensure.simulator.tab.372d21d428', 'Mobile Emulator'), - targetGroupId: sourceGroupId, - activate: shouldSurface, - ...(executionHostId ? { executionHostId } : {}) - }) + // Switch point: main opens the tab and its frame draws it; the window surfaces it after. if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedSimulatorTab(tabId) + const opening = openingSimulatorTabId(worktreeId) + if (opening) { + return opening + } + const sent = openSimulatorTabThroughLayoutOwner(store, { + worktreeId, + label, + groupId, + rightSplit, + surface: (tabId) => { + if (shouldSurface) { + surfaceCreatedSimulatorTab(worktreeId, tabId) + } + }, + drawHere + }) + if (sent) { + return sent + } } - if (shouldSurface) { - surfaceCreatedSimulatorTab(worktreeId, tabId) - } - return tabId + return drawHere() } -/** Switch point: main records the simulator tab the window just drew. */ -function recordOpenedSimulatorTab(tabId: string): void { - const state = useAppStore.getState() - const tab = state.getTab(tabId) - if (tab) { - recordOpenedSimulatorTabInLayoutOwner(state, tab) - } +function hostOf(executionHostId: ExecutionHostId | undefined): { + executionHostId?: ExecutionHostId +} { + return executionHostId ? { executionHostId } : {} } function surfaceCreatedSimulatorTab(worktreeId: string, tabId: string): void { diff --git a/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts index 1020007b212..8ae32342966 100644 --- a/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts +++ b/src/renderer/src/lib/launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredLaunchSlotOwner } from '@/lib/structured-agent-session-launch-slot' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import { suppressCancelledStructuredSessionTabs } from '@/runtime/structured-agent-session-tab-retirement' @@ -15,6 +15,28 @@ import { import { beginStructuredAgentSessionAuthoritativeInventory } from './structured-agent-session-launch-cancellation' import { resetStructuredAgentLaunchPersistenceForTests } from '../structured-agent-session-launch-persistence' import { refreshLocalStructuredSessionTabs } from '@/runtime/local-structured-session-tabs-sync/inventory-refresh' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import { useAppStore } from '@/store' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' +import { makeWorktree } from '@/store/slices/store-test-helpers' + +// Switch on, main draws the window's tabs: `layout.apply` and its frames are the test's main. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const layoutOwner: StructuredLaunchSlotOwner = { unifiedTabsByWorktree: {}, @@ -24,6 +46,10 @@ const layoutOwner: StructuredLaunchSlotOwner = { const WORKTREE_ID = 'repo-1::worktree-1' const SESSION_ID = 'session-close-race' +// A paired server keeps the window's tombstone in both builds; this machine keeps one only with +// the switch off (with it on, main hears the cancel: structured-launch-cancel-layout-owner.test.ts). +const SERVER_HOST = 'runtime:server-1' +const SERVER = { kind: 'environment', environmentId: 'server-1' } as const function latePublication(): RuntimeMobileSessionTabsResult { return { @@ -46,6 +72,157 @@ function latePublication(): RuntimeMobileSessionTabsResult { } } +/** A launch whose create is on its way to `executionHostId`. */ +function setLaunch( + executionHostId: ExecutionHostId, + target: RuntimeClientTarget, + promise: Promise<{ sessionId: string; fence: number }>, + operation = 'close-race' +): void { + setStructuredLaunchState({ + identity: `codex:${WORKTREE_ID}`, + intent: { + worktreeId: WORKTREE_ID, + executionHostId, + target, + sessionId: SESSION_ID, + agent: 'codex', + params: { + envelope: { + sessionId: SESSION_ID, + clientOperationId: `operation-${operation}`, + expectedRuntimeFence: null, + payloadFingerprint: `fingerprint-${operation}` + }, + worktree: `id:${WORKTREE_ID}`, + agent: 'codex' + } + }, + promptDelivery: 'auto-submit', + callers: { + outcome: 'pending', + attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, + entries: new Set(), + promptDeliveryResults: new Set(), + onSettled: () => undefined + }, + promise, + visibilityUnknown: false, + cancelled: false, + selection: { held: {} } + } satisfies StructuredLaunchState) +} + +/** The cancelled launch's late publication stays hidden until the launch settles, on `host`. */ +async function expectLatePublicationSuppressed( + host: ExecutionHostId, + target: RuntimeClientTarget +): Promise { + let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void + setLaunch( + host, + target, + new Promise((resolve) => { + resolveLaunch = resolve + }) + ) + const beforeCancel = beginStructuredAgentSessionAuthoritativeInventory() + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel, host) + ).toBe(false) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, host, layoutOwner) + const afterCancel = beginStructuredAgentSessionAuthoritativeInventory() + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterCancel, host) + ).toBe(false) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + + resolveLaunch({ sessionId: SESSION_ID, fence: 1 }) + await Promise.resolve() + expect(suppressCancelledStructuredSessionTabs(latePublication(), target).tabs).toEqual([]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), beforeCancel, host) + ).toBe(false) + + const afterSettlement = beginStructuredAgentSessionAuthoritativeInventory() + expect( + retireAbsentStructuredAgentSessionLaunchCancellationTombstones(new Set(), afterSettlement, host) + ).toBe(true) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(false) +} + +const TERMINAL = 'terminal-1' +let main: TabsDrawnFromMain | null = null + +/** Switch on: the worktree, with one terminal, drawn from main's frames. */ +async function drawWorktreeFromMain(): Promise { + useAppStore.setState({ + worktreesByRepo: { 'repo-1': [makeWorktree({ id: WORKTREE_ID, repoId: 'repo-1' })] }, + tabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TERMINAL, + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + { + id: TERMINAL, + entityId: TERMINAL, + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [WORKTREE_ID]: [ + { id: 'group-1', worktreeId: WORKTREE_ID, activeTabId: TERMINAL, tabOrder: [TERMINAL] } + ] + }, + activeGroupIdByWorktree: { [WORKTREE_ID]: 'group-1' }, + layoutByWorktree: { [WORKTREE_ID]: { type: 'leaf', groupId: 'group-1' } } + }) + main = await drawTabsFromMain(useAppStore) +} + +/** What the window shows of the chat, and what main's layout holds of it. */ +async function shownChat(): Promise<{ drawn: boolean; inMain: boolean; opened: boolean }> { + await main?.settle() + return { + drawn: (useAppStore.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).some( + (tab) => tab.entityId === SESSION_ID + ), + inMain: (layoutApplyTestTransport.mainModel?.workspaces[WORKTREE_ID]?.tabs ?? []).some( + (tab) => tab.entityId === SESSION_ID + ), + opened: layoutApplyTestTransport + .sentOfType('openAgentSessionTab') + .some((command) => command.sessionId === SESSION_ID) + } +} + +const NOT_SHOWN = { drawn: false, inMain: false, opened: false } + +afterEach(() => { + main?.close() + main = null +}) + describe('structured launch cancellation retirement', () => { beforeEach(() => { vi.clearAllMocks() @@ -62,182 +239,238 @@ describe('structured launch cancellation retirement', () => { }) }) - it('keeps a late publication suppressed until the cancelled launch settles', async () => { - let resolveLaunch!: (receipt: { sessionId: string; fence: number }) => void - const launchPromise = new Promise<{ sessionId: string; fence: number }>((resolve) => { - resolveLaunch = resolve - }) - setStructuredLaunchState({ - identity: `codex:${WORKTREE_ID}`, - intent: { - worktreeId: WORKTREE_ID, - executionHostId: 'local', - target: { kind: 'local' }, - sessionId: SESSION_ID, - agent: 'codex', - params: { - envelope: { - sessionId: SESSION_ID, - clientOperationId: 'operation-close-race', - expectedRuntimeFence: null, - payloadFingerprint: 'fingerprint-close-race' - }, - worktree: `id:${WORKTREE_ID}`, - agent: 'codex' - } - }, - promptDelivery: 'auto-submit', - callers: { - outcome: 'pending', - attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, - entries: new Set(), - promptDeliveryResults: new Set(), - onSettled: () => undefined - }, - promise: launchPromise, - visibilityUnknown: false, - cancelled: false, - selection: { held: {} } - } satisfies StructuredLaunchState) - - const beforeCancel = beginStructuredAgentSessionAuthoritativeInventory() - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - beforeCancel, - 'local' - ) - ).toBe(false) - markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) - const afterCancel = beginStructuredAgentSessionAuthoritativeInventory() - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - afterCancel, - 'local' - ) - ).toBe(false) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) - - resolveLaunch({ sessionId: SESSION_ID, fence: 1 }) - await Promise.resolve() - const suppressed = suppressCancelledStructuredSessionTabs(latePublication(), { kind: 'local' }) - expect(suppressed.tabs).toEqual([]) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - beforeCancel, - 'local' - ) - ).toBe(false) - - const afterSettlement = beginStructuredAgentSessionAuthoritativeInventory() - expect( - retireAbsentStructuredAgentSessionLaunchCancellationTombstones( - new Set(), - afterSettlement, - 'local' - ) - ).toBe(true) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( - false - ) + it('keeps a paired server’s late publication suppressed until the cancelled launch settles', async () => { + await expectLatePublicationSuppressed(SERVER_HOST, SERVER) }) + it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'keeps a late publication suppressed until the cancelled launch settles', + async () => { + await expectLatePublicationSuppressed('local', { kind: 'local' }) + } + ) + // A runtime refused its chats answers with no chat rows; that absence is not the host's answer. - it('keeps a tombstone through an inventory that cannot list chats', async () => { - setStructuredLaunchState({ - identity: `codex:${WORKTREE_ID}`, - intent: { - worktreeId: WORKTREE_ID, - executionHostId: 'local', - target: { kind: 'local' }, - sessionId: SESSION_ID, - agent: 'codex', - params: { - envelope: { - sessionId: SESSION_ID, - clientOperationId: 'operation-unverifiable', - expectedRuntimeFence: null, - payloadFingerprint: 'fingerprint-unverifiable' - }, - worktree: `id:${WORKTREE_ID}`, - agent: 'codex' + // Switch on, this machine keeps no tombstone: main's fence is in its close-before-create test. + it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'keeps a tombstone through an inventory that cannot list chats', + async () => { + setStructuredLaunchState({ + identity: `codex:${WORKTREE_ID}`, + intent: { + worktreeId: WORKTREE_ID, + executionHostId: 'local', + target: { kind: 'local' }, + sessionId: SESSION_ID, + agent: 'codex', + params: { + envelope: { + sessionId: SESSION_ID, + clientOperationId: 'operation-unverifiable', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint-unverifiable' + }, + worktree: `id:${WORKTREE_ID}`, + agent: 'codex' + } + }, + promptDelivery: 'auto-submit', + callers: { + outcome: 'pending', + attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, + entries: new Set(), + promptDeliveryResults: new Set(), + onSettled: () => undefined + }, + promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }), + visibilityUnknown: false, + cancelled: false, + selection: { held: {} } + } satisfies StructuredLaunchState) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + await new Promise((resolve) => setTimeout(resolve, 0)) + const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({ + ...latePublication(), + activeTabId: null, + activeTabType: null, + tabs: [], + ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) + }) + const call = vi.fn() + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + + call.mockResolvedValue({ + ok: true, + result: { snapshots: [frame(true)], authoritative: true } + }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + true + ) + + call.mockResolvedValue({ + ok: true, + result: { snapshots: [frame(false)], authoritative: true } + }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + } + ) + + it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'drains a restored cancellation before a newer inventory retires it', + async () => { + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + resetStructuredAgentLaunchRegistryForTests() + resetStructuredAgentLaunchPersistenceForTests() + + const close = Promise.withResolvers() + const call = vi.fn(({ method }: { method: string }) => { + if (method === 'agentSession.close') { + return close.promise.then(() => ({ ok: true, result: { ok: true } })) } - }, - promptDelivery: 'auto-submit', - callers: { - outcome: 'pending', - attempt: { kind: 'first', requestId: 'plus-pick', stagedPrompt: null }, - entries: new Set(), - promptDeliveryResults: new Set(), - onSettled: () => undefined - }, - promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }), - visibilityUnknown: false, - cancelled: false, - selection: { held: {} } - } satisfies StructuredLaunchState) - markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) - await new Promise((resolve) => setTimeout(resolve, 0)) - const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({ - ...latePublication(), - activeTabId: null, - activeTabType: null, - tabs: [], - ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) - }) - const call = vi.fn() - Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } }) + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { runtime: { call } } + }) - call.mockResolvedValue({ ok: true, result: { snapshots: [frame(true)], authoritative: true } }) - await refreshLocalStructuredSessionTabs() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) - call.mockResolvedValue({ ok: true, result: { snapshots: [frame(false)], authoritative: true } }) - await refreshLocalStructuredSessionTabs() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( - false - ) - }) + expect(call.mock.calls.map(([request]) => request.method)).toEqual([ + 'agentSession.close', + 'session.tabs.listAll' + ]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + true + ) - it('drains a restored cancellation before a newer inventory retires it', async () => { - markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) - resetStructuredAgentLaunchRegistryForTests() - resetStructuredAgentLaunchPersistenceForTests() + // The close shares the host's session lane with create, so settlement drains a late attach. + close.resolve() + await close.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + await refreshLocalStructuredSessionTabs() - const close = Promise.withResolvers() - const call = vi.fn(({ method }: { method: string }) => { - if (method === 'agentSession.close') { - return close.promise.then(() => ({ ok: true, result: { ok: true } })) - } - return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } }) - }) - Object.defineProperty(window, 'api', { - configurable: true, - value: { runtime: { call } } - }) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + expect( + call.mock.calls.filter(([request]) => request.method === 'agentSession.close') + ).toHaveLength(1) + } + ) - await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) + // Switch on, main holds this machine's cancel: neither inventory brings the chat back. + it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'keeps a local cancel through an inventory that cannot list chats', + async () => { + await drawWorktreeFromMain() + setLaunch('local', { kind: 'local' }, Promise.resolve({ sessionId: SESSION_ID, fence: 1 })) + const call = vi.fn((_request: { method: string }): Promise => + Promise.resolve({ ok: true, result: { ok: true } }) + ) + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + await new Promise((resolve) => setTimeout(resolve, 0)) + const closes = () => + call.mock.calls.filter(([request]) => request.method === 'agentSession.close') + expect(closes()).toHaveLength(1) - expect(call.mock.calls.map(([request]) => request.method)).toEqual([ - 'agentSession.close', - 'session.tabs.listAll' - ]) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + const listing = (unverifiable: boolean) => ({ + ok: true, + result: { + snapshots: [ + unverifiable + ? { ...latePublication(), tabs: [], agentSessionsUnverifiable: true as const } + : latePublication() + ], + authoritative: true + } + }) + call.mockImplementation(() => Promise.resolve(listing(true))) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + expect(await shownChat()).toEqual(NOT_SHOWN) - // The close shares the host's session lane with create, so settlement drains a late attach. - close.resolve() - await close.promise - await new Promise((resolve) => setTimeout(resolve, 0)) - await refreshLocalStructuredSessionTabs() + // A later inventory that lists the chat (its create landed late) does not show it either. + call.mockImplementation(() => Promise.resolve(listing(false))) + await refreshLocalStructuredSessionTabs() + expect(await shownChat()).toEqual(NOT_SHOWN) + expect(closes()).toHaveLength(1) + } + ) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( - false - ) - expect( - call.mock.calls.filter(([request]) => request.method === 'agentSession.close') - ).toHaveLength(1) - }) + // An older switch-off build saved the tombstone; this build drains it on its first inventory. + it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'drains a tombstone an older build saved: one close, and main shows no chat', + async () => { + localStorage.setItem( + 'orca:structuredAgentLaunchCancelledSessions:v1', + JSON.stringify([SESSION_ID]) + ) + resetStructuredAgentLaunchPersistenceForTests() + await drawWorktreeFromMain() + + const close = Promise.withResolvers() + const call = vi.fn(({ method }: { method: string }) => { + if (method === 'agentSession.close') { + return close.promise.then(() => ({ ok: true, result: { ok: true } })) + } + return Promise.resolve({ ok: true, result: { snapshots: [], authoritative: true } }) + }) + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + + await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) + expect(call.mock.calls.map(([request]) => request.method)).toEqual([ + 'agentSession.close', + 'session.tabs.listAll' + ]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + true + ) + + close.resolve() + await close.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + expect( + call.mock.calls.filter(([request]) => request.method === 'agentSession.close') + ).toHaveLength(1) + expect(await shownChat()).toEqual(NOT_SHOWN) + } + ) + + it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)( + 'tells main of a local cancel at once, so the next inventory has nothing to drain', + async () => { + const call = vi.fn(({ method }: { method: string }) => + Promise.resolve( + method === 'session.tabs.listAll' + ? { ok: true, result: { snapshots: [], authoritative: true } } + : { ok: true, result: { ok: true } } + ) + ) + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID, 'local', layoutOwner) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + + await refreshLocalStructuredSessionTabs(undefined, { authoritative: true }) + expect(call.mock.calls.map(([request]) => request.method)).toEqual([ + 'agentSession.close', + 'session.tabs.close', + 'session.tabs.listAll' + ]) + } + ) }) diff --git a/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts b/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts index f0fd7ebf76f..7d0e540f578 100644 --- a/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts +++ b/src/renderer/src/lib/pane-manager/client-hosted-browser-row-ephemerality.test.ts @@ -7,7 +7,7 @@ * driven for real below, and every absence is paired with a presence check through the same * oracle so an empty result can never pass for a proof. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore, type AppState } from '@/store' import { buildWorkspaceSessionPayload } from '../workspace-session' import { buildWorkspaceSessionPatch } from '../workspace-session-patch' @@ -17,17 +17,37 @@ import { getClientHostedBrowserRows, hydrateClientHostedBrowserRows } from './client-hosted-browser-row-state' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' + +// Main answers the window's layout commands; its frames draw the local browser tab. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const WT = 'wt-1' const CLIENT_HOSTED_PAGE_ID = 'client-hosted-page-1' let initialState: AppState +let main: TabsDrawnFromMain | undefined -function seedLocalBrowserTab(): string { +/** A layout-owner build draws the tab once main's frame is in. */ +async function seedLocalBrowserTab(): Promise { + main = await drawTabsFromMain(useAppStore) const workspace = useAppStore.getState().createBrowserTab(WT, 'https://local.test/', { title: 'Local page', activate: true }) + await main.settle() return workspace.id } @@ -59,13 +79,15 @@ beforeEach(() => { }) afterEach(() => { + main?.close() + main = undefined useAppStore.setState(initialState, true) hydrateClientHostedBrowserRows([]) }) describe('client-hosted rows and the persisted workspace session', () => { - it('persists a local browser tab but never a client-hosted row', () => { - const localWorkspaceId = seedLocalBrowserTab() + it('persists a local browser tab but never a client-hosted row', async () => { + const localWorkspaceId = await seedLocalBrowserTab() pushClientHostedRow() // Guard the oracle itself: the row has to be live for its absence downstream to mean anything. expect(getClientHostedBrowserRows(WT)).toHaveLength(1) @@ -77,8 +99,8 @@ describe('client-hosted rows and the persisted workspace session', () => { expect(serialize(payload)).not.toContain('https://client-hosted.test/') }) - it('keeps a client-hosted row out of incremental session patches too', () => { - const localWorkspaceId = seedLocalBrowserTab() + it('keeps a client-hosted row out of incremental session patches too', async () => { + const localWorkspaceId = await seedLocalBrowserTab() pushClientHostedRow() const patch = buildWorkspaceSessionPatch(useAppStore.getState(), [ @@ -93,8 +115,8 @@ describe('client-hosted rows and the persisted workspace session', () => { expect(serialize(patch)).not.toContain(CLIENT_HOSTED_PAGE_ID) }) - it('leaves the store tab model untouched when a row arrives', () => { - seedLocalBrowserTab() + it('leaves the store tab model untouched when a row arrives', async () => { + await seedLocalBrowserTab() const before = { browserTabs: useAppStore.getState().browserTabsByWorktree[WT], unifiedTabs: useAppStore.getState().unifiedTabsByWorktree[WT], @@ -110,8 +132,8 @@ describe('client-hosted rows and the persisted workspace session', () => { }) describe('client-hosted rows and the runtime window graph', () => { - it('publishes a local browser tab to the host graph but never a client-hosted row', () => { - const localWorkspaceId = seedLocalBrowserTab() + it('publishes a local browser tab to the host graph but never a client-hosted row', async () => { + const localWorkspaceId = await seedLocalBrowserTab() pushClientHostedRow() expect(getClientHostedBrowserRows(WT)).toHaveLength(1) diff --git a/src/renderer/src/lib/simulator-tab-layout-owner.test.ts b/src/renderer/src/lib/simulator-tab-layout-owner.test.ts index f6ff5302f60..c9e965fc975 100644 --- a/src/renderer/src/lib/simulator-tab-layout-owner.test.ts +++ b/src/renderer/src/lib/simulator-tab-layout-owner.test.ts @@ -1,17 +1,15 @@ // @vitest-environment happy-dom -// With ORCA_LAYOUT_OWNER=main, a simulator tab's open and close reach main as commands, so the -// window's next save changes nothing in main's model (what the save-divergence detector checks). -import { beforeEach, describe, expect, it, vi } from 'vitest' +// With ORCA_LAYOUT_OWNER=main, a simulator tab's open and close reach main as commands first and +// the window draws them from main's frames, so its next save changes nothing in main's model. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' -import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' +import type { WorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-model' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { createBrowserUuid } from '@/lib/browser-uuid' import { createTestStore, makeRuntimeOwnedWorktree, @@ -20,22 +18,21 @@ import { seedStore } from '@/store/slices/store-test-helpers' import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' type LayoutLoad = ReturnType type Mocks = { layoutOwnerIsMain: boolean store: ReturnType | null - main: LayoutLoad | null - sent: LayoutCommand[] } -const mocks = vi.hoisted((): Mocks => ({ - layoutOwnerIsMain: true, - store: null, - main: null, - sent: [] -})) +const mocks = vi.hoisted((): Mocks => ({ layoutOwnerIsMain: true, store: null })) +let main: TabsDrawnFromMain vi.mock('../../../shared/layout-owner-switch', () => ({ get ORCA_LAYOUT_OWNER_IS_MAIN() { @@ -50,30 +47,16 @@ vi.mock('@/store', () => ({ vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn( - async (_target: unknown, method: string, params?: { command?: LayoutCommand }) => { - if (method !== 'layout.apply' || !params?.command) { - return {} - } - mocks.sent.push(params.command) - const main = mainLoad() - // As runtime-layout-apply.ts: the window already confirmed its close, so main forces it. - const command: LayoutCommand = - params.command.type === 'closeTabs' ? { ...params.command, force: true } : params.command - const applied = applyLayoutCommand(main.layout, command, { - mintId: createBrowserUuid, - mintLeafId: createBrowserUuid, - now: Date.now - }) - if (!applied.ok) { - return { ok: false, code: applied.code } - } - mocks.main = { ...main, layout: applied.model } - return { ok: true, result: applied.result } - } - ) -})) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) createStoreCascadesMockApi() @@ -90,11 +73,8 @@ function store(): ReturnType { return mocks.store } -function mainLoad(): LayoutLoad { - if (!mocks.main) { - throw new Error('main not loaded') - } - return mocks.main +function sent(): readonly LayoutCommand[] { + return layoutApplyTestTransport.sent } function loadWindowSave(): LayoutLoad { @@ -107,18 +87,17 @@ function loadWindowSave(): LayoutLoad { /** The workspace as the window's save would give it to main, beside main's stored copy. */ function expectSaveTeachesMainNothing(worktreeId = WORKTREE): void { - const stored = saveWorkspaceLayout(mainLoad()) - const main = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - stored, - nameBasedLoadContext() - ).layout - expect(loadWindowSave().layout.workspaces[worktreeId]).toEqual(main.workspaces[worktreeId]) + // A lone group's tree is implied: the window may store its leaf, main none. + const withoutLoneLeaf = (workspace: WorkspaceLayout | undefined) => + workspace && { ...workspace, groupLayout: split(workspace.groupLayout) } + const split = (tree: TabGroupLayoutNode | undefined) => (tree?.type === 'split' ? tree : null) + expect(withoutLoneLeaf(loadWindowSave().layout.workspaces[worktreeId])).toEqual( + withoutLoneLeaf(layoutApplyTestTransport.mainModel!.workspaces[worktreeId]) + ) } async function settled(): Promise { - await vi.waitFor(() => Promise.resolve()) - await new Promise((resolve) => setTimeout(resolve, 0)) + await main.settle() } function terminalTab(id: string, groupId: string, worktreeId = WORKTREE): Tab { @@ -160,23 +139,31 @@ function seed(groups: TabGroup[], layout: TabGroupLayoutNode): void { activeGroupIdByWorktree: { [WORKTREE]: groups[0]!.id }, layoutByWorktree: { [WORKTREE]: layout } }) - // Main starts from the window's last save, as it does today. - mocks.main = loadWindowSave() } -beforeEach(() => { +/** Main takes the window's layout and draws it from here on. */ +async function seedDrawnFromMain(groups: TabGroup[], layout: TabGroupLayoutNode): Promise { + main?.close() + seed(groups, layout) + main = await drawTabsFromMain(store()) +} + +beforeEach(async () => { mocks.layoutOwnerIsMain = true - mocks.sent = [] vi.spyOn(console, 'warn').mockImplementation(() => {}) mocks.store = createTestStore() - seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }], { - type: 'leaf', - groupId: GROUP - }) + await seedDrawnFromMain( + [{ id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }], + { type: 'leaf', groupId: GROUP } + ) +}) + +afterEach(() => { + main.close() }) function sentTypes(): string[] { - return mocks.sent.map((command) => command.type) + return sent().map((command) => command.type) } describe('simulator tabs in a layout-owner build', () => { @@ -184,7 +171,7 @@ describe('simulator tabs in a layout-owner build', () => { expectSaveTeachesMainNothing() }) - it('records a simulator tab opened in the active group, and its close', async () => { + it('opens a simulator tab in the active group through main, and closes it there', async () => { const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' }) expect(tabId).toBeTruthy() await settled() @@ -196,12 +183,12 @@ describe('simulator tabs in a layout-owner build', () => { expect(sentTypes()).toEqual(['openSimulatorTab', 'closeTabs']) }) - it('records the split group a right-side open makes, and its collapse on close', async () => { + it('makes the right split with the tab in main, and collapses it on close', async () => { const tabId = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' }) await settled() expect(store().getState().groupsByWorktree[WORKTREE]).toHaveLength(2) expectSaveTeachesMainNothing() - expect(mainLoad().layout.workspaces[WORKTREE]?.groupLayout).toMatchObject({ + expect(layoutApplyTestTransport.mainModel!.workspaces[WORKTREE]?.groupLayout).toMatchObject({ type: 'split', direction: 'horizontal', first: { type: 'leaf', groupId: GROUP } @@ -213,9 +200,9 @@ describe('simulator tabs in a layout-owner build', () => { expectSaveTeachesMainNothing() }) - it('records a right-side open into the right group the workspace already has', async () => { + it('opens a right-side tab into the right group the workspace already has', async () => { const right = 'group-2' - seed( + await seedDrawnFromMain( [ { id: GROUP, worktreeId: WORKTREE, activeTabId: TERMINAL, tabOrder: [TERMINAL] }, { id: right, worktreeId: WORKTREE, activeTabId: 'terminal-2', tabOrder: ['terminal-2'] } @@ -233,26 +220,33 @@ describe('simulator tabs in a layout-owner build', () => { expectSaveTeachesMainNothing() }) - it('records an open into the window’s emptied root group', async () => { - seed([{ id: GROUP, worktreeId: WORKTREE, activeTabId: null, tabOrder: [] }], { - type: 'leaf', - groupId: GROUP - }) + it('opens into the root group of a workspace whose tabs all closed', async () => { + await seedDrawnFromMain( + [{ id: GROUP, worktreeId: WORKTREE, activeTabId: null, tabOrder: [] }], + { + type: 'leaf', + groupId: GROUP + } + ) + // Main keeps no empty group, so its frame drops GROUP; the open makes the root group again. + expect(store().getState().groupsByWorktree[WORKTREE] ?? []).toEqual([]) const tabId = ensureSimulatorTab(WORKTREE, { placement: 'activeGroup' }) await settled() - expect(mocks.sent).toMatchObject([ - { type: 'openSimulatorTab', tabId, groupId: GROUP, index: 0 } - ]) + const groups = store().getState().groupsByWorktree[WORKTREE] ?? [] + expect(groups).toHaveLength(1) + expect(store().getState().getTab(tabId!)?.groupId).toBe(groups[0]!.id) + expect(groups[0]!.tabOrder).toEqual([tabId]) + expect(sent()).toMatchObject([{ type: 'openSimulatorTab', tabId, groupId: groups[0]!.id }]) expectSaveTeachesMainNothing() }) it('sends nothing when the workspace already has its simulator tab', async () => { const tabId = ensureSimulatorTab(WORKTREE) await settled() - mocks.sent = [] + const before = sent().length expect(ensureSimulatorTab(WORKTREE)).toBe(tabId) await settled() - expect(mocks.sent).toEqual([]) + expect(sent()).toHaveLength(before) }) it('leaves a paired server’s simulator tab to that server', async () => { @@ -285,7 +279,7 @@ describe('simulator tabs in a layout-owner build', () => { expect(tabId).toBeTruthy() store().getState().closeUnifiedTab(tabId!) await settled() - expect(mocks.sent).toEqual([]) + expect(sent()).toEqual([]) }) it('sends nothing with the switch off', async () => { @@ -295,6 +289,6 @@ describe('simulator tabs in a layout-owner build', () => { const split = ensureSimulatorTab(WORKTREE, { placement: 'rightSplit' }) store().getState().closeUnifiedTab(split!) await settled() - expect(mocks.sent).toEqual([]) + expect(sent()).toEqual([]) }) }) diff --git a/src/renderer/src/lib/simulator-tab-layout-owner.ts b/src/renderer/src/lib/simulator-tab-layout-owner.ts index e5279ad6c85..1f1c5d802d3 100644 --- a/src/renderer/src/lib/simulator-tab-layout-owner.ts +++ b/src/renderer/src/lib/simulator-tab-layout-owner.ts @@ -1,35 +1,59 @@ -// Simulator tabs in a layout-owner build. The window still draws the tab first (the emulator pane -// attaches to it by id); main records each open and close under the window's ids, so a window save -// no longer teaches main a simulator tab. Nothing to undo on any answer. +// Simulator tab opens in a layout-owner build: the window sends the open under a proposed tab id +// (the emulator pane attaches by it); main's frame draws the tab, and only then is it surfaced. -import type { Tab } from '../../../shared/tab-types' +import { createBrowserUuid } from './browser-uuid' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { afterPendingTab, isPendingTab } from '@/runtime/layout-owner-pending-tabs' import type { AppState } from '@/store/types' -import { recordLayoutApply } from '@/runtime/layout-apply-send' -import { drawnTabPlacement } from '@/runtime/layout-owner-drawn-tab-placement' -type SimulatorTabState = Parameters[0] & - Pick +export type SimulatorTabOpen = { + worktreeId: string + label: string + /** The group the tab joins, or the group a new right split goes beside. */ + groupId: string + rightSplit: boolean + /** Runs once main's frame drew the tab. */ + surface: (tabId: string) => void + /** Today's draw under the proposed id, for a twin main may route to a paired server. */ + drawHere: (tabId: string) => string | null +} -/** A simulator tab the window just drew, placed where the window drew it. */ -export function recordOpenedSimulatorTabInLayoutOwner(state: SimulatorTabState, tab: Tab): void { - if (tab.contentType !== 'simulator') { - return - } - recordLayoutApply(state, tab.worktreeId, { - type: 'openSimulatorTab', - tabId: tab.id, - // Opened to the right: main makes the window's new split with the tab. - ...drawnTabPlacement(state, tab.worktreeId, tab.groupId, tab.id), - createdAt: tab.createdAt +/** A worktree's simulator tab main is still opening: one emulator tab per worktree. */ +const opening = new Map() + +export function openingSimulatorTabId(worktreeId: string): string | undefined { + const tabId = opening.get(worktreeId) + return tabId && isPendingTab(tabId) ? tabId : undefined +} + +/** The proposed tab id, or null when main is not asked and the caller draws as today. */ +export function openSimulatorTabThroughLayoutOwner( + state: AppState, + open: SimulatorTabOpen +): string | null { + const tabId = createBrowserUuid() + const placement = open.rightSplit + ? { + groupId: createBrowserUuid(), + split: { besideGroupId: open.groupId, direction: 'right' as const } + } + : { groupId: open.groupId } + const sent = openContentTabThroughLayoutOwner(state, { + worktreeId: open.worktreeId, + entityId: tabId, + command: { type: 'openSimulatorTab', tabId, ...placement }, + facts: { label: open.label }, + shown: open.surface, + drawHere: () => open.drawHere(tabId) }) -} - -/** A simulator tab the window just closed. */ -export function recordClosedSimulatorTabInLayoutOwner( - state: SimulatorTabState, - tab: Pick -): void { - if (tab.contentType === 'simulator') { - recordLayoutApply(state, tab.worktreeId, { type: 'closeTabs', tabIds: [tab.id] }) + if (!sent) { + return null } + opening.set(open.worktreeId, tabId) + afterPendingTab(tabId, () => { + if (opening.get(open.worktreeId) === tabId) { + opening.delete(open.worktreeId) + } + }) + return tabId } diff --git a/src/renderer/src/lib/structured-agent-session-action-launch.ts b/src/renderer/src/lib/structured-agent-session-action-launch.ts index c0477222200..763a3a233e6 100644 --- a/src/renderer/src/lib/structured-agent-session-action-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-action-launch.ts @@ -10,6 +10,9 @@ import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-lau import type { ProvisionalLaunchArgs } from './structured-agent-session-provisional-tab' import { insertUnifiedTabAfterAnchor } from './unified-tab-anchor-insertion' import { useAppStore } from '@/store' +import type { OpenedAgentSessionTab } from './structured-agent-session-tab-open-through-layout-owner' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' import { notifyHostDeclined, openDeclinedStructuredLaunchTerminal, @@ -54,7 +57,7 @@ export async function openTerminalForDeclinedChat( args: ProvisionalLaunchArgs, worktreeId: string, target: RuntimeClientTarget, - tab: Tab, + tab: OpenedAgentSessionTab, settlement: StructuredAgentLaunchSettlement ): Promise { if ( @@ -125,15 +128,12 @@ export async function openTerminalForDeclinedChat( notifyHostDeclined(args.plan.agent) return settlement } - state.moveUnifiedTabToGroup(replacement.id, current.groupId, { - activate: false, - recordInteraction: false - }) - if (Boolean(current.isPinned) !== Boolean(replacement.isPinned)) { - const setPinned = current.isPinned ? state.pinTab : state.unpinTab - setPinned(replacement.id, { recordInteraction: false }) + // Switch point: main places and pins the tab in one step; a pin sent alone would re-sort it. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await placeReplacementThroughMain(worktreeId, replacement, current) + } else { + placeReplacementAfterSource(worktreeId, replacement, current) } - insertUnifiedTabAfterAnchor(worktreeId, replacement.id, current.id) const placed = useAppStore.getState() const group = (placed.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === current.groupId) if ( @@ -157,13 +157,69 @@ export async function openTerminalForDeclinedChat( if (current.color !== null) { placed.setTabColor(replacement.entityId, current.color, { recordInteraction: false }) } - placed.closeUnifiedTab(tab.id, { recordInteraction: false }) - if (foreground) { + const showReplacement = (): void => { useAppStore.getState().setActiveTab(replacement.entityId) useAppStore.getState().activateTab(replacement.id) useAppStore.getState().setActiveTabType('terminal', worktreeId) focusTerminalTabSurface(replacement.id) } + // Switch-on, main's close lands later and shows the group's active tab: make it the terminal. + const showsFirst = foreground && ORCA_LAYOUT_OWNER_IS_MAIN + if (showsFirst) { + showReplacement() + } + placed.closeUnifiedTab(tab.id, { recordInteraction: false }) + if (foreground && !showsFirst) { + showReplacement() + } notifyHostDeclined(args.plan.agent) return { kind: 'terminal' } } + +/** The terminal goes right after the declined chat, pinned as the chat is. */ +function placeReplacementAfterSource(worktreeId: string, replacement: Tab, source: Tab): void { + const state = useAppStore.getState() + state.moveUnifiedTabToGroup(replacement.id, source.groupId, { + activate: false, + recordInteraction: false + }) + if (Boolean(source.isPinned) !== Boolean(replacement.isPinned)) { + const setPinned = source.isPinned ? state.pinTab : state.unpinTab + setPinned(replacement.id, { recordInteraction: false }) + } + insertUnifiedTabAfterAnchor(worktreeId, replacement.id, source.id) +} + +/** One main move puts the terminal after the chat with the chat's pin; resolves once drawn. */ +function placeReplacementThroughMain( + worktreeId: string, + replacement: Tab, + source: Tab +): Promise { + const state = useAppStore.getState() + const rows = new Set((state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + // Main's index counts only tabs it draws: a hidden launch slot has no row and is not in main. + const order = (state.groupsByWorktree[worktreeId] ?? []) + .find((group) => group.id === source.groupId) + ?.tabOrder.filter((id) => id !== replacement.id && rows.has(id)) + return new Promise((resolve) => { + const placeHere = (): void => { + placeReplacementAfterSource(worktreeId, replacement, source) + resolve() + } + const sent = sendLayoutCommandToOwner( + worktreeId, + { + type: 'moveTab', + tabId: replacement.id, + toGroupId: source.groupId, + index: (order?.indexOf(source.id) ?? -1) + 1, + isPinned: Boolean(source.isPinned) + }, + { apply: () => resolve(), asToday: placeHere, refused: () => resolve() } + ) + if (!sent) { + placeHere() + } + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts index be8c2303cc4..96df3f9f4d8 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-admission.test.ts @@ -1,6 +1,7 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Worktree } from '../../../shared/worktree/types' const mocks = vi.hoisted(() => ({ createSupport: vi.fn(), @@ -27,6 +28,17 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('@/runtime/structured-chat-host-permission', () => ({ readStructuredChatHostPermissionVerdict: mocks.hostPermission })) +// Switch-on, main opens this machine's chats and its frames draw them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) import { useAppStore } from '@/store' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' @@ -37,6 +49,10 @@ import { getStructuredAgentSessionLaunchSelection } from './structured-agent-ses import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent' import { LOCAL_ADMISSION_WAIT_MS } from './structured-agent-session-host-admission' import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' const WORKTREE = 'repo-1::/srv/app' const INITIAL_SETTINGS = useAppStore.getState().settings @@ -58,6 +74,32 @@ function pairedPlan( const localPlan = () => pairedPlan({ executionHostId: 'local' }) +/** The workspace's row names the paired server as its owner, whose layout draws its tabs. */ +function seedPairedServerWorkspace(): void { + const worktree: Worktree = { + id: WORKTREE, + repoId: 'repo-1', + path: '/srv/app', + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'app', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + hostId: 'runtime:server-1', + runtimeOwnerEnvironmentId: 'server-1' + } + useAppStore.setState({ worktreesByRepo: { 'repo-1': [worktree] } }) +} + /** Nothing of a chat exists on this machine: no tab, launch, record, queued prompt or intent. */ function expectNoChatCommitted(): void { expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) @@ -69,7 +111,9 @@ function expectNoChatCommitted(): void { ).toBeNull() } -beforeEach(() => { +let main: TabsDrawnFromMain + +beforeEach(async () => { vi.clearAllMocks() mocks.hostPermission.mockResolvedValue({ kind: 'allowed' }) localStorage.clear() @@ -77,11 +121,17 @@ beforeEach(() => { activeWorktreeId: WORKTREE, activeWorkspaceExecutionHostId: null, unifiedTabsByWorktree: {}, + worktreesByRepo: {}, settings: INITIAL_SETTINGS }) + main = await drawTabsFromMain(useAppStore) }) +afterEach(() => main.close()) + describe('a structured chat launch on a paired server', () => { + beforeEach(seedPairedServerWorkspace) + it('opens only the terminal when the server declines, keeping the workspace selected', async () => { mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' }) const onHostDeclined = vi.fn(() => ({ @@ -421,6 +471,8 @@ describe('a structured chat launch on this machine', () => { }) describe('a paired server that cannot resolve a new workspace yet', () => { + beforeEach(seedPairedServerWorkspace) + it('keeps its terminal fallback, as before', async () => { mocks.createSupport.mockRejectedValue(new Error('selector_not_found')) const onHostDeclined = vi.fn(() => ({ opened: true })) @@ -437,6 +489,7 @@ describe('a paired server that cannot resolve a new workspace yet', () => { }) it('keeps explicit refusal owned by admission and keeps an opened chat failure out of the menu', async () => { + seedPairedServerWorkspace() for (const supported of [false, true]) { mocks.toastError.mockClear() mocks.createSupport.mockResolvedValue({ supported, reason: 'wsl' }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts index 5eef23f445f..793eefb03c1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-close-race.test.ts @@ -5,6 +5,7 @@ import { toast } from 'sonner' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type * as RecoveryModule from '@/lib/structured-agent-session-launch-recovery' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' const mocks = vi.hoisted(() => ({ abandonIntent: vi.fn(), @@ -180,13 +181,31 @@ describe('a close that races a structured launch', () => { startStructuredAgentLaunch(worktreeId, 'codex', { requestId: 'request-1' }) await vi.waitFor(() => expect(refreshLocalStructuredSessionTabs).toHaveBeenCalledOnce()) expect(cancelStructuredAgentLaunch(worktreeId, intent.sessionId)).toBe(true) - expect(hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId)).toBe( - true - ) const persistedTombstones = localStorage.getItem('orca:structuredAgentLaunchCancelledSessions:v1') ?? '' - expect(persistedTombstones).toContain(JSON.stringify(intent.sessionId)) - expect(persistedTombstones).not.toContain(worktreeId) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // This machine's main hears the cancel at once and fences the chat; the window keeps none. + expect( + hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId) + ).toBe(false) + expect(persistedTombstones).not.toContain(JSON.stringify(intent.sessionId)) + expect(mocks.callStructuredAgentSession).toHaveBeenCalledWith( + { kind: 'local' }, + 'agentSession.close', + { sessionId: intent.sessionId } + ) + expect(mocks.callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'session.tabs.close', + expect.objectContaining({ tabId: `agent-session:${intent.sessionId}` }) + ) + } else { + expect( + hasStructuredAgentSessionLaunchCancellationTombstone(worktreeId, intent.sessionId) + ).toBe(true) + expect(persistedTombstones).toContain(JSON.stringify(intent.sessionId)) + expect(persistedTombstones).not.toContain(worktreeId) + } resolveRefresh([]) await flushLaunchSettlement() @@ -221,8 +240,11 @@ describe('a close that races a structured launch', () => { resolveRefresh([]) await flushLaunchSettlement() - // Neither sent nor given back to the closed chat's composer. - expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() + // Neither sent nor given back to the closed chat's composer; a layout-owner build's only call + // retires the chat on this machine's main. + expect(mocks.callRuntimeRpc.mock.calls.map(([, method]) => method)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? ['session.tabs.close'] : [] + ) expect(readNativeChatDraftCache(structuredAgentSessionDraftScopeKey(intent.sessionId))).toBe('') }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-registry.ts b/src/renderer/src/lib/structured-agent-session-launch-registry.ts index 8d026f1512a..d49450e7fcf 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-registry.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-registry.ts @@ -28,7 +28,10 @@ import { retireStructuredAgentLaunchCancellation } from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { cancelStructuredAgentLaunchOnLocalHost } from './structured-agent-session-local-launch-cancel' +import { + cancelStructuredAgentLaunchOnLocalHost, + resetStructuredAgentLocalLaunchCancelsForTests +} from './structured-agent-session-local-launch-cancel' export type StructuredLaunchState = StructuredLaunchRecoveryState & { identity: string @@ -56,6 +59,7 @@ export function resetStructuredAgentLaunchRegistryForTests(): void { structuredLaunchesBySessionId.clear() structuredLaunchListeners.clear() resetStructuredAgentLaunchCancellationForTests() + resetStructuredAgentLocalLaunchCancelsForTests() } export function notifyStructuredLaunchListeners(): void { @@ -237,12 +241,7 @@ function markStructuredAgentSessionLaunchCancelledInternal( if (matchesLaunchWorktree(state, worktreeId) && state) { // The launch's own owner outranks the caller's: it is the host the create was sent to. if (ORCA_LAYOUT_OWNER_IS_MAIN && state.intent.executionHostId === LOCAL_EXECUTION_HOST_ID) { - cancelStructuredAgentLaunchOnLocalHost( - worktreeId, - sessionId, - notifyStructuredLaunchListeners, - state.promise - ) + cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId) } else { markStructuredAgentLaunchCancellation( sessionId, @@ -261,7 +260,7 @@ function markStructuredAgentSessionLaunchCancelledInternal( !alreadyCancelled && executionHostId === LOCAL_EXECUTION_HOST_ID ) { - cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId, notifyStructuredLaunchListeners) + cancelStructuredAgentLaunchOnLocalHost(worktreeId, sessionId) } else if (!alreadyCancelled) { markStructuredAgentLaunchCancellation(sessionId, executionHostId, alreadyCancelled) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-surface.ts b/src/renderer/src/lib/structured-agent-session-launch-surface.ts index b6c8d641352..8c96f3b815a 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-surface.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-surface.ts @@ -24,7 +24,10 @@ import { } from './structured-agent-session-launch-registry' import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' -import { recordOpenedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' +import { + openAgentSessionTabThroughLayoutOwner, + type OpenedAgentSessionTab +} from './structured-agent-session-tab-open-through-layout-owner' import { clearWebSessionFocusIntentIfMatches, peekWebSessionFocusIntent, @@ -147,7 +150,7 @@ export function openStructuredAgentSessionProvisionalTab(args: { /** The tab the chat opens right after, in that tab's group. */ afterTabId?: string activate?: boolean -}): Tab { +}): OpenedAgentSessionTab { const state = useAppStore.getState() const existing = findStructuredLaunchTab(args) if (existing) { @@ -165,26 +168,55 @@ export function openStructuredAgentSessionProvisionalTab(args: { const group = state.groupsByWorktree?.[args.worktreeId]?.find((entry) => entry.tabOrder.includes(tabId) ) - const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', { - id: tabId, - entityId: args.sessionId, - executionHostId: args.executionHostId, - agentSessionAgent: args.agent, - label: defaultAgentChatLabel(args.agent), - targetGroupId: - group?.id ?? - (address?.worktreeId === args.worktreeId ? address.targetGroupId : args.targetGroupId), - ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), - activate: args.activate !== false - }) - // Switch point: main records the chat tab the window just drew. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordOpenedAgentSessionTabInLayoutOwner(useAppStore.getState(), tab) + const targetGroupId = + group?.id ?? + (address?.worktreeId === args.worktreeId ? address.targetGroupId : args.targetGroupId) + const drawHere = (): Tab => { + const tab = state.createUnifiedTab(args.worktreeId, 'agent-session', { + id: tabId, + entityId: args.sessionId, + executionHostId: args.executionHostId, + agentSessionAgent: args.agent, + label: defaultAgentChatLabel(args.agent), + targetGroupId, + ...(args.afterTabId ? { afterTabId: args.afterTabId } : {}), + activate: args.activate !== false + }) + if (args.activate !== false) { + state.setActiveTabType('agent-session', args.worktreeId) + } + return tab } - if (args.activate !== false) { - state.setActiveTabType('agent-session', args.worktreeId) + // Switch point: main opens the chat's tab and its frame draws it. + const sent = ORCA_LAYOUT_OWNER_IS_MAIN + ? openAgentSessionTabThroughLayoutOwner({ + worktreeId: args.worktreeId, + executionHostId: args.executionHostId, + sessionId: args.sessionId, + agent: args.agent, + ...(targetGroupId ? { targetGroupId } : {}), + // A reserved slot names where the chat goes: after the tab before it. + ...(args.afterTabId + ? { afterTabId: args.afterTabId } + : slotAnchor(address, args.worktreeId)), + activate: args.activate !== false, + drawHere + }) + : null + return sent ?? drawHere() +} + +/** The last tab still before a reserved chat slot, which main places the chat after. */ +function slotAnchor( + address: StructuredLaunchSurfaceAddress | undefined, + worktreeId: string +): { afterTabId?: string } { + if (address?.worktreeId !== worktreeId) { + return {} } - return tab + const tabs = useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [] + const afterTabId = address.precedingTabIds.findLast((id) => tabs.some((tab) => tab.id === id)) + return afterTabId ? { afterTabId } : {} } export function materializeStructuredLaunchSurface(launch: LaunchSurface, activate?: false): void { diff --git a/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts b/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts index 9ac272d43d8..8f5a3876e3d 100644 --- a/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts +++ b/src/renderer/src/lib/structured-agent-session-local-launch-cancel.ts @@ -1,11 +1,6 @@ -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import { retireStructuredAgentSessionTab } from '@/runtime/structured-agent-session-tab-retirement' -import { - deleteStructuredAgentLaunchRecord, - hasStructuredAgentLaunchCancellationTombstonePersisted -} from './structured-agent-session-launch-persistence' -import { markStructuredAgentLaunchCancellation } from './launch-cancel-tombstones-compat/structured-agent-session-launch-cancellation' +import { deleteStructuredAgentLaunchRecord } from './structured-agent-session-launch-persistence' // Chats a cancel closed on this machine's host; session ids are never reused. const closedByLocalCancel = new Set() @@ -15,16 +10,18 @@ export function wasStructuredAgentLaunchClosedByLocalCancel(sessionId: string): return closedByLocalCancel.has(sessionId) } +export function resetStructuredAgentLocalLaunchCancelsForTests(): void { + closedByLocalCancel.clear() +} + /** * ORCA_LAYOUT_OWNER=main only: this machine's host refuses a create whose close came first, or - * closes the chat as it lands, so a local cancel retires the chat on the host at once instead of - * keeping a tombstone until the host lists it. A host that cannot be told gets the tombstone. + * closes the chat as it lands, so a local cancel retires the chat on the host at once and keeps no + * tombstone. A failed retire is logged; main still remembers the close. */ export function cancelStructuredAgentLaunchOnLocalHost( worktreeId: string, - sessionId: string, - onTombstoned: () => void, - launchPromise?: Promise + sessionId: string ): void { deleteStructuredAgentLaunchRecord(sessionId) // Why: main answers a create for a chat closed meanwhile as ok, and no tombstone is written. @@ -35,16 +32,9 @@ export function cancelStructuredAgentLaunchOnLocalHost( sessionId, onError: (error) => { // A create that has not landed has no host tab yet; main still heard the close. - if (hasRuntimeRpcErrorCode(error, 'tab_not_found')) { - return + if (!hasRuntimeRpcErrorCode(error, 'tab_not_found')) { + console.warn('[structured-agent-session] local cancel retirement failed', error) } - markStructuredAgentLaunchCancellation( - sessionId, - LOCAL_EXECUTION_HOST_ID, - hasStructuredAgentLaunchCancellationTombstonePersisted(sessionId), - launchPromise - ) - onTombstoned() } }) } diff --git a/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts b/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts index 9de47ccd66d..a65b7ad1204 100644 --- a/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts +++ b/src/renderer/src/lib/structured-agent-session-new-chat-own-launch.test.ts @@ -1,11 +1,13 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-session-contracts' import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import type { StructuredAgentSessionLaunchIntent } from '@/lib/launch-structured-agent-session' import type * as LaunchAdmissionModule from './structured-agent-session-launch-admission' +import type * as RpcClientModule from '@/runtime/runtime-rpc-client' +import type { AppState } from '@/store/types' const mocks = vi.hoisted(() => ({ createIntent: vi.fn(), @@ -49,6 +51,12 @@ function emptyStoreState(): StoreState { } } const store = vi.hoisted((): { state: StoreState } => ({ state: emptyStoreState() })) +type DrawnStore = { + getState: () => AppState + subscribe: (listener: (state: AppState, previous: AppState) => void) => () => void +} +/** Switch on: a real store drawn from main's frames stands in for `store`. */ +const drawn = vi.hoisted((): { store: DrawnStore | null } => ({ store: null })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), message: vi.fn() } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) @@ -81,10 +89,21 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: (target: unknown, method: string, params?: unknown) => mocks.callRuntimeRpc(target, method, params) })) -vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: mocks.callRuntimeRpc, - ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined) -})) +// Switch-on, main answers the window's chat opens and its frames draw them. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply({ + ...(await importOriginal()), + callRuntimeRpc: mocks.callRuntimeRpc, + ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined) + }) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ getStructuredAgentSessionStatusFeed: () => ({ getSessionObservation: (sessionId: string) => @@ -97,33 +116,45 @@ vi.mock('@/runtime/structured-agent-session-status-feed', () => ({ })) vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ - ...store.state, - createUnifiedTab: ( - worktreeId: string, - contentType: Tab['contentType'], - init: { targetGroupId?: string } - ) => { - const groupId = - init.targetGroupId ?? store.state.activeGroupIdByWorktree[worktreeId] ?? 'group-left' - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch passes the agent-session fields a structured tab carries. - const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab - store.state.unifiedTabsByWorktree[worktreeId] = [ - ...(store.state.unifiedTabsByWorktree[worktreeId] ?? []), - tab - ] - return tab - }, - activateTab: mocks.activateTab, - focusGroup: mocks.focusGroup, - setActiveTabType: vi.fn(), - seedNativeChatLaunchDraft: vi.fn(), - clearNativeChatLaunchDraft: vi.fn() - }), - subscribe: () => () => undefined + getState: () => + drawn.store + ? { + ...drawn.store.getState(), + activateTab: mocks.activateTab, + focusGroup: mocks.focusGroup + } + : standInState(), + subscribe: (listener: (state: AppState, previous: AppState) => void) => + drawn.store?.subscribe(listener) ?? (() => undefined) } })) +function standInState() { + return { + ...store.state, + createUnifiedTab: ( + worktreeId: string, + contentType: Tab['contentType'], + init: { targetGroupId?: string } + ) => { + const groupId = + init.targetGroupId ?? store.state.activeGroupIdByWorktree[worktreeId] ?? 'group-left' + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch passes the agent-session fields a structured tab carries. + const tab = { ...init, contentType, worktreeId, groupId, createdAt: 1 } as Tab + store.state.unifiedTabsByWorktree[worktreeId] = [ + ...(store.state.unifiedTabsByWorktree[worktreeId] ?? []), + tab + ] + return tab + }, + activateTab: mocks.activateTab, + focusGroup: mocks.focusGroup, + setActiveTabType: vi.fn(), + seedNativeChatLaunchDraft: vi.fn(), + clearNativeChatLaunchDraft: vi.fn() + } +} + import { resetStructuredAgentSessionSendsForTests } from '@/components/native-chat/structured-agent-session-message-sender' import { clearNativeChatDraftCacheForTests } from '@/components/native-chat/native-chat-draft-cache' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' @@ -138,6 +169,12 @@ import { } from './structured-agent-session-launch-prompt' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { createTestStore, makeWorktree, seedStore } from '@/store/slices/store-test-helpers' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' const WORKTREE_ID = 'wt-new-chat' @@ -183,6 +220,64 @@ function published(...sessionIds: string[]): RuntimeMobileSessionTabsResult[] { })) } +let main: TabsDrawnFromMain | null = null + +/** + * Switch on: the split workspace in a real store, drawn from main's frames. Main keeps no empty + * group, so each side holds a terminal. + */ +async function drawSplitFromMain(): Promise { + const real = createTestStore() + const side = (groupId: string): Tab => ({ + id: `terminal-${groupId}`, + entityId: `terminal-${groupId}`, + groupId, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }) + const seeded = emptyStoreState() + seedStore(real, { + worktreesByRepo: { repo: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo' })] }, + activeWorktreeId: WORKTREE_ID, + tabsByWorktree: { + [WORKTREE_ID]: ['group-left', 'group-right'].map((groupId, index) => ({ + id: `terminal-${groupId}`, + ptyId: null, + worktreeId: WORKTREE_ID, + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: index, + createdAt: 1 + })) + }, + unifiedTabsByWorktree: { [WORKTREE_ID]: [side('group-left'), side('group-right')] }, + groupsByWorktree: { + [WORKTREE_ID]: seeded.groupsByWorktree[WORKTREE_ID]!.map((group) => ({ + ...group, + activeTabId: `terminal-${group.id}`, + tabOrder: [`terminal-${group.id}`] + })) + }, + layoutByWorktree: seeded.layoutByWorktree, + activeGroupIdByWorktree: seeded.activeGroupIdByWorktree + }) + main = await drawTabsFromMain(real) + drawn.store = real +} + +/** The chat tabs the workspace shows: switch-on, as main's frames drew them. */ +async function openedChats(): Promise { + await main?.settle() + const tabs = (drawn.store?.getState() ?? store.state).unifiedTabsByWorktree[WORKTREE_ID] ?? [] + return tabs.filter((tab) => tab.contentType === 'agent-session') +} + const first = launchIntent('session-first') const second = launchIntent('session-second') @@ -258,15 +353,27 @@ beforeEach(() => { }) }) +beforeEach(async () => { + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + await drawSplitFromMain() + } +}) + +afterEach(() => { + main?.close() + main = null + drawn.store = null +}) + describe('a second "new chat" with no text', () => { - it('opens a second chat while the first empty one is still starting', () => { + it('opens a second chat while the first empty one is still starting', async () => { mocks.launch.mockImplementation(() => new Promise(() => undefined)) const firstPick = pick('plus-pick-1') const secondPick = pick('plus-pick-2') expect(firstPick.sessionId).toBe(first.sessionId) expect(secondPick.sessionId).toBe(second.sessionId) - expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(await openedChats()).toHaveLength(2) }) it('opens a second chat beside an empty one that published and sits idle', async () => { @@ -276,7 +383,7 @@ describe('a second "new chat" with no text', () => { const secondPick = pick('plus-pick-2') expect(secondPick.sessionId).toBe(second.sessionId) - expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(await openedChats()).toHaveLength(2) await expect(secondPick.settlement).resolves.toEqual({ kind: 'structured', sessionId: second.sessionId @@ -306,30 +413,28 @@ describe('a "new chat" with text', () => { }) }) - it('opens its own chat beside an empty one still starting', () => { + it('opens its own chat beside an empty one still starting', async () => { mocks.launch.mockImplementation(() => new Promise(() => undefined)) const blank = pick('plus-pick-1') const notes = pick('notes-send', { prompt: 'review notes' }) expect(notes.sessionId).toBe(second.sessionId) - expect(store.state.unifiedTabsByWorktree[WORKTREE_ID]).toHaveLength(2) + expect(await openedChats()).toHaveLength(2) expect(hasStagedStructuredLaunchPrompt(blank.sessionId)).toBe(false) expect(hasStagedStructuredLaunchPrompt(second.sessionId)).toBe(true) }) - it('opens its own chat in the split it was sent from', () => { + it('opens its own chat in the split it was sent from', async () => { mocks.launch.mockImplementation(() => new Promise(() => undefined)) pick('plus-pick-1', { group: 'group-left' }) const notes = pick('notes-send', { prompt: 'review notes', group: 'group-right' }) expect(notes.sessionId).toBe(second.sessionId) - expect( - store.state.unifiedTabsByWorktree[WORKTREE_ID]?.find( - (tab) => tab.entityId === second.sessionId - )?.groupId - ).toBe('group-right') + expect((await openedChats()).find((tab) => tab.entityId === second.sessionId)?.groupId).toBe( + 'group-right' + ) expect(mocks.focusGroup).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts index 5fa9802c1c1..c83b6a87efc 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.test.ts @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type * as launchModule from './launch-structured-agent-session' import type { AgentSessionLaunchPlan } from './agent-session-launch-plan' @@ -18,6 +18,26 @@ import { addNativeChatPendingAttachment, clearNativeChatPendingAttachmentsForTests } from '@/components/native-chat/native-chat-pending-attachment-cache' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '@/store/slices/tabs-drawn-from-main.test-fixture' + +// Switch-on, main opens and closes the chat's tab and its frames draw it. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const mocks = vi.hoisted(() => ({ text: '', focus: vi.fn(), toast: vi.fn(), launch: vi.fn() })) vi.mock('./launch-structured-agent-session', async (original) => ({ @@ -32,8 +52,27 @@ vi.mock('@/components/native-chat/native-chat-composer-draft-store', () => ({ structuredAgentSessionDraftScopeKey: (id: string) => `agent-session:${id}` })) +function terminalRecord(id: string): TerminalTab { + return { + id, + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +let refuse = (): void => {} + +// The host's refusal lands once the test releases it, after the chat's tab is drawn. function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentSessionLaunchPlan { - mocks.launch.mockRejectedValue(error) + const refusal = new Promise((_, reject) => { + refuse = () => reject(error) + }) + mocks.launch.mockReturnValue(refusal) return adoptAgentSessionLaunchVerdict({ requestId: 'action', route: 'structured-native-chat', @@ -43,7 +82,9 @@ function planSettlingAs(error: StructuredAgentSessionCreateRefusalError): AgentS }) } -beforeEach(() => { +let main: TabsDrawnFromMain + +beforeEach(async () => { vi.clearAllMocks() mocks.text = '' resetStructuredAgentLaunchRegistryForTests() @@ -56,8 +97,56 @@ beforeEach(() => { tabsByWorktree: {}, tabBarOrderByWorktree: {} }) + main = await drawTabsFromMain(useAppStore) }) +afterEach(() => main.close()) + +// Main takes the terminal tabs the test drew straight into the store, as a real create sends them. +async function mainLoadsStoreLayout(): Promise { + main.close() + main = await drawTabsFromMain(useAppStore) +} + +/** The user's pin: the tab bar sends it to main switch-on, whose frame draws it. */ +function pinAsUser(tabId: string): void { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + useAppStore.getState().pinTab(tabId) + return + } + const worktreeId = useAppStore.getState().getTab(tabId)?.worktreeId ?? 'wt-1' + sendLayoutCommandToOwner( + worktreeId, + { type: 'setTabProps', tabId, isPinned: true }, + { apply: () => {}, asToday: () => useAppStore.getState().pinTab(tabId) } + ) +} + +/** The tab bar's rename and colour: main's commands with the switch on. */ +function labelAsUser(tabId: string, label: string, color: string): void { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + useAppStore.getState().setTabCustomLabel(tabId, label) + useAppStore.getState().setUnifiedTabColor(tabId, color) + return + } + const asToday = { apply: () => {}, asToday: () => {} } + sendLayoutCommandToOwner( + 'wt-1', + { type: 'renameTab', tabId, kind: 'custom', title: label }, + asToday + ) + sendLayoutCommandToOwner('wt-1', { type: 'setTabProps', tabId, color }, asToday) +} + +/** The group main's frame drew the tab in. */ +function groupOf(tabId: string): string { + const groupId = useAppStore.getState().getTab(tabId)?.groupId + if (!groupId) { + throw new Error(`No drawn tab ${tabId}`) + } + return groupId +} + function openChat(requestedSurface?: 'chat') { let complete!: (value: AgentTerminalLaunchCompletion) => void const completion = new Promise((resolve) => { @@ -100,6 +189,7 @@ describe('a provisional structured chat tab', () => { const { launch, chat, fallback, complete } = openChat( scenario === 'explicit' ? 'chat' : undefined ) + await main.settle() expect(mocks.focus).toHaveBeenCalledWith(chat.id) const view = renderHook(() => { const current = useNativeChatProvisionalLaunch('wt-1', chat.entityId) @@ -120,6 +210,8 @@ describe('a provisional structured chat tab', () => { if (scenario === 'closed') { useAppStore.getState().closeUnifiedTab(chat.id) } + await main.settle() + refuse() await vi.waitFor(() => expect( scenario === 'closed' || scenario === 'explicit' || fallback.mock.calls.length @@ -131,19 +223,26 @@ describe('a provisional structured chat tab', () => { view.unmount() return } - expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual(chat) + // Switch-on, main's frame draws the tab the open named. + expect(useAppStore.getState().unifiedTabsByWorktree['wt-1']).toContainEqual( + expect.objectContaining({ ...chat, contentType: 'agent-session' }) + ) // Move the source while its host is preparing the terminal, then invest in the composer. const sibling = useAppStore.getState().createUnifiedTab('wt-1', 'terminal', { entityId: 'sibling', - split: { sourceGroupId: chat.groupId, splitDirection: 'right', activate: true } + split: { sourceGroupId: groupOf(chat.id), splitDirection: 'right', activate: true } }) const groupId = sibling.groupId + // Main loads only terminal tabs with their terminal's record. + const siblingRecords = () => + ORCA_LAYOUT_OWNER_IS_MAIN ? [terminalRecord(sibling.entityId)] : [] + useAppStore.setState({ tabsByWorktree: { 'wt-1': siblingRecords() } }) + await mainLoadsStoreLayout() const pinnedSource = scenario === 'empty' || scenario === 'typed-during' if (pinnedSource) { - useAppStore.getState().pinTab(chat.id) - useAppStore.getState().pinTab(sibling.id) - useAppStore.getState().setTabCustomLabel(chat.id, 'My work') - useAppStore.getState().setUnifiedTabColor(chat.id, '#ff0000') + pinAsUser(chat.id) + pinAsUser(sibling.id) + labelAsUser(chat.id, 'My work', '#ff0000') } useAppStore.getState().moveUnifiedTabToGroup(chat.id, groupId) useAppStore.getState().reorderUnifiedTabs(groupId, [chat.id, sibling.id]) @@ -163,12 +262,12 @@ describe('a provisional structured chat tab', () => { if (scenario === 'closed-during') { useAppStore.getState().closeUnifiedTab(chat.id) } + await main.settle() if (pinnedSource) { useAppStore.getState().activateTab(chat.id) useAppStore.getState().setActiveTabType('agent-session', 'wt-1') } const priorFailure = getStructuredAgentSessionLaunchFailure('wt-1', chat.entityId) - const selected = useAppStore.getState().getActiveTab('wt-2') const terminal = scenario === 'failed' || scenario === 'unreconciled' ? null @@ -177,25 +276,14 @@ describe('a provisional structured chat tab', () => { .createUnifiedTab('wt-1', 'terminal', { id: 'replacement', activate: false }) if (terminal) { useAppStore.setState({ - tabsByWorktree: { - 'wt-1': [ - { - id: terminal.entityId, - ptyId: null, - worktreeId: 'wt-1', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - } + tabsByWorktree: { 'wt-1': [terminalRecord(terminal.entityId), ...siblingRecords()] } }) if (scenario === 'typed-before') { useAppStore.getState().pinTab(terminal.id) } } + await mainLoadsStoreLayout() + const selected = useAppStore.getState().getActiveTab('wt-2') const interaction = vi.fn() useAppStore.setState({ recordFeatureInteraction: interaction }) await act(async () => { @@ -205,6 +293,7 @@ describe('a provisional structured chat tab', () => { tabId: terminal?.id ?? 'missing' }) await launch?.settlement + await main.settle() }) expect(interaction).not.toHaveBeenCalled() const state = useAppStore.getState() @@ -267,4 +356,51 @@ describe('a provisional structured chat tab', () => { } view.unmount() }) + + // A hidden launch slot is in the window's group order but not in main's: the terminal's place + // is counted among the tabs both draw. + it("places a declined chat's terminal right after the chat past a hidden launch slot", async () => { + const { launch, chat, fallback, complete } = openChat() + await main.settle() + refuse() + await vi.waitFor(() => expect(fallback).toHaveBeenCalled()) + const chatGroupId = groupOf(chat.id) + const state = useAppStore.getState() + const sibling = state.createUnifiedTab('wt-1', 'terminal', { + entityId: 'sibling', + targetGroupId: chatGroupId, + activate: false + }) + const terminal = state.createUnifiedTab('wt-1', 'terminal', { + id: 'replacement', + targetGroupId: chatGroupId, + activate: false + }) + useAppStore.setState({ + tabsByWorktree: { + 'wt-1': ORCA_LAYOUT_OWNER_IS_MAIN + ? [terminalRecord(sibling.entityId), terminalRecord(terminal.entityId)] + : [] + } + }) + await mainLoadsStoreLayout() + const slot = structuredAgentSessionTabId('hidden-launch') + useAppStore.getState().ensureWorktreeRootGroup('wt-1', { + tabId: slot, + targetGroupId: chatGroupId, + precedingTabIds: [] + }) + const order = () => + useAppStore.getState().groupsByWorktree['wt-1']?.find((g) => g.id === chatGroupId)?.tabOrder + expect(order()).toEqual([slot, chat.id, sibling.id, terminal.id]) + + await act(async () => { + complete({ outcome: { status: 'created' }, tabId: terminal.id }) + await launch?.settlement + await main.settle() + }) + + expect(useAppStore.getState().getTab(chat.id)).toBeNull() + expect(order()?.filter((id) => id !== slot)).toEqual([terminal.id, sibling.id]) + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts index cdf6389ebeb..481608d4aca 100644 --- a/src/renderer/src/lib/structured-agent-session-provisional-tab.ts +++ b/src/renderer/src/lib/structured-agent-session-provisional-tab.ts @@ -1,5 +1,4 @@ import type { NativeChatComposerFocusIntent } from '@/components/native-chat/use-native-chat-composer-reveal-focus' -import type { Tab } from '../../../shared/tab-types' import type { AgentSessionLaunchPlan, AgentSessionLaunchTarget @@ -27,13 +26,14 @@ import { opensAtAction } from './structured-agent-session-action-launch' import { holdEmptyWorkspaceDefaultSurfaceForLaunch } from './empty-workspace-default-surface-claims' +import type { OpenedAgentSessionTab } from './structured-agent-session-tab-open-through-layout-owner' import { captureStructuredLaunchSurfaceAddress, openStructuredAgentSessionProvisionalTab } from './structured-agent-session-launch-surface' export { openStructuredAgentSessionProvisionalTab } from './structured-agent-session-launch-surface' -type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: Tab } +type LocalProvisionalLaunch = StructuredAgentLaunchHandle & { tab: OpenedAgentSessionTab } /** A chat's tab opens once its host admits it; a launch with no host to ask takes the direct path. */ export type StructuredAgentSessionProvisionalLaunch = diff --git a/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts b/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts new file mode 100644 index 00000000000..60be94e2882 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-tab-open-through-layout-owner.ts @@ -0,0 +1,68 @@ +// A chat tab open in a layout-owner build: the window sends the open under the chat's own tab id; +// main's frame draws the tab, and only then does the window select it. A caller acting on the +// returned tab in the store waits with `afterPendingTab`. + +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { Tab } from '../../../shared/tab-types' +import type { TuiAgent } from '../../../shared/tui-agent' +import { defaultAgentChatLabel } from '../../../shared/agent-session-chat-label' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { useAppStore } from '@/store' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { hasPendingEntityTab } from '@/runtime/layout-owner-pending-tabs' +import { contentTabOpenPlacement } from '@/store/slices/tabs/content-tab-open-placement' + +export type AgentSessionTabOpen = { + worktreeId: string + executionHostId: ExecutionHostId + sessionId: string + agent: TuiAgent + targetGroupId?: string + afterTabId?: string + activate: boolean + /** Today's draw, for a twin main may route to a paired server. */ + drawHere: () => Tab +} + +/** What a caller may rely on before main's frame draws the tab: its id and the chat it shows. */ +export type OpenedAgentSessionTab = Pick + +/** Null when main is not asked (a paired server owns the workspace's layout): draw as today. */ +export function openAgentSessionTabThroughLayoutOwner( + open: AgentSessionTabOpen +): OpenedAgentSessionTab | null { + const { worktreeId, sessionId } = open + const state = useAppStore.getState() + const tabId = structuredAgentSessionTabId(sessionId) + const label = defaultAgentChatLabel(open.agent) + const placement = contentTabOpenPlacement(state, worktreeId, tabId, { + targetGroupId: open.targetGroupId, + afterTabId: open.afterTabId, + executionHostId: open.executionHostId + }) + if (!placement) { + return null + } + const tab = { id: tabId, entityId: sessionId } + // A second launch of the same chat waits for the first open. + if (hasPendingEntityTab(worktreeId, sessionId)) { + return tab + } + const sent = openContentTabThroughLayoutOwner(state, { + worktreeId, + entityId: sessionId, + command: { type: 'openAgentSessionTab', tabId, sessionId, agent: open.agent, ...placement }, + facts: { label }, + shown: (shownId) => { + if (!open.activate) { + return + } + // As the window's own open: the chat is selected in its group; the focused group stays. + const current = useAppStore.getState() + current.activateTab(shownId, { worktreeId }) + current.setActiveTabType('agent-session', worktreeId) + }, + drawHere: () => open.drawHere().id + }) + return sent ? tab : null +} diff --git a/src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts b/src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts deleted file mode 100644 index 8fca9dfa97c..00000000000 --- a/src/renderer/src/runtime/agent-session-tab-layout-owner.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -// @vitest-environment happy-dom - -// With ORCA_LAYOUT_OWNER=main, a chat tab's open and close reach main as commands, so the window's -// next save changes nothing in main's model (what the save-divergence detector checks). -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' -import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' -import type { Tab, TabGroup } from '../../../shared/tab-types' -import { applyLayoutCommand } from '../../../shared/workspace-layout/workspace-layout-commands' -import type { LayoutCommand } from '../../../shared/workspace-layout/workspace-layout-command-types' -import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' -import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' -import { saveWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-save' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' -import { createBrowserUuid } from '@/lib/browser-uuid' -import { - createTestStore, - makeRuntimeOwnedWorktree, - makeWorktree, - runtimeOwnedRepoState, - seedStore -} from '@/store/slices/store-test-helpers' -import { createStoreCascadesMockApi } from '@/store/slices/store-cascades-test-harness' - -type LayoutLoad = ReturnType - -type Mocks = { - layoutOwnerIsMain: boolean - store: ReturnType | null - main: LayoutLoad | null - sent: LayoutCommand[] -} - -const mocks = vi.hoisted((): Mocks => ({ - layoutOwnerIsMain: true, - store: null, - main: null, - sent: [] -})) - -vi.mock('../../../shared/layout-owner-switch', () => ({ - get ORCA_LAYOUT_OWNER_IS_MAIN() { - return mocks.layoutOwnerIsMain - } -})) -vi.mock('@/store', () => ({ - get useAppStore() { - return mocks.store - } -})) -vi.mock('sonner', () => ({ - toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } -})) -vi.mock('./runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn( - async (_target: unknown, method: string, params?: { command?: LayoutCommand }) => { - if (method !== 'layout.apply' || !params?.command) { - return {} - } - mocks.sent.push(params.command) - const main = mainLoad() - // As runtime-layout-apply.ts: the window already confirmed its close, so main forces it. - const command: LayoutCommand = - params.command.type === 'closeTabs' ? { ...params.command, force: true } : params.command - const applied = applyLayoutCommand(main.layout, command, { - mintId: createBrowserUuid, - mintLeafId: createBrowserUuid, - now: Date.now - }) - if (!applied.ok) { - return { ok: false, code: applied.code } - } - mocks.main = { ...main, layout: applied.model } - return { ok: true, result: applied.result } - } - ) -})) - -createStoreCascadesMockApi() - -import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab' -import { applyStructuredSessionTabSnapshots } from './local-structured-session-tabs-sync/snapshot-apply' -import { resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync/inventory-generation-fence' -import { recordSyncedAgentSessionTabsInLayoutOwner } from './agent-session-tab-layout-owner' - -const WORKTREE = 'repo1::/path/wt' -const TERMINAL = 'terminal-1' -const GROUP = 'group-1' - -function store(): ReturnType { - if (!mocks.store) { - throw new Error('store not seeded') - } - return mocks.store -} - -function mainLoad(): LayoutLoad { - if (!mocks.main) { - throw new Error('main not loaded') - } - return mocks.main -} - -function windowSave(): WorkspaceSessionState { - return buildWorkspaceSessionPayload(store().getState()) -} - -/** What main's next read of its stored copy holds, as the detector reads it before a save. */ -function mainStoredModel() { - const stored = saveWorkspaceLayout(mainLoad()) - return loadWorkspaceLayoutWithoutReport(LOCAL_EXECUTION_HOST_ID, stored, nameBasedLoadContext()) - .layout -} - -/** The workspace as the window's save would give it to main, beside main's own copy. */ -function windowAndMain() { - const window = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - windowSave(), - nameBasedLoadContext() - ).layout - const main = mainStoredModel() - return { window: window.workspaces[WORKTREE], main: main.workspaces[WORKTREE] } -} - -function expectSaveTeachesMainNothing(): void { - const { window, main } = windowAndMain() - expect(window).toEqual(main) -} - -async function settled(): Promise { - await vi.waitFor(() => Promise.resolve()) - await new Promise((resolve) => setTimeout(resolve, 0)) -} - -function hostListing(version: number, chats: string[]): RuntimeMobileSessionTabsResult { - return { - worktree: WORKTREE, - publicationEpoch: 'epoch-1', - snapshotVersion: version, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: chats.map((sessionId) => ({ - type: 'agent-session' as const, - id: `agent-session:${sessionId}`, - title: 'Codex Chat', - sessionId, - agent: 'codex' as const, - isActive: false - })) - } -} - -beforeEach(() => { - mocks.layoutOwnerIsMain = true - mocks.sent = [] - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const testStore = createTestStore() - mocks.store = testStore - const terminal: Tab = { - id: TERMINAL, - entityId: TERMINAL, - groupId: GROUP, - worktreeId: WORKTREE, - contentType: 'terminal', - label: 'Terminal 1', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - const group: TabGroup = { - id: GROUP, - worktreeId: WORKTREE, - activeTabId: TERMINAL, - tabOrder: [TERMINAL] - } - seedStore(testStore, { - worktreesByRepo: { repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt' })] }, - activeWorktreeId: WORKTREE, - tabsByWorktree: { - [WORKTREE]: [ - { - id: TERMINAL, - ptyId: null, - worktreeId: WORKTREE, - title: 'Terminal 1', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - unifiedTabsByWorktree: { [WORKTREE]: [terminal] }, - groupsByWorktree: { [WORKTREE]: [group] }, - activeGroupIdByWorktree: { [WORKTREE]: GROUP }, - layoutByWorktree: { [WORKTREE]: { type: 'leaf', groupId: GROUP } } - }) - // Main starts from the window's last save, as it does today. - mocks.main = loadWorkspaceLayoutWithoutReport( - LOCAL_EXECUTION_HOST_ID, - windowSave(), - nameBasedLoadContext() - ) -}) - -afterEach(() => { - resetLocalStructuredSessionVersionForTests() -}) - -describe('chat tabs in a layout-owner build', () => { - it('starts with main holding the window’s layout', () => { - expectSaveTeachesMainNothing() - }) - - it('records a chat the window opens and closes, so its saves teach main nothing', async () => { - const tab = openStructuredAgentSessionProvisionalTab({ - worktreeId: WORKTREE, - executionHostId: LOCAL_EXECUTION_HOST_ID, - sessionId: 'chat-1', - agent: 'codex' - }) - await settled() - expectSaveTeachesMainNothing() - - store().getState().closeUnifiedTab(tab.id) - await settled() - expectSaveTeachesMainNothing() - expect(mocks.sent.map((command) => command.type)).toEqual(['openAgentSessionTab', 'closeTabs']) - }) - - it('records the chats a host listing adds to and removes from the tab bar', async () => { - applyStructuredSessionTabSnapshots([hostListing(1, ['chat-2'])]) - await settled() - expect( - store() - .getState() - .unifiedTabsByWorktree[WORKTREE]?.map((tab) => tab.entityId) - ).toEqual([TERMINAL, 'chat-2']) - expectSaveTeachesMainNothing() - - applyStructuredSessionTabSnapshots([hostListing(2, [])]) - await settled() - expect( - store() - .getState() - .unifiedTabsByWorktree[WORKTREE]?.map((tab) => tab.entityId) - ).toEqual([TERMINAL]) - expectSaveTeachesMainNothing() - }) - - it('sends a listing’s new chats in tab order, so each anchor is already in main', async () => { - const before = store().getState() - const chat = (sessionId: string): Tab => ({ - id: `structured-agent-session-${sessionId}`, - entityId: sessionId, - groupId: GROUP, - worktreeId: WORKTREE, - contentType: 'agent-session', - agentSessionAgent: 'codex', - label: 'Codex Chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: 5 - }) - const [a, b] = [chat('chat-a'), chat('chat-b')] - const after = { - ...before, - // The tab bar lists b first; the group shows a first. - unifiedTabsByWorktree: { [WORKTREE]: [...before.unifiedTabsByWorktree[WORKTREE]!, b, a] }, - groupsByWorktree: { - [WORKTREE]: [ - { ...before.groupsByWorktree[WORKTREE]![0]!, tabOrder: [TERMINAL, a.id, b.id] } - ] - } - } - recordSyncedAgentSessionTabsInLayoutOwner(before, after, [WORKTREE]) - await settled() - expect( - mocks.sent.flatMap((command) => - command.type === 'openAgentSessionTab' ? [[command.tabId, command.afterTabId]] : [] - ) - ).toEqual([ - [a.id, TERMINAL], - [b.id, a.id] - ]) - expect(mainLoad().layout.workspaces[WORKTREE]?.groups[0]?.tabOrder).toEqual([ - TERMINAL, - a.id, - b.id - ]) - }) - - it('leaves a paired server’s chat tabs to that server', async () => { - const remote = 'repo2::/path/remote' - seedStore( - store(), - runtimeOwnedRepoState('env-1', 'repo2', '/path/remote', { - worktreesByRepo: { - repo1: [makeWorktree({ id: WORKTREE, repoId: 'repo1', path: '/path/wt' })], - repo2: [makeRuntimeOwnedWorktree({ id: remote, repoId: 'repo2' }, 'env-1')] - } - }) - ) - const tab = openStructuredAgentSessionProvisionalTab({ - worktreeId: remote, - executionHostId: 'runtime:env-1', - sessionId: 'chat-5', - agent: 'codex' - }) - expect( - store() - .getState() - .unifiedTabsByWorktree[remote]?.map((entry) => entry.id) - ).toEqual([tab.id]) - store().getState().closeUnifiedTab(tab.id) - await settled() - expect(store().getState().unifiedTabsByWorktree[remote]).toEqual([]) - expect(mocks.sent).toEqual([]) - }) - - it('sends nothing with the switch off', async () => { - mocks.layoutOwnerIsMain = false - const tab = openStructuredAgentSessionProvisionalTab({ - worktreeId: WORKTREE, - executionHostId: LOCAL_EXECUTION_HOST_ID, - sessionId: 'chat-3', - agent: 'codex' - }) - applyStructuredSessionTabSnapshots([hostListing(1, ['chat-3', 'chat-4'])]) - store().getState().closeUnifiedTab(tab.id) - await settled() - expect(mocks.sent).toEqual([]) - }) -}) diff --git a/src/renderer/src/runtime/agent-session-tab-layout-owner.ts b/src/renderer/src/runtime/agent-session-tab-layout-owner.ts deleted file mode 100644 index 9ed50821921..00000000000 --- a/src/renderer/src/runtime/agent-session-tab-layout-owner.ts +++ /dev/null @@ -1,101 +0,0 @@ -// Chat (agent-session) tabs in a layout-owner build (E2-5). The window still draws a chat's tab -// first, from its own launch or from this machine's host listing the chat; main records each open, -// close and prop change under the window's ids, so a window save no longer teaches main a chat tab. -// Nothing to undo on any answer: the window's next save carries a change main could not take. - -import type { Tab } from '../../../shared/tab-types' -import type { AppState } from '@/store/types' -import { recordLayoutApply } from './layout-apply-send' -import { drawnTabPlacement } from './layout-owner-drawn-tab-placement' - -type ChatTabState = Parameters[0] & - Pick - -/** A chat tab the window just drew, placed where the window drew it. */ -export function recordOpenedAgentSessionTabInLayoutOwner(state: ChatTabState, tab: Tab): void { - if (tab.contentType !== 'agent-session' || !tab.agentSessionAgent) { - return - } - recordLayoutApply(state, tab.worktreeId, { - type: 'openAgentSessionTab', - tabId: tab.id, - sessionId: tab.entityId, - agent: tab.agentSessionAgent, - ...drawnTabPlacement(state, tab.worktreeId, tab.groupId, tab.id), - createdAt: tab.createdAt - }) - recordChatTabProps(state, tab, { color: null }) -} - -/** A chat tab the window just closed. */ -export function recordClosedAgentSessionTabInLayoutOwner( - state: ChatTabState, - tab: Pick -): void { - if (tab.contentType === 'agent-session') { - recordLayoutApply(state, tab.worktreeId, { type: 'closeTabs', tabIds: [tab.id] }) - } -} - -/** The chat tabs a host listing added to, removed from or restyled in these workspaces' tab bars. */ -export function recordSyncedAgentSessionTabsInLayoutOwner( - before: ChatTabState, - after: ChatTabState, - worktreeIds: Iterable -): void { - for (const worktreeId of new Set(worktreeIds)) { - const was = chatTabsById(before, worktreeId) - const now = chatTabsById(after, worktreeId) - for (const [id, tab] of was) { - if (!now.has(id)) { - recordClosedAgentSessionTabInLayoutOwner(after, tab) - } - } - const opened: Tab[] = [] - for (const [id, tab] of now) { - const old = was.get(id) - if (!old) { - opened.push(tab) - } else if (old !== tab) { - recordChatTabProps(after, tab, old) - } - } - // In tab order, so each open's anchor (the tab before it) already reached main. - const position = tabOrderPositions(after, worktreeId) - opened.sort((a, b) => (position.get(a.id) ?? Infinity) - (position.get(b.id) ?? Infinity)) - for (const tab of opened) { - recordOpenedAgentSessionTabInLayoutOwner(after, tab) - } - } -} - -function tabOrderPositions(state: ChatTabState, worktreeId: string): Map { - const ids = (state.groupsByWorktree[worktreeId] ?? []).flatMap((group) => group.tabOrder) - return new Map(ids.map((id, index) => [id, index])) -} - -function chatTabsById(state: ChatTabState, worktreeId: string): Map { - return new Map( - (state.unifiedTabsByWorktree[worktreeId] ?? []).flatMap((tab) => - tab.contentType === 'agent-session' ? [[tab.id, tab] as const] : [] - ) - ) -} - -/** A host's color or pin for a chat; an open carries neither, and a new tab is unpinned in main. */ -function recordChatTabProps( - state: ChatTabState, - tab: Tab, - old: Pick & Partial> -): void { - const color = tab.color ?? null - const props = { - ...(color !== (old.color ?? null) ? { color } : {}), - ...(tab.isPinned !== undefined && tab.isPinned !== old.isPinned - ? { isPinned: tab.isPinned } - : {}) - } - if (Object.keys(props).length > 0) { - recordLayoutApply(state, tab.worktreeId, { type: 'setTabProps', tabId: tab.id, ...props }) - } -} diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index bbf5ae79274..eea73886924 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -40,7 +40,7 @@ export type WindowLayoutCommand = | { type: 'movePane'; tabId: string; leafId: string; targetLeafId: string; side: PaneSide } | { type: 'setPaneRatios'; tabId: string; ratios: SplitRatioIntent[] } | { type: 'setChatPane'; tabId: string; leafId: string | null } - | { type: 'moveTab'; tabId: string; toGroupId: string; index: number } + | { type: 'moveTab'; tabId: string; toGroupId: string; index: number; isPinned?: boolean } | { type: 'splitGroup'; tabId: string; besideGroupId: string; direction: GroupSide } | { type: 'setGroupRatios'; ratios: SplitRatioIntent[] } | { type: 'closeTabs'; tabIds: [string]; recordClose?: 'user' | 'cleanup' } @@ -71,8 +71,9 @@ export type WindowLayoutCommand = groupId?: string afterTabId?: string index?: number - createdAt: number - browserCreatedAt: number + /** Absent from a switch-on open: main stamps the tab's time. */ + createdAt?: number + browserCreatedAt?: number split?: NewGroupSplit } | { type: 'openBrowserPage' | 'closeBrowserPage'; browserTabId: string; pageId: string } @@ -105,7 +106,7 @@ export type WindowLayoutCommand = groupId: string afterTabId?: string index?: number - createdAt: number + createdAt?: number split?: NewGroupSplit } | { type: 'promotePreviewTab'; tabId: string } @@ -115,7 +116,7 @@ export type WindowLayoutCommand = groupId: string afterTabId?: string index?: number - createdAt: number + createdAt?: number split?: NewGroupSplit } | { @@ -174,26 +175,24 @@ function drawnTabHolds(worktreeId: string, command: WindowLayoutCommand): DrawnT switch (command.type) { case 'closeTabs': return [{ workspace: worktreeId, ids: command.tabIds }] - case 'openBrowserTab': - return [{ workspace: worktreeId, ids: [command.tabId, command.browserTabId] }] case 'splitGroup': return [{ workspace: worktreeId, ids: [command.tabId], groupLayout: true }] case 'moveTab': case 'renameTab': case 'setTabProps': - case 'openAgentSessionTab': - case 'openSimulatorTab': case 'createTerminalTab': return [{ workspace: worktreeId, ids: [command.tabId] }] + case 'setGroupRatios': + return [{ workspace: worktreeId, ids: [], groupLayout: true }] + // Pane commands hold their tab's pane tree; records are not drawn from frames; editor, browser, + // chat and simulator opens and page changes are sent before the window draws them. + case 'openBrowserTab': case 'openBrowserPage': case 'closeBrowserPage': case 'replaceBrowserPage': case 'setBrowserTabProfile': - return [{ workspace: worktreeId, ids: [command.browserTabId] }] - case 'setGroupRatios': - return [{ workspace: worktreeId, ids: [], groupLayout: true }] - // Pane commands hold their tab's pane tree; records are not drawn from frames; editor opens, - // promotions, moves and renames are sent before the window draws them. + case 'openAgentSessionTab': + case 'openSimulatorTab': case 'openEditorTab': case 'promotePreviewTab': case 'moveTabToWorkspace': diff --git a/src/renderer/src/runtime/layout-owner-content-tab-open.ts b/src/renderer/src/runtime/layout-owner-content-tab-open.ts new file mode 100644 index 00000000000..89378f0fec0 --- /dev/null +++ b/src/renderer/src/runtime/layout-owner-content-tab-open.ts @@ -0,0 +1,63 @@ +// A browser, chat or simulator tab open in a layout-owner build: the window proposes the ids and +// sends the open; main's frame draws the tab with the window's facts for it, and only then does +// the window act on it (select, focus). A caller acting on the tab waits with `afterPendingTab`. + +import { sendLayoutApply, type WindowLayoutCommand } from './layout-apply-send' +import { beginPendingTab } from './layout-owner-pending-tabs' +import { expectTabFacts, type PendingTabFacts } from './layout-owner-pending-tab-facts' + +type ContentTabOpenCommand = Extract< + WindowLayoutCommand, + { type: 'openBrowserTab' | 'openAgentSessionTab' | 'openSimulatorTab' } +> + +export type ContentTabOpen = { + worktreeId: string + /** What the tab shows: a browser record, chat session or the simulator tab itself. */ + entityId: string + command: ContentTabOpenCommand + facts: Omit + /** Runs once main's frame drew the tab, with the id it shows. */ + shown: (tabId: string) => void + /** Today's draw, for a twin main may route to a paired server; the tab id it drew, if any. */ + drawHere: () => string | null +} + +/** False when main is not asked (a paired server owns the workspace's layout); draw as today. */ +export function openContentTabThroughLayoutOwner( + state: Parameters[0], + open: ContentTabOpen +): boolean { + const { worktreeId, command } = open + const groupId = command.groupId + const settle = beginPendingTab(command.tabId, worktreeId, open.entityId, { + kind: 'open', + groupId + }) + const release = expectTabFacts({ ...open.facts, worktreeId, tabId: command.tabId }) + const sent = sendLayoutApply(state, worktreeId, command, { + apply: (result) => { + const shownId = result.tabId ?? command.tabId + // The frame drew the tab with these facts; the store holds them from here on. + release() + try { + open.shown(shownId) + } finally { + settle(shownId) + } + }, + asToday: () => { + release() + settle(open.drawHere()) + }, + refused: () => { + release() + settle(null) + } + }) + if (!sent) { + release() + settle(null) + } + return sent +} diff --git a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.test.ts b/src/renderer/src/runtime/layout-owner-drawn-tab-placement.test.ts deleted file mode 100644 index 82db9803e29..00000000000 --- a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { TabGroup, TabGroupLayoutNode } from '../../../shared/tab-types' -import { drawnTabPlacement } from './layout-owner-drawn-tab-placement' - -const WT = 'wt-1' - -function stateOf(groups: TabGroup[], layout: TabGroupLayoutNode) { - return { groupsByWorktree: { [WT]: groups }, layoutByWorktree: { [WT]: layout } } -} - -function group(id: string, tabOrder: string[]): TabGroup { - return { id, worktreeId: WT, activeTabId: tabOrder[0] ?? null, tabOrder } -} - -describe('drawnTabPlacement', () => { - const single = stateOf([group('G', ['a', 'b', 'c'])], { type: 'leaf', groupId: 'G' }) - - it('places a tab drawn first at index 0', () => { - expect(drawnTabPlacement(single, WT, 'G', 'a')).toEqual({ groupId: 'G', index: 0 }) - }) - - it('places a middle tab after the tab before it', () => { - expect(drawnTabPlacement(single, WT, 'G', 'b')).toEqual({ groupId: 'G', afterTabId: 'a' }) - }) - - it('places the last tab after the tab before it', () => { - expect(drawnTabPlacement(single, WT, 'G', 'c')).toEqual({ groupId: 'G', afterTabId: 'b' }) - }) - - it('names only the group for a tab missing from the order', () => { - expect(drawnTabPlacement(single, WT, 'G', 'z')).toEqual({ groupId: 'G' }) - expect(drawnTabPlacement(single, WT, 'missing-group', 'a')).toEqual({ - groupId: 'missing-group' - }) - }) - - it('carries the split for a tab opened to the side in its own new group', () => { - const state = stateOf([group('G', ['a']), group('N', ['n'])], { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', groupId: 'G' }, - second: { type: 'leaf', groupId: 'N' } - }) - expect(drawnTabPlacement(state, WT, 'N', 'n')).toEqual({ - groupId: 'N', - index: 0, - split: { besideGroupId: 'G', direction: 'right' } - }) - }) -}) diff --git a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.ts b/src/renderer/src/runtime/layout-owner-drawn-tab-placement.ts deleted file mode 100644 index fa9b0315538..00000000000 --- a/src/renderer/src/runtime/layout-owner-drawn-tab-placement.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NewGroupSplit } from '../../../shared/workspace-layout/workspace-layout-command-types' -import { newGroupSplitOf } from './layout-owner-new-group-split' - -type DrawnTabPlacement = { - groupId: string - afterTabId?: string - index?: 0 - split?: NewGroupSplit -} - -/** - * Where the window drew a just-opened tab, as an open command's placement fields: after the tab - * before it, first in its group, or (missing from the order) only the group; plus the split that - * made its group when it was opened to the side. - */ -export function drawnTabPlacement( - state: Parameters[0], - worktreeId: string, - groupId: string, - tabId: string -): DrawnTabPlacement { - const order = state.groupsByWorktree[worktreeId]?.find((group) => group.id === groupId)?.tabOrder - const position = order?.indexOf(tabId) ?? -1 - const afterTabId = position > 0 ? order?.[position - 1] : undefined - return { - groupId, - ...(afterTabId ? { afterTabId } : position === 0 ? { index: 0 } : {}), - ...newGroupSplitOf(state, worktreeId, groupId, tabId) - } -} diff --git a/src/renderer/src/runtime/layout-owner-pending-tab-facts.ts b/src/renderer/src/runtime/layout-owner-pending-tab-facts.ts new file mode 100644 index 00000000000..5f583f9e4dc --- /dev/null +++ b/src/renderer/src/runtime/layout-owner-pending-tab-facts.ts @@ -0,0 +1,68 @@ +// Content facts of a tab this window asked main to open, in a layout-owner build: its tab-bar +// label and, for a browser tab, its page state and pages. Main holds only the tab's layout, so a +// frame build reads these as the store's own until the frame that draws the tab is in. + +import type { BrowserPage, BrowserWorkspace } from '../../../shared/browser-workspace-types' +import { + BROWSER_TAB_LAYOUT_FIELDS, + BROWSER_TAB_WORKSPACE_FIELDS, + type LayoutContentFacts +} from '../../../shared/workspace-layout/workspace-layout-beside' +import { omitStoredFields } from '../../../shared/workspace-layout/stored-record-fields' + +export type PendingTabFacts = { + worktreeId: string + tabId: string + label: string + browser?: { record: BrowserWorkspace; pages: BrowserPage[] } +} + +const pending = new Map() + +/** Kept until the release, which is a no-op once a later expectation for the tab replaced it. */ +export function expectTabFacts(facts: PendingTabFacts): () => void { + pending.set(facts.tabId, facts) + return () => { + if (pending.get(facts.tabId) === facts) { + pending.delete(facts.tabId) + } + } +} + +/** `facts` with the pending tabs of `keys` added where the store had none of their own. */ +export function withPendingTabFacts( + facts: LayoutContentFacts, + keys: readonly string[] +): LayoutContentFacts { + const entries = [...pending.values()].filter((entry) => keys.includes(entry.worktreeId)) + if (entries.length === 0) { + return facts + } + const tabLabels = { ...facts.tabLabels } + const browserTabs = { ...facts.browserTabs } + const browserPagesByWorkspace = { ...facts.browserPagesByWorkspace } + for (const { worktreeId, tabId, label, browser } of entries) { + if (tabLabels[worktreeId]?.[tabId] === undefined) { + tabLabels[worktreeId] = { ...tabLabels[worktreeId], [tabId]: label } + } + if (!browser) { + continue + } + const id = browser.record.id + if (!browserTabs[worktreeId]?.[id]) { + const live = omitStoredFields(browser.record, [ + ...BROWSER_TAB_LAYOUT_FIELDS, + ...BROWSER_TAB_WORKSPACE_FIELDS + ]) + browserTabs[worktreeId] = { ...browserTabs[worktreeId], [id]: { ...live, loading: false } } + } + if (!browserPagesByWorkspace[id]?.length) { + browserPagesByWorkspace[id] = browser.pages.map((page) => ({ ...page, loading: false })) + } + } + return { ...facts, tabLabels, browserTabs, browserPagesByWorkspace } +} + +export function _resetPendingTabFactsForTests(): void { + pending.clear() +} diff --git a/src/renderer/src/runtime/layout-owner-pending-tabs.ts b/src/renderer/src/runtime/layout-owner-pending-tabs.ts index d5a1e0bbf0e..ec4b091237d 100644 --- a/src/renderer/src/runtime/layout-owner-pending-tabs.ts +++ b/src/renderer/src/runtime/layout-owner-pending-tabs.ts @@ -50,6 +50,10 @@ export function afterPendingTab(tabId: string, run: (outcome: Outcome) => void): } } +export function isPendingTab(tabId: string): boolean { + return pending.has(tabId) +} + /** The group a pending open asked for. */ export function pendingTabGroupId(tabId: string): string | undefined { const ask = pending.get(tabId)?.ask diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.test.ts new file mode 100644 index 00000000000..cb6943da11a --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.test.ts @@ -0,0 +1,139 @@ +// @vitest-environment happy-dom + +// Switch-on, this machine's chat listing leaves a frame-drawn workspace's tabs as main drew them +// and only updates a drawn chat tab's label from the host's title. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' + +vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { applyStructuredSessionTabSnapshots } from './snapshot-apply' +import { resetLocalStructuredSessionVersionForTests } from './inventory-generation-fence' +import { resetWebSessionTabsSnapshotFreshnessForTests } from '../web-session-tabs-sync/tracking-lifecycle' + +const WT = 'repo-1::wt-drawn' +const GROUP = 'group-1' +const TERMINAL = 'terminal-1' +const CHAT = 'structured-agent-session-chat-1' + +function tab(id: string, entityId: string, contentType: Tab['contentType'], label: string): Tab { + return { + id, + entityId, + groupId: GROUP, + worktreeId: WT, + contentType, + label, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +const STRUCTURE_FIELDS = [ + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'layoutByWorktree', + 'activeGroupIdByWorktree', + 'tabBarOrderByWorktree', + 'activeTabIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabId', + 'activeTabType' +] as const + +function structure(state: AppState): Partial { + return Object.fromEntries(STRUCTURE_FIELDS.map((field) => [field, state[field]])) +} + +/** The host lists the drawn chat under a new title and a second chat main has not placed yet. */ +function listing(): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'host-group', + activeTabId: 'agent-session:chat-2', + activeTabType: 'agent-session', + navigationIntent: 'follow', + tabGroups: [ + { + id: 'host-group', + activeTabId: 'agent-session:chat-2', + tabOrder: ['agent-session:chat-1', 'agent-session:chat-2'] + } + ], + tabs: [ + { + type: 'agent-session', + id: 'agent-session:chat-1', + title: 'Renamed by host', + sessionId: 'chat-1', + agent: 'codex', + isActive: false + }, + { + type: 'agent-session', + id: 'agent-session:chat-2', + title: 'Second chat', + sessionId: 'chat-2', + agent: 'codex', + isActive: true + } + ] + } +} + +beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: WT, + unifiedTabsByWorktree: { + [WT]: [ + tab(TERMINAL, TERMINAL, 'terminal', 'Terminal'), + { ...tab(CHAT, 'chat-1', 'agent-session', 'Old title'), agentSessionAgent: 'codex' } + ] + }, + groupsByWorktree: { + [WT]: [{ id: GROUP, worktreeId: WT, activeTabId: TERMINAL, tabOrder: [TERMINAL, CHAT] }] + }, + layoutByWorktree: { [WT]: { type: 'leaf', groupId: GROUP } }, + activeGroupIdByWorktree: { [WT]: GROUP }, + tabBarOrderByWorktree: { [WT]: [TERMINAL, CHAT] }, + activeTabIdByWorktree: { [WT]: TERMINAL }, + activeTabTypeByWorktree: { [WT]: 'terminal' }, + activeTabId: TERMINAL, + activeTabType: 'terminal' + }) +}) + +afterEach(() => { + resetLocalStructuredSessionVersionForTests() + resetWebSessionTabsSnapshotFreshnessForTests() +}) + +describe('chat listing for a frame-drawn workspace', () => { + it('keeps the tabs main drew and takes the host title as the drawn chat label', () => { + const drawn = structure(useAppStore.getState()) + + applyStructuredSessionTabSnapshots([listing()]) + + const after = useAppStore.getState() + expect(after.unifiedTabsByWorktree[WT]?.find((t) => t.id === CHAT)?.label).toBe( + 'Renamed by host' + ) + const relabelled = { + ...drawn, + unifiedTabsByWorktree: { + [WT]: (drawn.unifiedTabsByWorktree?.[WT] ?? []).map((t) => + t.id === CHAT ? { ...t, label: 'Renamed by host' } : t + ) + } + } + expect(structure(after)).toEqual(relabelled) + }) +}) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.ts new file mode 100644 index 00000000000..cffe1fb0cf3 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/frame-drawn-chat-tabs.ts @@ -0,0 +1,31 @@ +// This machine's chat listing in a layout-owner build: main places and removes a listed chat's tab +// in its own layout, and its frames draw it, so the listing changes no tab-bar structure of a +// workspace frames draw. It still updates what a drawn chat tab shows (its label from the host). + +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import type { Tab } from '../../../../shared/tab-types' +import { defaultAgentChatLabel } from '../../../../shared/agent-session-chat-label' + +export type DrawnChatLabel = { tabId: string; label: string } + +/** The host's chat titles that differ from the labels on the window's drawn chat tabs. */ +export function drawnChatLabelChanges( + drawnTabs: readonly Tab[], + snapshot: RuntimeMobileSessionTabsResult +): DrawnChatLabel[] { + const changes: DrawnChatLabel[] = [] + for (const listed of snapshot.tabs) { + if (listed.type !== 'agent-session') { + continue + } + const drawn = drawnTabs.find( + (tab) => tab.contentType === 'agent-session' && tab.entityId === listed.sessionId + ) + // Same rule as the mirror's label, so a drawn tab reads as an undrawn one would. + const label = listed.title?.trim() || defaultAgentChatLabel(listed.agent) + if (drawn && drawn.label !== label) { + changes.push({ tabId: drawn.id, label }) + } + } + return changes +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts index f369c5a1089..83d60627afe 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts @@ -92,7 +92,7 @@ export function refreshLocalStructuredSessionTabs( export async function refreshLocalStructuredSessionWorktreeTabs( worktreeId: string, options: { reacceptCurrentVersion?: boolean } = {} -): Promise { +): Promise { // Structured chat can be switched off mid-call, which wipes the mirror; a late answer must not re-seed it. const expectedGeneration = localStructuredSessionGeneration() const snapshot = await callRuntimeRpc( @@ -100,7 +100,9 @@ export async function refreshLocalStructuredSessionWorktreeTabs( 'session.tabs.list', { worktree: toRuntimeWorktreeSelector(worktreeId) } ) - if (isCurrentLocalStructuredSessionGeneration(expectedGeneration)) { - applyStructuredSessionTabSnapshots([snapshot], undefined, options) + if (!isCurrentLocalStructuredSessionGeneration(expectedGeneration)) { + return null } + applyStructuredSessionTabSnapshots([snapshot], undefined, options) + return snapshot } diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index 3f15e082a42..8ee8941f4bd 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -33,9 +33,10 @@ import { import { suppressCancelledStructuredSessionTabs } from '../structured-agent-session-tab-retirement' import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-structured-session-owner' import { closeStructuredAgentSession } from '../structured-agent-session-close' -import { useAppStore } from '../../store' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { recordSyncedAgentSessionTabsInLayoutOwner } from '../agent-session-tab-layout-owner' +import { useAppStore } from '../../store' +import { framesDrawWorkspace } from '../workspace-layout-frame-drawn-workspaces' +import { drawnChatLabelChanges, type DrawnChatLabel } from './frame-drawn-chat-tabs' /** The host saying it no longer publishes this worktree at all, rather than publishing an empty one. */ function isWorktreeRetraction( @@ -60,6 +61,10 @@ export type StructuredSessionSnapshotApplyOptions = { onRetiredEpochDrop?: (worktreeId: string, publicationEpoch: string) => void /** Collects accepted publications so lifecycle listeners run after the store patch settles. */ onAcceptedAgentSession?: (worktreeId: string, sessionId: string) => void + /** Workspaces main's frames draw: the listing writes none of their tabs, only collects labels. */ + framesDraw?: (worktreeId: string) => boolean + /** Collects host titles for drawn chat tabs, applied as content after the store patch. */ + onDrawnChatLabel?: (change: DrawnChatLabel) => void } export function applyStructuredSessionTabSnapshots( @@ -68,7 +73,7 @@ export function applyStructuredSessionTabSnapshots( options: StructuredSessionSnapshotApplyOptions = {} ): void { const acceptedAgentSessions: { worktreeId: string; sessionId: string }[] = [] - const before = ORCA_LAYOUT_OWNER_IS_MAIN ? useAppStore.getState() : null + const drawnChatLabels: DrawnChatLabel[] = [] const settleStructuredSessionMirror = applyWebSessionTabsStorePatch( (state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner, undefined, { @@ -76,18 +81,17 @@ export function applyStructuredSessionTabSnapshots( onAcceptedAgentSession: (worktreeId, sessionId) => { acceptedAgentSessions.push({ worktreeId, sessionId }) options.onAcceptedAgentSession?.(worktreeId, sessionId) - } + }, + // Switch point: main places the listed chats' tabs; its frames draw them. + framesDraw: ORCA_LAYOUT_OWNER_IS_MAIN ? framesDrawWorkspace(state) : undefined, + onDrawnChatLabel: (change) => drawnChatLabels.push(change) }), { frames: [] } ) settleStructuredSessionMirror() - // Switch point: main records the chat tabs this listing added to or removed from the tab bars. - if (ORCA_LAYOUT_OWNER_IS_MAIN && before) { - recordSyncedAgentSessionTabsInLayoutOwner( - before, - useAppStore.getState(), - snapshots.map((snapshot) => snapshot.worktree) - ) + const { setTabLabel } = useAppStore.getState() + for (const { tabId, label } of drawnChatLabels) { + setTabLabel(tabId, label) } markStructuredAgentSessionLaunchesPublished(LOCAL_EXECUTION_HOST_ID, acceptedAgentSessions) if (options.authoritative) { @@ -167,6 +171,7 @@ export function applyLocalStructuredSessionTabSnapshots< options.onAcceptedAgentSession?.(snapshot.worktree, tab.sessionId) } } + // Runs for a drawn workspace too: its tab-id, focus and close-intent ledgers still follow the host. const patch = applyWebSessionTabsSnapshot( next, projectLocalStructuredSessionTabs(effectiveSnapshot), @@ -178,7 +183,17 @@ export function applyLocalStructuredSessionTabSnapshots< terminalPtyMode: 'local' } ) - next = patch === next ? next : ({ ...next, ...patch } as State) + if (options.framesDraw?.(snapshot.worktree)) { + // Main's frames own this workspace's tabs, so the patch (tab structure only here) is dropped. + for (const change of drawnChatLabelChanges( + next.unifiedTabsByWorktree[snapshot.worktree] ?? [], + effectiveSnapshot + )) { + options.onDrawnChatLabel?.(change) + } + } else { + next = patch === next ? next : ({ ...next, ...patch } as State) + } if (isWorktreeRetraction(effectiveSnapshot)) { // A retraction was applied above — the mirrored rows must go — but it is not a publication // to fence later frames against. Recording it would retire the renderer's own epoch, which diff --git a/src/renderer/src/runtime/session-tab-rows-main-window-parity.test.ts b/src/renderer/src/runtime/session-tab-rows-main-window-parity.test.ts new file mode 100644 index 00000000000..f559479123d --- /dev/null +++ b/src/renderer/src/runtime/session-tab-rows-main-window-parity.test.ts @@ -0,0 +1,204 @@ +// Diff and markdown-preview rows: main's projection of its layout model (the window's session plus +// the editor views the window publishes) must equal the rows the window builds for the same tabs. + +import { describe, expect, it, vi } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-session-contracts' +import type { LayoutEditorView } from '../../../shared/workspace-layout/layout-editor-view' +import { projectSessionTabs } from '../../../shared/workspace-layout/old-client/old-client-session-tab-rows' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { + addWorkspace, + emptySession, + GIT_KEY +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { ownedEditorFileId } from '../../../shared/workspace-session-pane-ownership' +import type { OpenFile } from '../store/slices/editor' +import { layoutEditorViewOfOpenFile } from '../store/slices/editor/tabs/open-file-layout-view' +import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import { makeState } from './sync-runtime-graph-test-harness' + +// Main's projection runs on the layout-owner path only. +vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +const README = '/Users/dev/orca/README.md' +const SERVER_DOC = '/srv/app/GUIDE.md' +const SERVER_DOC_ID = ownedEditorFileId(SERVER_DOC, GIT_KEY, 'env-1') + +const files: OpenFile[] = [ + { + id: README, + filePath: README, + relativePath: 'README.md', + worktreeId: GIT_KEY, + language: 'markdown', + mode: 'edit', + isDirty: true + }, + { + id: SERVER_DOC_ID, + filePath: SERVER_DOC, + relativePath: 'GUIDE.md', + worktreeId: GIT_KEY, + language: 'markdown', + runtimeEnvironmentId: 'env-1', + mode: 'edit', + isDirty: false + }, + { + id: `markdown-preview::${README}`, + filePath: README, + relativePath: 'README.md', + worktreeId: GIT_KEY, + language: 'markdown', + mode: 'markdown-preview', + markdownPreviewSourceFileId: README, + isDirty: false + }, + { + id: `markdown-preview::${SERVER_DOC_ID}`, + filePath: SERVER_DOC, + relativePath: 'GUIDE.md', + worktreeId: GIT_KEY, + language: 'markdown', + runtimeEnvironmentId: 'env-1', + mode: 'markdown-preview', + markdownPreviewSourceFileId: SERVER_DOC_ID, + isDirty: false + }, + // Its source tab is closed: the preview is its own source. + { + id: 'markdown-preview::/Users/dev/orca/CHANGELOG.md', + filePath: '/Users/dev/orca/CHANGELOG.md', + relativePath: 'CHANGELOG.md', + worktreeId: GIT_KEY, + language: 'markdown', + mode: 'markdown-preview', + markdownPreviewSourceFileId: '/Users/dev/orca/CHANGELOG.md', + isDirty: false + }, + { + id: `${GIT_KEY}::diff::unstaged::src/app.ts`, + filePath: '/Users/dev/orca/src/app.ts', + relativePath: 'src/app.ts', + worktreeId: GIT_KEY, + language: 'typescript', + mode: 'diff', + diffSource: 'unstaged', + isDirty: false + }, + { + id: `${GIT_KEY}::diff::branch::src/lib.ts`, + filePath: '/Users/dev/orca/src/lib.ts', + relativePath: 'src/lib.ts', + worktreeId: GIT_KEY, + language: 'typescript', + mode: 'diff', + diffSource: 'branch', + isDirty: false + }, + { + id: `${GIT_KEY}::all-diffs::uncommitted`, + filePath: '/Users/dev/orca', + relativePath: 'All Changes', + worktreeId: GIT_KEY, + language: 'plaintext', + mode: 'diff', + diffSource: 'combined-all', + isDirty: false + } +] + +const tabIdOf = (file: OpenFile): string => `tab:${file.id}` + +/** The window's session for `files`, one tab each in one group, and the views it publishes. */ +function scenario(activeFileId: string) { + const session = addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'group-1', + activeTabId: tabIdOf(files.find((file) => file.id === activeFileId)!), + tabs: files.map((file) => ({ + id: tabIdOf(file), + entityId: file.id, + kind: file.mode === 'diff' ? ('diff' as const) : ('editor' as const), + entry: { color: file.mode === 'diff' ? '#00ff00' : null } + })) + } + ]) + const editFiles = files.filter((file) => file.mode === 'edit') + session.openFilesByWorktree = { + [GIT_KEY]: editFiles.map((file) => ({ + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: GIT_KEY, + language: file.language, + ...(file.runtimeEnvironmentId ? { runtimeEnvironmentId: file.runtimeEnvironmentId } : {}), + ...(file.isDirty ? { dirtyDraftContent: '# draft' } : {}) + })) + } + const views: Record = {} + for (const file of files) { + const view = layoutEditorViewOfOpenFile(file) + if (view) { + views[tabIdOf(file)] = view + } + } + const windowState = makeState({ + openFiles: files, + unifiedTabsByWorktree: session.unifiedTabs!, + groupsByWorktree: session.tabGroups!, + layoutByWorktree: session.tabGroupLayouts!, + activeGroupIdByWorktree: { [GIT_KEY]: 'group-1' }, + activeFileId, + activeFileIdByWorktree: { [GIT_KEY]: activeFileId }, + activeTabType: 'editor', + activeTabTypeByWorktree: { [GIT_KEY]: 'editor' } + }) + const loaded = loadWorkspaceLayoutWithoutReport(LOCAL_EXECUTION_HOST_ID, session, { + ...nameBasedLoadContext(), + editorViewsOf: (key) => (key === GIT_KEY ? views : undefined) + }) + const main = projectSessionTabs( + GIT_KEY, + loaded.layout.workspaces[GIT_KEY]!, + loaded.desktopView, + loaded.facts, + false + ) + const window = buildMobileSessionTabSnapshots(windowState).find( + (snapshot) => snapshot.worktree === GIT_KEY + )! + return { main, window } +} + +/** What both sides publish, minus what only the runtime stamps. */ +function published(snapshot: Pick) { + return { tabs: snapshot.tabs, tabGroups: snapshot.tabGroups } +} + +describe("main's diff and markdown-preview rows", () => { + it.each([ + ['a diff', `${GIT_KEY}::diff::unstaged::src/app.ts`], + ['a markdown preview', `markdown-preview::${README}`] + ])("equal the window's rows with %s active", (_label, activeFileId) => { + const { main, window } = scenario(activeFileId) + // Every file but the combined diff, which neither side lists. + expect(window.tabs).toHaveLength(files.length - 1) + expect(published(main)).toEqual(published(window)) + expect(main.activeTabId).toBe(window.activeTabId) + expect(main.activeTabType).toBe(window.activeTabType) + }) + + it('links a preview to its source by the open-file id, owned ids included', () => { + const { main } = scenario(`markdown-preview::${README}`) + const previews = main.tabs.filter((tab) => tab.type === 'markdown' && tab.mode !== 'edit') + expect( + previews.map((tab) => tab.type === 'markdown' && [tab.sourceFileId, tab.isDirty]) + ).toEqual([ + [README, true], + [SERVER_DOC_ID, false], + ['markdown-preview::/Users/dev/orca/CHANGELOG.md', false] + ]) + }) +}) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 90700044922..6c7cd06bef8 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -83,7 +83,9 @@ export function retireStructuredAgentSessionTab(args: { function reacceptHostSessionTabs( target: RuntimeClientTarget, worktreeId: string, - intentOwner: WebSessionIntentOwner + intentOwner: WebSessionIntentOwner, + /** This machine's host still lists the chat. */ + stillListed?: { sessionId: string; reopen: () => void } ): void { // Lazy imports: both refresh paths apply frames through this module. const reread = @@ -97,8 +99,19 @@ function reacceptHostSessionTabs( }) ) : import('./local-structured-session-tabs-sync/inventory-refresh').then( - ({ refreshLocalStructuredSessionWorktreeTabs }) => - refreshLocalStructuredSessionWorktreeTabs(worktreeId, { reacceptCurrentVersion: true }) + async ({ refreshLocalStructuredSessionWorktreeTabs }) => { + const listed = await refreshLocalStructuredSessionWorktreeTabs(worktreeId, { + reacceptCurrentVersion: true + }) + if ( + stillListed && + listed?.tabs.some( + (tab) => tab.type === 'agent-session' && tab.sessionId === stillListed.sessionId + ) + ) { + stillListed.reopen() + } + } ) reread.catch((error: unknown) => console.warn('[structured-agent-session] tab re-read after a close failed', error) @@ -113,6 +126,8 @@ export function beginStructuredAgentSessionTabClose(args: { provisional: boolean layoutOwner: StructuredLaunchSlotOwner onError?: (error: unknown) => void + /** Main dropped the tab before the host answered: a host still listing the chat shows it again. */ + reopen?: () => void }): void { if (args.provisional) { markStructuredAgentSessionLaunchCancelled( @@ -144,7 +159,14 @@ export function beginStructuredAgentSessionTabClose(args: { // needs a read; a paired server's frames travel apart from its answer, so, as for its terminal // closes, every close is followed by a read. if (!hostTabClosed || args.target.kind === 'environment') { - reacceptHostSessionTabs(args.target, args.worktreeId, intentOwner) + reacceptHostSessionTabs( + args.target, + args.worktreeId, + intentOwner, + !hostTabClosed && args.reopen + ? { sessionId: args.sessionId, reopen: args.reopen } + : undefined + ) } }) } diff --git a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts index a647d207fdd..6e907026017 100644 --- a/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts +++ b/src/renderer/src/runtime/structured-launch-cancel-layout-owner.test.ts @@ -42,7 +42,6 @@ import { markStructuredAgentSessionLaunchCancelled, resetStructuredAgentLaunchRegistryForTests, setStructuredLaunchState, - subscribeStructuredAgentLaunchStatus, type StructuredLaunchState } from '@/lib/structured-agent-session-launch-registry' import { @@ -50,6 +49,7 @@ import { resetStructuredAgentLaunchPersistenceForTests, writeStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' +import { wasStructuredAgentLaunchClosedByLocalCancel } from '@/lib/structured-agent-session-local-launch-cancel' import { acceptPairedHostStructuredSessions, beginStructuredAgentSessionTabClose, @@ -184,19 +184,24 @@ describe('this machine, with main owning the layout', () => { expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) }) - it('a main that cannot be told gets the tombstone, so a late listing is still hidden', async () => { + // Main notes the close before anything in it can fail, so its fence refuses the late create and + // no chat is published to draw (structured-agent-session-close-before-create.test.ts). + it('a retire main fails is logged; no tombstone, and a late create reply revives nothing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) mocks.closeSession.mockRejectedValueOnce(new Error('host unavailable')) pendingLaunch('local', LOCAL) - const listener = vi.fn() - subscribeStructuredAgentLaunchStatus(listener) markStructuredAgentSessionLaunchCancelled(WORKTREE, SESSION, 'local', layoutOwner) await vi.waitFor(() => - expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(true) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] local cancel retirement failed', + expect.any(Error) + ) ) - expect(listener).toHaveBeenCalledTimes(2) - expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toEqual([]) + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE, SESSION)).toBe(false) + expect(wasStructuredAgentLaunchClosedByLocalCancel(SESSION)).toBe(true) + expect(suppressCancelledStructuredSessionTabs(hostFrame(), LOCAL).tabs).toHaveLength(1) }) it('switch off: keeps the tombstone and leaves the close to the tab close, as today', () => { diff --git a/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts b/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts index e16a12392c3..283845f9340 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-conversion-publish.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '../lib/agent-status' import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' import { createTestStore, makeWorktree } from '../store/slices/store-test-helpers' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '../store/slices/tabs-drawn-from-main.test-fixture' vi.mock('@/lib/doc-preview-grants', () => ({ releaseDocPreviewGrant: vi.fn(), @@ -14,6 +18,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands; its frames draw the browser tabs and conversions. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const WORKTREE_ID = 'repo1::/path/wt1' const DOC_LOCATION = { kind: 'workspace-doc' as const, @@ -21,7 +37,18 @@ const DOC_LOCATION = { filePath: '/home/alice/wt1/report/index.html' } -function createStoreWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** A layout-owner build draws each open and conversion once main's frame is in. */ +async function settled(): Promise { + await main?.settle() +} + +async function createStoreWithWorktree(): Promise> { const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -30,6 +57,7 @@ function createStoreWithWorktree(): ReturnType { }, activeWorktreeId: WORKTREE_ID }) + main = await drawTabsFromMain(store) return store } @@ -86,8 +114,8 @@ function publishedUnifiedTabIds(store: ReturnType): Set< // web page. Driven through the real store actions, not a hand-built state, so the mirror path the // conversion writes is the one the publisher reads. describe('mobile publish across an address-bar conversion', () => { - it('starts publishing a doc tab the moment it converts to web, and stops on the way back', () => { - const store = createStoreWithWorktree() + it('starts publishing a doc tab the moment it converts to web, and stops on the way back', async () => { + const store = await createStoreWithWorktree() // Presence precondition: an ordinary URL tab publishes throughout, so an empty answer would // fail rather than pass by the publisher being broken for browser tabs entirely. const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') @@ -96,6 +124,7 @@ describe('mobile publish across an address-bar conversion', () => { title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? '' expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id]) @@ -105,6 +134,7 @@ describe('mobile publish across an address-bar conversion', () => { url: 'https://converted.example/' }) expect(webPage).not.toBeNull() + await settled() expect(publishedBrowserWorkspaceIds(store).sort()).toEqual([urlTab.id, docTab.id].sort()) const docPage = store.getState().convertBrowserPage(webPage?.id ?? '', { @@ -112,15 +142,17 @@ describe('mobile publish across an address-bar conversion', () => { docLocation: DOC_LOCATION }) expect(docPage).not.toBeNull() + await settled() expect(publishedBrowserWorkspaceIds(store)).toEqual([urlTab.id]) }) // Why groups and the layout are driven separately: the group projection was the historically // untested half of the publish boundary, and a doc tab surviving in a group's tabOrder names a // tab the phone is never sent. - it('holds the doc tab out of published groups and the layout, and admits it on conversion', () => { - const store = createStoreWithWorktree() + it('holds the doc tab out of published groups and the layout, and admits it on conversion', async () => { + const store = await createStoreWithWorktree() const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() // The doc tab gets a split group of its own, so holding it back must also take its group out // of the projection AND its leaf out of the layout — the observable no earlier test drove. const sourceGroupId = store.getState().activeGroupIdByWorktree[WORKTREE_ID] ?? '' @@ -133,6 +165,7 @@ describe('mobile publish across an address-bar conversion', () => { browserRuntimeEnvironmentId: null, targetGroupId: splitGroupId ?? undefined }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id ?? '' const docUnifiedTabId = (store.getState().unifiedTabsByWorktree[WORKTREE_ID] ?? []).find( @@ -163,6 +196,7 @@ describe('mobile publish across an address-bar conversion', () => { url: 'https://converted.example/' }) expect(webPage).not.toBeNull() + await settled() expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(true) expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(true) expect(layoutGroupIds().sort()).toEqual([sourceGroupId, splitGroupId ?? ''].sort()) @@ -173,6 +207,7 @@ describe('mobile publish across an address-bar conversion', () => { docLocation: DOC_LOCATION }) expect(docPage).not.toBeNull() + await settled() expect(publishedUnifiedTabIds(store).has(docUnifiedTabId)).toBe(false) expect(publishedGroupIds().has(splitGroupId ?? '')).toBe(false) expect(layoutGroupIds()).toEqual([sourceGroupId]) diff --git a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts index 468da77a8d3..d2d5f131cb7 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts @@ -6,7 +6,6 @@ import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal import type { AppState } from '@/store/types' import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree' import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' -import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' import { isDrawnTabHeld } from './layout-owner-drawn-tab-holds' import { isTabPaneTreeHeld } from './layout-owner-pane-intents' @@ -71,21 +70,3 @@ export function terminalLayoutsWithFrameBindings( } return next } - -/** Every rebound workspace's bindings shown, and the workspaces only a build shows so. */ -export function terminalLayoutsWithReboundFrames( - layouts: TerminalLayouts, - cache: WorkspaceLayoutCache, - rebound: ReadonlyMap> -): { layouts: TerminalLayouts; unshown: string[] } { - const unshown: string[] = [] - for (const [key, tabIds] of rebound) { - const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds) - if (next) { - layouts = next - } else { - unshown.push(key) - } - } - return { layouts, unshown } -} diff --git a/src/renderer/src/runtime/workspace-layout-frame-builds.ts b/src/renderer/src/runtime/workspace-layout-frame-builds.ts index 33d3fa2e888..e5a529dfe26 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-builds.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-builds.ts @@ -23,6 +23,7 @@ import type { } from '../../../shared/workspace-layout/workspace-layout-beside' import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { withPendingTabFacts } from './layout-owner-pending-tab-facts' /** What the window's own commands hold as drawn; see buildLayoutFrameStorePatch. */ export type LayoutFrameBuildHolds = { @@ -45,7 +46,8 @@ function patchFromSlices( ): LayoutFrameBuildPatch { const built = buildLayoutFrameSlices({ layouts: Object.fromEntries(keys.map((key) => [key, cache[key]!])), - facts, + // A tab this window asked main to open draws with the window's facts for it. + facts: withPendingTabFacts(facts, keys), view }) return buildLayoutFrameStorePatch( diff --git a/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts b/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts index 45ee2e4d34c..f4a7424942a 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-held-tabs.test.ts @@ -17,8 +17,6 @@ import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test- import { buildWorkspaceSessionPayload } from '../lib/workspace-session' import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' import { createTestStore } from '../store/slices/store-test-helpers' -import { recordOpenedAgentSessionTabInLayoutOwner } from './agent-session-tab-layout-owner' -import { recordOpenedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner' import { withLayoutOwnerTabProps } from '@/components/tab-bar/tab-bar-layout-owner-props' import { endLayoutFramePositions } from './layout-frame-position' import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' @@ -237,61 +235,7 @@ describe('a tab the window drew, closed or moved, with a frame from before main sync.close() }) - it.each([ - [ - 'chat', - (store: ReturnType) => { - const tab = drawTab(store, { - id: 'agent-session-new', - contentType: 'agent-session', - agentSessionAgent: 'claude' - }) - recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab) - return tab - } - ], - [ - 'simulator', - (store: ReturnType) => { - const tab = drawTab(store, { id: 'simulator-new' }) - recordOpenedSimulatorTabInLayoutOwner(store.getState(), tab) - return tab - } - ] - ])('keeps a just-opened %s tab', async (_kind, open) => { - const { store, sync, before } = await boot() - const tab = open(store) - expect(rpc.calls.length).toBeGreaterThan(0) - - sendStaleFrame(before) - await settle() - expect(tabBar(store)).toContain(tab.id) - expect(groupOrder(store, 'group-b')).toContain(tab.id) - if (tab.contentType === 'editor') { - expect(store.getState().openFiles.map((file) => file.id)).toContain(tab.entityId) - } - sync.close() - }) - - it('keeps a just-opened browser tab and its page', async () => { - const { store, sync, before } = await boot() - const created = store.getState().createBrowserTab(GIT_KEY, 'https://example.com/', { - title: 'Example' - }) - const entry = store - .getState() - .unifiedTabsByWorktree[GIT_KEY]!.find((tab) => tab.entityId === created.id)! - expect(rpc.calls.some((call) => call.command.type === 'openBrowserTab')).toBe(true) - - sendStaleFrame(before) - await settle() - expect(tabBar(store)).toContain(entry.id) - expect(store.getState().browserTabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toContain( - created.id - ) - expect(store.getState().browserPagesByWorkspace[created.id]?.length).toBeGreaterThan(0) - sync.close() - }) + // Browser, chat and simulator opens draw from main's frame alone: content-tabs-from-main-frames.test.ts. it('keeps a reorder the window made, and main’s order for the rest', async () => { const { store, sync, before, answer } = await boot() diff --git a/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts b/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts index c46371fea5d..2abdadcaa13 100644 --- a/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts +++ b/src/renderer/src/store/slices/active-selection-owner-scoping.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' -import { createBrowserMockApi, createTestStore } from './browser-slice-test-harness' +import { createBrowserMockApi } from './browser-slice-test-harness' import { createEditorTabsStore } from './editor-slice-test-harness' import { createTestStore as createAppStore, makeWorktree, seedStore } from './store-test-helpers' import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' @@ -127,12 +127,17 @@ describe('selection in a workspace other than the active one', () => { expect(store.getState().activeFileId).toBe(fileId) }) - it('records a browser selection in its own workspace without moving the global surface', () => { - const store = createTestStore() + it('records a browser selection in its own workspace without moving the global surface', async () => { + const store = createAppStore() + seedStore(store, { activeWorktreeId: 'wt-1' }) + // A layout-owner build draws the tabs from main's frame before they can be selected. + const main = await drawTabsFromMain(store) const mainTab = store.getState().createBrowserTab('wt-1', 'https://main.example.com') const floatingTab = store .getState() .createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'https://floating.example.com') + await main.settle() + main.close() store.getState().setActiveBrowserTab(mainTab.id) store.setState({ activeTabType: 'editor' }) @@ -145,9 +150,13 @@ describe('selection in a workspace other than the active one', () => { expect(state.activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('browser') }) - it('still moves the global browser surface for the active workspace', () => { - const store = createTestStore() + it('still moves the global browser surface for the active workspace', async () => { + const store = createAppStore() + seedStore(store, { activeWorktreeId: 'wt-1' }) + const main = await drawTabsFromMain(store) const tab = store.getState().createBrowserTab('wt-1', 'https://main.example.com') + await main.settle() + main.close() store.setState({ activeTabType: 'editor' }) store.getState().setActiveBrowserTab(tab.id) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index c773d180605..3cb5a35f7bc 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -4,7 +4,7 @@ import { type AgentStatusEntry } from '../../../../shared/agent-status-types' import { flushMicrotasks } from './agent-status-test-harness' -import { createTestStore, makeTab, makeUnifiedTab } from './store-test-helpers' +import { createTestStore, makeTab, makeTabGroup, makeUnifiedTab } from './store-test-helpers' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { RetainedAgentEntry } from './agent-status' import { @@ -13,10 +13,34 @@ import { } from '../../../../shared/structured-agent-session-projection' import { buildAgentStatusTabPrefixDropPatch } from './agent-status-drop-reducer' import { createStoreSessionMockApi } from './store-session-test-harness' +import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: vi.fn() })) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +/** Resolves `pending` under fake timers by running the timer ticks main's drain waits on (a 0 ms timer fires at 1 ms). */ +async function underFakeTimers(pending: Promise): Promise { + let done = false + void pending.finally(() => { + done = true + }) + for (let turn = 0; turn < 50 && !done; turn += 1) { + await vi.advanceTimersByTimeAsync(1) + } + return pending +} beforeEach(() => { // Track the original global before the shared fixture installs the renderer API. @@ -417,7 +441,7 @@ afterEach(() => { it.each(['wt-1', 'folder-workspace-1'])( 'preserves an open chat and its read state when an unrelated terminal closes in %s', - (worktreeId) => { + async (worktreeId) => { vi.useFakeTimers() const store = createTestStore() const sessionId = 'chat-1' @@ -441,7 +465,19 @@ afterEach(() => { makeTab({ id: 'tab-live', worktreeId }) ] }, - unifiedTabsByWorktree: { [worktreeId]: [chat] } + unifiedTabsByWorktree: { [worktreeId]: [chat] }, + groupsByWorktree: { + [worktreeId]: [ + makeTabGroup({ + id: 'group-1', + worktreeId, + activeTabId: chat.id, + tabOrder: [chat.id] + }) + ] + }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId: 'group-1' } }, + activeGroupIdByWorktree: { [worktreeId]: 'group-1' } }) for (const key of [paneKey, missingOwnerPaneKey]) { store @@ -459,6 +495,7 @@ afterEach(() => { activityClearedAtByPaneKey: { [paneKey]: 100, [missingOwnerPaneKey]: 100 }, manuallyUnreadTurnsByPaneKey: { [paneKey]: 200, [missingOwnerPaneKey]: 200 } }) + const main = await underFakeTimers(drawTabsFromMain(store)) const before = store.getState() store.getState().closeTab('tab-closed') @@ -476,7 +513,9 @@ afterEach(() => { expect(after.manuallyUnreadTurnsByPaneKey[missingOwnerPaneKey]).toBeUndefined() store.getState().closeUnifiedTab(chat.id) + await underFakeTimers(main.settle()) store.getState().closeTab('tab-live') + main.close() const closed = store.getState() expect(closed.unifiedTabsByWorktree[worktreeId]).toEqual([]) diff --git a/src/renderer/src/store/slices/browser-annotation-retention.test.ts b/src/renderer/src/store/slices/browser-annotation-retention.test.ts index d469a8f9832..e3603a3bf29 100644 --- a/src/renderer/src/store/slices/browser-annotation-retention.test.ts +++ b/src/renderer/src/store/slices/browser-annotation-retention.test.ts @@ -1,30 +1,43 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { GRAB_BUDGET } from '../../../../shared/browser-grab-types' -import { createBrowserMockApi, createTestStore } from './browser-slice-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createBrowserMockApi } from './browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture' import { makeAnnotation } from './browser-annotation-test-fixture' +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: vi.fn() })) -function createAnnotatedPage() { +async function createAnnotatedPage() { vi.stubGlobal('window', { api: createBrowserMockApi(vi.fn()) }) - const store = createTestStore() - store.setState({ repos: [], folderWorkspaces: [] }) + const { store, main } = await createTestStoreDrawnFromMain({ repos: [], folderWorkspaces: [] }) const workspace = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = workspace.activePageId if (!pageId) { throw new Error('Expected browser page') } store.getState().addBrowserPageAnnotation(makeAnnotation(pageId)) - return { store, workspace, pageId } + return { store, main, workspace, pageId } } afterEach(() => vi.unstubAllGlobals()) describe('saved browser note lifetime and marker eligibility', () => { - it('retires same-URL geometry without changing the saved array or object references', () => { - const { store, pageId } = createAnnotatedPage() + it('retires same-URL geometry without changing the saved array or object references', async () => { + const { store, pageId } = await createAnnotatedPage() const saved = store.getState().browserAnnotationsByPageId[pageId] const note = saved[0] expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual([note.id]) @@ -47,8 +60,8 @@ describe('saved browser note lifetime and marker eligibility', () => { expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toEqual(['fresh']) }) - it('shares the 20-note budget across URLs and bounds marker IDs to retained notes', () => { - const { store, pageId } = createAnnotatedPage() + it('shares the 20-note budget across URLs and bounds marker IDs to retained notes', async () => { + const { store, pageId } = await createAnnotatedPage() for (let index = 0; index < GRAB_BUDGET.annotationsMaxPerPage + 3; index++) { const url = `https://example.com/page-${index % 2}` store.getState().setBrowserPageUrl(pageId, url) @@ -70,8 +83,8 @@ describe('saved browser note lifetime and marker eligibility', () => { expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() }) - it('prunes eligible IDs when the oldest current-document notes are evicted', () => { - const { store, pageId } = createAnnotatedPage() + it('prunes eligible IDs when the oldest current-document notes are evicted', async () => { + const { store, pageId } = await createAnnotatedPage() for (let index = 0; index < 22; index++) { store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, `note-${index}`)) } @@ -82,7 +95,7 @@ describe('saved browser note lifetime and marker eligibility', () => { }) it('removes an accepted delivery snapshot after navigation while preserving later edits and additions', async () => { - const { store, pageId } = createAnnotatedPage() + const { store, pageId } = await createAnnotatedPage() store.getState().addBrowserPageAnnotation(makeAnnotation(pageId, 'edited')) const delivered = store.getState().browserAnnotationsByPageId[pageId] let accept!: () => void @@ -110,37 +123,44 @@ describe('saved browser note lifetime and marker eligibility', () => { expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() }) - it.each(['page', 'workspace', 'conversion', 'hydration'] as const)( - 'cleans up note and marker authority on %s teardown', - (mode) => { - const { store, workspace, pageId } = createAnnotatedPage() - if (mode === 'page') { - store.getState().closeBrowserPage(pageId) - } - if (mode === 'workspace') { - store.getState().closeBrowserTab(workspace.id) - } - if (mode === 'conversion') { - store.getState().convertBrowserPage(pageId, { - kind: 'workspace-doc', - docLocation: { - kind: 'workspace-doc', - worktreeId: 'wt-1', - filePath: '/workspace/index.html' - } - }) - } - if (mode === 'hydration') { - store.getState().hydrateBrowserSession({ - activeRepoId: null, - activeWorktreeId: null, - activeTabId: null, - tabsByWorktree: {}, - terminalLayoutsByTabId: {} - }) - } - expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() - expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + it.each([ + ['page', 'closeBrowserPage'], + ['workspace', 'closeTabs'], + ['conversion', 'replaceBrowserPage'], + ['hydration', null] + ] as const)('cleans up note and marker authority on %s teardown', async (mode, mainCommand) => { + const { store, main, workspace, pageId } = await createAnnotatedPage() + if (mode === 'page') { + store.getState().closeBrowserPage(pageId) } - ) + if (mode === 'workspace') { + store.getState().closeBrowserTab(workspace.id) + } + if (mode === 'conversion') { + store.getState().convertBrowserPage(pageId, { + kind: 'workspace-doc', + docLocation: { + kind: 'workspace-doc', + worktreeId: 'wt-1', + filePath: '/workspace/index.html' + } + }) + } + if (mode === 'hydration') { + store.getState().hydrateBrowserSession({ + activeRepoId: null, + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} + }) + } + await main.settle() + // A layout-owner build's teardown is main's, drawn from its frame. + expect(layoutApplyTestTransport.sent.map((command) => command.type)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? ['openBrowserTab', ...(mainCommand ? [mainCommand] : [])] : [] + ) + expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() + expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() + }) }) diff --git a/src/renderer/src/store/slices/browser-cleanup-close.test.ts b/src/renderer/src/store/slices/browser-cleanup-close.test.ts index ca9eaa38e6a..8940efee5dd 100644 --- a/src/renderer/src/store/slices/browser-cleanup-close.test.ts +++ b/src/renderer/src/store/slices/browser-cleanup-close.test.ts @@ -1,8 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { + layoutFrameApplyObserver, + observeLayoutFrameApply +} from '@/runtime/workspace-layout-frame-apply-observer' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mockCallRuntimeRpc = vi.hoisted(() => vi.fn()) @@ -20,32 +25,53 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands; the paired runtime's calls reach the mock. vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, callRuntimeRpc: mockCallRuntimeRpc } + const { runtimeRpcClientWithLayoutApply } = + await import('@/runtime/layout-apply-test-transport.test-fixture') + return runtimeRpcClientWithLayoutApply({ ...actual, callRuntimeRpc: mockCallRuntimeRpc }) }) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) createStoreCascadesMockApi() const WT = 'repo1::/path/wt1' +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** A layout-owner build draws the tabs from main's frames; every change is in once this resolves. */ +async function settled(): Promise { + await main?.settle() +} + /** Seeds a worktree whose only tab is a remote-backed browser workspace. */ -function storeWithOnlyBrowserTab(): { +async function storeWithOnlyBrowserTab(): Promise<{ store: ReturnType workspaceId: string pageId: string -} { +}> { const store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT, activeTabType: 'terminal' }) + main = await drawTabsFromMain(store) const workspace = store.getState().createBrowserTab(WT, 'about:blank', { activate: true, browserPageId: 'remote-page-1', browserRuntimeEnvironmentId: 'env-1' }) + await settled() const pageId = store.getState().browserPagesByWorkspace[workspace.id]?.[0]?.id ?? '' store.getState().setRemoteBrowserPageHandle(pageId, { environmentId: 'env-1', @@ -56,20 +82,22 @@ function storeWithOnlyBrowserTab(): { } /** Three browser tabs in a known tab-bar order, so neighbor selection is deterministic. */ -function storeWithThreeBrowserTabs(): { +async function storeWithThreeBrowserTabs(): Promise<{ store: ReturnType ids: string[] -} { +}> { const store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT, activeTabType: 'terminal' }) + main = await drawTabsFromMain(store) const ids = [0, 1, 2].map( (index) => store.getState().createBrowserTab(WT, `https://example.test/${index}`, { activate: true }).id ) + await settled() return { store, ids } } @@ -81,12 +109,14 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: a failed create must land the user where the click did — the pre-click worktree — not on // the landing screen the real "you closed your last tab" path deliberately falls back to. - it('keeps the worktree active when it unwinds the only tab in that worktree', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('keeps the worktree active when it unwinds the only tab in that worktree', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() expect(store.getState().activeWorktreeId).toBe(WT) store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + const s = store.getState() expect(s.activeWorktreeId).toBe(WT) expect(s.browserTabsByWorktree[WT] ?? []).toEqual([]) @@ -95,29 +125,36 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: the landing-screen fallback is the correct answer for a real close, so pin that the // cleanup carve-out did not disable it for everyone. - it('still returns to the landing state when the user closes the only tab', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('still returns to the landing state when the user closes the only tab', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId) + await settled() + expect(store.getState().activeWorktreeId).toBeNull() }) // Why: the staged page's host tab was never minted, so a tabClose would either 404 or, worse, // race the in-flight create and kill the page the host is about to hand back. - it('does not ask the host to close the page it never published', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('does not ask the host to close the page it never published', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + + // A layout-owner build's close goes to main as `layout.apply`, answered before this mock. expect(mockCallRuntimeRpc).not.toHaveBeenCalled() }) - it('asks the host to close the page on a real close', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('asks the host to close the page on a real close', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId) + await settled() + expect(mockCallRuntimeRpc).toHaveBeenCalledWith( { kind: 'environment', environmentId: 'env-1' }, 'browser.tabClose', @@ -128,21 +165,25 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: reopen-stack entries are the user's undo history; a tab that never existed must not // occupy a slot, and reopening it would resurrect a workspace with no host page behind it. - it('does not enter the reopen stack', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('does not enter the reopen stack', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + const s = store.getState() expect(s.recentlyClosedBrowserTabsByWorktree[WT] ?? []).toEqual([]) expect(s.recentlyClosedTabKindsByWorktree[WT] ?? []).toEqual([]) }) - it('enters the reopen stack on a real close', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('enters the reopen stack on a real close', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() store.getState().closeBrowserTab(workspaceId) + await settled() + const s = store.getState() expect(s.recentlyClosedBrowserTabsByWorktree[WT]).toHaveLength(1) expect(s.recentlyClosedTabKindsByWorktree[WT]).toEqual(['browser']) @@ -150,36 +191,56 @@ describe('closeBrowserTab with reason cleanup', () => { // Why: feature-interaction counters drive onboarding nudges; an unwound create is not the user // exercising tabs. - it('does not record a tab interaction', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('does not record a tab interaction', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const recordFeatureInteraction = vi.fn() store.setState({ recordFeatureInteraction }) store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + expect(recordFeatureInteraction).not.toHaveBeenCalled() }) - it('records a tab interaction on a real close', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('records a tab interaction on a real close', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const recordFeatureInteraction = vi.fn() store.setState({ recordFeatureInteraction }) store.getState().closeBrowserTab(workspaceId) + await settled() + expect(recordFeatureInteraction).toHaveBeenCalledWith('terminal-tabs') }) - it('clears focus for closed pages without rescanning pages for unrelated focus entries', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('clears focus for closed pages without rescanning pages for unrelated focus entries', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const original = store.getState().browserPagesByWorkspace[workspaceId][0] let idReads = 0 + let counting = true const pages = Array.from({ length: 1000 }, (_, index) => ({ ...original, get id() { - idReads++ + if (counting) { + idReads++ + } return `closed-${index}` } })) + // The test transport's frame-apply check rebuilds every flush; its reads are not the close's. + const check = layoutFrameApplyObserver() + observeLayoutFrameApply( + check && + ((observation) => { + counting = false + try { + check(observation) + } finally { + counting = true + } + }) + ) const unrelated = Object.fromEntries( Array.from({ length: 1000 }, (_, i) => [`other-${i}`, true as const]) ) @@ -189,7 +250,12 @@ describe('closeBrowserTab with reason cleanup', () => { pendingAddressBarFocusByTabId: { ...unrelated, 'closed-999': true, [workspaceId]: true } }) idReads = 0 - store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + try { + store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + } finally { + observeLayoutFrameApply(check) + } expect(store.getState().pendingAddressBarFocusByPageId).toEqual({ ...unrelated, [workspaceId]: true @@ -199,8 +265,8 @@ describe('closeBrowserTab with reason cleanup', () => { }) // Why: the omit path must not hand every pending-focus selector a fresh record on each close. - it('keeps the pending focus records identical when the closed tab had no pending entry', () => { - const { store, workspaceId } = storeWithOnlyBrowserTab() + it('keeps the pending focus records identical when the closed tab had no pending entry', async () => { + const { store, workspaceId } = await storeWithOnlyBrowserTab() const pending = { 'other-tab': true as const } store.setState({ pendingAddressBarFocusByPageId: pending, @@ -209,18 +275,22 @@ describe('closeBrowserTab with reason cleanup', () => { store.getState().closeBrowserTab(workspaceId, { reason: 'cleanup' }) + await settled() + expect(store.getState().pendingAddressBarFocusByPageId).toBe(pending) expect(store.getState().pendingAddressBarFocusByTabId).toBe(pending) }) // Why: closing a tab the user is not looking at must never move focus, whatever the // pending-address-bar bookkeeping does around it. - it('leaves the active browser tab alone when a background tab closes', () => { - const { store, ids } = storeWithThreeBrowserTabs() + it('leaves the active browser tab alone when a background tab closes', async () => { + const { store, ids } = await storeWithThreeBrowserTabs() store.getState().setActiveBrowserTab(ids[0]!) store.getState().closeBrowserTab(ids[2]!) + await settled() + expect(store.getState().activeBrowserTabIdByWorktree[WT]).toBe(ids[0]!) }) }) diff --git a/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts b/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts index 5b3d7abc809..e9e7e4dcaa5 100644 --- a/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts +++ b/src/renderer/src/store/slices/browser-layout-owner-commands.test.ts @@ -1,13 +1,24 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +// A layout-owner build's browser tab and page commands: the window sends main each change under +// the ids it proposes, and draws it from main's frame (content-tabs-from-main-frames.test.ts +// covers what shows before and after main answers). +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' -import type { recordLayoutApply } from '@/runtime/layout-apply-send' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' - -const mocks = vi.hoisted(() => ({ send: vi.fn() })) +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) -vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.send })) +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } })) @@ -20,122 +31,120 @@ createStoreCascadesMockApi() const WT = 'repo1::/path/wt1' -function seeded(): ReturnType { - const store = createTestStore() +let store: ReturnType +let main: TabsDrawnFromMain + +beforeEach(async () => { + store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT }) - return store -} + main = await drawTabsFromMain(store) +}) -const sent = () => mocks.send.mock.calls.map(([, worktreeId, command]) => ({ worktreeId, command })) +afterEach(() => { + main.close() +}) -function entryOf(store: ReturnType, browserTabId: string) { +const sent = () => [...layoutApplyTestTransport.sent] + +function entryOf(browserTabId: string) { return store .getState() .unifiedTabsByWorktree[WT]!.find((entry) => entry.entityId === browserTabId)! } -beforeEach(() => { - mocks.send.mockReset() -}) - describe('browser tabs in a layout-owner build', () => { - it('records an open under the ids and place the window drew it with', () => { - const store = seeded() + it('sends an open under the ids it proposes, which main’s frame then draws', async () => { const first = store.getState().createBrowserTab(WT, 'about:blank', { sessionProfileId: 'p1' }) const second = store.getState().createBrowserTab(WT, 'https://example.com/') - const firstEntry = entryOf(store, first.id) + const [firstOpen, secondOpen] = layoutApplyTestTransport.sentOfType('openBrowserTab') + await main.settle() - expect(sent()).toEqual([ - { - worktreeId: WT, - command: { - type: 'openBrowserTab', - tabId: firstEntry.id, - browserTabId: first.id, - pageId: first.pageIds![0], - profileId: 'p1', - sessionPartition: null, - groupId: firstEntry.groupId, - index: 0, - createdAt: firstEntry.createdAt, - browserCreatedAt: first.createdAt - } - }, - { - worktreeId: WT, - command: expect.objectContaining({ - type: 'openBrowserTab', - tabId: entryOf(store, second.id).id, - browserTabId: second.id, - afterTabId: firstEntry.id - }) - } + const firstEntry = entryOf(first.id) + expect(firstOpen).toEqual({ + type: 'openBrowserTab', + workspace: WT, + tabId: firstEntry.id, + browserTabId: first.id, + pageId: store.getState().browserPagesByWorkspace[first.id]![0]!.id, + profileId: 'p1', + sessionPartition: null, + groupId: firstEntry.groupId + }) + expect(secondOpen).toMatchObject({ tabId: entryOf(second.id).id, browserTabId: second.id }) + expect(store.getState().groupsByWorktree[WT]![0]!.tabOrder).toEqual([ + firstEntry.id, + entryOf(second.id).id ]) }) - it('records a close with the window’s reason while the tab-bar entry still names it', () => { - const store = seeded() + it('sends a close with the window’s reason while the tab still shows', async () => { const user = store.getState().createBrowserTab(WT, 'about:blank') const cleanup = store.getState().createBrowserTab(WT, 'about:blank') - const userEntry = entryOf(store, user.id).id - const cleanupEntry = entryOf(store, cleanup.id).id - mocks.send.mockClear() + await main.settle() + const userEntry = entryOf(user.id).id + const cleanupEntry = entryOf(cleanup.id).id + const before = sent().length store.getState().closeBrowserTab(user.id) store.getState().closeBrowserTab(cleanup.id, { reason: 'cleanup' }) + await main.settle() - expect(sent().map(({ command }) => command)).toEqual([ - { type: 'closeTabs', tabIds: [userEntry], recordClose: 'user' }, - { type: 'closeTabs', tabIds: [cleanupEntry], recordClose: 'cleanup' } + expect(sent().slice(before)).toEqual([ + { type: 'closeTabs', workspace: WT, tabIds: [userEntry], recordClose: 'user' }, + { type: 'closeTabs', workspace: WT, tabIds: [cleanupEntry], recordClose: 'cleanup' } ]) }) - it('records page create, convert and close, and a profile switch', () => { - const store = seeded() + it('sends page create, convert and close, and a profile switch', async () => { const tab = store.getState().createBrowserTab(WT, 'about:blank') - mocks.send.mockClear() + await main.settle() + const before = sent().length // A conversion changes a page's kind: this document page becomes a web page. const page = store.getState().createBrowserPage(tab.id, 'about:blank', { docLocation: { kind: 'workspace-doc', worktreeId: WT, filePath: '/path/wt1/index.html' } })! + await main.settle() const converted = store.getState().convertBrowserPage(page.id, { kind: 'web', url: 'https://other.example/' })! + await main.settle() store.getState().closeBrowserPage(converted.id) + await main.settle() store.getState().switchBrowserTabProfile(tab.id, 'p2', 'persist:p2') + await main.settle() - expect(sent().map(({ command }) => command)).toEqual([ - { type: 'openBrowserPage', browserTabId: tab.id, pageId: page.id }, + expect(sent().slice(before)).toEqual([ + { type: 'openBrowserPage', workspace: WT, browserTabId: tab.id, pageId: page.id }, { type: 'replaceBrowserPage', + workspace: WT, browserTabId: tab.id, pageId: page.id, newPageId: converted.id }, - { type: 'closeBrowserPage', browserTabId: tab.id, pageId: converted.id }, + { type: 'closeBrowserPage', workspace: WT, browserTabId: tab.id, pageId: converted.id }, { type: 'setBrowserTabProfile', + workspace: WT, browserTabId: tab.id, profileId: 'p2', sessionPartition: 'persist:p2' } ]) - expect(sent().every(({ worktreeId }) => worktreeId === WT)).toBe(true) + expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1) }) it('sends nothing for a page or tab the window does not hold', () => { - const store = seeded() - expect(store.getState().createBrowserPage('missing', 'about:blank')).toBeNull() store.getState().closeBrowserPage('missing') store.getState().closeBrowserTab('missing') store.getState().switchBrowserTabProfile('missing', null) - expect(mocks.send).not.toHaveBeenCalled() + expect(sent()).toEqual([]) }) }) diff --git a/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts b/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts index 930f9bf7efd..753b7efabdb 100644 --- a/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts +++ b/src/renderer/src/store/slices/browser-mount-admission-removal.test.ts @@ -3,10 +3,11 @@ * leaves the store. A bulk worktree purge used to drop the page records without releasing it. * The sweep is the same function useTerminalWindowLifecycle runs on every store change. */ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -27,6 +28,18 @@ vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOr destroyPersistentWebview: vi.fn(() => Promise.resolve()) })) +// Main answers the window's layout commands; its frames draw the browser tabs. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + createStoreCascadesMockApi() const { isBrowserPageMountAdmitted } = @@ -36,13 +49,21 @@ const { destroyWebviewsRemovedFromStore, destroyWorktreeBrowserGuests } = const WT = 'repo1::/path/wt1' -function storeWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined + +/** A layout-owner build draws the tabs from main's frames; every change is in once this resolves. */ +async function settled(): Promise { + await main?.settle() +} + +async function storeWithWorktree(): Promise> { const store = createTestStore() seedStore(store, { worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, activeWorktreeId: WT, activeTabType: 'terminal' }) + main = await drawTabsFromMain(store) return store } @@ -58,15 +79,18 @@ function sweepRemovedPages( ) } -function openBackgroundTabs(store: ReturnType, count: number): string[] { - const pageIds: string[] = [] +async function openBackgroundTabs( + store: ReturnType, + count: number +): Promise { + const workspaceIds: string[] = [] for (let index = 0; index < count; index += 1) { - const workspace = store - .getState() - .createBrowserTab(WT, `https://example.com/${index}`, { activate: false }) - pageIds.push(store.getState().browserPagesByWorkspace[workspace.id]?.[0]?.id ?? '') + workspaceIds.push( + store.getState().createBrowserTab(WT, `https://example.com/${index}`, { activate: false }).id + ) } - return pageIds + await settled() + return workspaceIds.map((id) => store.getState().browserPagesByWorkspace[id]?.[0]?.id ?? '') } const admittedCount = (pageIds: readonly string[]): number => @@ -76,22 +100,27 @@ describe('browser page mount admission ends when the page leaves the store', () beforeEach(() => { vi.clearAllMocks() }) + afterEach(() => { + main?.close() + main = undefined + }) - it('ends on a bulk worktree purge', () => { - const store = storeWithWorktree() - const pageIds = openBackgroundTabs(store, 5) + it('ends on a bulk worktree purge', async () => { + const store = await storeWithWorktree() + const pageIds = await openBackgroundTabs(store, 5) const before = sweepRemovedPages(store, new Set()) expect(admittedCount(pageIds)).toBe(5) store.getState().purgeWorktreeTerminalState([WT]) + await settled() sweepRemovedPages(store, before) expect(admittedCount(pageIds)).toBe(0) }) - it('ends when the guest is evicted, though the page stays in the store', () => { - const store = storeWithWorktree() - const pageIds = openBackgroundTabs(store, 3) + it('ends when the guest is evicted, though the page stays in the store', async () => { + const store = await storeWithWorktree() + const pageIds = await openBackgroundTabs(store, 3) const state = store.getState() expect(admittedCount(pageIds)).toBe(3) @@ -101,19 +130,22 @@ describe('browser page mount admission ends when the page leaves the store', () expect(Object.values(store.getState().browserPagesByWorkspace).flat()).toHaveLength(3) }) - it('ends when the tab or the page is closed', () => { - const store = storeWithWorktree() - const [closedTabPage, keptTabPage] = openBackgroundTabs(store, 2) + it('ends when the tab or the page is closed', async () => { + const store = await storeWithWorktree() + const [closedTabPage, keptTabPage] = await openBackgroundTabs(store, 2) const keptWorkspaceId = store.getState().browserTabsByWorktree[WT]?.[1]?.id ?? '' const extraPage = store.getState().createBrowserPage(keptWorkspaceId, 'https://example.com/x', { activate: false }) + await settled() let seen = sweepRemovedPages(store, new Set()) const closedWorkspaceId = store.getState().browserTabsByWorktree[WT]?.[0]?.id ?? '' store.getState().closeBrowserTab(closedWorkspaceId) + await settled() seen = sweepRemovedPages(store, seen) store.getState().closeBrowserPage(extraPage?.id ?? '') + await settled() sweepRemovedPages(store, seen) expect(isBrowserPageMountAdmitted(closedTabPage)).toBe(false) diff --git a/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts index 3e91acab288..9fc774ecf69 100644 --- a/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts +++ b/src/renderer/src/store/slices/browser-page-close-intent-recording.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createTestStore, makeWorktree } from './store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) vi.mock('@/lib/doc-preview-grants', () => ({ @@ -13,16 +14,34 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { const actual = await importOriginal() return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc } }) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) -// Main's answer to the window's layout commands, as a switch-on build sends them. -mocks.callRuntimeRpc.mockImplementation((_target: unknown, method: string, params: unknown) => - method === 'layout.apply' ? layoutApplyTestTransport.call(params) : undefined -) +/** The paired runtime's answer to the page close; main answers the window's layout commands. */ +function runtimeAnswers(answer: () => Promise | undefined): void { + mocks.callRuntimeRpc.mockImplementation((_target: unknown, method: string, params: unknown) => + method === 'layout.apply' ? layoutApplyTestTransport.call(params) : answer() + ) +} +runtimeAnswers(() => undefined) + +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** Calls that reached the paired runtime, not main's layout. */ +const runtimeCalls = () => + mocks.callRuntimeRpc.mock.calls.filter(([, method]) => method !== 'layout.apply') const WORKTREE_ID = 'repo1::/path/wt1' const DOC_LOCATION = { @@ -31,10 +50,10 @@ const DOC_LOCATION = { filePath: '/home/alice/wt1/report/index.html' } -function createStoreWithClientHostedPage(handleOverrides: Record = {}): { - store: ReturnType - pageId: string -} { +async function createStoreWithClientHostedPage( + handleOverrides: Record = {} +): Promise<{ store: ReturnType; pageId: string }> { + runtimeAnswers(() => undefined) const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -43,7 +62,11 @@ function createStoreWithClientHostedPage(handleOverrides: Record ({ remoteBrowserPageHandlesByPageId: { @@ -72,8 +95,8 @@ function recordedIntents(store: ReturnType): string[] { // so the reconnect replay can deliver the close late instead of the page coming back. describe('durable close intents for unheard client-hosted page closes', () => { it('records an intent when the conversion-away close never reaches the runtime', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) const converted = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', @@ -85,8 +108,8 @@ describe('durable close intents for unheard client-hosted page closes', () => { }) it('records an intent when the plain page close never reaches the runtime', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) store.getState().closeBrowserPage(pageId) @@ -94,57 +117,61 @@ describe('durable close intents for unheard client-hosted page closes', () => { }) it('records nothing when the close succeeds', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockResolvedValue({}) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.resolve({})) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) it('records nothing when the runtime has definitively forgotten the page', async () => { - const { store, pageId } = createStoreWithClientHostedPage() - mocks.callRuntimeRpc.mockRejectedValue(new Error('browser_tab_not_found')) + const { store, pageId } = await createStoreWithClientHostedPage() + runtimeAnswers(() => Promise.reject(new Error('browser_tab_not_found'))) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) it('records nothing for a server-placed page, which dies with its runtime', async () => { - const { store, pageId } = createStoreWithClientHostedPage({ placement: { kind: 'server' } }) - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage({ + placement: { kind: 'server' } + }) + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) it('records nothing for a staged page the runtime never minted', async () => { - const { store, pageId } = createStoreWithClientHostedPage({ staged: true }) - mocks.callRuntimeRpc.mockRejectedValue(new Error('runtime unreachable')) + const { store, pageId } = await createStoreWithClientHostedPage({ staged: true }) + // A staged page is left out of the window's save, so frames would redraw it from main's copy. + main?.close() + runtimeAnswers(() => Promise.reject(new Error('runtime unreachable'))) store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) - await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled()) + await vi.waitFor(() => expect(runtimeCalls()).not.toHaveLength(0)) await Promise.resolve() expect(recordedIntents(store)).toEqual([]) }) diff --git a/src/renderer/src/store/slices/browser-page-conversion.test.ts b/src/renderer/src/store/slices/browser-page-conversion.test.ts index 7a0703b52d1..b584409ba44 100644 --- a/src/renderer/src/store/slices/browser-page-conversion.test.ts +++ b/src/renderer/src/store/slices/browser-page-conversion.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' +import type * as RuntimeRpcClientModule from '@/runtime/runtime-rpc-client' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { browserPageSchema } from '../../../../shared/workspace-session-browser-schema' import { createTestStore, makeWorktree } from './store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mocks = vi.hoisted(() => ({ releaseDocPreviewGrant: vi.fn(), callRuntimeRpc: vi.fn() })) vi.mock('@/lib/doc-preview-grants', () => ({ @@ -10,10 +12,21 @@ vi.mock('@/lib/doc-preview-grants', () => ({ ensureDocPreviewGrant: vi.fn(), buildDocPreviewGrantRequest: vi.fn() })) +// Main answers the window's layout commands (switch-on); every other call reaches the mock. vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, callRuntimeRpc: mocks.callRuntimeRpc.mockResolvedValue({}) } + const actual = await importOriginal() + const { runtimeRpcClientWithLayoutApply } = + await import('@/runtime/layout-apply-test-transport.test-fixture') + return runtimeRpcClientWithLayoutApply({ + ...actual, + callRuntimeRpc: mocks.callRuntimeRpc.mockResolvedValue({}) + }) }) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() @@ -33,7 +46,18 @@ const OTHER_DOC_LOCATION = { } const LIVE_GRANT_URL = `orca-preview://${'a'.repeat(32)}/report/index.html` -function createStoreWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +async function settled(): Promise { + await main?.settle() +} + +/** A store showing one worktree, its tabs drawn from main once the switch is on. */ +async function createStoreWithWorktree(): Promise> { const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -42,31 +66,34 @@ function createStoreWithWorktree(): ReturnType { }, activeWorktreeId: WORKTREE_ID }) + main = await drawTabsFromMain(store) return store } -function createDocTab(store: ReturnType): { +async function createDocTab(store: ReturnType): Promise<{ tabId: string pageId: string -} { +}> { const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' return { tabId: tab.id, pageId } } describe('convertBrowserPage doc→web', () => { - it('replaces the page under a fresh id in the same workspace row', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('replaces the page under a fresh id in the same workspace row', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) const converted = store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(converted).not.toBeNull() // Fresh id: the old id must never resurface in the other registry half. @@ -85,22 +112,24 @@ describe('convertBrowserPage doc→web', () => { expect(workspace?.activePageId).toBe(converted?.id) }) - it('releases the old grant exactly once, after the store stops naming the document', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('releases the old grant exactly once, after the store stops naming the document', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) mocks.releaseDocPreviewGrant.mockClear() store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1) expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId) }) - it('records one level of provenance so Back can return to the document', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('records one level of provenance so Back can return to the document', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() const page = store.getState().browserPagesByWorkspace[tabId]?.[0] expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) @@ -109,14 +138,16 @@ describe('convertBrowserPage doc→web', () => { expect(parsed.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) }) - it('refuses to convert a web page to web — that is navigation, not conversion', () => { - const store = createStoreWithWorktree() + it('refuses to convert a web page to web — that is navigation, not conversion', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' const converted = store .getState() .convertBrowserPage(pageId, { kind: 'web', url: 'https://other.example/' }) + await settled() expect(converted).toBeNull() expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id).toBe(pageId) @@ -125,9 +156,10 @@ describe('convertBrowserPage doc→web', () => { }) describe('convertBrowserPage web→doc', () => { - it('replaces the page with a client-local document page and mirrors it', () => { - const store = createStoreWithWorktree() + it('replaces the page with a client-local document page and mirrors it', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' mocks.releaseDocPreviewGrant.mockClear() @@ -135,6 +167,7 @@ describe('convertBrowserPage web→doc', () => { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(converted).not.toBeNull() expect(converted?.id).not.toBe(pageId) @@ -151,15 +184,17 @@ describe('convertBrowserPage web→doc', () => { expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled() }) - it('never lets a grant url into the converted page, even handed one directly', () => { - const store = createStoreWithWorktree() + it('never lets a grant url into the converted page, even handed one directly', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] const written = JSON.stringify([page, store.getState().browserTabsByWorktree[WORKTREE_ID]]) @@ -168,15 +203,16 @@ describe('convertBrowserPage web→doc', () => { }) describe('convertBrowserPage doc→doc retarget', () => { - it('replaces the page and its grant for a different document', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('replaces the page and its grant for a different document', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) mocks.releaseDocPreviewGrant.mockClear() const converted = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: OTHER_DOC_LOCATION }) + await settled() expect(converted?.id).not.toBe(pageId) expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(pageId) @@ -185,15 +221,16 @@ describe('convertBrowserPage doc→doc retarget', () => { expect(page?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) }) - it('is a no-op for the same document', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('is a no-op for the same document', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) mocks.releaseDocPreviewGrant.mockClear() const converted = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(converted).toBeNull() expect(store.getState().browserPagesByWorkspace[tabId]?.[0]?.id).toBe(pageId) @@ -202,14 +239,15 @@ describe('convertBrowserPage doc→doc retarget', () => { }) describe('convertBrowserPage history legs', () => { - it('the return leg swaps convertedFrom for convertedTo, so Forward can re-cross', () => { - const store = createStoreWithWorktree() - const { tabId, pageId } = createDocTab(store) + it('the return leg swaps convertedFrom for convertedTo, so Forward can re-cross', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId } = await createDocTab(store) const webPage = store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(webPage?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) expect(webPage?.convertedTo ?? null).toBeNull() @@ -220,6 +258,7 @@ describe('convertBrowserPage history legs', () => { { kind: 'workspace-doc', docLocation: DOC_LOCATION }, { leg: 'history-return' } ) + await settled() expect(returned?.docLocation).toEqual(DOC_LOCATION) expect(returned?.convertedFrom ?? null).toBeNull() @@ -235,12 +274,13 @@ describe('convertBrowserPage history legs', () => { expect(parsed.convertedTo).toEqual(expectedForward) }) - it('the advance leg re-records convertedFrom and consumes convertedTo — a two-entry ping-pong', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('the advance leg re-records convertedFrom and consumes convertedTo — a two-entry ping-pong', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) const webPage = store .getState() .convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() const docPage = store .getState() .convertBrowserPage( @@ -248,6 +288,7 @@ describe('convertBrowserPage history legs', () => { { kind: 'workspace-doc', docLocation: DOC_LOCATION }, { leg: 'history-return' } ) + await settled() const advanced = store .getState() @@ -256,6 +297,7 @@ describe('convertBrowserPage history legs', () => { { kind: 'web', url: 'https://example.com/' }, { leg: 'history-advance' } ) + await settled() expect(advanced?.url).toBe('https://example.com/') expect(advanced?.convertedFrom).toEqual({ kind: 'workspace-doc', docLocation: DOC_LOCATION }) @@ -264,17 +306,19 @@ describe('convertBrowserPage history legs', () => { }) describe('convertBrowserPage placement and activation', () => { - it('converts a background page without stealing the active page', () => { - const store = createStoreWithWorktree() - const { tabId, pageId: docPageId } = createDocTab(store) + it('converts a background page without stealing the active page', async () => { + const store = await createStoreWithWorktree() + const { tabId, pageId: docPageId } = await createDocTab(store) const webPage = store.getState().createBrowserPage(tabId, 'https://active.example/', { activate: true }) + await settled() const converted = store.getState().convertBrowserPage(docPageId, { kind: 'web', url: 'https://converted.example/' }) + await settled() const pages = store.getState().browserPagesByWorkspace[tabId] ?? [] // Replacement holds the old page's position. @@ -289,9 +333,9 @@ describe('convertBrowserPage placement and activation', () => { expect(converted?.id).not.toBe(docPageId) }) - it('clears the old page id from every per-page side table', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('clears the old page id from every per-page side table', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) // Every table SEEDED, so a deletion that stops running fails instead of passing vacuously. store.setState((s) => ({ pendingAddressBarFocusByPageId: { ...s.pendingAddressBarFocusByPageId, [pageId]: true }, @@ -307,6 +351,7 @@ describe('convertBrowserPage placement and activation', () => { })) store.getState().convertBrowserPage(pageId, { kind: 'web', url: 'https://example.com/' }) + await settled() expect(store.getState().pendingAddressBarFocusByPageId[pageId]).toBeUndefined() expect(store.getState().pendingAddressBarFocusByTabId[pageId]).toBeUndefined() @@ -316,9 +361,10 @@ describe('convertBrowserPage placement and activation', () => { // The leg the remote pane's address bar reaches: converting a runtime-owned page must close the // host's page and drop the handle, or a ghost page stays open on the remote host. - it('closes the remote page and drops its handle when a runtime-owned page converts', () => { - const store = createStoreWithWorktree() + it('closes the remote page and drops its handle when a runtime-owned page converts', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.setState((s) => ({ remoteBrowserPageHandlesByPageId: { @@ -332,6 +378,7 @@ describe('convertBrowserPage placement and activation', () => { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(converted).not.toBeNull() expect(store.getState().remoteBrowserPageHandlesByPageId[pageId]).toBeUndefined() @@ -345,17 +392,19 @@ describe('convertBrowserPage placement and activation', () => { // Ownership rides provenance both ways, or Back silently moves a remote tab's browsing onto // this desktop (the ssh-execution-boundary concern). - it('carries runtime ownership through provenance and honors it on the return leg', () => { - const store = createStoreWithWorktree() + it('carries runtime ownership through provenance and honors it on the return leg', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://remote.example/', { browserRuntimeEnvironmentId: 'env-1' }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' const docPage = store.getState().convertBrowserPage(pageId, { kind: 'workspace-doc', docLocation: DOC_LOCATION }) + await settled() expect(docPage?.convertedFrom).toEqual({ kind: 'url', url: 'https://remote.example/', @@ -371,12 +420,13 @@ describe('convertBrowserPage placement and activation', () => { }, { leg: 'history-return' } ) + await settled() expect(returned?.browserRuntimeEnvironmentId).toBe('env-1') }) - it('returns a worktree-inferred remote page as inferred, never as client-local', () => { - const store = createStoreWithWorktree() - const { pageId } = createDocTab(store) + it('returns a worktree-inferred remote page as inferred, never as client-local', async () => { + const store = await createStoreWithWorktree() + const { pageId } = await createDocTab(store) // The return leg says "inferred" by passing the property explicitly undefined. const returned = store .getState() @@ -385,18 +435,20 @@ describe('convertBrowserPage placement and activation', () => { { kind: 'web', url: 'https://remote.example/', browserRuntimeEnvironmentId: undefined }, { leg: 'history-return' } ) + await settled() expect(returned).not.toBeNull() expect('browserRuntimeEnvironmentId' in (returned ?? {})).toBe(false) }) - it('returns null for an unknown page and changes nothing', () => { - const store = createStoreWithWorktree() - createDocTab(store) + it('returns null for an unknown page and changes nothing', async () => { + const store = await createStoreWithWorktree() + await createDocTab(store) const before = store.getState().browserPagesByWorkspace const converted = store .getState() .convertBrowserPage('missing-page', { kind: 'web', url: 'https://example.com/' }) + await settled() expect(converted).toBeNull() expect(store.getState().browserPagesByWorkspace).toBe(before) diff --git a/src/renderer/src/store/slices/browser-page-conversion.ts b/src/renderer/src/store/slices/browser-page-conversion.ts index b7874f557c7..49d6f73215a 100644 --- a/src/renderer/src/store/slices/browser-page-conversion.ts +++ b/src/renderer/src/store/slices/browser-page-conversion.ts @@ -52,7 +52,11 @@ export function planBrowserPageConversion( }, pageId: string, target: BrowserPageConversionTarget, - options?: { leg?: BrowserPageConversionLeg } + options?: { + leg?: BrowserPageConversionLeg + /** The id main already holds the new page under. */ + newPageId?: string + } ): BrowserPageConversionPlan | null { const oldPage = findPage(state.browserPagesByWorkspace, pageId) if (!oldPage) { @@ -101,7 +105,7 @@ export function planBrowserPageConversion( // Why explicitly client-local: the document is read through a grant this desktop mints, // so the page never belongs to a remote runtime even when the worktree does. null, - undefined, + options?.newPageId, target.docLocation ) : buildBrowserPage( @@ -116,7 +120,8 @@ export function planBrowserPageConversion( // when it restores a worktree-owned remote page. 'browserRuntimeEnvironmentId' in target ? target.browserRuntimeEnvironmentId - : (oldPage.browserRuntimeEnvironmentId ?? null) + : (oldPage.browserRuntimeEnvironmentId ?? null), + options?.newPageId )), ...(convertedFrom ? { convertedFrom } : {}), ...(convertedTo ? { convertedTo } : {}) diff --git a/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts b/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts index 57da462aeac..620bc69f902 100644 --- a/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts +++ b/src/renderer/src/store/slices/browser-remote-page-lifecycle.test.ts @@ -1,16 +1,30 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import type { AppState } from '../types' import { createBrowserMockApi, createTestStore, resetBrowserRuntimeMocks, settingsWithRuntime } from './browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture' import { makeWorktree } from './worktrees-slice-test-fixtures' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock })) @@ -20,6 +34,84 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall) // @ts-expect-error test window mock globalThis.window = { api: mockApi } +/** wt-1's one browser tab, whose page is a runtime's remote page, seeded in its pane group. */ +function remoteBackedBrowserTab(): Partial { + return { + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'unified-browser-tab', + entityId: 'workspace-1', + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'browser', + label: 'Example', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + 'wt-1': [ + { + id: 'group-1', + worktreeId: 'wt-1', + activeTabId: 'unified-browser-tab', + tabOrder: ['unified-browser-tab'] + } + ] + }, + browserTabsByWorktree: { + 'wt-1': [ + { + id: 'workspace-1', + worktreeId: 'wt-1', + sessionProfileId: null, + activePageId: 'page-1', + pageIds: ['page-1'], + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + browserPagesByWorkspace: { + 'workspace-1': [ + { + id: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'wt-1', + url: 'https://example.com', + title: 'Example', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 1 + } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } + } + } +} + +/** A layout-owner build's close is main's: the window sent it, and main's frame drew it. */ +function expectMainClosed(command: 'closeBrowserPage' | 'closeTabs'): void { + expect(layoutApplyTestTransport.sent.map((sent) => sent.type)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [command] : [] + ) +} + describe('createBrowserSlice runtime guard', () => { beforeEach(() => { resetBrowserRuntimeMocks({ @@ -69,51 +161,14 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes the mapped remote tab when closing a browser page in the active runtime', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ settings: settingsWithRuntime('env-1'), - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } + ...remoteBackedBrowserTab() }) store.getState().closeBrowserPage('page-1') + await main.settle() + expectMainClosed('closeBrowserPage') await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -127,53 +182,16 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes mapped remote tabs when closing a browser workspace in the active runtime', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ settings: settingsWithRuntime('env-1'), - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, + ...remoteBackedBrowserTab(), activeBrowserTabId: 'workspace-1', - activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } + activeBrowserTabIdByWorktree: { 'wt-1': 'workspace-1' } }) store.getState().closeBrowserTab('workspace-1') + await main.settle() + expectMainClosed('closeTabs') await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -187,50 +205,12 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes mapped remote pages in their owning environment after switching local', async () => { - const store = createTestStore() - store.setState({ - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } - }) + const { store, main } = await createTestStoreDrawnFromMain(remoteBackedBrowserTab()) store.getState().closeBrowserPage('page-1') + await main.settle() + expectMainClosed('closeBrowserPage') + expect(store.getState().browserPagesByWorkspace['workspace-1'] ?? []).toEqual([]) await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ @@ -243,51 +223,15 @@ describe('createBrowserSlice runtime guard', () => { }) it('closes mapped remote tabs in their owning environment after switching environments', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ settings: settingsWithRuntime('env-2'), - browserTabsByWorktree: { - 'wt-1': [ - { - id: 'workspace-1', - worktreeId: 'wt-1', - sessionProfileId: null, - activePageId: 'page-1', - pageIds: ['page-1'], - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - browserPagesByWorkspace: { - 'workspace-1': [ - { - id: 'page-1', - workspaceId: 'workspace-1', - worktreeId: 'wt-1', - url: 'https://example.com', - title: 'Example', - loading: false, - faviconUrl: null, - canGoBack: false, - canGoForward: false, - loadError: null, - createdAt: 1 - } - ] - }, - remoteBrowserPageHandlesByPageId: { - 'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' } - } + ...remoteBackedBrowserTab() }) store.getState().closeBrowserTab('workspace-1') + await main.settle() + expectMainClosed('closeTabs') + expect(store.getState().browserTabsByWorktree['wt-1'] ?? []).toEqual([]) await vi.waitFor(() => { expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ diff --git a/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts b/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts index 06fcaab8512..fb919113ffa 100644 --- a/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts +++ b/src/renderer/src/store/slices/browser-remote-tab-creation.test.ts @@ -1,17 +1,31 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createBrowserMockApi, - createTestStore, resetBrowserRuntimeMocks, runtimeStatuses, settingsWithRuntime } from './browser-slice-test-harness' +import { + createTestStoreDrawnFromMain, + onePaneGroup +} from './browser-slice-drawn-from-main.test-fixture' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/store/slices/browser/paired-browser-tab-creator', () => ({ getRegisteredPairedBrowserTabCreator: () => null, loadPairedBrowserTabCreator: async () => createWebRuntimeSessionBrowserTabMock @@ -22,6 +36,12 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall) // @ts-expect-error test window mock globalThis.window = { api: mockApi } +/** A server-owned workspace's desktop tab: the window draws it, as main never holds that layout. */ +function expectDrawnHere(state: AppState): void { + expect(layoutApplyTestTransport.sent).toEqual([]) + expect(state.createUnifiedTab).toHaveBeenCalled() +} + describe('createBrowserSlice runtime guard', () => { beforeEach(() => { resetBrowserRuntimeMocks({ @@ -35,9 +55,9 @@ describe('createBrowserSlice runtime guard', () => { vi.unstubAllGlobals() }) - it('rejects direct client-local materialization in the web client', () => { + it('rejects direct client-local materialization in the web client', async () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - const store = createTestStore() + const { store } = await createTestStoreDrawnFromMain() expect(() => store.getState().createBrowserTab('wt-1', 'about:blank')).toThrow( 'must be created by a capable paired runtime' @@ -46,10 +66,11 @@ describe('createBrowserSlice runtime guard', () => { expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() }) - it('rejects direct remote materialization without the provider capability', () => { + it('rejects direct remote materialization without the provider capability', async () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - const store = createTestStore() - store.setState({ runtimeStatusByEnvironmentId: runtimeStatuses([]) }) + const { store } = await createTestStoreDrawnFromMain({ + runtimeStatusByEnvironmentId: runtimeStatuses([]) + }) expect(() => store.getState().createBrowserTab('wt-1', 'about:blank', { @@ -61,8 +82,9 @@ describe('createBrowserSlice runtime guard', () => { }) it('creates new browser tabs through the owning runtime for desktop remote worktrees', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + // group-1 is wt-remote's, where the new-tab click lands. + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: null } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -117,11 +139,13 @@ describe('createBrowserSlice runtime guard', () => { expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() expect(store.getState().recordFeatureInteraction).toHaveBeenCalledWith('browser-tab-created') + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) }) it('uses the desktop client browser when a remote npm host cannot stream', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: settingsWithRuntime('env-1'), runtimeStatusByEnvironmentId: runtimeStatuses([]), @@ -138,21 +162,22 @@ describe('createBrowserSlice runtime guard', () => { }) await store.getState().openNewBrowserTabInActiveWorkspace('group-1') + await main.settle() expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() const tab = store.getState().browserTabsByWorktree['wt-remote']?.[0] const page = tab ? store.getState().browserPagesByWorkspace[tab.id]?.[0] : undefined expect(page?.browserRuntimeEnvironmentId).toBeNull() - expect(store.getState().createUnifiedTab).toHaveBeenCalled() + expectDrawnHere(store.getState()) }) it('does not create a local fallback tab when remote browser creation fails', async () => { - const store = createTestStore() // Why: a remote-owned workspace must stay remote-owned. If the remote host // cannot create the page, we must NOT silently open a local desktop tab — // that produces confusing split ownership (issue #5321 UX requirement). createWebRuntimeSessionBrowserTabMock.mockResolvedValueOnce(false) - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -180,6 +205,8 @@ describe('createBrowserSlice runtime guard', () => { clientTargetGroupId: 'group-1' }) // No local tab created, no unified tab, no feature interaction recorded. + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() expect(store.getState().recordFeatureInteraction).not.toHaveBeenCalledWith( @@ -188,8 +215,8 @@ describe('createBrowserSlice runtime guard', () => { }) it('opens a remote sign-in tab with the imported browser profile', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -218,11 +245,13 @@ describe('createBrowserSlice runtime guard', () => { profileId: 'profile-1' }) expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) }) it('opens a local desktop sign-in tab when the remote host cannot stream browsers', async () => { - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: settingsWithRuntime('env-1'), runtimeStatusByEnvironmentId: runtimeStatuses([]), @@ -243,18 +272,20 @@ describe('createBrowserSlice runtime guard', () => { .getState() .openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1') ).resolves.toBe(true) + await main.settle() expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() expect(store.getState().browserTabsByWorktree['wt-remote']?.[0]).toMatchObject({ url: 'https://accounts.google.com/', sessionProfileId: 'profile-1' }) + expectDrawnHere(store.getState()) }) it('rejects a paired-web sign-in tab when the remote host cannot stream browsers', async () => { vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - const store = createTestStore() - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: settingsWithRuntime('env-1'), runtimeStatusByEnvironmentId: runtimeStatuses([]), @@ -278,12 +309,14 @@ describe('createBrowserSlice runtime guard', () => { expect(createWebRuntimeSessionBrowserTabMock).not.toHaveBeenCalled() expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) }) it('does not create a local fallback tab when remote browser creation throws', async () => { - const store = createTestStore() createWebRuntimeSessionBrowserTabMock.mockRejectedValueOnce(new Error('remote down')) - store.setState({ + const { store, main } = await createTestStoreDrawnFromMain({ + ...onePaneGroup('wt-remote'), activeWorktreeId: 'wt-remote', settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], runtimeStatusByEnvironmentId: runtimeStatuses(['browser.screencast.v1']), @@ -302,6 +335,8 @@ describe('createBrowserSlice runtime guard', () => { await expect(store.getState().openNewBrowserTabInActiveWorkspace('group-1')).rejects.toThrow( 'remote down' ) + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) expect(store.getState().browserTabsByWorktree['wt-remote']).toBeUndefined() expect(store.getState().createUnifiedTab).not.toHaveBeenCalled() diff --git a/src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts b/src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts new file mode 100644 index 00000000000..119dda31ef7 --- /dev/null +++ b/src/renderer/src/store/slices/browser-slice-drawn-from-main.test-fixture.ts @@ -0,0 +1,52 @@ +// The browser slice's partial test store with main drawing its tabs in a layout-owner build. + +import { onTestFinished } from 'vitest' +import type { AppState } from '../types' +import { createTabsGroupActions } from './tabs/tabs-group-actions' +import { createTestStore } from './browser-slice-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' + +export type BrowserTestStore = ReturnType + +/** A worktree's one empty pane group, which main places an open in; it replaces wt-1's. */ +export function onePaneGroup(worktreeId: string, groupId = 'group-1'): Partial { + return { + groupsByWorktree: { + [worktreeId]: [{ id: groupId, worktreeId, activeTabId: null, tabOrder: [] }] + }, + layoutByWorktree: { [worktreeId]: { type: 'leaf', groupId } }, + activeGroupIdByWorktree: { [worktreeId]: groupId } + } +} + +/** wt-1's pane group, plus the slices a session payload reads that the partial store lacks. */ +const DRAWN_STORE_SEED: Partial = { + repos: [], + editorDrafts: {}, + markdownFrontmatterVisible: {}, + activeFileIdByWorktree: {}, + workspaceDocHistory: [], + lastVisitedAtByWorktreeId: {}, + defaultTerminalTabsAppliedByWorktreeId: {}, + ...onePaneGroup('wt-1') +} + +/** + * The partial store with main drawing its tabs (switch-on): seeded first, since main loads the + * layout the store holds now. The test file installs the layout-apply transport (see + * tabs-drawn-from-main.test-fixture.ts); main closes when the test finishes. + */ +export async function createTestStoreDrawnFromMain( + seed?: Partial +): Promise<{ store: BrowserTestStore; main: TabsDrawnFromMain }> { + const store = createTestStore() + // Main drops a workspace with its last tab; the next open places through the real root group. + const { ensureWorktreeRootGroup, focusGroup } = createTabsGroupActions( + store.setState, + store.getState + ) + store.setState({ ...DRAWN_STORE_SEED, ensureWorktreeRootGroup, focusGroup, ...seed }) + const main = await drawTabsFromMain(store) + onTestFinished(() => main.close()) + return { store, main } +} diff --git a/src/renderer/src/store/slices/browser-slice-test-harness.ts b/src/renderer/src/store/slices/browser-slice-test-harness.ts index bad5a94139c..faf83f75cdd 100644 --- a/src/renderer/src/store/slices/browser-slice-test-harness.ts +++ b/src/renderer/src/store/slices/browser-slice-test-harness.ts @@ -56,6 +56,8 @@ export function createTestStore() { browserDefaultUrl: 'about:blank', unifiedTabsByWorktree: {}, groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, tabBarOrderByWorktree: {}, tabsByWorktree: {}, openFiles: [], diff --git a/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts b/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts index b3dd75a3839..675f5075edc 100644 --- a/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts +++ b/src/renderer/src/store/slices/browser-tab-creation-placement.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import type { Tab, TabGroup } from '../../../../shared/tab-types' import type { ExecutionHostId } from '../../../../shared/execution-host' @@ -12,6 +12,8 @@ import { seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -25,6 +27,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands; its frames draw the browser tabs. +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + createStoreCascadesMockApi() const WT = 'repo1::/path/wt-placement' @@ -104,19 +118,36 @@ function createdUnifiedId(store: Store, workspaceId: string, worktreeId = WT): s describe('browser tab creation placement', () => { let store: Store + let main: TabsDrawnFromMain | undefined beforeEach(() => { store = createTestStore() }) + afterEach(() => { + main?.close() + main = undefined + }) + + /** Main takes the seeded layout; a layout-owner build draws the created tab from its frame. */ + async function drawFromMain(): Promise { + main?.close() + main = await drawTabsFromMain(store) + } + async function settled(): Promise { + await main?.settle() + } it('appends Group New Browser Tab regardless of the active tab', async () => { seedSplit(store) + await drawFromMain() await store.getState().openNewBrowserTabInActiveWorkspace(G1) + await settled() const n = store.getState().unifiedTabsByWorktree[WT]!.at(-1)! expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C', n.id]) store.getState().activateTab('A') await store.getState().openNewBrowserTabInActiveWorkspace(G1) + await settled() const m = store.getState().unifiedTabsByWorktree[WT]!.at(-1)! expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C', n.id, m.id]) }) @@ -124,7 +155,9 @@ describe('browser tab creation placement', () => { it('uses the invoking group workspace instead of the globally selected one', async () => { seedSplit(store) store.setState({ activeWorktreeId: 'repo1::/elsewhere' }) + await drawFromMain() await store.getState().openNewBrowserTabInActiveWorkspace(G1) + await settled() expect(group(store, G1)?.tabOrder).toHaveLength(4) expect(store.getState().unifiedTabsByWorktree['repo1::/elsewhere']).toBeUndefined() }) @@ -136,8 +169,10 @@ describe('browser tab creation placement', () => { repo1: [makeWorktree({ id: WT, repoId: 'repo1', hostId: 'ssh:target' })] } }) + await drawFromMain() await store.getState().openNewBrowserTabInActiveWorkspace(G1) await store.getState().openBrowserProfileTabInActiveWorkspace('https://example.com', null) + await settled() const workspaces = store.getState().browserTabsByWorktree[WT] ?? [] expect(workspaces).toHaveLength(2) for (const workspace of workspaces) { @@ -147,32 +182,33 @@ describe('browser tab creation placement', () => { } }) - it('places a duplicate directly after its source', () => { + it('places a duplicate directly after its source', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() const duplicate = createdUnifiedId(store, workspace.id) expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', duplicate, 'C']) expect( - store - .getState() - .unifiedTabsByWorktree[WT]!.filter((tab) => tab.groupId === G1) - .map((tab) => [tab.id, tab.sortOrder]) - ).toEqual([ - ['A', 0], - ['B', 1], - ['C', 3], - [duplicate, 2] - ]) + Object.fromEntries( + store + .getState() + .unifiedTabsByWorktree[WT]!.filter((tab) => tab.groupId === G1) + .map((tab) => [tab.id, tab.sortOrder]) + ) + ).toEqual({ A: 0, B: 1, [duplicate]: 2, C: 3 }) }) - it('places a background link after its source without touching selection or MRU', () => { + it('places a background link after its source without touching selection or MRU', async () => { seedSplit(store) const before = store.getState() + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com/link', { afterTabId: 'B', activate: false }) + await settled() const link = createdUnifiedId(store, workspace.id) const after = store.getState() @@ -191,11 +227,13 @@ describe('browser tab creation placement', () => { expect(after.unifiedTabsByWorktree[WT]!.find((tab) => tab.id === 'B')?.lastFocusedAt).toBe(100) }) - it('focuses the resolved group for a foreground source action in an unfocused split', () => { + it('focuses the resolved group for a foreground source action in an unfocused split', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() const created = createdUnifiedId(store, workspace.id) const state = store.getState() expect(state.activeGroupIdByWorktree[WT]).toBe(G1) @@ -205,64 +243,88 @@ describe('browser tab creation placement', () => { expect(state.activeTabType).toBe('browser') }) - it('lets an explicit destination win over the anchor group', () => { + it('lets an explicit destination win over the anchor group', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B', targetGroupId: G2 }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) expect(group(store, G1)?.tabOrder).toEqual(['A', 'B', 'C']) }) - it('treats a closed anchor as absent and appends in the active group', () => { + it('treats a closed anchor as absent and appends in the active group', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'closed-tab' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('falls back to the active group, not the first group, for an expired requested group', () => { + it('falls back to the active group, not the first group, for an expired requested group', async () => { seedSplit(store) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { targetGroupId: 'deleted-group' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('ignores an ambiguous anchor id', () => { - seedSplit(store, { - tabs: [browserTab('A', G1), browserTab('B', G1), browserTab('B', G2), browserTab('C', G1)] - }) + it('ignores an ambiguous anchor id', async () => { + const seed = { + tabs: [ + browserTab('A', G1), + browserTab('B', G1), + browserTab('B', G2), + browserTab('C', G1), + browserTab('X', G2) + ] + } + seedSplit(store, seed) + await drawFromMain() + // Main's load repairs the malformed seed; the window's copy keeps it for the guard to read. + seedSplit(store, seed) const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('ignores an anchor whose group order no longer contains it', () => { - seedSplit(store, { + it('ignores an anchor whose group order no longer contains it', async () => { + const seed = { groups: [ makeTabGroup({ id: G1, worktreeId: WT, activeTabId: 'A', tabOrder: ['A', 'C'] }), makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] - }) + } + seedSplit(store, seed) + await drawFromMain() + // Main's load repairs the malformed seed; the window's copy keeps it for the guard to read. + seedSplit(store, seed) const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('collapses duplicate order entries while inserting once', () => { + it('collapses duplicate order entries while inserting once', async () => { seedSplit(store, { groups: [ makeTabGroup({ id: G1, worktreeId: WT, activeTabId: 'B', tabOrder: ['A', 'B', 'A', 'C'] }), makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] }) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'B' }) + await settled() expect(group(store, G1)?.tabOrder).toEqual([ 'A', 'B', @@ -271,7 +333,7 @@ describe('browser tab creation placement', () => { ]) }) - it('keeps an unpinned tab from a pinned source after the pinned prefix', () => { + it('keeps an unpinned tab from a pinned source after the pinned prefix', async () => { seedSplit(store, { tabs: [ browserTab('P1', G1, { isPinned: true }), @@ -284,9 +346,11 @@ describe('browser tab creation placement', () => { makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] }) + await drawFromMain() const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { afterTabId: 'P1' }) + await settled() expect(group(store, G1)?.tabOrder).toEqual([ 'P1', 'P2', @@ -350,25 +414,28 @@ describe('browser tab creation placement', () => { expect(sortOrderById).toMatchObject({ A: 0, C: 1, [created.id]: 2 }) }) - it('leaves the wrapper host unset instead of stamping a substituted routing host', () => { + it('leaves the wrapper host unset instead of stamping a substituted routing host', async () => { seedSplit(store) // Why: no repo row and a focused runtime make the routing resolver substitute runtime:env-1. store.setState({ activeWorktreeId: 'repo1::/elsewhere', settings: { ...store.getState().settings!, activeRuntimeEnvironmentId: 'env-1' } }) + await drawFromMain() const workspace = store.getState().createBrowserTab(WT, 'https://example.com', { activate: false, browserRuntimeEnvironmentId: null }) + await settled() const wrapper = store .getState() .unifiedTabsByWorktree[WT]!.find((tab) => tab.entityId === workspace.id) expect(wrapper).toBeDefined() - expect(wrapper?.executionHostId).toBeUndefined() + // A layout-owner build draws every tab with main's own host, never the routing substitute. + expect(wrapper?.executionHostId).toBe(ORCA_LAYOUT_OWNER_IS_MAIN ? 'local' : undefined) }) - it('rejects an anchor owned by a foreign execution host', () => { + it('rejects an anchor owned by a foreign execution host', async () => { seedSplit(store, { tabs: [browserTab('A', G1, { executionHostId: 'ssh:other' }), browserTab('X', G2)], groups: [ @@ -376,14 +443,16 @@ describe('browser tab creation placement', () => { makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ] }) + await drawFromMain() const workspace = store.getState().createBrowserTab(WT, 'https://example.com', { afterTabId: 'A', executionHostId: 'runtime:env-1' }) + await settled() expect(group(store, G2)?.tabOrder).toEqual(['X', createdUnifiedId(store, workspace.id)]) }) - it('accepts an SSH-backed anchor through the worktree runtime-owner alias', () => { + it('accepts an SSH-backed anchor through the worktree runtime-owner alias', async () => { const sshHost: ExecutionHostId = 'ssh:target-1' seedSplit(store, { tabs: [browserTab('A', G1, { executionHostId: sshHost }), browserTab('C', G1)], @@ -401,14 +470,16 @@ describe('browser tab creation placement', () => { ] } }) + await drawFromMain() const workspace = store.getState().createBrowserTab(WT, 'https://example.com', { afterTabId: 'A', executionHostId: 'runtime:env-1' }) + await settled() expect(group(store, G1)?.tabOrder).toEqual(['A', createdUnifiedId(store, workspace.id), 'C']) }) - it('places source actions in folder and floating workspaces', () => { + it('places source actions in folder and floating workspaces', async () => { const folderWt = 'folder:folder-1' const folder: FolderWorkspace = { id: 'folder-1', @@ -432,7 +503,12 @@ describe('browser tab creation placement', () => { unifiedTabsByWorktree: { ...store.getState().unifiedTabsByWorktree, [worktreeId]: ['A', 'B'].map((id) => - makeUnifiedTab({ id: `${worktreeId}-${id}`, worktreeId, groupId: `${worktreeId}-g` }) + makeUnifiedTab({ + id: `${worktreeId}-${id}`, + worktreeId, + groupId: `${worktreeId}-g`, + contentType: 'browser' + }) ) }, groupsByWorktree: { @@ -447,10 +523,12 @@ describe('browser tab creation placement', () => { ] } }) + await drawFromMain() const workspace = store.getState().createBrowserTab(worktreeId, 'https://example.com', { afterTabId: `${worktreeId}-A`, browserRuntimeEnvironmentId: null }) + await settled() expect(group(store, `${worktreeId}-g`, worktreeId)?.tabOrder).toEqual([ `${worktreeId}-A`, createdUnifiedId(store, workspace.id, worktreeId), @@ -459,17 +537,22 @@ describe('browser tab creation placement', () => { } }) - it('selects the first tab of an empty group structurally without recording a visit', () => { - seedSplit(store, { + it('selects the first tab of an empty group structurally without recording a visit', async () => { + const seed = { groups: [ makeTabGroup({ id: G1, worktreeId: WT, activeTabId: null, tabOrder: [], recentTabIds: [] }), makeTabGroup({ id: G2, worktreeId: WT, activeTabId: 'X', tabOrder: ['X'] }) ], tabs: [browserTab('X', G2)] - }) + } + seedSplit(store, seed) + await drawFromMain() + // Main stores no empty group; only the window's copy holds it. + seedSplit(store, seed) const workspace = store .getState() .createBrowserTab(WT, 'https://example.com', { targetGroupId: G1, activate: false }) + await settled() const created = createdUnifiedId(store, workspace.id) expect(group(store, G1)).toMatchObject({ activeTabId: created, recentTabIds: [] }) expect(store.getState().activeGroupIdByWorktree[WT]).toBe(G2) diff --git a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts index c60fe7bd2f7..3d70896238f 100644 --- a/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts +++ b/src/renderer/src/store/slices/browser-workspace-doc-location.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants' import { buildBrowserSessionData } from '@/lib/workspace-session-browser-tabs' @@ -7,6 +7,7 @@ import { browserWorkspaceSchema } from '../../../../shared/workspace-session-browser-schema' import { createTestStore, makeWorktree } from './store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mocks = vi.hoisted(() => ({ releaseDocPreviewGrant: vi.fn() })) vi.mock('@/lib/doc-preview-grants', () => ({ @@ -19,6 +20,17 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal() return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's layout commands and its frames draw the tabs (switch-on only). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) const WORKTREE_ID = 'repo1::/path/wt1' const DOC_LOCATION = { @@ -30,7 +42,18 @@ const DOC_LOCATION = { // process. Nothing in the store, on disk or on the wire may ever carry it. const LIVE_GRANT_URL = `orca-preview://${'a'.repeat(32)}/report/index.html` -function createStoreWithWorktree(): ReturnType { +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +async function settled(): Promise { + await main?.settle() +} + +/** A store showing one worktree, its tabs drawn from main once the switch is on. */ +async function createStoreWithWorktree(): Promise> { const store = createTestStore() store.setState({ repos: [{ id: 'repo1', path: '/repo1', displayName: 'Repo 1', badgeColor: '#000', addedAt: 0 }], @@ -39,6 +62,8 @@ function createStoreWithWorktree(): ReturnType { }, activeWorktreeId: WORKTREE_ID }) + main?.close() + main = await drawTabsFromMain(store) return store } @@ -55,13 +80,14 @@ function persistedSession( } describe('a browser page that shows a workspace document', () => { - it('is created blank, with the document as its identity', () => { - const store = createStoreWithWorktree() + it('is created blank, with the document as its identity', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] expect(page?.url).toBe(ORCA_BROWSER_BLANK_URL) @@ -78,10 +104,11 @@ describe('a browser page that shows a workspace document', () => { // The presence precondition for every blank-url assertion here: an ordinary tab created the same // way keeps the URL it was given, so a store that had stopped recording urls at all would fail // rather than pass by being uniformly blank. - it('leaves an ordinary browser tab url alone', () => { - const store = createStoreWithWorktree() + it('leaves an ordinary browser tab url alone', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.url).toBe('https://example.com/') expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.docLocation ?? null).toBeNull() @@ -89,13 +116,14 @@ describe('a browser page that shows a workspace document', () => { // Browser parity: a browser tab is named by the document it shows, and so is this one. Without // this the blank url that is correct for a document page named every preview "New Tab". - it('takes its name from the document, and falls back to the file', () => { - const store = createStoreWithWorktree() + it('takes its name from the document, and falls back to the file', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' expect(store.getState().browserPagesByWorkspace[tab.id]?.[0]?.title).toBe('index.html') @@ -108,13 +136,14 @@ describe('a browser page that shows a workspace document', () => { // Why a title needs the same refusal the url has: Chromium reports the URL as the title when a // document declares none, and titles are stored, mirrored onto the tab and written to disk. - it('refuses a grant url arriving as the document title', () => { - const store = createStoreWithWorktree() + it('refuses a grant url arriving as the document title', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().updateBrowserPageState(pageId, { title: LIVE_GRANT_URL }) @@ -126,9 +155,10 @@ describe('a browser page that shows a workspace document', () => { // The presence half for both: an ordinary page still gets the blank-url name, so a fallback that // had swallowed every title would fail here rather than pass by naming everything after a file. - it('still names an ordinary blank browser tab New Tab', () => { - const store = createStoreWithWorktree() + it('still names an ordinary blank browser tab New Tab', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, ORCA_BROWSER_BLANK_URL) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().updateBrowserPageState(pageId, { title: '' }) @@ -139,13 +169,14 @@ describe('a browser page that shows a workspace document', () => { // Why the third url door is fenced even though nothing reaches it today: creation and the title // update are fenced, and a door left open is one navigation report away from committing a grant // to the page every persisted, mirrored and published reader takes its url from. - it('keeps its blank url when a navigation commits one onto it', () => { - const store = createStoreWithWorktree() + it('keeps its blank url when a navigation commits one onto it', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, title: 'index.html', browserRuntimeEnvironmentId: null }) + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().setBrowserPageUrl(pageId, LIVE_GRANT_URL) @@ -163,9 +194,10 @@ describe('a browser page that shows a workspace document', () => { // The presence half for the door above: an ordinary page still takes the url it is given, so a // door that had stopped writing urls entirely would fail here rather than pass by writing none. - it('still commits a navigation url onto an ordinary browser tab', () => { - const store = createStoreWithWorktree() + it('still commits a navigation url onto an ordinary browser tab', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const pageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().setBrowserPageUrl(pageId, 'https://example.com/next') @@ -176,14 +208,16 @@ describe('a browser page that shows a workspace document', () => { }) }) - it('never asks for the address bar it does not have', () => { - const store = createStoreWithWorktree() + it('never asks for the address bar it does not have', async () => { + const store = await createStoreWithWorktree() const docTab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const newTab = store.getState().createBrowserTab(WORKTREE_ID, ORCA_BROWSER_BLANK_URL) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[docTab.id]).toBeUndefined() // The blank url is exactly what marks an ordinary New Tab as wanting focus there. @@ -194,15 +228,17 @@ describe('a browser page that shows a workspace document', () => { // its one page, while every later change re-mirrors from whichever page is active. A mirror that // does not carry this field leaves the strip entry naming a document the reader has switched away // from — and the publish boundary reads the tab, not the page. - it('follows the active page in and out of the document', () => { - const store = createStoreWithWorktree() + it('follows the active page in and out of the document', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' const urlPage = store.getState().createBrowserPage(tab.id, 'https://example.com/') + await settled() expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.docLocation ?? null).toBeNull() expect(store.getState().browserTabsByWorktree[WORKTREE_ID]?.[0]?.url).toBe( @@ -221,12 +257,13 @@ describe('a browser page that shows a workspace document', () => { // not — two documents named index.html in different folders share a title, and a document page's // url is blank by construction. Without this field in the comparison the strip entry keeps naming // the document the reader moved away from, and the publish boundary reads the entry. - it('repairs a workspace still naming the document its page has left', () => { - const store = createStoreWithWorktree() + it('repairs a workspace still naming the document its page has left', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const page = store.getState().browserPagesByWorkspace[tab.id]?.[0] const otherDocument = { ...DOC_LOCATION, filePath: '/home/alice/wt1/appendix/index.html' } store.setState({ @@ -246,49 +283,59 @@ describe('a browser page that shows a workspace document', () => { // Why closing has to say so: a grant is the only authority the preview scheme honors, and it // outlives the guest. A closed document that stays readable is a read authority nothing revokes // until the process ends. - it('revokes the grant of the document tab that was closed, and only that one', () => { - const store = createStoreWithWorktree() + it('revokes the grant of the document tab that was closed, and only that one', async () => { + const store = await createStoreWithWorktree() const docTab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[docTab.id]?.[0]?.id const urlTab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() store.getState().closeBrowserTab(urlTab.id) + await settled() expect(mocks.releaseDocPreviewGrant).not.toHaveBeenCalled() store.getState().closeBrowserTab(docTab.id) + await settled() expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledWith(docPageId) expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledTimes(1) }) - it('revokes a document grant when only that page is closed', () => { + it('revokes a document grant when only that page is closed', async () => { mocks.releaseDocPreviewGrant.mockClear() - const store = createStoreWithWorktree() + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const docPageId = store.getState().browserPagesByWorkspace[tab.id]?.[0]?.id ?? '' store.getState().createBrowserPage(tab.id, 'https://example.com/') + await settled() store.getState().closeBrowserPage(docPageId) + await settled() expect(mocks.releaseDocPreviewGrant).toHaveBeenCalledExactlyOnceWith(docPageId) expect(store.getState().browserPagesByWorkspace[tab.id]).toHaveLength(1) }) - it('reopens a closed document tab with its document identity', () => { - const store = createStoreWithWorktree() + it('reopens a closed document tab with its document identity', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() store.getState().closeBrowserTab(tab.id) + await settled() const reopened = store.getState().reopenClosedBrowserTab(WORKTREE_ID) + await settled() const page = reopened ? store.getState().browserPagesByWorkspace[reopened.id]?.[0] : undefined expect(page?.docLocation).toEqual(DOC_LOCATION) @@ -296,30 +343,35 @@ describe('a browser page that shows a workspace document', () => { expect(reopened?.docLocation).toEqual(DOC_LOCATION) }) - it('reopens a closed document page with its document identity', () => { - const store = createStoreWithWorktree() + it('reopens a closed document page with its document identity', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, 'https://example.com/') + await settled() const documentPage = store.getState().createBrowserPage(tab.id, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() if (!documentPage) { throw new Error('Expected a document page') } store.getState().closeBrowserPage(documentPage.id) + await settled() const reopened = store.getState().reopenClosedBrowserPage(tab.id) + await settled() expect(reopened?.docLocation).toEqual(DOC_LOCATION) expect(reopened?.url).toBe(ORCA_BROWSER_BLANK_URL) }) - it('writes the document and not the grant to the session', () => { - const store = createStoreWithWorktree() + it('writes the document and not the grant to the session', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) const written = JSON.stringify(session) @@ -332,12 +384,13 @@ describe('a browser page that shows a workspace document', () => { // Why the schema is asserted separately: both browser schemas are plain z.object, which strips // what it does not name. An unlisted docLocation survives every step above and disappears on // load, restoring the document as a blank New Tab under a strip entry that still names it. - it('survives the session schema in both halves', () => { - const store = createStoreWithWorktree() + it('survives the session schema in both halves', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) const parsedPage = browserPageSchema.parse(session.browserPagesByWorkspace?.[tab.id]?.[0]) @@ -349,15 +402,16 @@ describe('a browser page that shows a workspace document', () => { expect(parsedTab.docLocation).toEqual(DOC_LOCATION) }) - it('restores as the document it was, still blank', () => { - const store = createStoreWithWorktree() + it('restores as the document it was, still blank', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) - const restored = createStoreWithWorktree() + const restored = await createStoreWithWorktree() restored.getState().hydrateBrowserSession({ activeRepoId: 'repo1', activeWorktreeId: WORKTREE_ID, @@ -377,15 +431,16 @@ describe('a browser page that shows a workspace document', () => { // Why this shape and not just a missing field: salvage drops a malformed page row and leaves the // array empty, and hydration then rebuilds one page from the tab's own mirrored chrome. - it('restores from the tab alone when its page row was salvaged away', () => { - const store = createStoreWithWorktree() + it('restores from the tab alone when its page row was salvaged away', async () => { + const store = await createStoreWithWorktree() const tab = store.getState().createBrowserTab(WORKTREE_ID, LIVE_GRANT_URL, { docLocation: DOC_LOCATION, browserRuntimeEnvironmentId: null }) + await settled() const session = persistedSession(store) - const restored = createStoreWithWorktree() + const restored = await createStoreWithWorktree() restored.getState().hydrateBrowserSession({ activeRepoId: 'repo1', activeWorktreeId: WORKTREE_ID, @@ -404,8 +459,8 @@ describe('a browser page that shows a workspace document', () => { // Why a hand-written row and not one this build can produce: the store refuses to mint one, so // the only way a grant url reaches this field is a session written by another build. It names a // grant that died with the process that minted it, and the address bar would show it. - it('blanks a grant url a foreign session carried in', () => { - const restored = createStoreWithWorktree() + it('blanks a grant url a foreign session carried in', async () => { + const restored = await createStoreWithWorktree() restored.getState().hydrateBrowserSession({ activeRepoId: 'repo1', @@ -451,6 +506,7 @@ describe('a browser page that shows a workspace document', () => { ] } }) + await settled() expect(restored.getState().browserPagesByWorkspace['browser-1']?.[0]?.url).toBe( ORCA_BROWSER_BLANK_URL diff --git a/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts b/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts index ea74f0dc8c8..6bfe5afd403 100644 --- a/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts +++ b/src/renderer/src/store/slices/browser-workspace-host-ownership.test.ts @@ -1,16 +1,29 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isLocalBrowserPageOwner } from './browser' import type { AppState } from '../types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createBrowserMockApi, createTestStore, resetBrowserRuntimeMocks } from './browser-slice-test-harness' +import { createTestStoreDrawnFromMain } from './browser-slice-drawn-from-main.test-fixture' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentCall = vi.fn() const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock })) @@ -119,14 +132,26 @@ describe('createBrowserSlice runtime guard', () => { expect(isLocalBrowserPageOwner(store.getState(), 'remote-wt', null)).toBe(true) }) - it('stores a runtime-resolved browser partition without a renderer profile mirror', () => { - const store = createTestStore() - store.setState({ browserSessionProfiles: [] }) + it('stores a runtime-resolved browser partition without a renderer profile mirror', async () => { + const { store, main } = await createTestStoreDrawnFromMain({ browserSessionProfiles: [] }) const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { sessionProfileId: 'profile-isolated', sessionPartition: 'persist:orca-browser-session-profile-isolated' }) + await main.settle() + + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? [ + expect.objectContaining({ + browserTabId: tab.id, + profileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + ] + : [] + ) expect(tab.sessionProfileId).toBe('profile-isolated') expect(tab.sessionPartition).toBe('persist:orca-browser-session-profile-isolated') @@ -135,12 +160,13 @@ describe('createBrowserSlice runtime guard', () => { ) }) - it('stores a runtime-resolved partition when switching browser tab profiles', () => { - const store = createTestStore() + it('stores a runtime-resolved partition when switching browser tab profiles', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { sessionProfileId: null, sessionPartition: 'persist:orca-browser' }) + await main.settle() store .getState() @@ -149,6 +175,20 @@ describe('createBrowserSlice runtime guard', () => { 'profile-isolated', 'persist:orca-browser-session-profile-isolated' ) + await main.settle() + + // A layout-owner build's main records the switch; its frame draws the record. + expect(layoutApplyTestTransport.sentOfType('setBrowserTabProfile')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? [ + expect.objectContaining({ + browserTabId: tab.id, + profileId: 'profile-isolated', + sessionPartition: 'persist:orca-browser-session-profile-isolated' + }) + ] + : [] + ) expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toEqual( expect.objectContaining({ diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index f3c6f19f6e4..48cffee768d 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -3,15 +3,31 @@ import type { AppState } from '../types' import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types' import { makeAnnotation } from './browser-annotation-test-fixture' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { createBrowserMockApi, createTestStore, settingsWithRuntime } from './browser-slice-test-harness' +import { + createTestStoreDrawnFromMain, + type BrowserTestStore +} from './browser-slice-drawn-from-main.test-fixture' const createWebRuntimeSessionBrowserTabMock = vi.hoisted(() => vi.fn()) const runtimeEnvironmentTransportCall = vi.fn() +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) vi.mock('@/runtime/web-runtime-browser-creation', () => ({ createWebRuntimeSessionBrowserTab: createWebRuntimeSessionBrowserTabMock })) @@ -21,11 +37,7 @@ const mockApi = createBrowserMockApi(runtimeEnvironmentTransportCall) // @ts-expect-error test window mock globalThis.window = { api: mockApi } -function seedUnifiedBrowserTab( - store: ReturnType, - entityId: string, - label: string -): void { +function seedUnifiedBrowserTab(store: BrowserTestStore, entityId: string, label: string): void { store.setState({ unifiedTabsByWorktree: { 'wt-1': [ @@ -47,26 +59,30 @@ function seedUnifiedBrowserTab( } describe('createBrowserSlice annotations', () => { - it('announces the store-selected browser page before its guest is destroyed', () => { - const store = createTestStore() + it('announces the store-selected browser page before its guest is destroyed', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const previous = store.getState().createBrowserTab('wt-1', 'https://previous.example.com') + await main.settle() const closing = store.getState().createBrowserTab('wt-1', 'https://closing.example.com') + await main.settle() store.getState().setActiveBrowserTab(previous.id) store.getState().setActiveBrowserTab(closing.id) mockApi.browser.notifyActiveTabChanged.mockClear() store.getState().closeBrowserTab(closing.id) + await main.settle() expect(mockApi.browser.notifyActiveTabChanged).toHaveBeenCalledWith({ browserPageId: previous.activePageId }) }) - it('keeps a requested canonical page identity distinct from its workspace', () => { - const store = createTestStore() + it('keeps a requested canonical page identity distinct from its workspace', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'about:blank', { browserPageId: 'page-canonical' }) + await main.settle() expect(tab.id).not.toBe('page-canonical') expect(tab.activePageId).toBe('page-canonical') @@ -79,9 +95,10 @@ describe('createBrowserSlice annotations', () => { }) it('records browser-tab-created only for the explicit new-tab action', async () => { - const store = createTestStore() + const { store, main } = await createTestStoreDrawnFromMain() store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() expect(store.getState().recordFeatureInteraction).not.toHaveBeenCalledWith( 'browser-tab-created' ) @@ -92,13 +109,14 @@ describe('createBrowserSlice annotations', () => { }) it('opens a local sign-in tab with the imported browser profile', async () => { - const store = createTestStore() + const { store, main } = await createTestStoreDrawnFromMain() await expect( store .getState() .openBrowserProfileTabInActiveWorkspace('https://accounts.google.com/', 'profile-1') ).resolves.toBe(true) + await main.settle() expect(store.getState().browserTabsByWorktree['wt-1']?.[0]).toMatchObject({ url: 'https://accounts.google.com/', @@ -106,9 +124,10 @@ describe('createBrowserSlice annotations', () => { }) }) - it('retains saved page annotations while retiring geometry when the URL changes', () => { - const store = createTestStore() + it('retains saved page annotations while retiring geometry when the URL changes', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -126,9 +145,10 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserAnnotationMarkerIdsByPageId[pageId]).toBeUndefined() }) - it('can commit a navigation URL without hiding an active recovery error', () => { - const store = createTestStore() + it('can commit a navigation URL without hiding an active recovery error', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -151,9 +171,10 @@ describe('createBrowserSlice annotations', () => { }) }) - it('keeps certificate challenges transient across navigation, success, and close', () => { - const store = createTestStore() + it('keeps certificate challenges transient across navigation, success, and close', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://localhost:3443/') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -181,31 +202,56 @@ describe('createBrowserSlice annotations', () => { store.getState().setBrowserPageCertificateFailure(pageId, failure) store.getState().closeBrowserTab(tab.id) + await main.settle() expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() }) - it('creates inactive browser unified tabs without stealing the visible tab', () => { - const store = createTestStore() - - store.getState().createBrowserTab('wt-1', 'https://example.com', { activate: false }) - - expect(store.getState().createUnifiedTab).toHaveBeenCalledWith( - 'wt-1', - 'browser', - expect.objectContaining({ activate: false }) - ) - expect(store.getState().activeTabType).toBe('terminal') - expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull() - }) - - it('creates pages cold so a deferred guest is never owed a navigation', () => { - const store = createTestStore() + it('creates inactive browser unified tabs without stealing the visible tab', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { activate: false }) - store.getState().createBrowserPage(tab.id, 'https://example.com/second', { activate: false }) + await main.settle() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // Main opens the tab and its frame draws it; the window leaves it unselected. + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')).toEqual([ + expect.objectContaining({ browserTabId: tab.id, groupId: 'group-1' }) + ]) + expect(store.getState().unifiedTabsByWorktree['wt-1']).toEqual([ + expect.objectContaining({ contentType: 'browser', entityId: tab.id }) + ]) + expect(store.getState().activateTab).not.toHaveBeenCalled() + } else { + expect(store.getState().createUnifiedTab).toHaveBeenCalledWith( + 'wt-1', + 'browser', + expect.objectContaining({ activate: false }) + ) + } + // A background open leaves the worktree's remembered browser tab unset. + expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull() + expect(store.getState().activeTabType).toBe('terminal') + expect(store.getState().activeBrowserTabId).toBeNull() + }) + + it('creates pages cold so a deferred guest is never owed a navigation', async () => { + const { store, main } = await createTestStoreDrawnFromMain() + + const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { + activate: false + }) + await main.settle() + const second = store + .getState() + .createBrowserPage(tab.id, 'https://example.com/second', { activate: false }) + await main.settle() + + // A layout-owner build's main adds the page and its frame draws it. + expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN ? [expect.objectContaining({ pageId: second?.id })] : [] + ) // Why: only a live guest reports loading; a background page has none until first shown. expect(store.getState().browserPagesByWorkspace[tab.id]?.map((page) => page.loading)).toEqual([ false, @@ -214,8 +260,8 @@ describe('createBrowserSlice annotations', () => { expect(tab.loading).toBe(false) }) - it('uses local browser profile defaults for client-local fallback pages', () => { - const store = createTestStore() + it('uses local browser profile defaults for client-local fallback pages', async () => { + const { store, main } = await createTestStoreDrawnFromMain() store.setState({ settings: settingsWithRuntime('env-1'), defaultBrowserSessionProfileIdByHostId: { @@ -227,19 +273,22 @@ describe('createBrowserSlice annotations', () => { const localFallback = store.getState().createBrowserTab('wt-1', 'about:blank', { browserRuntimeEnvironmentId: null }) + await main.settle() const remoteTab = store.getState().createBrowserTab('wt-1', 'about:blank', { browserRuntimeEnvironmentId: 'env-1' }) + await main.settle() expect(localFallback.sessionProfileId).toBe('local-profile') expect(remoteTab.sessionProfileId).toBe('runtime-profile') }) - it('preserves browser map references when a page-state update is unchanged', () => { - const store = createTestStore() + it('preserves browser map references when a page-state update is unchanged', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -264,11 +313,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree) }) - it('persists a captured favicon with history and refreshes it with the page state', () => { - const store = createTestStore() + it('persists a captured favicon with history and refreshes it with the page state', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -283,11 +333,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserUrlHistory[0]?.faviconUrl).toBe(refreshedFavicon) }) - it('clears a stale history favicon when a page reports none, and keeps it when none is reported', () => { - const store = createTestStore() + it('clears a stale history favicon when a page reports none, and keeps it when none is reported', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -330,11 +381,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserUrlHistory).toBe(revisited) }) - it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', () => { - const store = createTestStore() + it('repairs a stale active browser unified-tab label on an otherwise unchanged title update', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -350,11 +402,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree) }) - it('repairs stale active browser workspace metadata on an otherwise unchanged page update', () => { - const store = createTestStore() + it('repairs stale active browser workspace metadata on an otherwise unchanged page update', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -393,11 +446,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserPagesByWorkspace).toBe(browserPagesByWorkspace) }) - it('updates the active browser unified-tab label without a second tab-label write', () => { - const store = createTestStore() + it('updates the active browser unified-tab label without a second tab-label write', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -410,11 +464,12 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().setTabLabel).not.toHaveBeenCalled() }) - it('updates inactive browser pages without relabeling or rebuilding the workspace map', () => { - const store = createTestStore() + it('updates inactive browser pages without relabeling or rebuilding the workspace map', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', { title: 'Example' }) + await main.settle() const activePageId = tab.activePageId if (!activePageId) { throw new Error('Expected a new browser page') @@ -425,9 +480,15 @@ describe('createBrowserSlice annotations', () => { title: 'Inactive', activate: false }) + await main.settle() if (!inactivePage) { throw new Error('Expected inactive browser page') } + expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? [expect.objectContaining({ browserTabId: tab.id, pageId: inactivePage.id })] + : [] + ) seedUnifiedBrowserTab(store, tab.id, 'Example') const browserPagesByWorkspace = store.getState().browserPagesByWorkspace const browserTabsByWorktree = store.getState().browserTabsByWorktree @@ -450,9 +511,10 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().setTabLabel).not.toHaveBeenCalled() }) - it('caps stored browser annotations per page', () => { - const store = createTestStore() + it('caps stored browser annotations per page', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -467,9 +529,10 @@ describe('createBrowserSlice annotations', () => { expect(annotations[0]?.id).toBe('annotation-3') }) - it('sanitizes persistent annotation payloads at the store boundary', () => { - const store = createTestStore() + it('sanitizes persistent annotation payloads at the store boundary', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -496,9 +559,10 @@ describe('createBrowserSlice annotations', () => { expect(stored?.payload.screenshot).toBeNull() }) - it('updates an existing annotation comment and intent', () => { - const store = createTestStore() + it('updates an existing annotation comment and intent', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -514,9 +578,10 @@ describe('createBrowserSlice annotations', () => { expect(stored?.intent).toBe('change') }) - it('sanitizes an oversized comment when updating an annotation', () => { - const store = createTestStore() + it('sanitizes an oversized comment when updating an annotation', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -533,9 +598,10 @@ describe('createBrowserSlice annotations', () => { expect(stored?.comment).toHaveLength(GRAB_BUDGET.annotationCommentMaxLength) }) - it('is a no-op when updating an annotation id that does not exist', () => { - const store = createTestStore() + it('is a no-op when updating an annotation id that does not exist', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const pageId = tab.activePageId if (!pageId) { throw new Error('Expected a new browser page') @@ -553,15 +619,17 @@ describe('createBrowserSlice annotations', () => { }) describe('createBrowserSlice floating tabs', () => { - it('tracks new floating browser tabs without changing the main browser surface', () => { - const store = createTestStore() + it('tracks new floating browser tabs without changing the main browser surface', async () => { + const { store, main } = await createTestStoreDrawnFromMain() store.setState({ activeWorktreeId: 'wt-1', activeTabType: 'terminal' } as Partial) const mainTab = store.getState().createBrowserTab('wt-1', 'https://example.com') + await main.settle() const activeTabTypeBeforeFloating = store.getState().activeTabType const tab = store.getState().createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'about:blank', { focusAddressBar: true }) + await main.settle() expect(store.getState().activeBrowserTabId).toBe(mainTab.id) expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBe(mainTab.id) @@ -574,30 +642,46 @@ describe('createBrowserSlice floating tabs', () => { }) describe('createBrowserSlice closed browser workspaces', () => { - it('reopens duplicate-URL browser pages on the originally active page', () => { - const store = createTestStore() + it('reopens duplicate-URL browser pages on the originally active page', async () => { + const { store, main } = await createTestStoreDrawnFromMain() const tab = store.getState().createBrowserTab('wt-1', 'https://example.com/dashboard', { title: 'First copy' }) + await main.settle() const secondPage = store.getState().createBrowserPage(tab.id, 'https://example.com/dashboard', { title: 'Second copy' }) + await main.settle() if (!secondPage) { throw new Error('Expected a second browser page') } store.getState().closeBrowserTab(tab.id) + await main.settle() const restored = store.getState().reopenClosedBrowserTab('wt-1') + // Main draws the tab, then the window sends its second page. + await main.settle() + await main.settle() if (!restored) { throw new Error('Expected a reopened browser workspace') } const restoredPages = store.getState().browserPagesByWorkspace[restored.id] ?? [] - const activePage = restoredPages.find((page) => page.id === restored.activePageId) + // The returned record is as proposed; the drawn one carries the reselected page. + const activePageId = store + .getState() + .browserTabsByWorktree['wt-1']?.find((entry) => entry.id === restored.id)?.activePageId + const activePage = restoredPages.find((page) => page.id === activePageId) expect(restoredPages.map((page) => page.url)).toEqual([ 'https://example.com/dashboard', 'https://example.com/dashboard' ]) expect(activePage?.title).toBe('Second copy') + // A layout-owner build's main closed the tab, then reopened it with both pages. + expect(layoutApplyTestTransport.sent.map((command) => command.type)).toEqual( + ORCA_LAYOUT_OWNER_IS_MAIN + ? ['openBrowserTab', 'openBrowserPage', 'closeTabs', 'openBrowserTab', 'openBrowserPage'] + : [] + ) }) }) diff --git a/src/renderer/src/store/slices/browser/browser-close-actions.ts b/src/renderer/src/store/slices/browser/browser-close-actions.ts index f3ac38d44a8..f15cfc4b897 100644 --- a/src/renderer/src/store/slices/browser/browser-close-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-close-actions.ts @@ -13,8 +13,13 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' import { destroyWorkspaceWebviews } from '../browser-webview-cleanup' import { omitRecordKeys } from '../worktrees/teardown/record-key-omission' +import { landOnEmptiedWorktree } from '../tabs/tabs-close-actions' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { recordBrowserTabClose } from './browser-layout-owner-commands' +import { + closeBrowserTabThroughLayoutOwner, + withDrawnBrowserPages, + type DrawnBrowserTab +} from './browser-tab-close-through-layout-owner' export function createBrowserCloseActions( set: BrowserSliceSet, @@ -25,220 +30,246 @@ export function createBrowserCloseActions( // Why: a cleanup close unwinds a tab that never finished being created — it owns no host // page to close and must not enter the reopen stack as if the user had closed something. const isCleanup = options?.reason === 'cleanup' - let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - // Why collected rather than released inside the reducer: revoking is main-process work, and a - // grant is the only authority the preview scheme honors — a closed document must stop being - // readable, and it must stop being readable even if the reducer bails out below. - let docPageIdsToRelease: string[] = [] - let activeBrowserWorktreeIdToNotify: string | null = null - set((s) => { - let owningWorktreeId: string | null = null - let closedWorkspace: BrowserWorkspace | null = null - const nextBrowserTabsByWorktree: Record = {} - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const removedTab = tabs.find((tab) => tab.id === tabId) ?? null - const filtered = tabs.filter((tab) => tab.id !== tabId) - if (filtered.length !== tabs.length) { - owningWorktreeId = worktreeId - closedWorkspace = removedTab + const closeHere = (drawn?: DrawnBrowserTab): void => { + let remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + // Why collected rather than released inside the reducer: revoking is main-process work, and a + // grant is the only authority the preview scheme honors — a closed document must stop being + // readable, and it must stop being readable even if the reducer bails out below. + let docPageIdsToRelease: string[] = [] + let activeBrowserWorktreeIdToNotify: string | null = null + set((current) => { + const s = drawn ? withDrawnBrowserPages(current, drawn) : current + // Main's frame already dropped a drawn tab's record. + let owningWorktreeId: string | null = drawn?.worktreeId ?? null + let closedWorkspace: BrowserWorkspace | null = drawn?.record ?? null + const nextBrowserTabsByWorktree: Record = {} + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const removedTab = tabs.find((tab) => tab.id === tabId) ?? null + const filtered = tabs.filter((tab) => tab.id !== tabId) + if (filtered.length !== tabs.length) { + owningWorktreeId = worktreeId + closedWorkspace = removedTab + } + if (filtered.length > 0) { + nextBrowserTabsByWorktree[worktreeId] = filtered + } } - if (filtered.length > 0) { - nextBrowserTabsByWorktree[worktreeId] = filtered + if (!owningWorktreeId || !closedWorkspace) { + return current } - } - if (!owningWorktreeId || !closedWorkspace) { - return s - } - const closedPages = s.browserPagesByWorkspace[tabId] ?? [] - const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } - delete nextBrowserPagesByWorkspace[tabId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - for (const page of closedPages) { - delete nextBrowserAnnotationsByPageId[page.id] - delete nextBrowserAnnotationMarkerIdsByPageId[page.id] - delete nextBrowserCertificateFailuresByPageId[page.id] - } - docPageIdsToRelease = closedPages.filter((page) => page.docLocation).map((page) => page.id) - remotePagesToClose = isCleanup - ? [] - : closedPages.flatMap((page) => { - const handle = s.remoteBrowserPageHandlesByPageId[page.id] - return handle ? [{ worktreeId: page.worktreeId, handle }] : [] - }) - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - for (const page of closedPages) { - delete nextRemoteBrowserPageHandlesByPageId[page.id] - } - - const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { - const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) - nextActiveBrowserTabIdByWorktree[owningWorktreeId] = - (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) - ? neighborId - : remainingBrowserTabs[0]?.id) ?? null - } - - const nextTabBarOrder = { - ...s.tabBarOrderByWorktree, - [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( - (entryId) => entryId !== tabId - ) - } - - const isActiveTabInOwningWorktree = - s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId - if (isActiveTabInOwningWorktree) { - activeBrowserWorktreeIdToNotify = owningWorktreeId - } - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - let nextActiveTabType = s.activeTabType - if (remainingBrowserTabs.length === 0) { - const fallbackTabType = getFallbackTabTypeForWorktree( - owningWorktreeId, - s.openFiles, - s.tabsByWorktree - ) - nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType - if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { - nextActiveTabType = fallbackTabType + const closedPages = s.browserPagesByWorkspace[tabId] ?? [] + const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } + delete nextBrowserPagesByWorkspace[tabId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + for (const page of closedPages) { + delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserAnnotationMarkerIdsByPageId[page.id] + delete nextBrowserCertificateFailuresByPageId[page.id] + } + docPageIdsToRelease = closedPages + .filter((page) => page.docLocation) + .map((page) => page.id) + remotePagesToClose = isCleanup + ? [] + : closedPages.flatMap((page) => { + const handle = s.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [{ worktreeId: page.worktreeId, handle }] : [] + }) + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + for (const page of closedPages) { + delete nextRemoteBrowserPageHandlesByPageId[page.id] } - } - const nextRecentlyClosedBrowserTabsByWorktree = { ...s.recentlyClosedBrowserTabsByWorktree } - if (!isCleanup) { - const existingSnapshots = nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] - const position = getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) - nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ - { - workspace: closedWorkspace, - pages: closedPages, - ...(position ? { position } : {}) - }, - ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) - ].slice(0, 10) - } - const nextRecentlyClosedTabKindsByWorktree = isCleanup - ? s.recentlyClosedTabKindsByWorktree - : pushRecentlyClosedTabKind( - s.recentlyClosedTabKindsByWorktree, + const remainingBrowserTabs = nextBrowserTabsByWorktree[owningWorktreeId] ?? [] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + if (nextActiveBrowserTabIdByWorktree[owningWorktreeId] === tabId) { + const neighborId = pickNeighbor(s.tabBarOrderByWorktree[owningWorktreeId] ?? [], tabId) + nextActiveBrowserTabIdByWorktree[owningWorktreeId] = + (neighborId && remainingBrowserTabs.some((tab) => tab.id === neighborId) + ? neighborId + : remainingBrowserTabs[0]?.id) ?? null + } + + const nextTabBarOrder = { + ...s.tabBarOrderByWorktree, + [owningWorktreeId]: (s.tabBarOrderByWorktree[owningWorktreeId] ?? []).filter( + (entryId) => entryId !== tabId + ) + } + + const isActiveTabInOwningWorktree = + s.activeWorktreeId === owningWorktreeId && s.activeBrowserTabId === tabId + if (isActiveTabInOwningWorktree) { + activeBrowserWorktreeIdToNotify = owningWorktreeId + } + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + let nextActiveTabType = s.activeTabType + if (remainingBrowserTabs.length === 0) { + const fallbackTabType = getFallbackTabTypeForWorktree( owningWorktreeId, - 'browser' + s.openFiles, + s.tabsByWorktree ) + nextActiveTabTypeByWorktree[owningWorktreeId] = fallbackTabType + if (isActiveTabInOwningWorktree && s.activeTabType === 'browser') { + nextActiveTabType = fallbackTabType + } + } - const nextRecentlyClosedBrowserPagesByWorkspace = { - ...s.recentlyClosedBrowserPagesByWorkspace - } - delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] + const nextRecentlyClosedBrowserTabsByWorktree = { + ...s.recentlyClosedBrowserTabsByWorktree + } + if (!isCleanup) { + const existingSnapshots = + nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] ?? [] + const position = drawn + ? drawn.position + : getRecentlyClosedTabPosition(s, owningWorktreeId, tabId) + nextRecentlyClosedBrowserTabsByWorktree[owningWorktreeId] = [ + { + workspace: closedWorkspace, + pages: closedPages, + ...(position ? { position } : {}) + }, + ...existingSnapshots.filter((entry) => entry.workspace.id !== closedWorkspace.id) + ].slice(0, 10) + } + const nextRecentlyClosedTabKindsByWorktree = isCleanup + ? s.recentlyClosedTabKindsByWorktree + : pushRecentlyClosedTabKind( + s.recentlyClosedTabKindsByWorktree, + owningWorktreeId, + 'browser' + ) - // Why omit rather than rebuild: only the closed ids can match, so this touches the - // closed pages instead of every pending entry and keeps the record identity when none did. - const closedPageIds = closedPages.map((page) => page.id) - const nextPendingAddressBarFocusByPageId = omitRecordKeys( - s.pendingAddressBarFocusByPageId, - closedPageIds - ) - const nextPendingAddressBarFocusByTabId = omitRecordKeys(s.pendingAddressBarFocusByTabId, [ - ...closedPageIds, - tabId - ]) + const nextRecentlyClosedBrowserPagesByWorkspace = { + ...s.recentlyClosedBrowserPagesByWorkspace + } + delete nextRecentlyClosedBrowserPagesByWorkspace[tabId] - return { - browserTabsByWorktree: nextBrowserTabsByWorktree, - browserPagesByWorkspace: nextBrowserPagesByWorkspace, - activeBrowserTabId: - s.activeBrowserTabId === tabId - ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) - : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, - tabBarOrderByWorktree: nextTabBarOrder, - activeTabType: nextActiveTabType, - pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, - recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, - recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, - browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId - } - }) + // Why omit rather than rebuild: only the closed ids can match, so this touches the + // closed pages instead of every pending entry and keeps the record identity when none did. + const closedPageIds = closedPages.map((page) => page.id) + const nextPendingAddressBarFocusByPageId = omitRecordKeys( + s.pendingAddressBarFocusByPageId, + closedPageIds + ) + const nextPendingAddressBarFocusByTabId = omitRecordKeys( + s.pendingAddressBarFocusByTabId, + [...closedPageIds, tabId] + ) - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents - ) - } + return { + browserTabsByWorktree: nextBrowserTabsByWorktree, + browserPagesByWorkspace: nextBrowserPagesByWorkspace, + activeBrowserTabId: + s.activeBrowserTabId === tabId + ? (nextActiveBrowserTabIdByWorktree[owningWorktreeId] ?? null) + : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrder, + activeTabType: nextActiveTabType, + pendingAddressBarFocusByPageId: nextPendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: nextPendingAddressBarFocusByTabId, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, + recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + } + }) - for (const docPageId of docPageIdsToRelease) { - releaseDocPreviewGrant(docPageId) - } - - for (const tabs of Object.values(get().unifiedTabsByWorktree)) { - const workspaceItem = tabs.find( - (entry) => entry.contentType === 'browser' && entry.entityId === tabId - ) - // Switch point: main records the close while the tab-bar entry still names the tab. - if (ORCA_LAYOUT_OWNER_IS_MAIN && workspaceItem) { - recordBrowserTabClose( - get(), - workspaceItem.worktreeId, - tabId, - isCleanup ? 'cleanup' : 'user' + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents ) } - if (workspaceItem) { - get().closeUnifiedTab( - workspaceItem.id, - isCleanup - ? { preserveWorktreeSelection: true, recordInteraction: false } - : options?.preserveWorktreeSelection - ? { preserveWorktreeSelection: true } - : undefined - ) - } - } - // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. - if (activeBrowserWorktreeIdToNotify) { - const state = get() - const activeWorkspaceId = - state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] - const activeWorkspace = activeWorkspaceId - ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) - : null - const activePage = activeWorkspace?.activePageId - ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( - (page) => page.id === activeWorkspace.activePageId + for (const docPageId of docPageIdsToRelease) { + releaseDocPreviewGrant(docPageId) + } + + // Main's frame already dropped a drawn tab's entry. + for (const tabs of drawn ? [] : Object.values(get().unifiedTabsByWorktree)) { + const workspaceItem = tabs.find( + (entry) => entry.contentType === 'browser' && entry.entityId === tabId + ) + if (workspaceItem) { + get().closeUnifiedTab( + workspaceItem.id, + isCleanup + ? { preserveWorktreeSelection: true, recordInteraction: false } + : options?.preserveWorktreeSelection + ? { preserveWorktreeSelection: true } + : undefined ) - : undefined - if ( - activeWorkspace?.activePageId && - isLocalBrowserPageOwner( - state, - activeBrowserWorktreeIdToNotify, - activePage?.browserRuntimeEnvironmentId - ) && - typeof window !== 'undefined' && - window.api?.browser - ) { - window.api.browser - .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) - .catch(() => {}) + } + } + + // Why: announce the MRU page before guest teardown so bridge fallback cannot choose registration order. + if (activeBrowserWorktreeIdToNotify) { + const state = get() + const activeWorkspaceId = + state.activeBrowserTabIdByWorktree[activeBrowserWorktreeIdToNotify] + const activeWorkspace = activeWorkspaceId + ? findWorkspace(state.browserTabsByWorktree, activeWorkspaceId) + : null + const activePage = activeWorkspace?.activePageId + ? (state.browserPagesByWorkspace[activeWorkspace.id] ?? []).find( + (page) => page.id === activeWorkspace.activePageId + ) + : undefined + if ( + activeWorkspace?.activePageId && + isLocalBrowserPageOwner( + state, + activeBrowserWorktreeIdToNotify, + activePage?.browserRuntimeEnvironmentId + ) && + typeof window !== 'undefined' && + window.api?.browser + ) { + window.api.browser + .notifyActiveTabChanged({ browserPageId: activeWorkspace.activePageId }) + .catch(() => {}) + } } } + // Switch point: main closes the tab first; today's close then runs over the tab as drawn. + const owningWorktreeId = ORCA_LAYOUT_OWNER_IS_MAIN + ? findWorkspace(get().browserTabsByWorktree, tabId)?.worktreeId + : undefined + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + closeBrowserTabThroughLayoutOwner( + get, + tabId, + isCleanup ? 'cleanup' : 'user', + (drawn) => closeHere(drawn), + // As today's tab-bar close: a cleanup keeps the selection and counts no interaction. + isCleanup || options?.preserveWorktreeSelection || !owningWorktreeId + ? undefined + : () => landOnEmptiedWorktree(set, get, owningWorktreeId) + ) + ) { + if (!isCleanup) { + get().recordFeatureInteraction?.('terminal-tabs') + } + return + } + closeHere() }, shutdownWorktreeBrowsers: (worktreeId, options) => { diff --git a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts index 5dd58f5f382..73493b750bf 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-actions.ts @@ -8,7 +8,7 @@ import { projectHydratedBrowserSession } from './browser-hydration-state' import { ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' import { findWorkspace } from '../browser-page-records' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { recordLayoutApply } from '@/runtime/layout-apply-send' +import { sendBrowserLayoutCommand } from './browser-layout-owner-commands' import { selectFolderWorkspaces } from '../../workspace-record-selectors' export function createBrowserHydrationActions( @@ -76,38 +76,48 @@ export function createBrowserHydrationActions( }, switchBrowserTabProfile: (workspaceId, profileId, sessionPartition) => { - set((s) => { - for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { - const tabIndex = tabs.findIndex((t) => t.id === workspaceId) - if (tabIndex !== -1) { - const updatedTabs = [...tabs] - updatedTabs[tabIndex] = { - ...updatedTabs[tabIndex], - sessionProfileId: profileId, - sessionPartition: sessionPartition ?? null - } - return { - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [worktreeId]: updatedTabs + const switchHere = (): void => + set((s) => { + for (const [worktreeId, tabs] of Object.entries(s.browserTabsByWorktree)) { + const tabIndex = tabs.findIndex((t) => t.id === workspaceId) + if (tabIndex !== -1) { + const updatedTabs = [...tabs] + updatedTabs[tabIndex] = { + ...updatedTabs[tabIndex], + sessionProfileId: profileId, + sessionPartition: sessionPartition ?? null + } + return { + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [worktreeId]: updatedTabs + } } } } - } - return s - }) - // Switch point: main records the tab's new profile. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - const tab = findWorkspace(get().browserTabsByWorktree, workspaceId) - if (tab) { - recordLayoutApply(get(), tab.worktreeId, { + return s + }) + // Switch point: main switches the tab's profile; its frame shows the change. + const tab = ORCA_LAYOUT_OWNER_IS_MAIN + ? findWorkspace(get().browserTabsByWorktree, workspaceId) + : null + if ( + tab && + sendBrowserLayoutCommand( + get, + tab.worktreeId, + { type: 'setBrowserTabProfile', browserTabId: workspaceId, profileId, sessionPartition: sessionPartition ?? null - }) - } + }, + { here: switchHere } + ) + ) { + return } + switchHere() } } } diff --git a/src/renderer/src/store/slices/browser/browser-hydration-state.ts b/src/renderer/src/store/slices/browser/browser-hydration-state.ts index 1e069116ff5..93e99c902f1 100644 --- a/src/renderer/src/store/slices/browser/browser-hydration-state.ts +++ b/src/renderer/src/store/slices/browser/browser-hydration-state.ts @@ -23,7 +23,9 @@ export function projectHydratedBrowserSession( | 'tabsByWorktree' >, session: WorkspaceSessionState, - validWorktreeIds: ReadonlySet + validWorktreeIds: ReadonlySet, + /** A workspace with no remembered browser tab keeps none, instead of restoring its first. */ + options: { unchosenStaysUnset?: true } = {} ) { const persistedTabsByWorktree = session.browserTabsByWorktree ?? {} const persistedPagesByWorkspace = session.browserPagesByWorkspace ?? {} @@ -113,7 +115,9 @@ export function projectHydratedBrowserSession( activeBrowserTabIdByWorktree[worktreeId] = persistedTabId && validBrowserTabIds.has(persistedTabId) ? persistedTabId - : (tabs[0]?.id ?? null) + : !persistedTabId && options.unchosenStaysUnset + ? null + : (tabs[0]?.id ?? null) } const activeWorktreeId = s.activeWorktreeId diff --git a/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts b/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts index 02af41e8a13..4fd33a0c202 100644 --- a/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts +++ b/src/renderer/src/store/slices/browser/browser-layout-owner-commands.ts @@ -1,56 +1,52 @@ -// Browser tab and page changes in a layout-owner build (phase C5). The window still draws first -// and saves its whole copy; main records each change under the window's own ids, so a later -// command naming the tab or page finds it. Nothing to undo on any answer: the window's next save -// carries the change when main cannot take it. +// A browser page create, close, convert or profile switch in a layout-owner build: the window sends +// the command first and changes nothing until main answers. Main's frame shows the tab's own +// fields (its pages, its profile); the window then writes what only it holds (each page's state). +// A refusal leaves the tab as it was. -import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types' -import { recordLayoutApply, type sendLayoutApply } from '@/runtime/layout-apply-send' -import { drawnTabPlacement } from '@/runtime/layout-owner-drawn-tab-placement' -import type { AppState } from '../../types' +import { sendLayoutApply, type WindowLayoutCommand } from '@/runtime/layout-apply-send' +import { beginPendingTab } from '@/runtime/layout-owner-pending-tabs' +import type { BrowserSliceGet } from './browser-slice-contract' -type BrowserCommandState = Parameters[0] & - Pick +type BrowserLayoutCommand = Extract< + WindowLayoutCommand, + { type: 'openBrowserPage' | 'closeBrowserPage' | 'replaceBrowserPage' | 'setBrowserTabProfile' } +> -/** The open of a browser tab the window just drew, placed where the window drew it. */ -export function recordBrowserTabOpen(state: BrowserCommandState, tab: BrowserWorkspace): void { - const entry = browserTabEntry(state, tab.worktreeId, tab.id) - const pageId = tab.pageIds?.[0] - if (!entry || !pageId) { - return - } - recordLayoutApply(state, tab.worktreeId, { - type: 'openBrowserTab', - tabId: entry.id, - browserTabId: tab.id, - pageId, - profileId: tab.sessionProfileId ?? null, - sessionPartition: tab.sessionPartition ?? null, - // Open to the side: main makes the window's new split with the tab. - ...drawnTabPlacement(state, tab.worktreeId, entry.groupId, entry.id), - createdAt: entry.createdAt, - browserCreatedAt: tab.createdAt - }) -} - -/** The close of a browser tab whose tab-bar entry the window is about to remove. */ -export function recordBrowserTabClose( - state: BrowserCommandState, +/** + * False when main is not asked (a paired server owns the workspace's layout): the caller writes + * the change as today. `here` is today's write, for a twin main may route to a paired server; + * `shown` runs once main's frame is in. + */ +export function sendBrowserLayoutCommand( + get: BrowserSliceGet, worktreeId: string, - browserTabId: string, - reason: 'user' | 'cleanup' -): void { - const entry = browserTabEntry(state, worktreeId, browserTabId) - if (entry) { - recordLayoutApply(state, worktreeId, { - type: 'closeTabs', - tabIds: [entry.id], - recordClose: reason - }) + command: BrowserLayoutCommand, + write: { here: () => void; shown?: () => void } +): boolean { + // A page being added is pending on its tab: a caller acting on the tab's pages waits for it. + const settle = + command.type === 'openBrowserPage' + ? beginPendingTab(command.pageId, worktreeId, command.browserTabId, { kind: 'open' }) + : () => {} + const sent = sendLayoutApply(get(), worktreeId, command, { + apply: () => { + try { + write.shown?.() + } finally { + settle(command.browserTabId) + } + }, + asToday: () => { + try { + write.here() + } finally { + settle(command.browserTabId) + } + }, + refused: () => settle(null) + }) + if (!sent) { + settle(null) } -} - -function browserTabEntry(state: BrowserCommandState, worktreeId: string, browserTabId: string) { - return state.unifiedTabsByWorktree[worktreeId]?.find( - (entry) => entry.contentType === 'browser' && entry.entityId === browserTabId - ) + return sent } diff --git a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts index 59aca0e2cc1..b267728534d 100644 --- a/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-conversion-actions.ts @@ -13,7 +13,7 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' import { isLocalBrowserPageOwner } from './browser-host-state' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { recordLayoutApply } from '@/runtime/layout-apply-send' +import { sendBrowserLayoutCommand } from './browser-layout-owner-commands' export function createBrowserPageConversionActions( set: BrowserSliceSet, @@ -40,102 +40,122 @@ export function createBrowserPageConversionActions( ? runtimeEnvironmentIdOrLocalUnlessTwin(get(), oldPageForOwnership.worktreeId) : null ) - let converted: BrowserPage | null = null - let docPageIdToRelease: string | null = null - let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null - set((s) => { - const plan = planBrowserPageConversion(s, pageId, target, options) - if (!plan) { - return s - } - converted = plan.newPage - // Why collected rather than released inside the reducer: revoking is main-process work, and - // it must happen exactly once even if a later set() retries the reducer. - if (plan.oldPage.docLocation) { - docPageIdToRelease = plan.oldPage.id - } - const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] - if (remoteHandle) { - remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } - } - const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } - delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } - delete nextBrowserAnnotationsByPageId[plan.oldPage.id] - delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id] - const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } - delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [plan.workspace.id]: plan.nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [plan.workspace.worktreeId]: ( - s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] - ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== plan.oldPage.id - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, - browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId - } - }) - // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does - // not track, so the initializers' null narrowing would otherwise read these as never. - const newPage = converted as BrowserPage | null - const remoteClose = remotePageToClose as { - worktreeId: string - handle: RemoteBrowserPageHandle - } | null - if (!newPage) { - return null - } - if (remoteClose) { - closeRemoteBrowserPageInOwningEnvironment( - remoteClose.worktreeId, - remoteClose.handle, - get().recordClientHostedBrowserCloseIntents - ) - } - // Why after the reducer: a closed document must stop being readable, and the grant is the only - // authority the preview scheme honors — but the store row has to stop naming it first. - if (docPageIdToRelease) { - releaseDocPreviewGrant(docPageIdToRelease) - } - const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) - // Switch point: main puts the new page in the old one's place. - if (ORCA_LAYOUT_OWNER_IS_MAIN && workspaceAfter) { - recordLayoutApply(get(), workspaceAfter.worktreeId, { - type: 'replaceBrowserPage', - browserTabId: workspaceAfter.id, - pageId, - newPageId: newPage.id + const convertHere = (newPageId?: string): BrowserPage | null => { + let converted: BrowserPage | null = null + let docPageIdToRelease: string | null = null + let remotePageToClose: { worktreeId: string; handle: RemoteBrowserPageHandle } | null = null + set((s) => { + const plan = planBrowserPageConversion(s, pageId, target, { + ...options, + ...(newPageId ? { newPageId } : {}) + }) + if (!plan) { + return s + } + converted = plan.newPage + // Why collected rather than released inside the reducer: revoking is main-process work, and + // it must happen exactly once even if a later set() retries the reducer. + if (plan.oldPage.docLocation) { + docPageIdToRelease = plan.oldPage.id + } + const remoteHandle = s.remoteBrowserPageHandlesByPageId[plan.oldPage.id] + if (remoteHandle) { + remotePageToClose = { worktreeId: plan.oldPage.worktreeId, handle: remoteHandle } + } + const nextRemoteBrowserPageHandlesByPageId = { ...s.remoteBrowserPageHandlesByPageId } + delete nextRemoteBrowserPageHandlesByPageId[plan.oldPage.id] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } + delete nextBrowserAnnotationsByPageId[plan.oldPage.id] + delete nextBrowserAnnotationMarkerIdsByPageId[plan.oldPage.id] + const nextBrowserCertificateFailuresByPageId = { ...s.browserCertificateFailuresByPageId } + delete nextBrowserCertificateFailuresByPageId[plan.oldPage.id] + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [plan.workspace.id]: plan.nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [plan.workspace.worktreeId]: ( + s.browserTabsByWorktree[plan.workspace.worktreeId] ?? [] + ).map((tab) => (tab.id === plan.workspace.id ? plan.nextWorkspace : tab)) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== plan.oldPage.id + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + } }) - } - if (workspaceAfter?.activePageId === newPage.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find( - (entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId - ) - if (item) { - get().setTabLabel(item.id, newPage.title) + // Why the casts: the assignments happen inside set()'s callback, which TS's flow analysis does + // not track, so the initializers' null narrowing would otherwise read these as never. + const newPage = converted as BrowserPage | null + const remoteClose = remotePageToClose as { + worktreeId: string + handle: RemoteBrowserPageHandle + } | null + if (!newPage) { + return null } + if (remoteClose) { + closeRemoteBrowserPageInOwningEnvironment( + remoteClose.worktreeId, + remoteClose.handle, + get().recordClientHostedBrowserCloseIntents + ) + } + // Why after the reducer: a closed document must stop being readable, and the grant is the only + // authority the preview scheme honors — but the store row has to stop naming it first. + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + const workspaceAfter = findWorkspace(get().browserTabsByWorktree, newPage.workspaceId) + if (workspaceAfter?.activePageId === newPage.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find( + (entry) => entry.contentType === 'browser' && entry.entityId === newPage.workspaceId + ) + if (item) { + get().setTabLabel(item.id, newPage.title) + } + } + return newPage } - return newPage + // Switch point: main puts the new page in the old one's place; the page shows once it did. + const sentPlan = ORCA_LAYOUT_OWNER_IS_MAIN + ? planBrowserPageConversion(get(), pageId, target, options) + : null + if ( + sentPlan && + sendBrowserLayoutCommand( + get, + sentPlan.workspace.worktreeId, + { + type: 'replaceBrowserPage', + browserTabId: sentPlan.workspace.id, + pageId, + newPageId: sentPlan.newPage.id + }, + { + here: () => convertHere(sentPlan.newPage.id), + shown: () => convertHere(sentPlan.newPage.id) + } + ) + ) { + return sentPlan.newPage + } + return convertHere() }, reopenClosedBrowserPage: (workspaceId) => { diff --git a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts index 03ec28ddf11..6c92fbd1ff4 100644 --- a/src/renderer/src/store/slices/browser/browser-page-create-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-page-create-actions.ts @@ -15,7 +15,7 @@ import { closeRemoteBrowserPageInOwningEnvironment } from './browser-remote-clos import { releaseDocPreviewGrant } from '@/lib/doc-preview-grants' import { admitBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { recordLayoutApply } from '@/runtime/layout-apply-send' +import { sendBrowserLayoutCommand } from './browser-layout-owner-commands' export function createBrowserPageCreateActions( set: BrowserSliceSet, @@ -39,189 +39,212 @@ export function createBrowserPageCreateActions( if (!options?.browserRuntimeEnvironmentId && !options?.docLocation) { admitBrowserPageMount(page.id) } - - set((s) => { - const pages = s.browserPagesByWorkspace[workspaceId] ?? [] - const shouldActivate = options?.activate ?? true - const nextPages = [...pages, page] - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: shouldActivate ? page.id : (workspace.activePageId ?? page.id), - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const shouldUpdateGlobalActiveSurface = - shouldActivate && - s.activeWorktreeId === workspace.worktreeId && - s.activeBrowserTabIdByWorktree[workspace.worktreeId] === workspaceId - const shouldFocusAddressBar = - shouldUpdateGlobalActiveSurface && - !page.docLocation && - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) - - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspaceId]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspaceId ? nextWorkspace : tab) - ) - }, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId + const addPage = (): void => { + // The tab may have closed while main was asked. + const current = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (!current) { + return } - }) + set((s) => { + const pages = s.browserPagesByWorkspace[workspaceId] ?? [] + const shouldActivate = options?.activate ?? true + const nextPages = [...pages, page] + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...current, + activePageId: shouldActivate ? page.id : (current.activePageId ?? page.id), + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const shouldUpdateGlobalActiveSurface = + shouldActivate && + s.activeWorktreeId === current.worktreeId && + s.activeBrowserTabIdByWorktree[current.worktreeId] === workspaceId + const shouldFocusAddressBar = + shouldUpdateGlobalActiveSurface && + !page.docLocation && + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL) - // Switch point: main records the page under its id before any later command names it. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordLayoutApply(get(), workspace.worktreeId, { - type: 'openBrowserPage', - browserTabId: workspaceId, - pageId: page.id + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspaceId]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [current.worktreeId]: (s.browserTabsByWorktree[current.worktreeId] ?? []).map( + (tab) => (tab.id === workspaceId ? nextWorkspace : tab) + ) + }, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } }) - } - const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) - if (nextWorkspace?.activePageId === page.id) { - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) - if (item) { - get().setTabLabel(item.id, page.title) + + const nextWorkspace = findWorkspace(get().browserTabsByWorktree, workspaceId) + if (nextWorkspace?.activePageId === page.id) { + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === workspaceId) + if (item) { + get().setTabLabel(item.id, page.title) + } } } + // Switch point: main adds the page to the tab first; the window shows it once main did. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + sendBrowserLayoutCommand( + get, + workspace.worktreeId, + { type: 'openBrowserPage', browserTabId: workspaceId, pageId: page.id }, + { here: addPage, shown: addPage } + ) + ) { + return page + } + addPage() return page }, closeBrowserPage: (pageId) => { - let closedWorkspaceIdForLabel: string | null = null - let docPageIdToRelease: string | null = null - const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] - set((s) => { - const page = findPage(s.browserPagesByWorkspace, pageId) - if (!page) { - return s - } - const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) - if (!workspace) { - return s - } - closedWorkspaceIdForLabel = page.workspaceId - docPageIdToRelease = page.docLocation ? page.id : null - const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] - const nextPages = currentPages.filter((entry) => entry.id !== pageId) - const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) - const nextActivePageId = - workspace.activePageId === pageId - ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) - : workspace.activePageId - const nextWorkspace = mirrorWorkspaceFromActivePage( - { - ...workspace, - activePageId: nextActivePageId, - pageIds: nextPages.map((entry) => entry.id) - }, - nextPages - ) - const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] - if (remoteHandle) { - remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) - } - const nextRemoteBrowserPageHandlesByPageId = { - ...s.remoteBrowserPageHandlesByPageId - } - delete nextRemoteBrowserPageHandlesByPageId[pageId] - const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } - const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } - delete nextBrowserAnnotationsByPageId[pageId] - delete nextBrowserAnnotationMarkerIdsByPageId[pageId] - const nextBrowserCertificateFailuresByPageId = { - ...s.browserCertificateFailuresByPageId - } - delete nextBrowserCertificateFailuresByPageId[pageId] + const closeHere = (): void => { + let closedWorkspaceIdForLabel: string | null = null + let docPageIdToRelease: string | null = null + const remotePagesToClose: { worktreeId: string; handle: RemoteBrowserPageHandle }[] = [] + set((s) => { + const page = findPage(s.browserPagesByWorkspace, pageId) + if (!page) { + return s + } + const workspace = findWorkspace(s.browserTabsByWorktree, page.workspaceId) + if (!workspace) { + return s + } + closedWorkspaceIdForLabel = page.workspaceId + docPageIdToRelease = page.docLocation ? page.id : null + const currentPages = s.browserPagesByWorkspace[workspace.id] ?? [] + const nextPages = currentPages.filter((entry) => entry.id !== pageId) + const closedIdx = currentPages.findIndex((entry) => entry.id === pageId) + const nextActivePageId = + workspace.activePageId === pageId + ? ((nextPages[closedIdx] ?? nextPages[closedIdx - 1] ?? null)?.id ?? null) + : workspace.activePageId + const nextWorkspace = mirrorWorkspaceFromActivePage( + { + ...workspace, + activePageId: nextActivePageId, + pageIds: nextPages.map((entry) => entry.id) + }, + nextPages + ) + const remoteHandle = s.remoteBrowserPageHandlesByPageId[pageId] + if (remoteHandle) { + remotePagesToClose.push({ worktreeId: page.worktreeId, handle: remoteHandle }) + } + const nextRemoteBrowserPageHandlesByPageId = { + ...s.remoteBrowserPageHandlesByPageId + } + delete nextRemoteBrowserPageHandlesByPageId[pageId] + const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserAnnotationMarkerIdsByPageId = { ...s.browserAnnotationMarkerIdsByPageId } + delete nextBrowserAnnotationsByPageId[pageId] + delete nextBrowserAnnotationMarkerIdsByPageId[pageId] + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + delete nextBrowserCertificateFailuresByPageId[pageId] - return { - browserPagesByWorkspace: { - ...s.browserPagesByWorkspace, - [workspace.id]: nextPages - }, - browserTabsByWorktree: { - ...s.browserTabsByWorktree, - [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( - (tab) => (tab.id === workspace.id ? nextWorkspace : tab) - ) - }, - recentlyClosedBrowserPagesByWorkspace: { - ...s.recentlyClosedBrowserPagesByWorkspace, - [workspace.id]: [ - page, - ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( - (entry) => entry.id !== page.id + return { + browserPagesByWorkspace: { + ...s.browserPagesByWorkspace, + [workspace.id]: nextPages + }, + browserTabsByWorktree: { + ...s.browserTabsByWorktree, + [workspace.worktreeId]: (s.browserTabsByWorktree[workspace.worktreeId] ?? []).map( + (tab) => (tab.id === workspace.id ? nextWorkspace : tab) ) - ].slice(0, 10) - }, - pendingAddressBarFocusByPageId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByPageId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - pendingAddressBarFocusByTabId: Object.fromEntries( - Object.entries(s.pendingAddressBarFocusByTabId).filter( - ([pendingPageId]) => pendingPageId !== pageId - ) - ), - remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, - browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, - browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, - browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + }, + recentlyClosedBrowserPagesByWorkspace: { + ...s.recentlyClosedBrowserPagesByWorkspace, + [workspace.id]: [ + page, + ...(s.recentlyClosedBrowserPagesByWorkspace[workspace.id] ?? []).filter( + (entry) => entry.id !== page.id + ) + ].slice(0, 10) + }, + pendingAddressBarFocusByPageId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByPageId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + pendingAddressBarFocusByTabId: Object.fromEntries( + Object.entries(s.pendingAddressBarFocusByTabId).filter( + ([pendingPageId]) => pendingPageId !== pageId + ) + ), + remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, + browserAnnotationsByPageId: nextBrowserAnnotationsByPageId, + browserAnnotationMarkerIdsByPageId: nextBrowserAnnotationMarkerIdsByPageId + } + }) + + for (const remotePage of remotePagesToClose) { + closeRemoteBrowserPageInOwningEnvironment( + remotePage.worktreeId, + remotePage.handle, + get().recordClientHostedBrowserCloseIntents + ) } - }) - for (const remotePage of remotePagesToClose) { - closeRemoteBrowserPageInOwningEnvironment( - remotePage.worktreeId, - remotePage.handle, - get().recordClientHostedBrowserCloseIntents + if (docPageIdToRelease) { + releaseDocPreviewGrant(docPageIdToRelease) + } + + const closedWorkspaceId = closedWorkspaceIdForLabel + if (!closedWorkspaceId) { + return + } + const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) + const item = Object.values(get().unifiedTabsByWorktree) + .flat() + .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) + if (item && workspace) { + get().setTabLabel(item.id, workspace.title) + } + } + const page = ORCA_LAYOUT_OWNER_IS_MAIN + ? findPage(get().browserPagesByWorkspace, pageId) + : null + const owner = page ? findWorkspace(get().browserTabsByWorktree, page.workspaceId) : null + // Switch point: main drops the page from its tab first; the window closes it once main did. + if ( + page && + owner && + sendBrowserLayoutCommand( + get, + owner.worktreeId, + { type: 'closeBrowserPage', browserTabId: owner.id, pageId }, + { here: closeHere, shown: closeHere } ) - } - - if (docPageIdToRelease) { - releaseDocPreviewGrant(docPageIdToRelease) - } - - const closedWorkspaceId = closedWorkspaceIdForLabel - if (!closedWorkspaceId) { + ) { return } - const workspace = findWorkspace(get().browserTabsByWorktree, closedWorkspaceId) - // Switch point: main drops the page from its tab. - if (ORCA_LAYOUT_OWNER_IS_MAIN && workspace) { - recordLayoutApply(get(), workspace.worktreeId, { - type: 'closeBrowserPage', - browserTabId: closedWorkspaceId, - pageId - }) - } - const item = Object.values(get().unifiedTabsByWorktree) - .flat() - .find((entry) => entry.contentType === 'browser' && entry.entityId === closedWorkspaceId) - if (item && workspace) { - get().setTabLabel(item.id, workspace.title) - } + closeHere() } } } diff --git a/src/renderer/src/store/slices/browser/browser-tab-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-actions.ts index 23cd123ed5d..57d2a14d0a0 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-actions.ts @@ -1,10 +1,11 @@ import { createBrowserUuid } from '@/lib/browser-uuid' import { translate } from '@/i18n/i18n' -import { - FLOATING_TERMINAL_WORKTREE_ID, - ORCA_BROWSER_BLANK_URL -} from '../../../../../shared/constants' -import type { BrowserSlice, BrowserSliceGet, BrowserSliceSet } from './browser-slice-contract' +import type { + BrowserSlice, + BrowserSliceGet, + BrowserSliceSet, + CreateBrowserTabOptions +} from './browser-slice-contract' import { assertManagedBrowserMaterializationAllowed, getClientCreationActionPolicy @@ -18,14 +19,20 @@ import { import { getBrowserSessionProfileHostId } from './browser-host-state' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/worktree-runtime-owner' import { admitBrowserPageMount } from '@/components/browser-pane/host-guest/browser-page-mount-admission' -import { ownsGlobalSelection } from '../../global-selection-owner' import { getRegisteredPairedBrowserTabCreator, loadPairedBrowserTabCreator } from './paired-browser-tab-creator' import { findGroupAndWorktree } from '../tab-group-state' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { recordBrowserTabOpen } from './browser-layout-owner-commands' +import { openBrowserTabThroughLayoutOwner } from './browser-tab-open-through-layout-owner' +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { + FLOATING_TERMINAL_WORKTREE_ID, + ORCA_BROWSER_BLANK_URL +} from '../../../../../shared/constants' +import type { AppState } from '../../types' +import { ownsGlobalSelection } from '../../global-selection-owner' export function createBrowserTabActions( set: BrowserSliceSet, @@ -80,105 +87,56 @@ export function createBrowserTabActions( options?.sessionPartition ) - set((s) => { - const existingTabs = s.browserTabsByWorktree[worktreeId] ?? [] - const nextTabBarOrder = (() => { - const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] - const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) - const editorIds = s.openFiles - .filter((file) => file.worktreeId === worktreeId) - .map((file) => file.id) - const browserIds = existingTabs.map((tab) => tab.id) - const allExistingIds = new Set([...terminalIds, ...editorIds, ...browserIds]) - const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) - const inBase = new Set(base) - for (const entryId of [...terminalIds, ...editorIds, ...browserIds]) { - if (!inBase.has(entryId)) { - base.push(entryId) - inBase.add(entryId) - } - } - base.push(workspaceId) - return base - })() - - const shouldActivate = options?.activate ?? true - const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId) - const shouldFocusFloatingTab = - shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID - const shouldFocusAddressBar = - (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && - // Why the doc check and not just the url: a document page is blank by construction, and - // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. - !page.docLocation && - (options?.focusAddressBar ?? - (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) - - return { + const drawHere = (): void => { + set((s) => ({ browserTabsByWorktree: { ...s.browserTabsByWorktree, - [worktreeId]: [...existingTabs, browserTab] + [worktreeId]: [...(s.browserTabsByWorktree[worktreeId] ?? []), browserTab] }, browserPagesByWorkspace: { ...s.browserPagesByWorkspace, [workspaceId]: [page] }, - tabBarOrderByWorktree: { - ...s.tabBarOrderByWorktree, - [worktreeId]: nextTabBarOrder - }, - activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, - activeBrowserTabIdByWorktree: { - ...s.activeBrowserTabIdByWorktree, - [worktreeId]: shouldActivate - ? workspaceId - : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) - }, - activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, - activeTabTypeByWorktree: shouldActivate - ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } - : s.activeTabTypeByWorktree, - pendingAddressBarFocusByPageId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByPageId, - [page.id]: true - } - : s.pendingAddressBarFocusByPageId, - pendingAddressBarFocusByTabId: shouldFocusAddressBar - ? { - ...s.pendingAddressBarFocusByTabId, - [workspaceId]: true, - [page.id]: true - } - : s.pendingAddressBarFocusByTabId - } - }) + ...browserTabOpenView(s, worktreeId, browserTab, page, options) + })) - const state = get() - const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( - (t) => t.contentType === 'browser' && t.entityId === workspaceId - ) - if (!alreadyHasUnifiedTab) { - const shouldActivate = options?.activate ?? true - const created = state.createUnifiedTab(worktreeId, 'browser', { - entityId: workspaceId, - label: browserTab.title, - targetGroupId: options?.targetGroupId, - ...(options?.split ? { split: options.split } : {}), - ...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}), - // Why no routing-host default: a substituted host would disown the wrapper from its worktree. - ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), - activate: shouldActivate - }) - // Why: unified creation already selected the tab and recorded the visit; only the group moves. - if (shouldActivate && created) { - get().focusGroup(worktreeId, created.groupId) + const state = get() + const alreadyHasUnifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (t) => t.contentType === 'browser' && t.entityId === workspaceId + ) + if (!alreadyHasUnifiedTab) { + const shouldActivate = options?.activate ?? true + const created = state.createUnifiedTab(worktreeId, 'browser', { + entityId: workspaceId, + label: browserTab.title, + targetGroupId: options?.targetGroupId, + ...(options?.split ? { split: options.split } : {}), + ...(options?.afterTabId ? { afterTabId: options.afterTabId } : {}), + // Why no routing-host default: a substituted host would disown the wrapper from its worktree. + ...(options?.executionHostId ? { executionHostId: options.executionHostId } : {}), + activate: shouldActivate + }) + // Why: unified creation already selected the tab and recorded the visit; only the group moves. + if (shouldActivate && created) { + get().focusGroup(worktreeId, created.groupId) + } } } - // Switch point: main records the tab under these ids before any later command names it. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordBrowserTabOpen(get(), browserTab) + // Switch point: main opens the tab under these ids and its frame draws it. + if ( + ORCA_LAYOUT_OWNER_IS_MAIN && + openBrowserTabThroughLayoutOwner(get, { + worktreeId, + browserTab, + page, + options, + showView: () => set((s) => browserTabOpenView(s, worktreeId, browserTab, page, options)), + drawHere + }) + ) { + return browserTab } + drawHere() return browserTab }, @@ -281,3 +239,73 @@ export function createBrowserTabActions( } } } + +// The window's own view of a browser tab it opens: its tab-bar order entry, browser selection and +// the address-bar focus a blank New Tab asks for; a layout-owner build writes it once drawn. +function browserTabOpenView( + s: AppState, + worktreeId: string, + browserTab: BrowserWorkspace, + page: BrowserPage, + options: CreateBrowserTabOptions | undefined +): Partial { + const workspaceId = browserTab.id + // Without this tab: a frame may already have drawn its record. + const existingTabs = (s.browserTabsByWorktree[worktreeId] ?? []).filter( + (tab) => tab.id !== workspaceId + ) + const nextTabBarOrder = (() => { + const currentOrder = s.tabBarOrderByWorktree[worktreeId] ?? [] + const terminalIds = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id) + const editorIds = s.openFiles + .filter((file) => file.worktreeId === worktreeId) + .map((file) => file.id) + const ids = [...terminalIds, ...editorIds, ...existingTabs.map((tab) => tab.id)] + const allExistingIds = new Set(ids) + const base = currentOrder.filter((entryId) => allExistingIds.has(entryId)) + const inBase = new Set(base) + return [...base, ...[...allExistingIds].filter((entryId) => !inBase.has(entryId)), workspaceId] + })() + + const shouldActivate = options?.activate ?? true + const shouldUpdateGlobalActiveSurface = shouldActivate && ownsGlobalSelection(s, worktreeId) + const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID + const shouldFocusAddressBar = + (shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) && + // Why the doc check and not just the url: a document page is blank by construction, and + // the blank url is exactly what marks a New Tab as wanting the address bar it does not have. + !page.docLocation && + (options?.focusAddressBar ?? + (page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL)) + + return { + tabBarOrderByWorktree: { + ...s.tabBarOrderByWorktree, + [worktreeId]: nextTabBarOrder + }, + activeBrowserTabId: shouldUpdateGlobalActiveSurface ? workspaceId : s.activeBrowserTabId, + activeBrowserTabIdByWorktree: { + ...s.activeBrowserTabIdByWorktree, + [worktreeId]: shouldActivate + ? workspaceId + : (s.activeBrowserTabIdByWorktree[worktreeId] ?? null) + }, + activeTabType: shouldUpdateGlobalActiveSurface ? 'browser' : s.activeTabType, + activeTabTypeByWorktree: shouldActivate + ? { ...s.activeTabTypeByWorktree, [worktreeId]: 'browser' } + : s.activeTabTypeByWorktree, + pendingAddressBarFocusByPageId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByPageId, + [page.id]: true + } + : s.pendingAddressBarFocusByPageId, + pendingAddressBarFocusByTabId: shouldFocusAddressBar + ? { + ...s.pendingAddressBarFocusByTabId, + [workspaceId]: true, + [page.id]: true + } + : s.pendingAddressBarFocusByTabId + } +} diff --git a/src/renderer/src/store/slices/browser/browser-tab-close-through-layout-owner.ts b/src/renderer/src/store/slices/browser/browser-tab-close-through-layout-owner.ts new file mode 100644 index 00000000000..3cd1f6d5ff1 --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-close-through-layout-owner.ts @@ -0,0 +1,90 @@ +// A browser tab close in a layout-owner build: main closes the tab-bar entry and its record first, +// and its frame drops both. The window then runs today's close over the tab as it drew it (the +// reopen list, host pages, document grants), since the frame already took the record away. + +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { dedupeTabOrder } from '../../../../../shared/workspace-layout/tab-order' +import type { AppState } from '../../types' +import { pickNextActiveTab } from '../tab-group-state' +import { + getRecentlyClosedTabPosition, + type RecentlyClosedTabPosition +} from '../recently-closed-tab-position' +import { closeContentTabThroughLayoutOwner } from '../tabs/content-tab-close-through-layout-owner' +import type { BrowserSliceGet, RemoteBrowserPageHandle } from './browser-slice-contract' + +/** A browser tab as the window drew it when it asked main to close it. */ +export type DrawnBrowserTab = { + worktreeId: string + record: BrowserWorkspace + pages: BrowserPage[] + handles: Record + /** Where it sat, for the reopen list; the frame has moved the rest on. */ + position: RecentlyClosedTabPosition | undefined +} + +/** `state` with the tab's pages and host handles back as drawn, for today's close to read. */ +export function withDrawnBrowserPages(state: AppState, drawn: DrawnBrowserTab): AppState { + const { record } = drawn + return { + ...state, + browserPagesByWorkspace: { + ...state.browserPagesByWorkspace, + [record.id]: state.browserPagesByWorkspace[record.id] ?? drawn.pages + }, + remoteBrowserPageHandlesByPageId: { + ...drawn.handles, + ...state.remoteBrowserPageHandlesByPageId + } + } +} + +/** + * Sends the close of the browser tab showing `browserTabId`; false when it shows in no tab-bar + * entry or main is not asked (a paired server owns the workspace's layout): close it as today. + * `closeHere` is today's close; given the drawn tab, it runs once main's frame dropped it. + */ +export function closeBrowserTabThroughLayoutOwner( + get: BrowserSliceGet, + browserTabId: string, + reason: 'user' | 'cleanup', + closeHere: (drawn?: DrawnBrowserTab) => void, + landIfEmptied?: () => boolean +): boolean { + const state = get() + const entry = Object.values(state.unifiedTabsByWorktree) + .flat() + .find((tab) => tab.contentType === 'browser' && tab.entityId === browserTabId) + const record = entry + ? state.browserTabsByWorktree[entry.worktreeId]?.find((tab) => tab.id === browserTabId) + : undefined + if (!entry || !record) { + return false + } + const pages = state.browserPagesByWorkspace[browserTabId] ?? [] + const drawn: DrawnBrowserTab = { + worktreeId: entry.worktreeId, + record, + pages, + handles: Object.fromEntries( + pages.flatMap((page) => { + const handle = state.remoteBrowserPageHandlesByPageId[page.id] + return handle ? [[page.id, handle] as const] : [] + }) + ), + position: getRecentlyClosedTabPosition(state, entry.worktreeId, browserTabId) + } + const tabs = state.unifiedTabsByWorktree[entry.worktreeId] ?? [] + const group = state.groupsByWorktree[entry.worktreeId]?.find((g) => g.id === entry.groupId) + const nextActiveTabId = + group?.activeTabId === entry.id + ? pickNextActiveTab(dedupeTabOrder(group.tabOrder), group.recentTabIds, entry.id, tabs) + : (group?.activeTabId ?? null) + return closeContentTabThroughLayoutOwner(get, entry, { + nextActiveTabId, + recordClose: reason, + closeHere: () => closeHere(), + closed: () => closeHere(drawn), + ...(landIfEmptied ? { landIfEmptied } : {}) + }) +} diff --git a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts index a0cbbb2819e..91dcbbac557 100644 --- a/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts +++ b/src/renderer/src/store/slices/browser/browser-tab-focus-actions.ts @@ -7,6 +7,8 @@ import type { import { findWorkspace } from '../browser-page-records' import { restoreRecentlyClosedTabPosition } from '../recently-closed-tabs' import { isLocalBrowserPageOwner } from './browser-host-state' +import type { BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { afterPendingEntityTabs, hasPendingEntityTab } from '@/runtime/layout-owner-pending-tabs' export function createBrowserTabFocusActions( set: BrowserSliceSet, @@ -34,6 +36,10 @@ export function createBrowserTabFocusActions( if (!entryToRestore) { return null } + /** The restored record; a layout-owner build's, before main's frame drew it, as proposed. */ + const restoredRecord = (restored: BrowserWorkspace): BrowserWorkspace | null => + get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? + (hasPendingEntityTab(worktreeId, restored.id) ? restored : null) const snap = entryToRestore.workspace const pages = entryToRestore.pages @@ -49,10 +55,12 @@ export function createBrowserTabFocusActions( ...(snap.docLocation ? { docLocation: snap.docLocation } : {}), targetGroupId: entryToRestore.position?.groupId }) - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - return ( - get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + const position = entryToRestore.position + // A layout-owner build's tab moves once main's frame drew it. + afterPendingEntityTabs(worktreeId, restored.id, () => + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, position) ) + return restoredRecord(restored) } // Why: append remaining pages in original order so multi-page workspaces preserve their page sequence. @@ -67,29 +75,34 @@ export function createBrowserTabFocusActions( ...(firstPage.docLocation ? { docLocation: firstPage.docLocation } : {}) }) - for (const p of restPages) { - get().createBrowserPage(restored.id, p.url, { - activate: false, - title: p.title, - browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, - ...(p.docLocation ? { docLocation: p.docLocation } : {}) - }) - } - - // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. - const activePageId = snap.activePageId - if (activePageId) { - const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] - const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) - const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null - if (targetPage && targetPage.id !== restoredPages[0]?.id) { - get().setActiveBrowserPage(restored.id, targetPage.id) + const position = entryToRestore.position + const selectRestoredPage = (): void => { + // Why: duplicate URLs are valid, so matching by URL can pick the wrong copy; restore preserves order, so map by index. + const activePageId = snap.activePageId + if (activePageId) { + const restoredPages = get().browserPagesByWorkspace[restored.id] ?? [] + const activePageIndex = pages.findIndex((orig) => orig.id === activePageId) + const targetPage = activePageIndex !== -1 ? restoredPages[activePageIndex] : null + if (targetPage && targetPage.id !== restoredPages[0]?.id) { + get().setActiveBrowserPage(restored.id, targetPage.id) + } } + + restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, position) } - - restoreRecentlyClosedTabPosition(get, worktreeId, restored.id, entryToRestore.position) - - return get().browserTabsByWorktree[worktreeId]?.find((tab) => tab.id === restored.id) ?? null + // A layout-owner build adds the pages once main drew the tab, and selects once it added them. + afterPendingEntityTabs(worktreeId, restored.id, () => { + for (const p of restPages) { + get().createBrowserPage(restored.id, p.url, { + activate: false, + title: p.title, + browserRuntimeEnvironmentId: p.browserRuntimeEnvironmentId, + ...(p.docLocation ? { docLocation: p.docLocation } : {}) + }) + } + afterPendingEntityTabs(worktreeId, restored.id, selectRestoredPage) + }) + return restoredRecord(restored) }, setActiveBrowserTab: (tabId, targetWorktreeId) => { diff --git a/src/renderer/src/store/slices/browser/browser-tab-open-through-layout-owner.ts b/src/renderer/src/store/slices/browser/browser-tab-open-through-layout-owner.ts new file mode 100644 index 00000000000..5eddb9edb2c --- /dev/null +++ b/src/renderer/src/store/slices/browser/browser-tab-open-through-layout-owner.ts @@ -0,0 +1,75 @@ +// A browser tab open in a layout-owner build: the window proposes the tab-bar entry, record and +// page ids and sends the open; main's frame draws the tab with the window's page state, and only +// then does the window select it. A caller acting on the record waits with `afterPendingTab`. + +import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { contentTabOpenPlacement } from '../tabs/content-tab-open-placement' +import type { BrowserSliceGet, CreateBrowserTabOptions } from './browser-slice-contract' + +type BrowserTabOpen = { + worktreeId: string + browserTab: BrowserWorkspace + page: BrowserPage + options: CreateBrowserTabOptions | undefined + /** The window's own view of the tab, written once main's frame drew it. */ + showView: () => void + /** Today's draw, for a twin main may route to a paired server. */ + drawHere: () => void +} + +/** False when main is not asked (a paired server owns the workspace's layout): draw as today. */ +export function openBrowserTabThroughLayoutOwner( + get: BrowserSliceGet, + open: BrowserTabOpen +): boolean { + const { worktreeId, browserTab, page, options } = open + const state = get() + const tabId = createBrowserUuid() + const placement = contentTabOpenPlacement(state, worktreeId, tabId, { + targetGroupId: options?.targetGroupId, + afterTabId: options?.afterTabId, + split: options?.split, + executionHostId: options?.executionHostId + }) + if (!placement) { + return false + } + const activate = options?.activate ?? true + return openContentTabThroughLayoutOwner(state, { + worktreeId, + entityId: browserTab.id, + command: { + type: 'openBrowserTab', + tabId, + browserTabId: browserTab.id, + pageId: page.id, + profileId: browserTab.sessionProfileId ?? null, + sessionPartition: browserTab.sessionPartition ?? null, + ...placement + }, + facts: { label: browserTab.title, browser: { record: browserTab, pages: [page] } }, + shown: (shownId) => { + open.showView() + if (!activate) { + return + } + get().activateTab(shownId, { worktreeId }) + const groupId = get().unifiedTabsByWorktree[worktreeId]?.find( + (t) => t.id === shownId + )?.groupId + if (groupId) { + get().focusGroup(worktreeId, groupId) + } + }, + drawHere: () => { + open.drawHere() + return ( + get().unifiedTabsByWorktree[worktreeId]?.find( + (t) => t.contentType === 'browser' && t.entityId === browserTab.id + )?.id ?? null + ) + } + }) +} diff --git a/src/renderer/src/store/slices/content-tabs-from-main-frames.test.ts b/src/renderer/src/store/slices/content-tabs-from-main-frames.test.ts new file mode 100644 index 00000000000..abb69f63efd --- /dev/null +++ b/src/renderer/src/store/slices/content-tabs-from-main-frames.test.ts @@ -0,0 +1,384 @@ +// Browser, chat and simulator tabs in a layout-owner build (ORCA_LAYOUT_OWNER=main): the window +// sends each open, close and page change to main first and draws the tab only from main's frame, +// so nothing shows before main answers and a refused close leaves the tab. Switched off, the +// window draws them itself and sends nothing. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-projection' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' + +const mocks = vi.hoisted((): { store: unknown } => ({ store: null })) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store + } +})) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createStoreCascadesMockApi() + +import { ensureSimulatorTab } from '@/lib/ensure-simulator-tab' +import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab' +import { applyStructuredSessionTabSnapshots } from '@/runtime/local-structured-session-tabs-sync/snapshot-apply' +import { resetLocalStructuredSessionVersionForTests } from '@/runtime/local-structured-session-tabs-sync/inventory-generation-fence' + +const WT = 'repo1::/path/wt1' +const ROOT = 'group-root' + +let store: ReturnType +let main: TabsDrawnFromMain + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + store = createTestStore() + mocks.store = store + seedStore(store, { + worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt1' })] }, + activeWorktreeId: WT, + groupsByWorktree: { [WT]: [{ id: ROOT, worktreeId: WT, activeTabId: null, tabOrder: [] }] }, + activeGroupIdByWorktree: { [WT]: ROOT }, + layoutByWorktree: { [WT]: { type: 'leaf', groupId: ROOT } }, + unifiedTabsByWorktree: { [WT]: [] } + }) + main = await drawTabsFromMain(store) +}) + +afterEach(() => { + main.close() + resetLocalStructuredSessionVersionForTests() +}) + +const tabs = () => store.getState().unifiedTabsByWorktree[WT] ?? [] +const tabOf = (entityId: string) => tabs().find((tab) => tab.entityId === entityId) +const mainTabs = () => layoutApplyTestTransport.mainModel?.workspaces[WT]?.tabs ?? [] + +function hostListing(chats: string[]): RuntimeMobileSessionTabsResult { + return { + worktree: WT, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: chats.map((sessionId) => ({ + type: 'agent-session' as const, + id: `agent-session:${sessionId}`, + title: 'Codex Chat', + sessionId, + agent: 'codex' as const, + isActive: false + })) + } +} + +const TERMINAL = 'terminal-1' + +/** The workspace with one terminal in the root group, drawn from main again. */ +async function redrawWithTerminal(): Promise { + main.close() + seedStore(store, { + tabsByWorktree: { + [WT]: [ + { + id: TERMINAL, + ptyId: null, + worktreeId: WT, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + unifiedTabsByWorktree: { + [WT]: [ + { + id: TERMINAL, + entityId: TERMINAL, + groupId: ROOT, + worktreeId: WT, + contentType: 'terminal', + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + [WT]: [{ id: ROOT, worktreeId: WT, activeTabId: TERMINAL, tabOrder: [TERMINAL] }] + } + }) + main = await drawTabsFromMain(store) +} + +/** Opens with main's answer held back, then answers it; returns the opened entity id. */ +async function openHeld(open: () => string): Promise { + layoutApplyTestTransport.answerWith('pending') + const entityId = open() + await main.settle() + // Nothing drawn before main answers. + expect(tabOf(entityId)).toBeUndefined() + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + return entityId +} + +describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('content tabs drawn from main’s frames', () => { + it('shows a browser tab, its label and page only once main’s frame drew it, then selects it', async () => { + const id = await openHeld( + () => store.getState().createBrowserTab(WT, 'https://example.com/', { title: 'Example' }).id + ) + const entry = tabOf(id)! + expect(entry).toMatchObject({ contentType: 'browser', label: 'Example', groupId: ROOT }) + expect(layoutApplyTestTransport.sentOfType('openBrowserTab')[0]).toMatchObject({ + tabId: entry.id, + browserTabId: id, + groupId: ROOT + }) + const pages = store.getState().browserPagesByWorkspace[id]! + expect(pages.map((page) => page.url)).toEqual(['https://example.com/']) + expect(store.getState().browserTabsByWorktree[WT]?.[0]).toMatchObject({ + id, + pageIds: [pages[0]!.id], + title: 'Example' + }) + expect(store.getState().groupsByWorktree[WT]?.[0]?.activeTabId).toBe(entry.id) + expect(store.getState().activeBrowserTabIdByWorktree[WT]).toBe(id) + }) + + it('closes a browser tab once main did, keeps it on the reopen list, and reopens it', async () => { + const id = store.getState().createBrowserTab(WT, 'https://example.com/', { title: 'Ex' }).id + await main.settle() + layoutApplyTestTransport.answerWith('pending') + store.getState().closeBrowserTab(id) + await main.settle() + expect(tabOf(id)).toBeDefined() + + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + expect(tabOf(id)).toBeUndefined() + expect(store.getState().browserTabsByWorktree[WT] ?? []).toEqual([]) + expect(layoutApplyTestTransport.sentOfType('closeTabs').at(-1)).toMatchObject({ + recordClose: 'user' + }) + expect( + store.getState().recentlyClosedBrowserTabsByWorktree[WT]?.map((entry) => entry.workspace.id) + ).toEqual([id]) + + const reopened = store.getState().reopenClosedBrowserTab(WT) + await main.settle() + expect(reopened).not.toBeNull() + expect(tabOf(reopened!.id)).toMatchObject({ label: 'Ex' }) + expect(store.getState().browserPagesByWorkspace[reopened!.id]?.[0]?.url).toBe( + 'https://example.com/' + ) + }) + + it('keeps a cleanup close off the reopen list, in main and in the window', async () => { + const id = store.getState().createBrowserTab(WT, 'about:blank').id + await main.settle() + store.getState().closeBrowserTab(id, { reason: 'cleanup' }) + await main.settle() + expect(tabOf(id)).toBeUndefined() + expect(layoutApplyTestTransport.sentOfType('closeTabs').at(-1)).toMatchObject({ + recordClose: 'cleanup' + }) + expect(store.getState().recentlyClosedBrowserTabsByWorktree[WT] ?? []).toEqual([]) + }) + + it('adds, converts and closes a page and switches a profile only once main did', async () => { + const id = store.getState().createBrowserTab(WT, 'https://a.test/').id + await main.settle() + layoutApplyTestTransport.answerWith('pending') + const page = store.getState().createBrowserPage(id, 'https://b.test/')! + await main.settle() + expect(store.getState().browserPagesByWorkspace[id]).toHaveLength(1) + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + expect(store.getState().browserPagesByWorkspace[id]?.map((p) => p.id)).toContain(page.id) + expect(layoutApplyTestTransport.sentOfType('openBrowserPage')).toEqual([ + expect.objectContaining({ browserTabId: id, pageId: page.id }) + ]) + + store.getState().closeBrowserPage(page.id) + await main.settle() + expect(store.getState().browserPagesByWorkspace[id]?.map((p) => p.id)).not.toContain(page.id) + + store.getState().switchBrowserTabProfile(id, 'profile-2', 'persist:profile-2') + await main.settle() + expect(store.getState().browserTabsByWorktree[WT]?.[0]).toMatchObject({ + sessionProfileId: 'profile-2', + sessionPartition: 'persist:profile-2' + }) + expect(mainTabs().filter((tab) => tab.kind === 'browser')).toHaveLength(1) + }) + + it('opens and closes a chat tab through main, and keeps one main refused to close', async () => { + const sessionId = await openHeld(() => { + openStructuredAgentSessionProvisionalTab({ + worktreeId: WT, + executionHostId: 'local', + sessionId: 'chat-1', + agent: 'codex' + }) + return 'chat-1' + }) + const chat = tabOf(sessionId)! + expect(chat).toMatchObject({ contentType: 'agent-session', label: expect.any(String) }) + expect(chat.label).not.toBe('') + expect(store.getState().groupsByWorktree[WT]?.[0]?.activeTabId).toBe(chat.id) + + // A deliberate freeze refuses before anything changes, so the tab stays. + layoutApplyTestTransport.answerWith({ refuse: 'wsl_move_in_progress' }) + store.getState().closeUnifiedTab(chat.id) + await main.settle() + expect(tabOf(sessionId)).toBeDefined() + + layoutApplyTestTransport.answerWith('apply') + store.getState().closeUnifiedTab(chat.id) + await main.settle() + expect(tabOf(sessionId)).toBeUndefined() + expect(mainTabs().some((tab) => tab.entityId === sessionId)).toBe(false) + }) + + it('closes a window-only tab of a workspace main does not hold, as main’s tab_not_found does', async () => { + const OTHER = 'repo1::/path/wt2' + const tab = { + id: 'browser-tab-1', + entityId: 'browser-1', + groupId: 'group-other', + worktreeId: OTHER, + contentType: 'browser' as const, + label: 'Page', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + seedStore(store, { + unifiedTabsByWorktree: { ...store.getState().unifiedTabsByWorktree, [OTHER]: [tab] }, + groupsByWorktree: { + ...store.getState().groupsByWorktree, + [OTHER]: [{ id: 'group-other', worktreeId: OTHER, activeTabId: tab.id, tabOrder: [tab.id] }] + } + }) + layoutApplyTestTransport.answerWith({ refuse: 'workspace_not_found' }) + + store.getState().closeUnifiedTab(tab.id) + await main.settle() + + expect(layoutApplyTestTransport.sentOfType('closeTabs')).toEqual([ + expect.objectContaining({ workspace: OTHER, tabIds: [tab.id] }) + ]) + expect(store.getState().unifiedTabsByWorktree[OTHER] ?? []).toEqual([]) + layoutApplyTestTransport.answerWith('apply') + }) + + it('draws a listing’s new chats in order once main places them; the listing sends nothing', async () => { + await redrawWithTerminal() + applyStructuredSessionTabSnapshots([hostListing(['chat-a', 'chat-b'])]) + await main.settle() + expect(layoutApplyTestTransport.sent).toEqual([]) + expect(tabOf('chat-a')).toBeUndefined() + + // Main places listed chats itself (chatTabLayoutCommand): in the active + // group, in listing order. + const commands = ['chat-a', 'chat-b'].map((sessionId) => ({ + type: 'openAgentSessionTab' as const, + workspace: WT, + tabId: structuredAgentSessionTabId(sessionId), + sessionId, + agent: 'codex' as const, + groupId: store.getState().activeGroupIdByWorktree[WT] + })) + for (const command of commands) { + await layoutApplyTestTransport.call({ command }) + } + await main.settle() + expect(tabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b']) + expect(store.getState().groupsByWorktree[WT]?.[0]?.tabOrder).toEqual([ + TERMINAL, + structuredAgentSessionTabId('chat-a'), + structuredAgentSessionTabId('chat-b') + ]) + expect(mainTabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b']) + }) + + it('opens a simulator tab to the right through main and surfaces it once drawn', async () => { + store.getState().createBrowserTab(WT, 'about:blank') + await main.settle() + layoutApplyTestTransport.answerWith('pending') + const tabId = ensureSimulatorTab(WT, { placement: 'rightSplit' })! + // A second ask while main opens it names the same tab. + expect(ensureSimulatorTab(WT, { placement: 'rightSplit' })).toBe(tabId) + await main.settle() + expect(tabs().some((tab) => tab.id === tabId)).toBe(false) + + layoutApplyTestTransport.answerWith('apply') + layoutApplyTestTransport.answerPending() + await main.settle() + const simulator = tabs().find((tab) => tab.id === tabId)! + expect(simulator).toMatchObject({ contentType: 'simulator' }) + expect(simulator.groupId).not.toBe(ROOT) + expect(store.getState().activeGroupIdByWorktree[WT]).toBe(simulator.groupId) + expect(layoutApplyTestTransport.sentOfType('openSimulatorTab')).toHaveLength(1) + + store.getState().closeUnifiedTab(tabId) + await main.settle() + expect(tabs().some((tab) => tab.id === tabId)).toBe(false) + }) +}) + +describe.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)('content tabs with the switch off', () => { + it('draws browser, chat and simulator tabs at once and sends nothing', () => { + const id = store.getState().createBrowserTab(WT, 'https://example.com/').id + openStructuredAgentSessionProvisionalTab({ + worktreeId: WT, + executionHostId: 'local', + sessionId: 'chat-1', + agent: 'codex' + }) + const simulator = ensureSimulatorTab(WT) + expect(tabOf(id)).toBeDefined() + expect(tabOf('chat-1')).toBeDefined() + expect(tabs().some((tab) => tab.id === simulator)).toBe(true) + store.getState().closeBrowserTab(id) + expect(tabOf(id)).toBeUndefined() + expect(layoutApplyTestTransport.sent).toEqual([]) + }) + + it('draws a listing’s new chats itself, in order, and sends nothing', async () => { + await redrawWithTerminal() + applyStructuredSessionTabSnapshots([hostListing(['chat-a', 'chat-b'])]) + expect(tabs().map((tab) => tab.entityId)).toEqual([TERMINAL, 'chat-a', 'chat-b']) + expect(layoutApplyTestTransport.sent).toEqual([]) + }) +}) diff --git a/src/renderer/src/store/slices/content-tabs-paired-server-workspace.test.ts b/src/renderer/src/store/slices/content-tabs-paired-server-workspace.test.ts new file mode 100644 index 00000000000..f2ff177a3b7 --- /dev/null +++ b/src/renderer/src/store/slices/content-tabs-paired-server-workspace.test.ts @@ -0,0 +1,183 @@ +// A workspace a paired server owns (its row names the server, as the app's catalog sets it): its +// browser and chat tabs open and close as today in both builds, and never reach main's layout. +// An SSH worktree is laid out by this machine's main but has no host to run a chat on. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeClientTarget } from '../../../../shared/runtime-client-target' +import type * as RpcClientModule from '@/runtime/runtime-rpc-client' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' +import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' + +const mocks = vi.hoisted(() => ({ + store: ((): unknown => null)(), + hostCalls: ((): { target: RuntimeClientTarget; method: string }[] => [])() +})) + +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + callRuntimeRpc: (target: RuntimeClientTarget, method: string, params?: unknown) => { + if (target.kind === 'local' && method === 'layout.apply') { + return layoutApplyTestTransport.call(params) + } + mocks.hostCalls.push({ target, method }) + return Promise.resolve({ ok: true }) + }, + ensureRuntimeEnvironmentCompatible: vi.fn(async () => undefined) + } +}) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) +vi.mock('@/store', () => ({ + get useAppStore() { + return mocks.store + } +})) +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +createStoreCascadesMockApi() + +import { openStructuredAgentSessionProvisionalTab } from '@/lib/structured-agent-session-provisional-tab' +import { resolveStructuredAgentSessionOwner } from '@/runtime/structured-agent-session-owner' + +const SERVER = 'server-1' +const SERVER_WT = 'repo-s::/srv/app' +const LOCAL_WT = 'repo-l::/work/app' +const SSH_WT = 'repo-h::/home/app' +const SSH_HOST = toSshExecutionHostId('box') + +let store: ReturnType +let main: TabsDrawnFromMain + +function group(worktreeId: string) { + return [{ id: `group-${worktreeId}`, worktreeId, activeTabId: null, tabOrder: [] }] +} + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.hostCalls = [] + store = createTestStore() + mocks.store = store + seedStore(store, { + worktreesByRepo: { + 'repo-s': [ + makeWorktree({ + id: SERVER_WT, + repoId: 'repo-s', + path: '/srv/app', + hostId: toRuntimeExecutionHostId(SERVER) + }) + ], + 'repo-l': [makeWorktree({ id: LOCAL_WT, repoId: 'repo-l', path: '/work/app' })], + 'repo-h': [ + makeWorktree({ id: SSH_WT, repoId: 'repo-h', path: '/home/app', hostId: SSH_HOST }) + ] + }, + activeWorktreeId: SERVER_WT, + groupsByWorktree: { + [SERVER_WT]: group(SERVER_WT), + [LOCAL_WT]: group(LOCAL_WT), + [SSH_WT]: group(SSH_WT) + }, + activeGroupIdByWorktree: { + [SERVER_WT]: `group-${SERVER_WT}`, + [LOCAL_WT]: `group-${LOCAL_WT}`, + [SSH_WT]: `group-${SSH_WT}` + }, + layoutByWorktree: { + [SERVER_WT]: { type: 'leaf', groupId: `group-${SERVER_WT}` }, + [LOCAL_WT]: { type: 'leaf', groupId: `group-${LOCAL_WT}` }, + [SSH_WT]: { type: 'leaf', groupId: `group-${SSH_WT}` } + }, + unifiedTabsByWorktree: { [SERVER_WT]: [], [LOCAL_WT]: [], [SSH_WT]: [] } + }) + main = await drawTabsFromMain(store) +}) + +afterEach(() => { + main.close() +}) + +const tabsOf = (worktreeId: string) => store.getState().unifiedTabsByWorktree[worktreeId] ?? [] +const sentFor = (worktreeId: string) => + layoutApplyTestTransport.sent.filter((command) => command.workspace === worktreeId) + +describe("a paired server's workspace", () => { + it('opens and closes a browser tab without main', async () => { + const browserTab = store.getState().createBrowserTab(SERVER_WT, 'https://example.com') + await main.settle() + const drawn = tabsOf(SERVER_WT).find((tab) => tab.entityId === browserTab.id) + expect(drawn).toMatchObject({ contentType: 'browser' }) + + store.getState().closeBrowserTab(browserTab.id) + await main.settle() + + expect(tabsOf(SERVER_WT).some((tab) => tab.entityId === browserTab.id)).toBe(false) + expect(sentFor(SERVER_WT)).toEqual([]) + }) + + it('opens and closes a chat tab without main', async () => { + const chat = openStructuredAgentSessionProvisionalTab({ + worktreeId: SERVER_WT, + executionHostId: toRuntimeExecutionHostId(SERVER), + sessionId: 'chat-1', + agent: 'codex' + }) + await main.settle() + expect(tabsOf(SERVER_WT).map((tab) => tab.id)).toEqual([chat.id]) + + store.getState().closeUnifiedTab(chat.id) + await main.settle() + + expect(tabsOf(SERVER_WT)).toEqual([]) + expect(sentFor(SERVER_WT)).toEqual([]) + // The close still reaches the server that runs the chat. + await vi.waitFor(() => + expect(mocks.hostCalls).toContainEqual({ + target: { kind: 'environment', environmentId: SERVER }, + method: 'session.tabs.close' + }) + ) + }) + + it.skipIf(!ORCA_LAYOUT_OWNER_IS_MAIN)( + "presence: this machine's workspace sends the same opens and closes to main", + async () => { + const browserTab = store.getState().createBrowserTab(LOCAL_WT, 'https://example.com') + await main.settle() + store.getState().closeBrowserTab(browserTab.id) + const chat = openStructuredAgentSessionProvisionalTab({ + worktreeId: LOCAL_WT, + executionHostId: 'local', + sessionId: 'chat-2', + agent: 'codex' + }) + await main.settle() + store.getState().closeUnifiedTab(chat.id) + await main.settle() + + expect(sentFor(LOCAL_WT).map((command) => command.type)).toEqual([ + 'openBrowserTab', + 'closeTabs', + 'openAgentSessionTab', + 'closeTabs' + ]) + } + ) +}) + +describe('an SSH worktree', () => { + it("has no host to run a chat on, where this machine's worktree has its own", () => { + expect(resolveStructuredAgentSessionOwner(store.getState(), SSH_WT)).toBeNull() + expect(resolveStructuredAgentSessionOwner(store.getState(), LOCAL_WT)).toBe('local') + }) +}) diff --git a/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts b/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts index 419099580d3..f382c3e51b1 100644 --- a/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts +++ b/src/renderer/src/store/slices/editor-tab-layout-owner.test.ts @@ -363,6 +363,29 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('editor tabs drawn from main’s frame expect(store.getState().openFiles.map((file) => file.id)).toContain(a) }) + it('drops a tab’s content when main lost its workspace before the close came in', async () => { + const a = openFile('/a.ts') + await main.settle() + store.getState().setEditorDraft(a, 'draft') + const tabId = tabFor(a)!.id + layoutApplyTestTransport.answerWith((command) => + command.type === 'closeTabs' ? 'pending' : 'apply' + ) + + store.getState().closeFile(a) + // Main drops the tab on its own (its workspace went away), then answers the window's close. + layoutApplyTestTransport.answerWith('apply') + await layoutApplyTestTransport.call({ + command: { type: 'closeTabs', workspace: WT, tabIds: [tabId] } + }) + layoutApplyTestTransport.answerPending({ ok: false, code: 'workspace_not_found' }) + await main.settle() + + expect(tabFor(a)).toBeUndefined() + expect(store.getState().openFiles.map((file) => file.id)).not.toContain(a) + expect(store.getState().editorDrafts[a]).toBeUndefined() + }) + it('reopens a closed tab at its place with one open (index)', async () => { const a = openFile('/a.ts') const b = openFile('/b.ts') diff --git a/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts b/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts index be5d7db397e..f2b6e3c6300 100644 --- a/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts +++ b/src/renderer/src/store/slices/editor/tabs/editor-tab-close-through-layout-owner.ts @@ -1,12 +1,9 @@ -// Editor tab closes in a layout-owner build: main closes the tab and its frame drops it; the window -// changes no tab first, so a close main refuses leaves the tab. Once the frame is in, the group -// shows the tab today's close picks (its previous one). Waiters on the tab learn the outcome. +// Editor tabs whose file record the window dropped, in a layout-owner build: main closes them. +// A single tab's close goes through `closeContentTabThroughLayoutOwner`. -import type { Tab } from '../../../../../../shared/tab-types' import { sendLayoutApply } from '@/runtime/layout-apply-send' import { afterPendingTabs, - beginPendingTab, hasPendingEntityTab, isTabClosePending, markPendingCloseCleansFile, @@ -14,92 +11,8 @@ import { } from '@/runtime/layout-owner-pending-tabs' import type { AppState } from '../../../types' import type { OpenFile } from '../types/open-file' -import { pickNextActiveTab } from '../../tab-group-state' import { isEditorTabContentType } from './editor-tab-content-type' -/** - * Once main's frame dropped the closed tab, its group shows the tab today's close picks (one - * picked before that is itself gone falls back to the group's history), and the workspace's - * surface follows its active group. Nothing while the tab still shows. - */ -function selectAfterClose( - get: () => AppState, - tab: Pick, - nextActiveTabId: string | null -): void { - const state = get() - const worktreeId = tab.worktreeId - if ((state.unifiedTabsByWorktree[worktreeId] ?? []).some((entry) => entry.id === tab.id)) { - return - } - const activeGroupId = state.activeGroupIdByWorktree[worktreeId] - const group = state.groupsByWorktree[worktreeId]?.find((entry) => entry.id === tab.groupId) - const next = - group && nextActiveTabId && group.tabOrder.includes(nextActiveTabId) - ? nextActiveTabId - : group - ? pickNextActiveTab(group.tabOrder, group.recentTabIds, tab.id) - : null - if (next && group?.activeTabId !== next) { - state.activateTab(next, { worktreeId, preservePreview: true, recordFocus: false }) - } - // Activation focuses the tab's group; the user's focused group stays, and shows its surface. - if (activeGroupId) { - get().focusGroup(worktreeId, activeGroupId) - } -} - -/** - * Sends an editor tab's close; false when it is no editor tab or main is not asked (a paired - * server owns the workspace's layout), and the caller closes it as today. `closeHere` is today's - * close, for a twin main may route to a paired server. - */ -export function closeEditorTabThroughLayoutOwner( - get: () => AppState, - tab: Pick, - /** The tab today's close would show next in the group, picked before the close. */ - nextActiveTabId: string | null, - closeHere: () => void -): boolean { - if (!isEditorTabContentType(tab.contentType)) { - return false - } - // Its waiters (closeFile's cleanup) stay on the close already in flight. - if (isTabClosePending(tab.id)) { - return true - } - const settle = beginPendingTab(tab.id, tab.worktreeId, tab.entityId, { kind: 'close' }) - const sent = sendLayoutApply( - get(), - tab.worktreeId, - { type: 'closeTabs', tabIds: [tab.id] }, - { - apply: (result) => { - const closed = result.closed?.includes(tab.id) ?? true - // Waiters first: a closeFile's content cleanup must not undo the selection. - settle(closed ? tab.id : null) - if (closed) { - selectAfterClose(get, tab, nextActiveTabId) - } - }, - asToday: () => { - let closedId: string | null = null - try { - closeHere() - closedId = tab.id - } finally { - settle(closedId) - } - }, - refused: () => settle(null) - } - ) - if (!sent) { - settle(null) - } - return sent -} - /** * closeFile's tabs: main closes each, and the file's content goes once every close is in, unless * one was refused or the file reopened meanwhile. A repeat close joins the one in flight; so does diff --git a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts index ca3c982eeac..349fe77d104 100644 --- a/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts +++ b/src/renderer/src/store/slices/native-chat-launch-draft-teardown.test.ts @@ -6,9 +6,10 @@ * its sibling `nativeChatLaunchPromptByTabId` does — tab close, orphan sweep, * the bulk worktree purge, and the single removeWorktree teardown. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -24,6 +25,18 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const mockApi = { worktrees: { list: vi.fn().mockResolvedValue([]), @@ -85,10 +98,15 @@ function seedDrafts(store: ReturnType): void { } describe('nativeChatLaunchDraftByTabId teardown', () => { + let main: TabsDrawnFromMain | undefined beforeEach(() => { vi.clearAllMocks() mockApi.worktrees.remove.mockResolvedValue(undefined) }) + afterEach(() => { + main?.close() + main = undefined + }) it('closeTab drops the closed tab’s draft only', () => { const store = createTestStore() @@ -241,15 +259,18 @@ describe('nativeChatLaunchDraftByTabId teardown', () => { return chat } - it('closing a structured chat tab keeps its conversation’s unsent draft', () => { + it('closing a structured chat tab keeps its conversation’s unsent draft', async () => { const store = createTestStore() const chat = seedStructuredChat(store) + main = await drawTabsFromMain(store) const conversation = structuredAgentSessionDraftScopeKey('session-1') updateNativeChatComposerDraft(conversation, { text: 'unsent' }, 'immediate') store.getState().closeUnifiedTab(chat.id) + await main.settle() - expect(store.getState().unifiedTabsByWorktree[WT1]).toEqual([]) + // Main's frame drops a workspace with no tabs left; the window keeps an empty list. + expect(store.getState().unifiedTabsByWorktree[WT1] ?? []).toEqual([]) expect(readNativeChatComposerDraft(conversation).text).toBe('unsent') }) diff --git a/src/renderer/src/store/slices/recently-closed-tabs.test.ts b/src/renderer/src/store/slices/recently-closed-tabs.test.ts index 86c391c89d3..22d9d00e494 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.test.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.test.ts @@ -48,6 +48,7 @@ import { restoreRecentlyClosedTabPosition } from './recently-closed-tabs' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' +import { drawTabsFromMain } from './tabs-drawn-from-main.test-fixture' import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture' import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync' import { endLayoutFramePositions } from '@/runtime/layout-frame-position' @@ -440,6 +441,8 @@ describe('restoreRecentlyClosedTabPosition', () => { describe('reopenClosedTab cross-type MRU', () => { it('reopens closed tabs of mixed kinds in most-recent-first order', async () => { const store = makeSeededStore() + // A layout-owner build draws the browser tab from main's frames. + const main = await drawTabsFromMain(store) // Close order: editor → terminal → browser. store.setState({ openFiles: [makeOpenFile({ id: '/repo/a.ts', worktreeId: WT })] }) @@ -450,7 +453,9 @@ describe('reopenClosedTab cross-type MRU', () => { store.getState().closeTab(terminal.id) await layoutApplyTestTransport.settle() const browser = store.getState().createBrowserTab(WT, 'https://example.com', { title: 'Ex' }) + await main.settle() store.getState().closeBrowserTab(browser.id) + await main.settle() expect(store.getState().recentlyClosedTabKindsByWorktree[WT]).toEqual([ 'browser', @@ -460,6 +465,7 @@ describe('reopenClosedTab cross-type MRU', () => { // First press: browser comes back. expect(store.getState().reopenClosedTab(WT)).toBe(true) + await main.settle() expect(store.getState().browserTabsByWorktree[WT]?.[0]?.url).toBe('https://example.com') expect(store.getState().tabsByWorktree[WT] ?? []).toHaveLength(0) @@ -474,6 +480,7 @@ describe('reopenClosedTab cross-type MRU', () => { // Nothing left. expect(store.getState().reopenClosedTab(WT)).toBe(false) + main.close() }) it('skips kind entries whose per-type snapshot has drained', () => { diff --git a/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts b/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts index ef17ec9deb3..a925e1135a9 100644 --- a/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-removal-successor.test.ts @@ -4,6 +4,7 @@ import { installActiveWorktreeRemovalSuccessor } from '@/lib/active-worktree-rem import { createTestStore, makeWorktree, seedStore, TEST_REPO } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' import { clearSshHostWorkspaces } from '@/components/sidebar/ssh-host-remove-workspaces' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const storeAccess = vi.hoisted(() => ({ getState: vi.fn() })) vi.mock('@/store', () => ({ useAppStore: storeAccess })) @@ -20,6 +21,17 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } }) vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorktree: vi.fn() })) +// Main answers the window's browser-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -34,10 +46,13 @@ beforeEach(() => { removeForHost.mockReset().mockResolvedValue(undefined) }) let dispose: (() => void) | null = null +let layoutMain: TabsDrawnFromMain | undefined afterEach(() => { dispose?.() dispose = null + layoutMain?.close() + layoutMain = undefined }) const active = makeWorktree({ id: 'repo1::/active', repoId: 'repo1', hostId: 'ssh:host' }) @@ -83,9 +98,12 @@ describe('deleting the active workspace', () => { it('respects a user closing the last browser tab after startup dropped its row', async () => { const store = setup() + layoutMain = await drawTabsFromMain(store) const tabId = store.getState().createBrowserTab(active.id, 'https://example.com') + await layoutMain.settle() store.setState({ worktreesByRepo: { repo1: [sibling] } }) store.getState().closeBrowserTab(tabId.id) + await layoutMain.settle() await Promise.resolve() expect(store.getState().activeWorktreeId).toBeNull() expect(activateAndRevealWorktree).not.toHaveBeenCalled() diff --git a/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts b/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts index 5f154b41d41..aa5d0fb7e29 100644 --- a/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-split-groups.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, @@ -12,6 +12,7 @@ import { } from './store-test-helpers' import { buildOrphanTerminalCleanupPatch } from './terminal-orphan-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => [])) @@ -36,8 +37,26 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { } }) +// Main answers the window's browser-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const mockApi = createStoreCascadesMockApi() +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + const itSwitchOff = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN) const itSwitchOn = it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN) @@ -197,7 +216,7 @@ describe('setActiveWorktree', () => { expect(s.activeTabId).toBe(terminalTabId) }) - it('promotes the next tab in the focused split into the global active surface on close', () => { + it('promotes the next tab in the focused split into the global active surface on close', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' const terminalTabId = 'terminal-1' @@ -268,8 +287,10 @@ describe('setActiveWorktree', () => { }, activeGroupIdByWorktree: { [wt]: groupId } }) + main = await drawTabsFromMain(store) store.getState().closeBrowserTab(browserTabId) + await main.settle() const s = store.getState() expect(s.groupsByWorktree[wt]?.[0]?.activeTabId).toBe(terminalTabId) diff --git a/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts b/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts index 1ccc070b008..361f408cf75 100644 --- a/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-tab-close.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest' import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, @@ -10,6 +10,7 @@ import { seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' +import { drawTabsFromMain, type TabsDrawnFromMain } from './tabs-drawn-from-main.test-fixture' const mockUnregisterPtyDataHandlers = vi.hoisted(() => vi.fn<() => unknown[]>(() => [])) const mockRestorePtyDataHandlersAfterFailedShutdown = vi.hoisted(() => vi.fn()) @@ -33,8 +34,31 @@ vi.mock('@/lib/agent-status', async (importOriginal) => { } }) +// Main answers the window's browser-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + const mockApi = createStoreCascadesMockApi() +let main: TabsDrawnFromMain | undefined +afterEach(() => { + main?.close() + main = undefined +}) + +/** Every change is drawn once this resolves; at once with the switch off. */ +async function settled(): Promise { + await main?.settle() +} + describe('setActiveWorktree', () => { beforeEach(() => { vi.clearAllMocks() @@ -394,7 +418,7 @@ describe('setActiveWorktree', () => { expect(s.activeFileId).toBe(fileId) }) - it('does not switch the global surface when creating a browser tab for a background worktree', () => { + it('does not switch the global surface when creating a browser tab for a background worktree', async () => { const store = createTestStore() const activeWt = 'repo1::/path/wt1' const backgroundWt = 'repo1::/path/wt2' @@ -413,10 +437,12 @@ describe('setActiveWorktree', () => { [backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })] } }) + main = await drawTabsFromMain(store) const browserTab = store .getState() .createBrowserTab(backgroundWt, 'https://example.com', { activate: true }) + await settled() const s = store.getState() expect(s.activeTabType).toBe('terminal') @@ -424,7 +450,7 @@ describe('setActiveWorktree', () => { expect(s.activeBrowserTabIdByWorktree[backgroundWt]).toBe(browserTab.id) }) - it('uses unified MRU selection when closing an active browser tab', () => { + it('uses unified MRU selection when closing an active browser tab', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -434,20 +460,27 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: wt }) + main = await drawTabsFromMain(store) const previous = store.getState().createBrowserTab(wt, 'https://previous.example.com') + await settled() store.getState().createBrowserTab(wt, 'https://neighbor.example.com') + await settled() const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com') + await settled() store.getState().setActiveBrowserTab(previous.id) + await settled() store.getState().setActiveBrowserTab(closing.id) + await settled() store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeBrowserTabId).toBe(previous.id) expect(store.getState().activeBrowserTabIdByWorktree[wt]).toBe(previous.id) }) - it('keeps the unified MRU target when closing the last browser tab', () => { + it('keeps the unified MRU target when closing the last browser tab', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -457,18 +490,23 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: wt }) + main = await drawTabsFromMain(store) store.getState().createTab(wt) + await settled() const previous = store.getState().createTab(wt) + await settled() const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com') + await settled() store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeTabId).toBe(previous.id) expect(store.getState().activeTabType).toBe('terminal') }) - it('keeps a valid browser target for an inactive worktree after close', () => { + it('keeps a valid browser target for an inactive worktree after close', async () => { const store = createTestStore() const activeWt = 'repo1::/path/active' const backgroundWt = 'repo1::/path/background' @@ -482,23 +520,30 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: activeWt }) + main = await drawTabsFromMain(store) const previous = store .getState() .createBrowserTab(backgroundWt, 'https://previous.example.com', { activate: false }) + await settled() const closing = store.getState().createBrowserTab(backgroundWt, 'https://closing.example.com', { activate: false }) + await settled() store.setState({ activeBrowserTabIdByWorktree: { [backgroundWt]: closing.id } }) + // Main loads the window's edited layout, as it would from a saved session. + main?.close() + main = await drawTabsFromMain(store) store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeBrowserTabIdByWorktree[backgroundWt]).toBe(previous.id) }) - it('keeps the global browser target when closing a legacy tab without a unified wrapper', () => { + it('keeps the global browser target when closing a legacy tab without a unified wrapper', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -508,25 +553,32 @@ describe('setActiveWorktree', () => { }, activeWorktreeId: wt }) + main = await drawTabsFromMain(store) const previous = store.getState().createBrowserTab(wt, 'https://previous.example.com', { activate: false }) + await settled() const closing = store.getState().createBrowserTab(wt, 'https://closing.example.com', { activate: false }) + await settled() store.setState({ unifiedTabsByWorktree: {}, activeBrowserTabId: closing.id, activeBrowserTabIdByWorktree: { [wt]: closing.id } }) + // Main loads the window's edited layout, as it would from a saved session. + main?.close() + main = await drawTabsFromMain(store) store.getState().closeBrowserTab(closing.id) + await settled() expect(store.getState().activeBrowserTabId).toBe(previous.id) }) - it('queues and consumes a one-shot address-bar focus request for a fresh blank browser tab', () => { + it('queues and consumes a one-shot address-bar focus request for a fresh blank browser tab', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -540,15 +592,17 @@ describe('setActiveWorktree', () => { [wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })] } }) + main = await drawTabsFromMain(store) const browserTab = store.getState().createBrowserTab(wt, 'about:blank', { activate: true }) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBe(true) expect(store.getState().consumeAddressBarFocusRequest(browserTab.id)).toBe(true) expect(store.getState().consumeAddressBarFocusRequest(browserTab.id)).toBe(false) }) - it('does not queue address-bar focus for background or already-navigated browser tabs', () => { + it('does not queue address-bar focus for background or already-navigated browser tabs', async () => { const store = createTestStore() const activeWt = 'repo1::/path/wt1' const backgroundWt = 'repo1::/path/wt2' @@ -567,19 +621,22 @@ describe('setActiveWorktree', () => { [backgroundWt]: [makeTab({ id: 'terminal-2', worktreeId: backgroundWt })] } }) + main = await drawTabsFromMain(store) const backgroundBlankTab = store .getState() .createBrowserTab(backgroundWt, 'about:blank', { activate: true }) + await settled() const activeNavigatedTab = store .getState() .createBrowserTab(activeWt, 'https://example.com', { activate: true }) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[backgroundBlankTab.id]).toBeUndefined() expect(store.getState().pendingAddressBarFocusByTabId[activeNavigatedTab.id]).toBeUndefined() }) - it('drops a pending address-bar focus request when the new browser tab closes before mount', () => { + it('drops a pending address-bar focus request when the new browser tab closes before mount', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -593,11 +650,14 @@ describe('setActiveWorktree', () => { [wt]: [makeTab({ id: 'terminal-1', worktreeId: wt })] } }) + main = await drawTabsFromMain(store) const browserTab = store.getState().createBrowserTab(wt, 'about:blank', { activate: true }) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBe(true) store.getState().closeBrowserTab(browserTab.id) + await settled() expect(store.getState().pendingAddressBarFocusByTabId[browserTab.id]).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts b/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts index a0f994bb0b2..a205aa16d40 100644 --- a/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts +++ b/src/renderer/src/store/slices/store-open-to-side-layout-owner.test.ts @@ -5,7 +5,8 @@ import type * as AgentStatusModule from '@/lib/agent-status' import { createTestStore, makeWorktree, seedStore } from './store-test-helpers' import { createStoreCascadesMockApi } from './store-cascades-test-harness' import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split' -import { recordOpenedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' +import { openContentTabThroughLayoutOwner } from '@/runtime/layout-owner-content-tab-open' +import { contentTabOpenPlacement } from './tabs/content-tab-open-placement' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import type { TabGroupLayoutNode } from '../../../../shared/tab-types' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -133,11 +134,15 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('open to the side in a layout-owner bu split: { sourceGroupId: editorGroup, splitDirection: 'down' } }) const terminalGroup = store.getState().groupsByWorktree[WT]!.at(-1)!.id - store.getState().createBrowserTab(WT, 'about:blank', { + // The browser tab and its split are drawn from main's frame. + const browser = store.getState().createBrowserTab(WT, 'about:blank', { split: { sourceGroupId: ROOT, splitDirection: 'left' } }) - const browserGroup = store.getState().groupsByWorktree[WT]!.at(-1)!.id - expect(await settledSent()).toEqual([ + const sentNow = await settledSent() + const browserGroup = store + .getState() + .unifiedTabsByWorktree[WT]!.find((tab) => tab.entityId === browser.id)!.groupId + expect(sentNow).toEqual([ expect.objectContaining({ type: 'createTerminalTab', tabId: onlyTabOf(terminalGroup), @@ -191,17 +196,29 @@ describe.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)('open to the side in a layout-owner bu [ 'chat', () => { - // As openStructuredAgentSessionProvisionalTab does, on this test's store. - const tab = store.getState().createUnifiedTab(WT, 'agent-session', { - entityId: 'chat-1', - agentSessionAgent: 'codex', + // As openAgentSessionTabThroughLayoutOwner does, on this test's store. + const tabId = 'agent-session:chat-1' + const placement = contentTabOpenPlacement(store.getState(), WT, tabId, { targetGroupId: ROOT + })! + openContentTabThroughLayoutOwner(store.getState(), { + worktreeId: WT, + entityId: 'chat-1', + command: { + type: 'openAgentSessionTab', + tabId, + sessionId: 'chat-1', + agent: 'codex', + ...placement + }, + facts: { label: 'Codex' }, + shown: () => {}, + drawHere: () => null }) - recordOpenedAgentSessionTabInLayoutOwner(store.getState(), tab) } ] ])( - 'records a %s tab opened into the emptied root after its neighbour split again', + 'opens a %s tab into the emptied root after its neighbour split again', async (_kind, openIntoRoot) => { // The splits beside the emptied root hold tabs, so main has only those. store.getState().createTab(WT, undefined, undefined, { diff --git a/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts b/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts index fa741e347a5..195ba9177c0 100644 --- a/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts +++ b/src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture.ts @@ -13,6 +13,7 @@ import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' import { _resetLayoutFramePositionForTests } from '@/runtime/layout-frame-position' import { _resetPendingTabsForTests } from '@/runtime/layout-owner-pending-tabs' +import { _resetPendingTabFactsForTests } from '@/runtime/layout-owner-pending-tab-facts' import { _resetContentRekeysForTests } from '@/runtime/layout-owner-content-rekeys' import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture' import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync' @@ -71,6 +72,7 @@ export async function drawTabsFromMain(store: DrawnStore): Promise AppState, + tab: Pick, + nextActiveTabId: string | null +): void { + const state = get() + const worktreeId = tab.worktreeId + if ((state.unifiedTabsByWorktree[worktreeId] ?? []).some((entry) => entry.id === tab.id)) { + return + } + const activeGroupId = state.activeGroupIdByWorktree[worktreeId] + const group = state.groupsByWorktree[worktreeId]?.find((entry) => entry.id === tab.groupId) + const next = + group && nextActiveTabId && group.tabOrder.includes(nextActiveTabId) + ? nextActiveTabId + : group + ? pickNextActiveTab(group.tabOrder, group.recentTabIds, tab.id) + : null + if (next && group?.activeTabId !== next) { + state.activateTab(next, { worktreeId, preservePreview: true, recordFocus: false }) + } + // Activation focuses the tab's group; the user's focused group stays, and shows its surface. + if (activeGroupId) { + get().focusGroup(worktreeId, activeGroupId) + } +} + +/** Tabs whose twin close main answered as today, while today's close runs. */ +const closingAsToday = new Set() + +export type ContentTabClose = { + /** The tab today's close would show next in the group, picked before the close. */ + nextActiveTabId: string | null + /** Today's close, for a twin main may route to a paired server. */ + closeHere: () => void + /** Why a browser tab closes: a cleanup close stays off main's reopen list. */ + recordClose?: 'user' | 'cleanup' + /** The window's own cleanup once main's frame dropped the tab, before the selection moves. */ + closed?: () => void + /** Lands an emptied active worktree on the welcome screen; true when it did. */ + landIfEmptied?: () => boolean +} + +/** + * Sends a tab's close; false when main does not decide its kind or is not asked (a paired server + * owns the workspace's layout), and the caller closes it as today. + */ +export function closeContentTabThroughLayoutOwner( + get: () => AppState, + tab: Pick, + close: ContentTabClose +): boolean { + // Today's close of a twin's tab closes its tab-bar entry as today too. + if (!closesThroughLayoutOwner(tab.contentType) || closingAsToday.has(tab.id)) { + return false + } + // A repeat close joins the one in flight; its waiters (closeFile's cleanup) stay on it. + if (isTabClosePending(tab.id)) { + return true + } + // A close of a tab still opening goes once main answered the open. + if (isPendingTab(tab.id)) { + afterPendingTab(tab.id, () => { + const drawn = get().unifiedTabsByWorktree[tab.worktreeId]?.find((e) => e.id === tab.id) + if (drawn) { + closeContentTabThroughLayoutOwner(get, drawn, close) + } + }) + return true + } + const settle = beginPendingTab(tab.id, tab.worktreeId, tab.entityId, { kind: 'close' }) + const dropped = (): void => { + // Waiters first: a closeFile's content cleanup must not undo the selection. + settle(tab.id) + close.closed?.() + if (!close.landIfEmptied?.()) { + selectAfterClose(get, tab, close.nextActiveTabId) + } + } + const closeAsToday = (): void => { + closingAsToday.add(tab.id) + let closedId: string | null = null + try { + close.closeHere() + closedId = tab.id + } finally { + closingAsToday.delete(tab.id) + settle(closedId) + } + } + const sent = sendLayoutApply( + get(), + tab.worktreeId, + { + type: 'closeTabs', + tabIds: [tab.id], + ...(close.recordClose ? { recordClose: close.recordClose } : {}) + }, + { + apply: (result) => { + if (result.closed?.includes(tab.id) ?? true) { + dropped() + } else { + settle(null) + } + }, + asToday: closeAsToday, + // A tab main no longer holds, or whose workspace it no longer holds, was closed there first + // (a host's chat close, a removed workspace). A workspace main never held draws only here. + refused: (code) => { + const shows = get().unifiedTabsByWorktree[tab.worktreeId]?.some((e) => e.id === tab.id) + if ((code === 'tab_not_found' || code === 'workspace_not_found') && !shows) { + dropped() + } else if (code === 'workspace_not_found') { + closeAsToday() + } else { + settle(null) + } + } + } + ) + if (!sent) { + settle(null) + } + return sent +} diff --git a/src/renderer/src/store/slices/tabs/content-tab-open-placement.ts b/src/renderer/src/store/slices/tabs/content-tab-open-placement.ts new file mode 100644 index 00000000000..30d86399549 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/content-tab-open-placement.ts @@ -0,0 +1,62 @@ +// Where a tab the window asks main to open goes, resolved as today's create resolves it: the +// requested live group, else the anchor's, else the active one, else the workspace's first (a +// workspace with none gets its root group). An open to the side names a new group beside its +// source, and an empty group only the window holds names the split that made it. + +import { createBrowserUuid } from '@/lib/browser-uuid' +import type { ExecutionHostId } from '../../../../../shared/execution-host' +import { folderWorkspaceToWorktree } from '../../../../../shared/folder-workspace-worktree' +import type { NewGroupSplit } from '../../../../../shared/workspace-layout/workspace-layout-command-types' +import type { AppState } from '../../types' +import { newGroupSplitOf } from '@/runtime/layout-owner-new-group-split' +import { selectFolderWorkspaces } from '../../workspace-record-selectors' +import { resolveUnifiedTabCreatePlacement } from './tabs-create-placement' +import type { TabSplitTarget } from './tabs-slice-contract' + +export type ContentTabOpenPlacement = { + groupId: string + afterTabId?: string + split?: NewGroupSplit +} + +export function contentTabOpenPlacement( + state: AppState, + worktreeId: string, + tabId: string, + request: { + targetGroupId?: string + afterTabId?: string + split?: TabSplitTarget + executionHostId?: ExecutionHostId + } +): ContentTabOpenPlacement | null { + if (request.split) { + return { + groupId: createBrowserUuid(), + split: { besideGroupId: request.split.sourceGroupId, direction: request.split.splitDirection } + } + } + const groups = state.groupsByWorktree[worktreeId] ?? [] + const placement = resolveUnifiedTabCreatePlacement({ + groups, + tabs: state.unifiedTabsByWorktree[worktreeId] ?? [], + activeGroupId: state.activeGroupIdByWorktree[worktreeId], + targetGroupId: request.targetGroupId, + afterTabId: request.afterTabId, + executionHostId: request.executionHostId, + lookupWorktrees: () => + [ + ...(state.allWorktrees?.() ?? []), + ...(selectFolderWorkspaces(state) ?? []).map(folderWorkspaceToWorktree) + ].filter((worktree) => worktree.id === worktreeId) + }) + const groupId = placement.groupId ?? groups[0]?.id ?? state.ensureWorktreeRootGroup?.(worktreeId) + if (!groupId) { + return null + } + return { + groupId, + ...(placement.anchorTabId ? { afterTabId: placement.anchorTabId } : {}), + ...newGroupSplitOf(state, worktreeId, groupId, tabId) + } +} diff --git a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts index 6c5045f473a..eb2d74debb5 100644 --- a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts +++ b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts @@ -1,6 +1,9 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../../shared/tab-types' import { createTestStore, makeWorktree, seedStore } from '../store-test-helpers' +import { drawTabsFromMain, type TabsDrawnFromMain } from '../tabs-drawn-from-main.test-fixture' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' +import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture' const mocks = vi.hoisted(() => ({ beginClose: vi.fn() })) @@ -10,6 +13,17 @@ vi.mock('sonner', () => ({ vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: mocks.beginClose })) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) // `repoId::path` names both checkouts: this machine's and the paired server's. const WORKTREE = 'repo-1::/work/app' @@ -56,9 +70,14 @@ function storeWith(tab: Tab): ReturnType { return store } +let main: TabsDrawnFromMain | undefined beforeEach(() => { mocks.beginClose.mockReset() }) +afterEach(() => { + main?.close() + main = undefined +}) describe('closing a structured chat from outside its workspace', () => { it('stops it on the host stamped on its tab, not on whichever host shares its id', () => { @@ -76,12 +95,24 @@ describe('closing a structured chat from outside its workspace', () => { ) }) - it('names no host for an unstamped chat whose workspace two hosts publish', () => { + // Switch on, main's layout holds only chats this machine's host placed, so the stamp is a fact. + it('an unstamped chat whose workspace two hosts publish: no host off, main’s host on', async () => { const store = storeWith(chatTab()) + main = await drawTabsFromMain(store) store.getState().closeUnifiedTab('agent-session:chat-1') + await main.settle() - expect(mocks.beginClose).not.toHaveBeenCalled() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + expect(mocks.beginClose).toHaveBeenCalledWith( + expect.objectContaining({ target: { kind: 'local' }, sessionId: 'chat-1' }) + ) + expect(layoutApplyTestTransport.sentOfType('closeTabs')).toEqual([ + expect.objectContaining({ workspace: WORKTREE, tabIds: ['agent-session:chat-1'] }) + ]) + } else { + expect(mocks.beginClose).not.toHaveBeenCalled() + } expect(store.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index b86dbf5ce75..0269a1db8c1 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -24,9 +24,12 @@ import { omitRecordKeys } from '../worktrees/teardown/record-key-omission' import { removeTabsAttention } from '../workspace-attention/subject-attention-transitions' import { settleAttentionRemovedBy } from '../workspace-attention/attention-removal-follow-up' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' -import { closeEditorTabThroughLayoutOwner } from '../editor/tabs/editor-tab-close-through-layout-owner' -import { recordClosedAgentSessionTabInLayoutOwner } from '@/runtime/agent-session-tab-layout-owner' -import { recordClosedSimulatorTabInLayoutOwner } from '@/lib/simulator-tab-layout-owner' +import { closeContentTabThroughLayoutOwner } from './content-tab-close-through-layout-owner' +import { isEditorTabContentType } from '../editor/tabs/editor-tab-content-type' +import type { AppState } from '../../types' +import type { TuiAgent } from '../../../../../shared/tui-agent' +import { isTuiAgent } from '../../../../../shared/tui-agent-config' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../../shared/execution-host' export function createTabsCloseActions( set: TabsSliceSet, @@ -75,7 +78,14 @@ export function createTabsCloseActions( worktreeId, sessionId: tab.entityId, provisional, - layoutOwner: state + layoutOwner: state, + // Switch point: main's frames draw this machine's chats, so main opens it again. + ...(ORCA_LAYOUT_OWNER_IS_MAIN && + target.kind === 'local' && + tab.agentSessionAgent && + isTuiAgent(tab.agentSessionAgent) + ? { reopen: reopenChatTab(worktreeId, tab.entityId, tab.agentSessionAgent) } + : {}) }) } else { // Closing still removes the tab; no host can be named to stop its chat on. @@ -148,11 +158,7 @@ export function createTabsCloseActions( // never added a tab, so it must leave the selection exactly as the click found it. const shouldDeactivateWorktree = !opts?.preserveWorktreeSelection && - current.activeWorktreeId === worktreeId && - nextTabs.length === 0 && - (current.tabsByWorktree[worktreeId] ?? []).length === 0 && - (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && - !current.openFiles.some((file) => file.worktreeId === worktreeId) + landsOnEmptiedWorktree(current, worktreeId, nextTabs.length) return { unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: nextTabs }, groupsByWorktree: { @@ -167,33 +173,7 @@ export function createTabsCloseActions( : {}), ...structuredAttention, // Why: closing the last tab can leave the worktree selected but render-empty, so write the landing-state fallback directly. - ...(shouldDeactivateWorktree - ? { - activeWorktreeId: null, - activeWorkspaceKey: null, - activeWorkspaceExecutionHostId: null, - activeTabId: null, - activeBrowserTabId: null, - activeFileId: null, - activeTabType: 'terminal' as const, - activeTabIdByWorktree: { - ...current.activeTabIdByWorktree, - [worktreeId]: null - }, - activeBrowserTabIdByWorktree: { - ...current.activeBrowserTabIdByWorktree, - [worktreeId]: null - }, - activeFileIdByWorktree: { - ...current.activeFileIdByWorktree, - [worktreeId]: null - }, - activeTabTypeByWorktree: { - ...current.activeTabTypeByWorktree, - [worktreeId]: 'terminal' - } - } - : {}), + ...(shouldDeactivateWorktree ? emptiedWorktreeLandingPatch(current, worktreeId) : {}), ...(!shouldDeactivateWorktree && ownsGlobalSelection(current, worktreeId) ? buildActiveSurfacePatch( { @@ -216,10 +196,29 @@ export function createTabsCloseActions( } }) ) - // Switch point: main closes an editor tab first; its frame drops it. + // Switch point: main closes an editor, browser, chat or simulator tab first; its frame drops it. if ( ORCA_LAYOUT_OWNER_IS_MAIN && - closeEditorTabThroughLayoutOwner(get, tab, nextActiveTabId, closeHere) + closeContentTabThroughLayoutOwner(get, tab, { + nextActiveTabId, + closeHere, + // An editor close lands through closeFile, which owns its content. + ...(isEditorTabContentType(tab.contentType) || opts?.preserveWorktreeSelection + ? {} + : { landIfEmptied: () => landOnEmptiedWorktree(set, get, worktreeId) }), + // A chat tab owns its markers; nothing else retires them. + ...(tab.contentType === 'agent-session' + ? { + closed: () => + settleAttentionRemovedBy( + set, + get, + () => set((current) => removeTabsAttention(current, [tabId])), + { tabsBefore: state } + ) + } + : {}) + }) ) { if (opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') @@ -227,12 +226,6 @@ export function createTabsCloseActions( return { closedTabId: tabId, wasLastTab, worktreeId } } closeHere() - // Switch point: main records a chat or simulator tab the window just closed. - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - recordClosedAgentSessionTabInLayoutOwner(get(), tab) - recordClosedSimulatorTabInLayoutOwner(get(), tab) - } - if (opts?.recordInteraction !== false) { get().recordFeatureInteraction?.('terminal-tabs') } @@ -240,3 +233,88 @@ export function createTabsCloseActions( } } } + +// "The user emptied this worktree": closing its last tab lands on the welcome screen, unless the +// close keeps the selection (a cleanup that unwinds a create never added a tab). +type LandingState = Pick< + AppState, + | 'activeWorktreeId' + | 'unifiedTabsByWorktree' + | 'tabsByWorktree' + | 'browserTabsByWorktree' + | 'openFiles' + | 'activeTabIdByWorktree' + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeTabTypeByWorktree' +> + +function landsOnEmptiedWorktree( + current: LandingState, + worktreeId: string, + remainingTabs: number +): boolean { + return ( + current.activeWorktreeId === worktreeId && + remainingTabs === 0 && + (current.tabsByWorktree[worktreeId] ?? []).length === 0 && + (current.browserTabsByWorktree[worktreeId] ?? []).length === 0 && + !current.openFiles.some((file) => file.worktreeId === worktreeId) + ) +} + +function emptiedWorktreeLandingPatch(current: LandingState, worktreeId: string) { + return { + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + activeTabId: null, + activeBrowserTabId: null, + activeFileId: null, + activeTabType: 'terminal' as const, + activeTabIdByWorktree: { ...current.activeTabIdByWorktree, [worktreeId]: null }, + activeBrowserTabIdByWorktree: { ...current.activeBrowserTabIdByWorktree, [worktreeId]: null }, + activeFileIdByWorktree: { ...current.activeFileIdByWorktree, [worktreeId]: null }, + activeTabTypeByWorktree: { + ...current.activeTabTypeByWorktree, + [worktreeId]: 'terminal' as const + } + } +} + +type LandingSet = (update: (state: AppState) => Partial) => void + +/** After main's frame dropped a closed tab: lands when that emptied the active worktree. */ +export function landOnEmptiedWorktree( + set: LandingSet, + get: () => AppState, + worktreeId: string +): boolean { + const state = get() + if ( + !landsOnEmptiedWorktree( + state, + worktreeId, + (state.unifiedTabsByWorktree[worktreeId] ?? []).length + ) + ) { + return false + } + set((current) => emptiedWorktreeLandingPatch(current, worktreeId)) + return true +} + +/** Main opens the chat where it opens any chat this host lists: a host-kept chat comes back. */ +function reopenChatTab(worktreeId: string, sessionId: string, agent: TuiAgent): () => void { + return () => + void import('@/lib/structured-agent-session-launch-surface').then( + ({ openStructuredAgentSessionProvisionalTab }) => + openStructuredAgentSessionProvisionalTab({ + worktreeId, + executionHostId: LOCAL_EXECUTION_HOST_ID, + sessionId, + agent, + activate: false + }) + ) +} diff --git a/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts b/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts index 73f52c582ed..f53d80ff529 100644 --- a/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts +++ b/src/renderer/src/store/slices/workspace-attention/attention-removal-follow-up.ts @@ -123,9 +123,16 @@ export function settleAttentionRemovedBy( set: WorkspaceAttentionSet, get: WorkspaceAttentionGet, remove: () => void, - options?: { settleFlags?: boolean } + options?: { + settleFlags?: boolean + /** Where the tabs were before a removal main's frame already made. */ + tabsBefore?: Pick< + AttentionRemovalSnapshot, + 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'terminalLayoutsByTabId' + > + } ): void { - const before = get() + const before = { ...get(), ...options?.tabsBefore } remove() const removed = collectRemovedAttention(before, get()) if (!removed) { diff --git a/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts b/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts index 359681cf699..b8f976d57c4 100644 --- a/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts +++ b/src/renderer/src/store/slices/workspace-attention/workspace-unread-removal-characterization.test.ts @@ -18,6 +18,7 @@ import { type PaneTarget } from './workspace-unread-characterization-fixture' import { completeAgentTurn, ringBell } from './workspace-unread-characterization-driver' +import { drawTabsFromMain, type TabsDrawnFromMain } from '../tabs-drawn-from-main.test-fixture' // Pins what removing a tab or pane does to unread: its markers go, and a workspace whose removed // marker was its last live one is read. @@ -41,6 +42,20 @@ vi.mock('../worktrees/metadata/worktree-meta-persist', async (importOriginal) => return { ...actual, persistWorktreeMeta: vi.fn(async () => undefined) } }) +// Main answers the window's chat-tab commands and its frames draw them (switch-on builds). +vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeRpcClientWithLayoutApply(await importOriginal()) +) +vi.mock('@/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('@/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +let main: TabsDrawnFromMain | undefined + beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers({ toFake: ['Date'] }) @@ -52,6 +67,8 @@ beforeEach(() => { }) afterEach(() => { + main?.close() + main = undefined vi.useRealTimers() }) @@ -124,7 +141,7 @@ describe('closing tabs', () => { `) }) - it('closing a structured chat tab removes its markers', () => { + it('closing a structured chat tab removes its markers', async () => { const { store, persists } = createAttentionStore({ experimental: true }) const chat = store.getState().createUnifiedTab(WS_A, 'agent-session', { id: 'chat-1', @@ -136,9 +153,11 @@ describe('closing tabs', () => { unreadTerminalPanes: { [paneKey]: 'agent-completion' }, unreadAgentCompletionPanes: { [paneKey]: 'agent-completion' } }) + main = await drawTabsFromMain(store) observe(store, persists) store.getState().closeUnifiedTab(chat.id) + await main.settle() expect(observe(store, persists)).toMatchInlineSnapshot(` [ "rows a@local:read:0 b@local:read:0 sortEpoch=0", diff --git a/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts b/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts index 646e8c9e338..841fa86bc8a 100644 --- a/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts +++ b/src/renderer/src/store/slices/worktrees/listing/stale-listing-purges-created-worktree.test.ts @@ -14,10 +14,13 @@ import { seedStore } from '../../store-test-helpers' import { createStoreCascadesMockApi } from '../../store-cascades-test-harness' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../../shared/layout-owner-switch' import { + readStructuredAgentLaunchRecord, resetStructuredAgentLaunchPersistenceForTests, writeStructuredAgentLaunchRecord } from '@/lib/structured-agent-session-launch-persistence' +import { wasStructuredAgentLaunchClosedByLocalCancel } from '@/lib/structured-agent-session-local-launch-cancel' import { hasStructuredAgentSessionLaunchCancellationTombstone, resetStructuredAgentLaunchRegistryForTests @@ -35,6 +38,14 @@ const CREATED_ID = `${REPO_ID}::/tmp/wt-created` const SESSION_ID = 'claude_repro_session' const AGENT_TAB_ID = `agent-session:${SESSION_ID}` +// A layout-owner build closes a local launch on its host at once and keeps no tombstone. +function launchWasCancelled(): boolean { + return ORCA_LAYOUT_OWNER_IS_MAIN + ? wasStructuredAgentLaunchClosedByLocalCancel(SESSION_ID) && + readStructuredAgentLaunchRecord(SESSION_ID) === undefined + : hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID) +} + function detected(worktree: Worktree): DetectedWorktree { return { ...worktree, ownership: 'orca-managed', selectedCheckout: false, visible: true } } @@ -160,7 +171,7 @@ describe('an authoritative listing that omits a just-created worktree', () => { expect(applyListing(store, [existingRow])).toBe('applied') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(true) + expect(launchWasCancelled()).toBe(true) expect(state.activeWorktreeId).toBeNull() expect(state.unifiedTabsByWorktree[CREATED_ID]).toBeUndefined() expect(state.tabsByWorktree[CREATED_ID]).toBeUndefined() @@ -175,7 +186,7 @@ describe('an authoritative listing that omits a just-created worktree', () => { expect(applyListing(store, [existingRow])).toBe('applied') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(true) + expect(launchWasCancelled()).toBe(true) expect(state.activeWorktreeId).toBeNull() expect(state.unifiedTabsByWorktree[CREATED_ID]).toBeUndefined() }) @@ -188,7 +199,8 @@ describe('an authoritative listing that omits a just-created worktree', () => { expect(applyListing(store, [...rows])).toBe('applied') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(false) + expect(readStructuredAgentLaunchRecord(SESSION_ID)).toBeDefined() + expect(launchWasCancelled()).toBe(false) expect(state.activeWorktreeId).toBe(CREATED_ID) expect(state.unifiedTabsByWorktree[CREATED_ID]).toHaveLength(2) }) @@ -230,7 +242,8 @@ describe('a listing versioned before an applied create', () => { expect(applyListing(store, [existingRow], before)).toBe('superseded') const state = store.getState() - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(false) + expect(readStructuredAgentLaunchRecord(SESSION_ID)).toBeDefined() + expect(launchWasCancelled()).toBe(false) expect(state.activeWorktreeId).toBe(CREATED_ID) expect(state.unifiedTabsByWorktree[CREATED_ID]).toHaveLength(2) expect(state.worktreesByRepo[REPO_ID]!.map((w) => w.id)).toEqual([EXISTING_ID, CREATED_ID]) @@ -282,7 +295,7 @@ describe('a listing versioned before an applied create', () => { expect(applyListing(store, [existingRow])).toBe('applied') - expect(hasStructuredAgentSessionLaunchCancellationTombstone(CREATED_ID, SESSION_ID)).toBe(true) + expect(launchWasCancelled()).toBe(true) expect(store.getState().activeWorktreeId).toBeNull() }) }) diff --git a/src/renderer/src/store/workspace-layout-frame-held-tabs.ts b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts index 9778043e032..5c78dd9b3ff 100644 --- a/src/renderer/src/store/workspace-layout-frame-held-tabs.ts +++ b/src/renderer/src/store/workspace-layout-frame-held-tabs.ts @@ -1,9 +1,10 @@ // Builds frame slices only and never calls set: the store patch applies what it returns. // Slices rebuilt from a frame, with each tab the window's own command still holds put back as the // store shows it (present, gone, or where the window placed it), and the rest as main has it. A -// group's order keeps main's for the tabs nothing holds. +// group's order keeps main's for the tabs nothing holds, and the window's hidden chat launch slots. import type { Tab, TabGroup } from '../../../shared/tab-types' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { isEditorTabContentType } from './slices/editor/tabs/editor-tab-content-type' import type { LayoutFrameSlices } from './workspace-layout-frame-slices' import type { LayoutFrameStoreState } from './workspace-layout-frame-store-patch' @@ -97,6 +98,24 @@ function heldGroups( ] } +/** A chat's place the window reserves until its launch answers: in a group's order, with no row. */ +function launchSlotIds( + state: LayoutFrameStoreState, + built: LayoutFrameSlices, + key: string +): string[] { + const rows = new Set( + [...(state.unifiedTabsByWorktree[key] ?? []), ...(built.unifiedTabsByWorktree[key] ?? [])].map( + idOf + ) + ) + return (state.groupsByWorktree[key] ?? []).flatMap((group) => + group.tabOrder.filter((id) => id.startsWith(LAUNCH_SLOT_ID_PREFIX) && !rows.has(id)) + ) +} + +const LAUNCH_SLOT_ID_PREFIX = structuredAgentSessionTabId('') + function entityIds(tabs: readonly Tab[], kind: (tab: Tab) => boolean): Set { return new Set(tabs.filter(kind).map((tab) => tab.entityId)) } @@ -117,10 +136,11 @@ export function withHeldTabsAsDrawn( ...(built.unifiedTabsByWorktree[key] ?? []) ].filter(isHeld) const layoutHeld = held.isGroupLayoutHeld(key) - if (heldTabs.length === 0 && !layoutHeld) { + const slots = launchSlotIds(state, built, key) + if (heldTabs.length === 0 && slots.length === 0 && !layoutHeld) { continue } - const heldIds = new Set(heldTabs.map((tab) => tab.id)) + const heldIds = new Set([...heldTabs.map((tab) => tab.id), ...slots]) const groups = heldGroups( state.groupsByWorktree[key] ?? [], next.groupsByWorktree[key] ?? [], diff --git a/src/renderer/src/store/workspace-layout-frame-slices.ts b/src/renderer/src/store/workspace-layout-frame-slices.ts index 976f337ba7b..786b5aacdf3 100644 --- a/src/renderer/src/store/workspace-layout-frame-slices.ts +++ b/src/renderer/src/store/workspace-layout-frame-slices.ts @@ -274,7 +274,9 @@ export function buildLayoutFrameSlices({ tabsByWorktree: rows.tabsByWorktree }, session, - workspaceKeys + workspaceKeys, + // A frame redraws the window's own selection: one it never made stays none. + { unchosenStaysUnset: true } ) const editorTabs = { ...tabs, diff --git a/src/shared/rpc-contract/workspace-layout-params.ts b/src/shared/rpc-contract/workspace-layout-params.ts index 5249471fc93..544dc7f5a5c 100644 --- a/src/shared/rpc-contract/workspace-layout-params.ts +++ b/src/shared/rpc-contract/workspace-layout-params.ts @@ -80,7 +80,8 @@ const MoveTabCommand = z.object({ type: z.literal('moveTab'), ...OnTab, toGroupId: z.string().min(1), - index: z.number().int().nonnegative() + index: z.number().int().nonnegative(), + isPinned: z.boolean().optional() }) /** Main mints the new group's id, or moves a lone tab's group whole. */ diff --git a/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts b/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts new file mode 100644 index 00000000000..7075e4d3013 --- /dev/null +++ b/src/shared/workspace-layout/old-client/old-client-session-editor-rows.ts @@ -0,0 +1,142 @@ +// Editor, diff and markdown-preview tabs as today's `session.tabs.*` rows, built from the model's +// file records and the in-memory views the window publishes. Each row matches the window's +// (buildMobileMarkdownTab / buildMobileFileTab), with its open-file id as `sourceFileId`. + +import type { + RuntimeMobileSessionFileTab, + RuntimeMobileSessionMarkdownTab +} from '../../runtime-mobile-session-tab-contracts' +import type { LayoutEditorView } from '../layout-editor-view' +import type { DesktopLayoutView } from '../workspace-layout-beside' +import { editorFileKey, editorTabFile } from '../workspace-layout-editor-tab-file' +import type { LayoutContentTab, LayoutEditorFile, WorkspaceLayout } from '../workspace-layout-model' + +export type EditorRowScope = { + key: string + workspace: WorkspaceLayout + view: Pick + activeTabId: string | null +} + +type EditorRow = RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab + +const fileName = (relativePath: string, fallback: string): string => + relativePath.split(/[\\/]/).pop() || relativePath || fallback + +const tabRowProps = (tab: LayoutContentTab, scope: EditorRowScope) => ({ + color: tab.color ?? null, + isPinned: tab.isPinned === true, + isActive: scope.activeTabId === tab.id +}) + +function hasDraft( + scope: EditorRowScope, + file: Pick +): boolean { + return scope.view.editorDrafts[scope.key]?.[editorFileKey(file)]?.dirtyDraftContent !== undefined +} + +/** An editor tab's row: a markdown preview from its view, else the file record it shows. */ +export function editorTabRow(tab: LayoutContentTab, scope: EditorRowScope): EditorRow | null { + const editorView = scope.workspace.editorViews?.[tab.id] + if (editorView?.view.mode === 'markdown-preview') { + return markdownPreviewRow( + tab, + editorView.file, + editorView.view.markdownPreviewSourceFileId, + scope + ) + } + const file = editorTabFile(scope.workspace, tab.entityId) + if (!file) { + return null + } + const common = { + id: tab.id, + filePath: file.filePath, + relativePath: file.relativePath, + isDirty: hasDraft(scope, file), + ...tabRowProps(tab, scope) + } + if (file.language === 'markdown') { + return { + ...common, + type: 'markdown', + title: fileName(file.relativePath, 'Markdown'), + language: 'markdown', + mode: 'edit', + // The window's open-file id (a path or an owned id), which the tab's entity id is. + sourceFileId: tab.entityId, + sourceFilePath: file.filePath, + sourceRelativePath: file.relativePath, + documentVersion: `file:${tab.entityId}` + } + } + return { + ...common, + type: 'file', + title: fileName(file.relativePath, 'File'), + language: file.language, + mode: 'edit' + } +} + +/** The source is the open edit tab the preview names; with that tab closed, the preview itself. */ +function markdownPreviewRow( + tab: LayoutContentTab, + preview: LayoutEditorView['file'], + sourceFileId: string | undefined, + scope: EditorRowScope +): RuntimeMobileSessionMarkdownTab { + const sourceShown = + sourceFileId !== undefined && + scope.workspace.tabs.some((other) => other.kind === 'editor' && other.entityId === sourceFileId) + const source = sourceShown ? editorTabFile(scope.workspace, sourceFileId) : undefined + const sourceId = source && sourceFileId ? sourceFileId : tab.entityId + const sourceFile = source ?? preview + return { + type: 'markdown', + id: tab.id, + title: fileName(preview.relativePath, 'Markdown'), + filePath: preview.filePath, + relativePath: preview.relativePath, + language: 'markdown', + mode: 'markdown-preview', + // A preview holds no draft of its own; its source's draft makes it dirty. + isDirty: source ? hasDraft(scope, source) : false, + sourceFileId: sourceId, + sourceFilePath: sourceFile.filePath, + sourceRelativePath: sourceFile.relativePath, + documentVersion: `file:${sourceId}`, + ...tabRowProps(tab, scope) + } +} + +/** A diff tab's row from its view; a combined diff (display labels as paths) is not listed. */ +export function diffTabRow( + tab: LayoutContentTab, + scope: EditorRowScope +): RuntimeMobileSessionFileTab | null { + const editorView = scope.workspace.editorViews?.[tab.id] + if (editorView?.view.mode !== 'diff') { + return null + } + const { diffSource } = editorView.view + if (diffSource?.startsWith('combined-')) { + return null + } + const { file } = editorView + return { + type: 'file', + id: tab.id, + title: fileName(file.relativePath, 'File'), + filePath: file.filePath, + relativePath: file.relativePath, + language: file.language, + mode: 'diff', + ...(diffSource === 'staged' || diffSource === 'unstaged' ? { diffSource } : {}), + // The model holds no unsaved edit of a diff's modified side; the window's row it lists wins. + isDirty: false, + ...tabRowProps(tab, scope) + } +} diff --git a/src/shared/workspace-layout/old-client/old-client-session-row-ids.ts b/src/shared/workspace-layout/old-client/old-client-session-row-ids.ts new file mode 100644 index 00000000000..21105a0df70 --- /dev/null +++ b/src/shared/workspace-layout/old-client/old-client-session-row-ids.ts @@ -0,0 +1,22 @@ +// The ids today's `session.tabs.*` payload gives its rows. Rows keep today's ids so old clients' +// keys and selections stay valid (wire rule 3). + +import type { LayoutTab } from '../workspace-layout-model' + +export const TERMINAL_ROW_SEPARATOR = '::' +export const AGENT_SESSION_ROW_PREFIX = 'agent-session:' + +/** One row per pane: `::`, as the window and headless publishers write it. */ +export function terminalRowId(entityId: string, leafId: string): string { + return `${entityId}${TERMINAL_ROW_SEPARATOR}${leafId}` +} + +/** The runtime publishes chat tabs under their session id, in rows and in group tab orders. */ +export function agentSessionRowId(sessionId: string): string { + return `${AGENT_SESSION_ROW_PREFIX}${sessionId}` +} + +/** A tab's entry in a published group tab order. */ +export function publishedTabOrderId(tab: LayoutTab): string { + return tab.kind === 'agent-session' ? agentSessionRowId(tab.entityId) : tab.id +} diff --git a/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts b/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts index c7a73b9e2f6..b77ad171302 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tab-rows.ts @@ -4,24 +4,10 @@ import type { LayoutTab, WorkspaceLayout } from '../workspace-layout-model' import { layoutContainsLeafId } from '../terminal-pane-tree' +import { AGENT_SESSION_ROW_PREFIX, TERMINAL_ROW_SEPARATOR } from './old-client-session-row-ids' -const TERMINAL_ROW_SEPARATOR = '::' -const AGENT_SESSION_ROW_PREFIX = 'agent-session:' - -/** One row per pane: `::`, as the window and headless publishers write it. */ -export function terminalRowId(entityId: string, leafId: string): string { - return `${entityId}${TERMINAL_ROW_SEPARATOR}${leafId}` -} - -/** The runtime publishes chat tabs under their session id, in rows and in group tab orders. */ -export function agentSessionRowId(sessionId: string): string { - return `${AGENT_SESSION_ROW_PREFIX}${sessionId}` -} - -/** A tab's entry in a published group tab order. */ -export function publishedTabOrderId(tab: LayoutTab): string { - return tab.kind === 'agent-session' ? agentSessionRowId(tab.entityId) : tab.id -} +export { agentSessionRowId, publishedTabOrderId, terminalRowId } from './old-client-session-row-ids' +export { projectSessionTabs } from './old-client-session-tabs-projection' export type SessionTabRowTarget = { tab: LayoutTab; leafId?: string } diff --git a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts index 8b1eaf2611d..1c20f12625f 100644 --- a/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts +++ b/src/shared/workspace-layout/old-client/old-client-session-tabs-projection.ts @@ -9,8 +9,6 @@ import { resolveTerminalTabTitle } from '../../tab-title-resolution' import type { RuntimeMobileSessionAgentTab, RuntimeMobileSessionBrowserTab, - RuntimeMobileSessionFileTab, - RuntimeMobileSessionMarkdownTab, RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTerminalTab } from '../../runtime-mobile-session-tab-contracts' @@ -20,11 +18,8 @@ import type { } from '../../runtime-session-contracts' import type { TerminalLayoutSnapshot } from '../../terminal-tab-types' import { pruneGroupLayout } from '../../workspace-session-terminal-tab-close' -import { - agentSessionRowId, - publishedTabOrderId, - terminalRowId -} from './old-client-session-tab-rows' +import { diffTabRow, editorTabRow } from './old-client-session-editor-rows' +import { agentSessionRowId, publishedTabOrderId, terminalRowId } from './old-client-session-row-ids' import { collectLayoutLeafIdsInOrder } from '../terminal-pane-tree' import { groupsInLayoutOrder } from './old-client-group-order' import { @@ -32,7 +27,6 @@ import { type DesktopLayoutView, type LayoutContentFacts } from '../workspace-layout-beside' -import { editorFileKey, editorTabFile } from '../workspace-layout-editor-tab-file' import type { LayoutContentTab, LayoutTab, @@ -148,7 +142,11 @@ function projectTab(tab: LayoutTab, scope: Scope): RuntimeMobileSessionSnapshotT case 'terminal': return terminalRows(tab, scope) case 'editor': { - const row = editorRow(tab, scope) + const row = editorTabRow(tab, scope) + return row ? [row] : [] + } + case 'diff': { + const row = diffTabRow(tab, scope) return row ? [row] : [] } case 'browser': { @@ -159,9 +157,7 @@ function projectTab(tab: LayoutTab, scope: Scope): RuntimeMobileSessionSnapshotT const row = agentSessionRow(tab, scope) return row ? [row] : [] } - // The model holds no file record for a diff (the window lists it from its own state); the - // others need the desktop to draw them and are not listed today. - case 'diff': + // These need the desktop to draw them. case 'conflict-review': case 'check-details': case 'simulator': @@ -244,41 +240,6 @@ function recordOf( return entries.length > 0 ? Object.fromEntries(entries) : undefined } -/** Only edit-mode files are in the model; other editor tabs are the desktop view's own. */ -function editorRow( - tab: LayoutContentTab, - scope: Scope -): RuntimeMobileSessionMarkdownTab | RuntimeMobileSessionFileTab | null { - const file = editorTabFile(scope.workspace, tab.entityId) - if (!file) { - return null - } - const common = { - id: tab.id, - title: file.relativePath.split(/[\\/]/).pop() || file.relativePath || 'File', - filePath: file.filePath, - relativePath: file.relativePath, - isDirty: - scope.view.editorDrafts[scope.key]?.[editorFileKey(file)]?.dirtyDraftContent !== undefined, - color: tab.color ?? null, - isPinned: tab.isPinned === true, - isActive: scope.activeTabId === tab.id - } - if (file.language === 'markdown') { - return { - ...common, - type: 'markdown', - language: 'markdown', - mode: 'edit', - sourceFileId: file.filePath, - sourceFilePath: file.filePath, - sourceRelativePath: file.relativePath, - documentVersion: `file:${file.filePath}` - } - } - return { ...common, type: 'file', language: file.language, mode: 'edit' } -} - function browserRow(tab: LayoutContentTab, scope: Scope): RuntimeMobileSessionBrowserTab | null { const record = scope.workspace.browserTabs?.find((entry) => entry.id === tab.entityId) const live = scope.facts.browserTabs[scope.key]?.[tab.entityId] ?? BLANK_BROWSER_TAB_STATE diff --git a/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts b/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts index 3e3a256dbfc..075dd6d96df 100644 --- a/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts +++ b/src/shared/workspace-layout/workspace-layout-browser-commands.test.ts @@ -83,7 +83,7 @@ describe('openBrowserTab under the window’s ids', () => { ]) }) - it('places a tab whose group main lacks beside its anchor', () => { + it('places a tab whose group main lacks in the workspace’s first group', () => { const { ctx, model } = setup() const split = build( ctx, @@ -107,8 +107,8 @@ describe('openBrowserTab under the window’s ids', () => { model ).model expect(split.workspaces[WS]!.groups.map((group) => group.tabOrder)).toEqual([ - [expect.any(String)], - ['entry-1', 'entry-2'] + [expect.any(String), 'entry-2'], + ['entry-1'] ]) }) diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index ada7bc9f969..fb717e4e342 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -89,7 +89,14 @@ export type LayoutCommand = */ recordClose?: 'user' | 'cleanup' }) - | (On & { type: 'moveTab'; tabId: string; toGroupId: string; index: number }) + | (On & { + type: 'moveTab' + tabId: string + toGroupId: string + index: number + /** Pins or unpins the tab where `index` puts it, in the same step. */ + isPinned?: boolean + }) | (On & { type: 'splitGroup'; tabId: string; besideGroupId: string; direction: GroupSide }) /** `ratios`, or from a window older than them, `groupLayout`: exactly one. */ | (On & { diff --git a/src/shared/workspace-layout/workspace-layout-group-commands.ts b/src/shared/workspace-layout/workspace-layout-group-commands.ts index e5f411aef49..1bcd1c32bc8 100644 --- a/src/shared/workspace-layout/workspace-layout-group-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-group-commands.ts @@ -40,7 +40,9 @@ export function moveTab(model: WorkspaceLayoutModel, command: CommandOf<'moveTab const groups = workspace.groups.map((group) => group.id === source.id ? { ...group, tabOrder } : group ) - return applied(withWorkspace(model, command.workspace, { ...workspace, groups })) + return applied( + withWorkspace(model, command.workspace, withMovedPin({ ...workspace, groups }, command)) + ) } const tab = findTab(workspace, command.tabId) if (tab && groupShowsTabFile(workspace, command.toGroupId, tab)) { @@ -57,14 +59,29 @@ export function moveTab(model: WorkspaceLayoutModel, command: CommandOf<'moveTab return refuse('invalid_params') } return applied( - withWorkspace(model, command.workspace, { - ...workspace, - groups: moved.groups, - groupLayout: moved.layout ?? undefined - }) + withWorkspace( + model, + command.workspace, + withMovedPin( + { ...workspace, groups: moved.groups, groupLayout: moved.layout ?? undefined }, + command + ) + ) ) } +/** The pin a move sets lands with it, where `index` put the tab: no pin re-sort. */ +function withMovedPin(workspace: WorkspaceLayout, command: CommandOf<'moveTab'>): WorkspaceLayout { + const { isPinned } = command + if (isPinned === undefined) { + return workspace + } + return { + ...workspace, + tabs: workspace.tabs.map((tab) => (tab.id === command.tabId ? { ...tab, isPinned } : tab)) + } +} + export function splitGroup( model: WorkspaceLayoutModel, command: CommandOf<'splitGroup'>, diff --git a/src/shared/workspace-layout/workspace-layout-move-tab-pin.test.ts b/src/shared/workspace-layout/workspace-layout-move-tab-pin.test.ts new file mode 100644 index 00000000000..b8a8412c0b0 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-move-tab-pin.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' +import { build, testContext, WS } from './workspace-layout-command.test-fixture' +import type { WorkspaceLayoutModel } from './workspace-layout-model' + +/** Terminal tabs A, B and C in one group, A and B pinned. */ +function setup() { + const ctx = testContext() + const { model, results } = build(ctx, [ + { type: 'createTerminalTab', workspace: WS }, + { type: 'createTerminalTab', workspace: WS }, + { type: 'createTerminalTab', workspace: WS } + ]) + const [a, b, c] = results.map((result) => result!.tabId!) + const pinned = build( + ctx, + [ + { type: 'setTabProps', workspace: WS, tabId: a!, isPinned: true }, + { type: 'setTabProps', workspace: WS, tabId: b!, isPinned: true } + ], + model + ) + return { ctx, model: pinned.model, a: a!, b: b!, c: c! } +} + +const order = (model: WorkspaceLayoutModel) => model.workspaces[WS]!.groups[0]!.tabOrder +const pinOf = (model: WorkspaceLayoutModel, tabId: string) => + model.workspaces[WS]!.tabs.find((tab) => tab.id === tabId)?.isPinned === true + +describe('moveTab with a pin', () => { + it('pins the tab where the move puts it, without the pin moving it again', () => { + const { ctx, model, a, b, c } = setup() + const groupId = model.workspaces[WS]!.groups[0]!.id + + const moved = build( + ctx, + [{ type: 'moveTab', workspace: WS, tabId: c, toGroupId: groupId, index: 1, isPinned: true }], + model + ).model + + expect(order(moved)).toEqual([a, c, b]) + expect(pinOf(moved, c)).toBe(true) + }) + + it('unpins the tab where the move puts it', () => { + const { ctx, model, a, b, c } = setup() + const groupId = model.workspaces[WS]!.groups[0]!.id + const unpinnedB = build( + ctx, + [{ type: 'setTabProps', workspace: WS, tabId: b, isPinned: false }], + model + ).model + + const moved = build( + ctx, + [{ type: 'moveTab', workspace: WS, tabId: a, toGroupId: groupId, index: 1, isPinned: false }], + unpinnedB + ).model + + expect(order(moved)).toEqual([b, a, c]) + expect(pinOf(moved, a)).toBe(false) + }) + + it('a move without a pin leaves the pin as it was', () => { + const { ctx, model, a, b, c } = setup() + const groupId = model.workspaces[WS]!.groups[0]!.id + + const moved = build( + ctx, + [{ type: 'moveTab', workspace: WS, tabId: b, toGroupId: groupId, index: 0 }], + model + ).model + + expect(order(moved)).toEqual([b, a, c]) + expect(pinOf(moved, b)).toBe(true) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-tab-commands.ts b/src/shared/workspace-layout/workspace-layout-tab-commands.ts index 53ddd8f2e6c..3aa39f34085 100644 --- a/src/shared/workspace-layout/workspace-layout-tab-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-tab-commands.ts @@ -276,12 +276,9 @@ export function placeContentTab( context: LayoutContext ): { model: WorkspaceLayoutModel; refused: RefusedClose[] } { const workspace = workspaceOrEmpty(model, key) - const anchor = placement.afterTabId - // A named group this copy lacks falls back to the anchor's. + // A host's open names no group; a window's names one from main's own frame. const group = - workspace.groups.find((entry) => entry.id === placement.groupId) ?? - (anchor ? workspace.groups.find((entry) => entry.tabOrder.includes(anchor)) : undefined) ?? - workspace.groups[0] + workspace.groups.find((entry) => entry.id === placement.groupId) ?? workspace.groups[0] const preview = tab.isPreview ? workspace.tabs.find( (entry) => diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts index 92cb0939c12..85cc9e7874a 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-execution.ts @@ -1,6 +1,7 @@ import { expect, vi } from 'vitest' import { AGENT_SESSION_ITEM_DETAIL_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' +import { resetStructuredAgentSessionClosesBeforeCreateForTests } from '../../../src/main/runtime/rpc/methods/structured-agent-session-close-before-create' import { AGENT_SESSION_CREATE_MESSAGE_RUNTIME_CAPABILITY } from '../../../src/shared/agent-session-create-capabilities' import { attachParams, @@ -15,6 +16,8 @@ import type { } from './versioned-agent-session-wire' export function runtimeStub(overrides: Record = {}): unknown { + // A fresh runtime remembers no chat closed before its create; scenarios reuse one session id. + resetStructuredAgentSessionClosesBeforeCreateForTests() const subscriptions = new RuntimeSubscriptionRegistry() return { getRuntimeId: () => 'runtime-1', diff --git a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts index 247b99ce2dc..fdad3a07675 100644 --- a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts +++ b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import type * as RuntimeRpcClient from '../../src/renderer/src/runtime/runtime-rpc-client' +import type * as RuntimeLayoutClient from '../../src/renderer/src/runtime/runtime-layout-client' import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' import { setStructuredAgentSessionHost } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import { useAppStore } from '../../src/renderer/src/store' @@ -24,6 +25,14 @@ import { isWebSessionCloseIntentPending, resetWebSessionCloseIntentForTests } from '../../src/renderer/src/runtime/web-session-close-intent' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../src/shared/layout-owner-switch' +import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' +import { makeWorktree } from '../../src/renderer/src/store/slices/worktrees-slice-test-fixtures' +import { layoutApplyTestTransport } from '../../src/renderer/src/runtime/layout-apply-test-transport.test-fixture' +import { + drawTabsFromMain, + type TabsDrawnFromMain +} from '../../src/renderer/src/store/slices/tabs-drawn-from-main.test-fixture' type CloseRequest = { requestId: string; tabId: string; worktreeId: string; expiresAt?: number } @@ -43,11 +52,15 @@ const bridge = vi.hoisted((): ProcessBoundary => ({ pairedClose: null })) +// Switch-on, the window's layout commands reach main's model, which the runtime's own commands share. vi.mock('../../src/renderer/src/runtime/runtime-rpc-client', async (importOriginal) => { const actual = await importOriginal() - return { - ...actual, - callRuntimeRpc: async ( + const { runtimeRpcClientWithLayoutApply } = + await import('../../src/renderer/src/runtime/layout-apply-test-transport.test-fixture') + // An untyped mock: the runtime client's generic result type is the caller's to name. + const callRuntimeRpc = vi.fn() + callRuntimeRpc.mockImplementation( + async ( target: RuntimeClientTarget, method: string, params: { worktree: string; tabId: string; reason?: 'user' } @@ -72,8 +85,32 @@ vi.mock('../../src/renderer/src/runtime/runtime-rpc-client', async (importOrigin reason: params.reason }) } - } + ) + return runtimeRpcClientWithLayoutApply({ ...actual, callRuntimeRpc }) }) +vi.mock('../../src/renderer/src/runtime/runtime-layout-client', async (importOriginal) => + ( + await import('../../src/renderer/src/runtime/layout-apply-test-transport.test-fixture') + ).runtimeLayoutClientWithTestFrames(await importOriginal()) +) + +let main: TabsDrawnFromMain | undefined + +/** Main takes the window's current layout and answers its own layout commands from that model too. */ +async function mainHoldsWindowLayout(runtime: OrcaRuntimeService): Promise { + main?.close() + main = await drawTabsFromMain(useAppStore) + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + Object.assign(runtime, { + applyLayoutCommand: (command: unknown) => layoutApplyTestTransport.call({ command }) + }) + } +} + +function closeMain(): void { + main?.close() + main = undefined +} vi.mock('../../src/renderer/src/runtime/structured-agent-session-close', () => ({ closeStructuredAgentSession: async (_target: unknown, sessionId: string) => { @@ -168,17 +205,16 @@ describe('reopened structured session close', () => { hostSessionCloses.push(sessionId) }, setSessionTabVisibility: async () => undefined, + // The host's tab index, which main opens chat tabs under: the current chat holds its first + // session's tab id, so the reopened one has its own (an id names one chat there). + getSessionTabId: (sessionId: string) => + sessionId === CURRENT_SESSION ? CURRENT_TAB : REOPENED_TAB, listSessionTabs: () => [ { sessionId: CURRENT_SESSION, workspaceId: WORKTREE, agent: 'claude' }, { sessionId: REOPENED_SESSION, workspaceId: WORKTREE, agent: 'claude' } ] } as never ) - await runtime.restoreStructuredAgentSessionTabs() - const hostSnapshot: RuntimeMobileSessionTabsResult = await runtime.listMobileSessionTabs( - `id:${WORKTREE}` - ) - // The current chat after /clear: first session's tab id, new session's entity. useAppStore.setState({ activeWorktreeId: WORKTREE }) useAppStore.getState().createUnifiedTab(WORKTREE, 'agent-session', { @@ -186,7 +222,13 @@ describe('reopened structured session close', () => { entityId: CURRENT_SESSION, label: 'Claude Chat' }) + await mainHoldsWindowLayout(runtime) + await runtime.restoreStructuredAgentSessionTabs() + const hostSnapshot: RuntimeMobileSessionTabsResult = await runtime.listMobileSessionTabs( + `id:${WORKTREE}` + ) applyStructuredSessionTabSnapshots([hostSnapshot]) + await main?.settle() const agentTabs = () => (useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []) @@ -203,6 +245,7 @@ describe('reopened structured session close', () => { }) afterEach(() => { + closeMain() setStructuredAgentSessionHost(null) bridge.runtime = null bridge.closeHeld = null @@ -238,7 +281,7 @@ describe('reopened structured session close', () => { expect(agentTabs()).toEqual([{ id: CURRENT_TAB, entityId: CURRENT_SESSION }]) expect(bridge.rendererSessionCloses).toEqual([REOPENED_SESSION]) expect(hostSessionCloses).toEqual([REOPENED_SESSION]) - // The window already removed the tab, so main's relay is acknowledged, not reported missing. + // The window started this close, so main's relay is acknowledged, not reported missing. expect(closeResponses).toEqual([undefined]) }) @@ -285,6 +328,8 @@ describe('in-flight structured session close', () => { async function setup(options: { hostFailsClose?: boolean } = {}) { let closeRequest: ((request: CloseRequest) => void) | undefined const pendingResponses = new Map void>() + // A failing close answers when the test says, so its frame can be read first. + const failingCloses: (() => void)[] = [] const runtime = new OrcaRuntimeService() bridge.runtime = runtime vi.stubGlobal('api', { @@ -308,7 +353,7 @@ describe('in-flight structured session close', () => { closeSessionTab: (tabId: string, worktreeId: string) => new Promise((resolve, reject) => { if (options.hostFailsClose) { - reject(new Error('window relay failed')) + failingCloses.push(() => reject(new Error('window relay failed'))) return } const requestId = `close-${++nextRequest}` @@ -346,16 +391,21 @@ describe('in-flight structured session close', () => { [...hostTabs].map((sessionId) => ({ sessionId, workspaceId: WORKTREE, agent: 'claude' })) } as never ) - await runtime.restoreStructuredAgentSessionTabs() - useAppStore.setState({ activeWorktreeId: WORKTREE }) + await mainHoldsWindowLayout(runtime) + await runtime.restoreStructuredAgentSessionTabs() applyStructuredSessionTabSnapshots([await runtime.listMobileSessionTabs(`id:${WORKTREE}`)]) + await main?.settle() // The window's live subscription to the host's tab list. runtime.onMobileSessionTabsChanged((snapshot) => applyStructuredSessionTabSnapshots([snapshot])) const strip = (): string[] => (useAppStore.getState().groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder) - return { runtime, strip, hostSessionCloses } + const failHostCloses = async (): Promise => { + await vi.waitFor(() => expect(failingCloses.length).toBeGreaterThan(0)) + failingCloses.splice(0).forEach((fail) => fail()) + } + return { runtime, strip, hostSessionCloses, failHostCloses } } const initialStoreState = useAppStore.getState() @@ -365,6 +415,7 @@ describe('in-flight structured session close', () => { }) afterEach(() => { + closeMain() setStructuredAgentSessionHost(null) bridge.runtime = null bridge.rendererSessionCloses = [] @@ -400,6 +451,8 @@ describe('in-flight structured session close', () => { try { useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) retirement = captureRetirement() + // Switch-on, the strip drops the chat once main's frame for the close lands. + await main?.settle() expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) // The host has not handled the close yet, so its next frame still lists chat-b. @@ -427,10 +480,12 @@ describe('in-flight structured session close', () => { }) it('shows the chat again when the host could not close it', async () => { - const { strip } = await setup({ hostFailsClose: true }) + const { strip, failHostCloses } = await setup({ hostFailsClose: true }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) + await main?.settle() expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) + await failHostCloses() // The host still has the chat, so once the close fails the window re-reads and shows it. await vi.waitFor(() => expect(strip()).toContain(tabIdOf('chat-b'))) @@ -473,7 +528,19 @@ describe('in-flight structured session close', () => { ) const strip = (): string[] => (useAppStore.getState().groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder) - useAppStore.setState({ activeWorktreeId: WORKTREE }) + // The server owns the workspace, so its layout is mirrored, never drawn by main's frames. + useAppStore.setState({ + activeWorktreeId: WORKTREE, + worktreesByRepo: { + 'repo-1': [ + makeWorktree({ + id: WORKTREE, + repoId: 'repo-1', + hostId: toRuntimeExecutionHostId(SERVER) + }) + ] + } + }) applyServerFrame(frame(1)) const chatB = useAppStore .getState() @@ -501,6 +568,7 @@ describe('in-flight structured session close', () => { try { useAppStore.getState().closeUnifiedTab(chatB!.id) + await main?.settle() const after = before.filter((id) => id !== chatB!.id) expect(strip()).toEqual(after)