mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
test: remove 659 low-value renderer tests (#37544)
This commit is contained in:
@@ -1071,10 +1071,8 @@
|
||||
2 test-no-wall-clock:src/renderer/src/lib/use-tab-agent-observed-signal-dispatch.test.tsx
|
||||
2 test-no-wall-clock:src/renderer/src/lib/wake-sleeping-agents-live-unmounted-pane.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/lib/workspace-activation-path-gate.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/lib/workspace-session.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/lib/workspace-terminal-host-authority.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts
|
||||
4 test-no-wall-clock:src/renderer/src/runtime/close-mirrored-editor-tab.test.ts
|
||||
2 test-no-wall-clock:src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts
|
||||
1 test-no-wall-clock:src/renderer/src/runtime/web-runtime-session-frame-drawn-writes.test.ts
|
||||
|
||||
@@ -2262,14 +2262,14 @@
|
||||
"providers": ["local", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Drives the real boot read and the shipping host split against partitioned session fixtures; no live SSH host is exercised, and the partition routing under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.",
|
||||
"coverageNotes": "The test-value audit retains renderer in-memory read/write composition for editor-only drafts, contested primary/shadow rows, declined-sibling preservation, and two-cycle legacy repair/tombstone handling. Separate supplied routing, folder ownership, field-selection and hydration examples were removed; their alarms are lost. APIs are fixtures: no durable persistence, real SSH delivery, PTY lifecycle, mobile rendering or actual restart is established. Historical run evidence is preserved and does not establish the current narrowed suite.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/12721",
|
||||
"https://github.com/stablyai/orca/issues/18173",
|
||||
"https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts"
|
||||
],
|
||||
"invariant": "Every workspace the `ssh:<targetId>` partition names is hydrated at boot, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it. Adoption never destroys what it was not told about: a host row the partition holds nothing for cannot replace a populated base row, and a bare id the read found contested is gap-filled rather than replaced, because that is exactly the id where `local` and `ssh:<targetId>` are not one workspace written twice. Tab-, pane- and file-keyed rows are recovered through the same indexes the split routed them by. An explicit empty tab row written by this build survives the round trip as a present empty row and is declined by the shared seeding predicate. Boot enumerates the partitions persistence actually holds rather than inferring them from the repo catalog, so a target whose only workspace is a folder is read like any other; a folder workspace routes to the same partition main's RuntimeWorkspaceSessionController writes it to, so one workspace is never written into two stores; and a workspace adopted out of a partition routes back to that partition even on a boot whose repo catalog cannot name the host, so the write cannot re-strand what the read just reunited. `contested` means a repo id the catalog registers on more than one host, or two SSH partitions naming one id - never the mere co-presence of 'local' and `ssh:<targetId>`, which is the repair's own input.",
|
||||
"oracle": "Assert the renderer routes an SSH folder workspace to `ssh:<targetId>`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Assert the reunited workspace routes to `ssh:<targetId>`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.",
|
||||
"oracle": "Drive the shipping split and renderer read together for an editor-only draft, a contested primary plus parked shadow, and legacy repair followed by a closed-last-terminal round trip. Drive an assembled read into addressed patch callbacks while writing a sibling and require the declined row to survive in its own partition. These in-memory composition checks do not establish file durability or actual reboot recovery.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts"
|
||||
],
|
||||
@@ -2283,40 +2283,15 @@
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts",
|
||||
"assertions": [
|
||||
"hydrates tabs the runtime persisted into the ssh partition",
|
||||
"adopts the stranded workspace rows alongside its tabs",
|
||||
"adopts a hibernated agent record the host partition alone holds",
|
||||
"leaves a workspace the local partition already holds tabs for untouched",
|
||||
"routes the reunited workspace back to the partition that owns it",
|
||||
"does not delete the worktree tabs across a publish and the next pull",
|
||||
"publishes the stranded tabs rather than an empty list",
|
||||
"does not let an empty host row destroy an unsaved draft the base alone holds",
|
||||
"still adopts a populated host row over the base leftovers",
|
||||
"adopts the layout of a tab the host slice names only in unifiedTabs",
|
||||
"does not overwrite a contested workspace's own rows with the ssh workspace's",
|
||||
"still fills a gap on a contested id",
|
||||
"does not let a legacy bare recency key move another host workspace of the same id",
|
||||
"still adopts a host-qualified recency key, which names its own owner",
|
||||
"writes an SSH workspace emptied by this build into the partition that owns it",
|
||||
"restores that tombstone as an explicit empty row, not a deleted key",
|
||||
"leaves the restored workspace un-seeded by the shared seeding predicate",
|
||||
"does not adopt a stale populated ssh row over a tombstone in the owning partition",
|
||||
"resurrects the legacy-transition shape exactly once and not again",
|
||||
"publishes the tombstone rather than a row the host can read as unknown"
|
||||
"sends an editor-only SSH workspace to its partition and reads it back",
|
||||
"resurrects the legacy-transition shape exactly once and not again"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts",
|
||||
"assertions": [
|
||||
"routes a folder workspace to the partition main writes it to",
|
||||
"keeps a folder row out of the local partition so a save cannot erase main\u2019s copy",
|
||||
"reads an ssh partition that only a folder workspace owns",
|
||||
"returns adopted rows to the partition they were read from with no repo catalog",
|
||||
"gap-fills instead of replacing, so the local workspace keeps its unsaved draft",
|
||||
"never names the rival partition as the write target for a contested id",
|
||||
"gap-fills a contested id from the same partition on every boot",
|
||||
"does not adopt a partition the catalog says does not own the workspace",
|
||||
"does not adopt a contested id the assembled session holds no row for"
|
||||
"keeps a declined row in its own partition when a sibling workspace is written"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -3303,7 +3278,7 @@
|
||||
"providers": ["local", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic candidate-builder contracts inject local and paired-runtime worktrees with the same bare id into the real aggregated tab buckets, including folder workspaces and a paired runtime transporting an SSH-owned worktree. A mirror contract preserves distinct same-id runtime tabs. A paired Electron journey verifies both visible browser rows and both visible simulator rows, then requires each selection to render its owning active tab and update backing host-qualified state. The test-value audit narrowed simulator-palette-search.test.ts to the recency tie, same-id host isolation, ambiguous legacy host rejection and oversized-input getter tripwire. Supplied timestamp, empty-query context ordering, host projection, alias/range, metadata matching, active-tab score, over-token/whitespace and malformed display-name fallback examples were removed. Their separate alarms are lost; retained candidate and activation contracts do not establish equivalent coverage. Historical run commands and results remain historical.",
|
||||
"coverageNotes": "Deterministic candidate-builder contracts inject local and paired-runtime worktrees with the same bare id into the real aggregated tab buckets, including folder workspaces and a paired runtime transporting an SSH-owned worktree. A mirror contract preserves distinct same-id runtime tabs. A paired Electron journey verifies both visible browser rows and both visible simulator rows, then requires each selection to render its owning active tab and update backing host-qualified state. The test-value audit narrowed simulator-palette-search.test.ts to the recency tie, same-id host isolation, ambiguous legacy host rejection and oversized-input getter tripwire. Supplied timestamp, empty-query context ordering, host projection, alias/range, metadata matching, active-tab score, over-token/whitespace and malformed display-name fallback examples were removed. Their separate alarms are lost; retained candidate and activation contracts do not establish equivalent coverage. Historical run commands and results remain historical. The audit also removed the standalone workspace-tab-palette-search suite: supplied row, title, metadata, alias, range, ranking and ownership examples no longer provide their separate alarms. Other retained contracts are not replacement coverage.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4965",
|
||||
"https://github.com/stablyai/orca/pull/15371"
|
||||
@@ -3317,9 +3292,9 @@
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/worktree-jump/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/worktree-jump/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/open-mobile-emulator-tab.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/worktree-jump/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/worktree-jump/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/lib/open-mobile-emulator-tab.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-cmd-j-host-qualified-tabs.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
@@ -3331,7 +3306,6 @@
|
||||
"src/renderer/src/lib/browser-palette-page-entries.test.ts",
|
||||
"src/renderer/src/lib/browser-palette-search.test.ts",
|
||||
"src/renderer/src/lib/simulator-palette-search.test.ts",
|
||||
"src/renderer/src/lib/workspace-tab-palette-search.test.ts",
|
||||
"src/renderer/src/lib/browser-page-palette-activation.test.ts",
|
||||
"src/renderer/src/lib/simulator-tab-palette-activation.test.ts",
|
||||
"src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts",
|
||||
@@ -3460,7 +3434,7 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 10.5,
|
||||
"summary": "The review-hardened candidate passed 189/189 focused contracts, including direct browser-result host fallback, SSH plus paired-runtime current-row aliases, explicit-owner fail-closed behavior, generic workspace rows, simulator reuse, local creation stamping, activation, and same-id distinct-runtime mirror preservation. Historical command preserved after the test-value audit removed the palette repo resolver suite. currentCommand is an administrative link to a remaining gate command; this historical run has not validated that command or equivalent coverage.",
|
||||
"currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts"
|
||||
"currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
@@ -3477,7 +3451,7 @@
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/palette-repo-resolution.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/worktree-jump/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/workspace-tab-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"currentCommand": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/cmd-j/worktree-checks-review-index.test.ts src/renderer/src/components/worktree-jump/WorktreeJumpPalette.test.tsx src/renderer/src/components/tab-bar/use-open-tab-search.test.ts src/renderer/src/lib/cmd-j-host-qualified-candidate-ownership.test.ts src/renderer/src/lib/browser-palette-page-entries.test.ts src/renderer/src/lib/browser-palette-search.test.ts src/renderer/src/lib/simulator-palette-search.test.ts src/renderer/src/lib/browser-page-palette-activation.test.ts src/renderer/src/lib/simulator-tab-palette-activation.test.ts src/renderer/src/lib/ensure-simulator-tab-behavior.test.ts src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts src/renderer/src/store/slices/tabs-open-close-lifecycle.test.ts src/renderer/src/store/slices/tabs-pane-layout-operations.test.ts src/shared/workspace-session-schema.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 24.3,
|
||||
"summary": "The OpenCode-review-hardened candidate passed 225/225 focused contracts, including unfiltered ownership ambiguity under host filters and scoped open-tab search, review-cache ownership, rendered palette repo metadata, folder workspaces, inactive local simulator creation, paired SSH/runtime display metadata, and explicit-owner fail-closed resolution. Historical command preserved after the test-value audit removed the palette repo resolver suite. currentCommand is an administrative link to a remaining gate command; this historical run has not validated that command or equivalent coverage."
|
||||
@@ -5183,7 +5157,7 @@
|
||||
"Mobile observers are covered only by the platform-neutral clientKind contract, not by a live phone journey.",
|
||||
"Host-originated CLI creates deliberately still reveal on every client; that broader authority question is untouched by this gate.",
|
||||
"Mobile creates deliberately keep the all-surface reveal: a phone has no renderer to provision repo setup/default tabs off the activation event.",
|
||||
"The Electron journey drives the store create action, so the composer's automatic self-navigation on create is proved by worktree-creation-flow.test.ts and worktree.spec.ts rather than by this journey.",
|
||||
"The Electron journey drives the store create action and does not establish the composer's automatic self-navigation. The test-value audit removed the mocked worktree-creation-flow suite and accepts loss of its separate callback/navigation alarms; no replacement coverage is claimed.",
|
||||
"Headless coverage is a modeled runtime shape; CLI bootstrap and argument parsing for orca serve are not exercised here.",
|
||||
"Host-first rollout: the fix is enforced by the host, so a NEW client paired to an OLD always-on server still sees the original cross-client jump. Shipping clients alone fixes nothing for anyone; the runtime/server must be updated. Nothing breaks under that skew — the old host simply keeps its old behavior — but release notes and support triage must check the host version, not the client version. No skew combination is worse than pre-fix main: an old CLI is recognized by its cliProvenanceRequest marker so its --activate reveal survives an updated host.",
|
||||
"A future contributor could still hardcode a wrong navigation target at a call site: the required object key makes omission a compile error, but not substitution. Only the folder/no-startup branch has a behavioral test that would catch it.",
|
||||
@@ -10680,7 +10654,7 @@
|
||||
"providers": ["local", "remote-runtime", "ssh", "wsl"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "remote-runtime", "ssh", "wsl"],
|
||||
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project eligibility/owner capture/stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus separate paired Electron and web clients prove selected-runtime Add Project operations and disabled creation when the only listed host is blocked or unavailable; headless-server parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected. The static wrapper-rendering suite was removed during the low-value-test audit; its supplied-prop host-browser and control-visibility output assertions are no longer checked by that suite. This removal supplies no new runtime-routing evidence. The fake-React Clone hook unit suite was also retired: its captured-owner and same-ID sibling assertions no longer supply unit evidence. Historical run commands and counts remain historical; this retirement adds no runtime-routing, cross-platform, or mobile evidence. The test-value audit narrowed resolved-worktree-execution-host.test.ts to cache reuse and invalidation. Its direct hydrated-worktree, missing-folder, restored-owner, host-stamp and empty-runtime selection alarms are lost. Other routing and recovery suites do not establish equivalent selector coverage; historical run commands and results remain historical.",
|
||||
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project eligibility/owner capture/stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus separate paired Electron and web clients prove selected-runtime Add Project operations and disabled creation when the only listed host is blocked or unavailable; headless-server parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected. The static wrapper-rendering suite was removed during the low-value-test audit; its supplied-prop host-browser and control-visibility output assertions are no longer checked by that suite. This removal supplies no new runtime-routing evidence. The fake-React Clone hook unit suite was also retired: its captured-owner and same-ID sibling assertions no longer supply unit evidence. Historical run commands and counts remain historical; this retirement adds no runtime-routing, cross-platform, or mobile evidence. The test-value audit narrowed resolved-worktree-execution-host.test.ts to cache reuse and invalidation. Its direct hydrated-worktree, missing-folder, restored-owner, host-stamp and empty-runtime selection alarms are lost. Other routing and recovery suites do not establish equivalent selector coverage; historical run commands and results remain historical. The test-value audit narrowed worktree-runtime-owner.test.ts to deterministic owner-lookup work bounds with correct worktree/folder results and changed-collection invalidation. Its separate host/default/folder/mirror inventory alarms are lost; these retained in-memory checks establish no actual host delivery, restart or equivalent replacement coverage. Historical run commands and results remain historical.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/9687",
|
||||
"https://linear.app/stably/issue/STA-4024/p1-paired-web-add-project-enables-unavailable-host-fallback-after-pr"
|
||||
|
||||
@@ -10915,96 +10915,40 @@
|
||||
"src/renderer/src/lib/workspace-session-host-contention.test.ts": 265,
|
||||
"src/renderer/src/lib/workspace-session-host-emptied-fields.test.ts": 279,
|
||||
"src/renderer/src/lib/workspace-session-host-persistence.test.ts": 313,
|
||||
"src/renderer/src/lib/workspace-session-liveness.test.ts": 298,
|
||||
"src/renderer/src/lib/workspace-session-patch.test.ts": 336,
|
||||
"src/renderer/src/lib/workspace-session-persistence-gate.test.ts": 314,
|
||||
"src/renderer/src/lib/workspace-session-reconnect-targets.test.ts": 47,
|
||||
"src/renderer/src/lib/workspace-session-resume-outcome.test.ts": 4234,
|
||||
"src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts": 309,
|
||||
"src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts": 1775,
|
||||
"src/renderer/src/lib/workspace-session-staged-browser-tabs.test.ts": 38,
|
||||
"src/renderer/src/lib/workspace-session-twin-repo-host.test.ts": 283,
|
||||
"src/renderer/src/lib/workspace-session-unified-tabs.test.ts": 238,
|
||||
"src/renderer/src/lib/workspace-session.test.ts": 310,
|
||||
"src/renderer/src/lib/workspace-switch-user-act.test.ts": 111,
|
||||
"src/renderer/src/lib/workspace-tab-agent-metadata.test.ts": 52,
|
||||
"src/renderer/src/lib/workspace-tab-palette-activation.store.test.ts": 1994,
|
||||
"src/renderer/src/lib/workspace-tab-palette-activation.test.ts": 294,
|
||||
"src/renderer/src/lib/workspace-tab-palette-results.test.ts": 353,
|
||||
"src/renderer/src/lib/workspace-tab-palette-search.test.ts": 642,
|
||||
"src/renderer/src/lib/workspace-terminal-host-authority.test.ts": 2066,
|
||||
"src/renderer/src/lib/worktree-activation-agent-startup.test.ts": 2065,
|
||||
"src/renderer/src/lib/worktree-activation-automation-filter.test.ts": 2431,
|
||||
"src/renderer/src/lib/worktree-activation-created-agent.test.ts": 2295,
|
||||
"src/renderer/src/lib/worktree-activation-default-agent-chat.test.ts": 2051,
|
||||
"src/renderer/src/lib/worktree-activation-emptied-workspace-reseed.test.ts": 3630,
|
||||
"src/renderer/src/lib/worktree-activation-empty-remote.test.ts": 2091,
|
||||
"src/renderer/src/lib/worktree-activation-issue-command.test.ts": 2207,
|
||||
"src/renderer/src/lib/worktree-activation-launch-awaiting-host.test.ts": 2161,
|
||||
"src/renderer/src/lib/worktree-activation-leave-visit.test.ts": 2037,
|
||||
"src/renderer/src/lib/worktree-activation-pty-inventory.test.ts": 315,
|
||||
"src/renderer/src/lib/worktree-activation-setup-script.test.ts": 2232,
|
||||
"src/renderer/src/lib/worktree-activation-structured-chat-surface.test.ts": 2043,
|
||||
"src/renderer/src/lib/worktree-activation-web-runtime.test.ts": 2768,
|
||||
"src/renderer/src/lib/worktree-activity-state.test.ts": 197,
|
||||
"src/renderer/src/lib/worktree-agent-activation-gate-layout-owner.test.ts": 2069,
|
||||
"src/renderer/src/lib/worktree-agent-activation-gate-main-placement.test.ts": 1815,
|
||||
"src/renderer/src/lib/worktree-agent-activation-gate.test.ts": 1861,
|
||||
"src/renderer/src/lib/worktree-agent-activation-seam.test.ts": 3440,
|
||||
"src/renderer/src/lib/worktree-agent-live-surface-adoption-cli-tab.test.ts": 2040,
|
||||
"src/renderer/src/lib/worktree-agent-live-surface-adoption-layout-owner.test.ts": 1898,
|
||||
"src/renderer/src/lib/worktree-agent-structured-inventory.test.ts": 261,
|
||||
"src/renderer/src/lib/worktree-attachment-label.test.ts": 50,
|
||||
"src/renderer/src/lib/worktree-creation-agent-seeds.test.ts": 1792,
|
||||
"src/renderer/src/lib/worktree-creation-backend-startup-focus.test.ts": 2267,
|
||||
"src/renderer/src/lib/worktree-creation-background-mount.test.ts": 2678,
|
||||
"src/renderer/src/lib/worktree-creation-chat-setup.test.ts": 2301,
|
||||
"src/renderer/src/lib/worktree-creation-flow-ready-toast.test.ts": 2071,
|
||||
"src/renderer/src/lib/worktree-creation-flow-stranded-surface.test.ts": 1381,
|
||||
"src/renderer/src/lib/worktree-creation-flow.test.ts": 1651,
|
||||
"src/renderer/src/lib/worktree-creation-hook-flow.test.ts": 2017,
|
||||
"src/renderer/src/lib/worktree-creation-hook-preparation.test.ts": 172,
|
||||
"src/renderer/src/lib/worktree-creation-host-agent-launch.test.ts": 1483,
|
||||
"src/renderer/src/lib/worktree-creation-initial-surface.test.ts": 5258,
|
||||
"src/renderer/src/lib/worktree-creation-ready-toast.test.tsx": 501,
|
||||
"src/renderer/src/lib/worktree-creation-structured-session-host-decline.test.ts": 2772,
|
||||
"src/renderer/src/lib/worktree-creation-structured-session-lifecycle.test.ts": 2633,
|
||||
"src/renderer/src/lib/worktree-creation-structured-session-prompts.test.tsx": 4554,
|
||||
"src/renderer/src/lib/worktree-creation-structured-session.test.ts": 1289,
|
||||
"src/renderer/src/lib/worktree-default-display-name.test.ts": 42,
|
||||
"src/renderer/src/lib/worktree-display-name-order.test.ts": 49,
|
||||
"src/renderer/src/lib/worktree-host-connection-phase.test.ts": 1716,
|
||||
"src/renderer/src/lib/worktree-initial-terminal-close-records.test.ts": 2185,
|
||||
"src/renderer/src/lib/worktree-initial-terminal-seeding-twin-host.test.ts": 1862,
|
||||
"src/renderer/src/lib/worktree-live-terminal-surface-owners.test.ts": 266,
|
||||
"src/renderer/src/lib/worktree-operation-generation.test.ts": 526,
|
||||
"src/renderer/src/lib/worktree-operation-route-index.test.ts": 262,
|
||||
"src/renderer/src/lib/worktree-operation-route.test.ts": 342,
|
||||
"src/renderer/src/lib/worktree-owner-rows-token.test.ts": 42,
|
||||
"src/renderer/src/lib/worktree-palette-attachment-urls.test.ts": 145,
|
||||
"src/renderer/src/lib/worktree-palette-comment-snippet.test.ts": 53,
|
||||
"src/renderer/src/lib/worktree-palette-create-action.test.ts": 64,
|
||||
"src/renderer/src/lib/worktree-palette-multi-keyword.test.ts": 285,
|
||||
"src/renderer/src/lib/worktree-palette-review-match.test.ts": 198,
|
||||
"src/renderer/src/lib/worktree-palette-runtime-owner-identity.test.ts": 425,
|
||||
"src/renderer/src/lib/worktree-palette-search-visits.test.ts": 222,
|
||||
"src/renderer/src/lib/worktree-palette-search.test.ts": 206,
|
||||
"src/renderer/src/lib/worktree-palette-task-url-match.test.ts": 165,
|
||||
"src/renderer/src/lib/worktree-reactivation-preserved-pane-replacement.test.ts": 2268,
|
||||
"src/renderer/src/lib/worktree-reactivation-runtime-owned-resume-deferral.test.ts": 1961,
|
||||
"src/renderer/src/lib/worktree-reactivation-tab-forkbomb.test.ts": 4131,
|
||||
"src/renderer/src/lib/worktree-row-open-in-owner.test.ts": 476,
|
||||
"src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts": 309,
|
||||
"src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts": 121,
|
||||
"src/renderer/src/lib/worktree-runtime-owner-index.test.ts": 46,
|
||||
"src/renderer/src/lib/worktree-runtime-owner.test.ts": 380,
|
||||
"src/renderer/src/lib/worktree-sort-order-host-split.test.ts": 304,
|
||||
"src/renderer/src/lib/worktree-status-sparse-pane.test.ts": 230,
|
||||
"src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts": 271,
|
||||
"src/renderer/src/lib/worktree-status-terminal-layout-roots.test.ts": 256,
|
||||
"src/renderer/src/lib/worktree-status.test.ts": 306,
|
||||
"src/renderer/src/lib/worktree-visit-recency.test.ts": 50,
|
||||
"src/renderer/src/lib/wsl-host-account-owner.test.ts": 45,
|
||||
"src/renderer/src/lib/wsl-host-user-start.test.ts": 80,
|
||||
"src/renderer/src/lib/wsl-move-renderer-store-keys.test.ts": 112,
|
||||
"src/renderer/src/lib/wsl-move-renderer-store-rewrites.test.ts": 1072,
|
||||
|
||||
@@ -47,7 +47,6 @@ function controllerFor({ repos, catalog = null, saved = [], staleOn = [] }: Setu
|
||||
return { controller, sessions, getRepos }
|
||||
}
|
||||
|
||||
// The window's save routing is checked against the same table in workspace-session-twin-repo-host.test.ts.
|
||||
describe('routing a repo id registered on two hosts, in a layout-owner build', () => {
|
||||
it.each(TWIN_REPO_HOST_CASES)('$name', ({ repos, catalog, saved, staleOn, expected }) => {
|
||||
layoutOwner.isMain = true
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceSessionPayload, type WorkspaceSessionSnapshot } from './workspace-session'
|
||||
|
||||
function createSnapshot(
|
||||
overrides: Partial<WorkspaceSessionSnapshot> = {}
|
||||
): WorkspaceSessionSnapshot {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorkspaceKey: 'worktree:wt-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
localOnlyScrollbackByTabId: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserUrlHistory: [],
|
||||
workspaceDocHistory: [],
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
sshConnectionStates: new Map(),
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
defaultTerminalTabsAppliedByWorktreeId: {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace session live PTY persistence', () => {
|
||||
it('does not treat slept terminal wake hints as active on restart', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'tab-1',
|
||||
title: 'shell',
|
||||
ptyId: 'preserved-wake-hint',
|
||||
worktreeId: 'wt-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [] }
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.activeWorktreeIdsOnShutdown).toEqual([])
|
||||
})
|
||||
|
||||
it('does not persist remote session ids for slept SSH tabs', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-ssh': [
|
||||
{
|
||||
id: 'tab-ssh',
|
||||
title: 'remote',
|
||||
ptyId: 'relay-sess-42',
|
||||
worktreeId: 'wt-ssh'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-ssh': [] },
|
||||
lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'relay-sess-42' },
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/repo-ssh',
|
||||
displayName: 'SSH',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.activeWorktreeIdsOnShutdown).toEqual([])
|
||||
expect(payload.remoteSessionIdsByTabId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reconnects a persisted SSH PTY when shutdown observes a transient relay drop', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-ssh': [
|
||||
{
|
||||
id: 'tab-ssh',
|
||||
title: 'remote',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-ssh'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-ssh': [] },
|
||||
lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' },
|
||||
// Involuntary drops use reconnecting/reconnection-failed/error; disconnected means explicit user action.
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]),
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/repo-ssh',
|
||||
displayName: 'SSH',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.remoteSessionIdsByTabId).toEqual({
|
||||
'tab-ssh': 'ssh:conn-1@@pty-42'
|
||||
})
|
||||
expect(payload.activeConnectionIdsAtShutdown).toEqual(['conn-1'])
|
||||
})
|
||||
|
||||
it('keeps explicitly disconnected hosts out of the startup reconnect list', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-ssh': [
|
||||
{
|
||||
id: 'tab-ssh',
|
||||
title: 'remote',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-ssh'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-ssh': [] },
|
||||
lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' },
|
||||
// Keep disconnected session IDs for focus restore, but never auto-dial hosts the user deliberately left offline.
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'disconnected' } as never]]),
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-ssh',
|
||||
path: '/repo-ssh',
|
||||
displayName: 'SSH',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.remoteSessionIdsByTabId).toEqual({
|
||||
'tab-ssh': 'ssh:conn-1@@pty-42'
|
||||
})
|
||||
expect(payload.activeConnectionIdsAtShutdown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never derives runtime-owned SSH targets into the startup reconnect list', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-vm': [
|
||||
{
|
||||
id: 'tab-vm',
|
||||
title: 'vm',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-vm'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-vm': [] },
|
||||
lastKnownRelayPtyIdByTabId: { 'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7' },
|
||||
// Seed optimistic runtime-owned status so exclusion is independently tested on both session-ID union and connected-state paths; missing status would already gate it out.
|
||||
sshConnectionStates: new Map([
|
||||
['runtime-ssh-vm1', { status: 'reconnecting' } as never],
|
||||
['runtime-ssh-vm2', { status: 'connected' } as never]
|
||||
]),
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-vm',
|
||||
path: '/repo-vm',
|
||||
displayName: 'VM',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
connectionId: 'runtime-ssh-vm1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-vm': [{ id: 'wt-vm', repoId: 'repo-vm' } as never]
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Runtime owns ephemeral-VM target lifecycle; renderer startup connect would touch hidden/unmanageable hosts.
|
||||
expect(payload.remoteSessionIdsByTabId).toEqual({
|
||||
'tab-vm': 'ssh:runtime-ssh-vm1@@pty-7'
|
||||
})
|
||||
expect(payload.activeConnectionIdsAtShutdown).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,540 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { WorkspaceSessionSnapshot } from './workspace-session'
|
||||
import { buildWorkspaceSessionPatch } from './workspace-session-patch'
|
||||
|
||||
function createSnapshot(
|
||||
overrides: Partial<WorkspaceSessionSnapshot> = {}
|
||||
): WorkspaceSessionSnapshot {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', title: 'shell', ptyId: 'pty-1', worktreeId: 'wt-1' }],
|
||||
'wt-2': [{ id: 'tab-2', title: 'editor', ptyId: null, worktreeId: 'wt-2' }]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1'],
|
||||
'tab-2': []
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
},
|
||||
activeTabIdByWorktree: { 'wt-1': 'tab-1', 'wt-2': 'tab-2' },
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/demo.ts',
|
||||
filePath: '/tmp/demo.ts',
|
||||
relativePath: 'demo.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
isPreview: false,
|
||||
content: '',
|
||||
originalContent: ''
|
||||
},
|
||||
{
|
||||
id: '/tmp/demo.diff',
|
||||
filePath: '/tmp/demo.diff',
|
||||
relativePath: 'demo.diff',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'diff',
|
||||
mode: 'diff',
|
||||
isDirty: false,
|
||||
isPreview: false,
|
||||
content: '',
|
||||
originalContent: ''
|
||||
}
|
||||
],
|
||||
activeFileIdByWorktree: { 'wt-1': '/tmp/demo.ts' },
|
||||
activeTabTypeByWorktree: { 'wt-1': 'editor', 'wt-2': 'terminal' },
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserUrlHistory: [],
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
sshConnectionStates: new Map(),
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {},
|
||||
...overrides
|
||||
} as WorkspaceSessionSnapshot
|
||||
}
|
||||
|
||||
function createRepo(id: string, connectionId: string | null): Repo {
|
||||
return {
|
||||
id,
|
||||
path: `/${id}`,
|
||||
displayName: id,
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildWorkspaceSessionPatch', () => {
|
||||
it('returns only the direct key for active tab changes', () => {
|
||||
const patch = buildWorkspaceSessionPatch(createSnapshot({ activeTabId: 'tab-2' }), [
|
||||
'activeTabId'
|
||||
])
|
||||
|
||||
expect(patch).toEqual({ activeTabId: 'tab-2' })
|
||||
})
|
||||
|
||||
it('writes a changed workspace key and host so a refocus replaces the persisted ones', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
activeWorktreeId: 'wt-2',
|
||||
activeWorkspaceKey: 'worktree:wt-2',
|
||||
activeWorkspaceExecutionHostId: null
|
||||
}),
|
||||
['activeWorktreeId', 'activeWorkspaceKey', 'activeWorkspaceExecutionHostId']
|
||||
)
|
||||
|
||||
expect(patch).toEqual({
|
||||
activeWorktreeId: 'wt-2',
|
||||
activeWorkspaceKey: 'worktree:wt-2',
|
||||
activeWorkspaceExecutionHostId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('derives only editor session keys for open file changes', () => {
|
||||
const patch = buildWorkspaceSessionPatch(createSnapshot(), ['openFiles'])
|
||||
|
||||
expect(Object.keys(patch).sort()).toEqual(
|
||||
[
|
||||
'activeFileIdByWorktree',
|
||||
'activeTabTypeByWorktree',
|
||||
'markdownFrontmatterVisible',
|
||||
'openFilesByWorktree'
|
||||
].sort()
|
||||
)
|
||||
expect(patch.openFilesByWorktree).toEqual({
|
||||
'wt-1': [
|
||||
{
|
||||
filePath: '/tmp/demo.ts',
|
||||
relativePath: 'demo.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
isPreview: undefined
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('derives editor session keys when only editor drafts change', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/demo.ts',
|
||||
filePath: '/tmp/demo.ts',
|
||||
relativePath: 'demo.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: true
|
||||
} as never
|
||||
],
|
||||
editorDrafts: { '/tmp/demo.ts': 'edited' }
|
||||
}),
|
||||
['editorDrafts']
|
||||
)
|
||||
|
||||
expect(Object.keys(patch).sort()).toEqual(
|
||||
[
|
||||
'activeFileIdByWorktree',
|
||||
'activeTabTypeByWorktree',
|
||||
'markdownFrontmatterVisible',
|
||||
'openFilesByWorktree'
|
||||
].sort()
|
||||
)
|
||||
expect(patch.openFilesByWorktree?.['wt-1'][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
filePath: '/tmp/demo.ts',
|
||||
dirtyDraftContent: 'edited'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('derives editor session keys when markdown front-matter visibility changes', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
markdownFrontmatterVisible: {
|
||||
'/tmp/demo.ts': true,
|
||||
'/tmp/demo.diff': true
|
||||
}
|
||||
}),
|
||||
['markdownFrontmatterVisible']
|
||||
)
|
||||
|
||||
expect(Object.keys(patch).sort()).toEqual(
|
||||
[
|
||||
'activeFileIdByWorktree',
|
||||
'activeTabTypeByWorktree',
|
||||
'markdownFrontmatterVisible',
|
||||
'openFilesByWorktree'
|
||||
].sort()
|
||||
)
|
||||
expect(patch.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': true })
|
||||
})
|
||||
|
||||
it('sanitizes terminal tabs and prunes local buffers when tab topology changes', () => {
|
||||
const localWorktreeId = 'repo-1::/local/worktree'
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
[localWorktreeId]: [
|
||||
{
|
||||
id: 'tab-local',
|
||||
title: 'shell',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: localWorktreeId,
|
||||
pendingActivationSpawn: true,
|
||||
recovery: {
|
||||
attemptedAt: [1],
|
||||
generation: 1,
|
||||
outcome: 'pending',
|
||||
startedAt: 1,
|
||||
reason: 'reattach-unverifiable',
|
||||
tabGeneration: 1
|
||||
}
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-local': ['pty-1']
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-local': {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
buffersByLeafId: { 'pane:1': 'serialized-local-scrollback' },
|
||||
scrollbackRefsByLeafId: { 'pane:1': 'v1-local' },
|
||||
ptyIdsByLeafId: { 'pane:1': 'pty-1' }
|
||||
}
|
||||
},
|
||||
repos: [createRepo('repo-1', null)]
|
||||
}),
|
||||
['tabsByWorktree']
|
||||
)
|
||||
|
||||
expect(Object.keys(patch).sort()).toEqual(
|
||||
[
|
||||
// Remote-session patches must carry derived reconnect targets too, or crash between writes strands stale target IDs.
|
||||
'activeConnectionIdsAtShutdown',
|
||||
'activeWorktreeIdsOnShutdown',
|
||||
'localOnlyScrollbackByTabId',
|
||||
'remoteSessionIdsByTabId',
|
||||
'tabsByWorktree',
|
||||
'terminalLayoutsByTabId'
|
||||
].sort()
|
||||
)
|
||||
expect('pendingActivationSpawn' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false)
|
||||
// Recovery ledgers describe live mounted heals; persisting them blocks legitimate post-restart recovery.
|
||||
expect('recovery' in patch.tabsByWorktree![localWorktreeId][0]).toBe(false)
|
||||
expect(patch.terminalLayoutsByTabId?.['tab-local'].buffersByLeafId).toBeUndefined()
|
||||
expect(patch.terminalLayoutsByTabId?.['tab-local'].scrollbackRefsByLeafId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('patches the local-only scrollback home on its own, pruned like the shared one', () => {
|
||||
const remoteWorktreeId = 'repo-ssh::/remote/worktree'
|
||||
const localWorktreeId = 'repo-1::/local/worktree'
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
[remoteWorktreeId]: [
|
||||
{
|
||||
id: 'tab-remote',
|
||||
title: 'shell',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'pty-r',
|
||||
worktreeId: remoteWorktreeId
|
||||
}
|
||||
],
|
||||
[localWorktreeId]: [
|
||||
{
|
||||
id: 'tab-local',
|
||||
title: 'shell',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
ptyId: 'pty-l',
|
||||
worktreeId: localWorktreeId
|
||||
}
|
||||
]
|
||||
},
|
||||
localOnlyScrollbackByTabId: {
|
||||
'tab-remote': { 'pane:1': 'remote-park' },
|
||||
'tab-local': { 'pane:1': 'local-park' }
|
||||
},
|
||||
repos: [createRepo('repo-1', null), createRepo('repo-ssh', 'conn-1')]
|
||||
}),
|
||||
['localOnlyScrollbackByTabId']
|
||||
)
|
||||
|
||||
// Local-only and remote layouts change independently; do not resend all layouts after local-only parks.
|
||||
expect(Object.keys(patch)).toEqual(['localOnlyScrollbackByTabId'])
|
||||
expect(patch.localOnlyScrollbackByTabId).toEqual({ 'tab-remote': { 'pane:1': 'remote-park' } })
|
||||
})
|
||||
|
||||
it('writes an emptied local-only scrollback home as an empty map so the clear sticks', () => {
|
||||
const patch = buildWorkspaceSessionPatch(createSnapshot({ localOnlyScrollbackByTabId: {} }), [
|
||||
'localOnlyScrollbackByTabId'
|
||||
])
|
||||
|
||||
expect(patch).toEqual({ localOnlyScrollbackByTabId: {} })
|
||||
})
|
||||
|
||||
it('keeps optional clearing keys in patches', () => {
|
||||
const patch = buildWorkspaceSessionPatch(createSnapshot({ sshConnectionStates: new Map() }), [
|
||||
'sshConnectionStates'
|
||||
])
|
||||
|
||||
expect(Object.hasOwn(patch, 'activeConnectionIdsAtShutdown')).toBe(true)
|
||||
expect(patch.activeConnectionIdsAtShutdown).toBeUndefined()
|
||||
})
|
||||
|
||||
it('derives reconnect targets from surviving relay session ids in terminal-field patches', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-ssh': [{ id: 'tab-ssh', title: 'remote', ptyId: null, worktreeId: 'wt-ssh' } as never]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-ssh': [] },
|
||||
lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'ssh:conn-1@@pty-42' },
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'reconnecting' } as never]]),
|
||||
repos: [createRepo('repo-ssh', 'conn-1')],
|
||||
worktreesByRepo: { 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never] }
|
||||
}),
|
||||
['lastKnownRelayPtyIdByTabId']
|
||||
)
|
||||
|
||||
expect(patch.remoteSessionIdsByTabId).toEqual({ 'tab-ssh': 'ssh:conn-1@@pty-42' })
|
||||
expect(patch.activeConnectionIdsAtShutdown).toEqual(['conn-1'])
|
||||
})
|
||||
|
||||
it('patches tab chrome as a sanitized bundle when split groups change', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'term-unified-1',
|
||||
entityId: 'tab-1',
|
||||
groupId: 'group-left',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
label: 'shell',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-left',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'term-unified-1',
|
||||
tabOrder: ['term-unified-1']
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
'wt-1': {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-left' },
|
||||
second: { type: 'leaf', groupId: 'group-right' }
|
||||
}
|
||||
},
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-right' }
|
||||
}),
|
||||
['groupsByWorktree']
|
||||
)
|
||||
|
||||
expect(Object.keys(patch).sort()).toEqual(
|
||||
['activeGroupIdByWorktree', 'tabGroupLayouts', 'tabGroups', 'unifiedTabs'].sort()
|
||||
)
|
||||
expect(patch.tabGroups?.['wt-1']).toEqual([
|
||||
expect.objectContaining({ id: 'group-left', tabOrder: ['term-unified-1'] })
|
||||
])
|
||||
expect(patch.tabGroupLayouts?.['wt-1']).toEqual({ type: 'leaf', groupId: 'group-left' })
|
||||
expect(patch.activeGroupIdByWorktree?.['wt-1']).toBe('group-left')
|
||||
})
|
||||
|
||||
it('persists default terminal tab idempotency marker changes', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({ defaultTerminalTabsAppliedByWorktreeId: { 'wt-1': true } }),
|
||||
['defaultTerminalTabsAppliedByWorktreeId']
|
||||
)
|
||||
|
||||
expect(patch).toEqual({
|
||||
defaultTerminalTabsAppliedByWorktreeId: { 'wt-1': true }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps default terminal tab marker clearing keys in patches', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({ defaultTerminalTabsAppliedByWorktreeId: {} }),
|
||||
['defaultTerminalTabsAppliedByWorktreeId']
|
||||
)
|
||||
|
||||
expect(Object.hasOwn(patch, 'defaultTerminalTabsAppliedByWorktreeId')).toBe(true)
|
||||
expect(patch.defaultTerminalTabsAppliedByWorktreeId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears the last sleeping agent session with an empty map the host split keeps', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({ sleepingAgentSessionsByPaneKey: {} }),
|
||||
['sleepingAgentSessionsByPaneKey']
|
||||
)
|
||||
|
||||
// undefined would be dropped before the write, leaving the record on disk until quit.
|
||||
expect(patch.sleepingAgentSessionsByPaneKey).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a staged browser tab out of incremental patches too', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'staged-1',
|
||||
activePageId: 'staged-page',
|
||||
pageIds: ['staged-page'],
|
||||
worktreeId: 'wt-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'staged-1': [{ id: 'staged-page', workspaceId: 'staged-1', worktreeId: 'wt-1' } as never]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'staged-page': { environmentId: 'env-1', remotePageId: 'staged-page', staged: true }
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'staged-1' }
|
||||
}),
|
||||
['browserTabsByWorktree', 'browserPagesByWorkspace', 'activeBrowserTabIdByWorktree']
|
||||
)
|
||||
|
||||
expect(patch.browserTabsByWorktree).toEqual({ 'wt-1': [] })
|
||||
expect(patch.browserPagesByWorkspace).toEqual({})
|
||||
expect(patch.activeBrowserTabIdByWorktree).toEqual({ 'wt-1': null })
|
||||
})
|
||||
|
||||
// Clearing staged flags can leave row bytes unchanged; handle-map changes alone can make tabs persistable.
|
||||
it('persists an adopted browser tab when only the handle map changed', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'adopted-1',
|
||||
activePageId: 'adopted-page',
|
||||
pageIds: ['adopted-page'],
|
||||
worktreeId: 'wt-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'adopted-1': [
|
||||
{ id: 'adopted-page', workspaceId: 'adopted-1', worktreeId: 'wt-1' } as never
|
||||
]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'adopted-page': { environmentId: 'env-1', remotePageId: 'host-page-1' }
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'adopted-1' },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'unified-1',
|
||||
entityId: 'adopted-1',
|
||||
contentType: 'browser',
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'unified-1', tabOrder: ['unified-1'] }
|
||||
] as never
|
||||
},
|
||||
layoutByWorktree: { 'wt-1': { type: 'leaf', groupId: 'group-1' } }
|
||||
}),
|
||||
['remoteBrowserPageHandlesByPageId']
|
||||
)
|
||||
|
||||
expect(patch.browserTabsByWorktree?.['wt-1']).toHaveLength(1)
|
||||
expect(patch.browserPagesByWorkspace?.['adopted-1']).toHaveLength(1)
|
||||
expect(patch.activeBrowserTabIdByWorktree).toEqual({ 'wt-1': 'adopted-1' })
|
||||
expect(patch.unifiedTabs?.['wt-1']).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Quit uses incremental writes; preserving remote-page identity only in full payloads still loses it on actual shutdown.
|
||||
it('stamps the remote page identity onto incrementally patched browser page rows', () => {
|
||||
const patch = buildWorkspaceSessionPatch(
|
||||
createSnapshot({
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'adopted-1',
|
||||
activePageId: 'adopted-page',
|
||||
pageIds: ['adopted-page'],
|
||||
worktreeId: 'wt-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'adopted-1': [
|
||||
{ id: 'adopted-page', workspaceId: 'adopted-1', worktreeId: 'wt-1' } as never
|
||||
]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'adopted-page': {
|
||||
environmentId: 'env-1',
|
||||
remotePageId: 'host-page-1',
|
||||
placement: {
|
||||
kind: 'client',
|
||||
browserHostClientId: 'host-a',
|
||||
browserHostGeneration: 1,
|
||||
pageHostGeneration: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'adopted-1' }
|
||||
}),
|
||||
['browserPagesByWorkspace']
|
||||
)
|
||||
|
||||
expect(patch.browserPagesByWorkspace?.['adopted-1']?.[0]).toMatchObject({
|
||||
remoteBrowserPageId: 'host-page-1',
|
||||
remoteBrowserPageClientHosted: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldPersistWorkspaceSession } from './workspace-session'
|
||||
|
||||
describe('shouldPersistWorkspaceSession', () => {
|
||||
it('returns false before either flag is set', () => {
|
||||
expect(
|
||||
shouldPersistWorkspaceSession({
|
||||
workspaceSessionReady: false,
|
||||
hydrationSucceeded: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the UI is ready but hydration failed', () => {
|
||||
// Error UI may mount, but persistence must remain closed or empty memory overwrites disk.
|
||||
expect(
|
||||
shouldPersistWorkspaceSession({
|
||||
workspaceSessionReady: true,
|
||||
hydrationSucceeded: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when hydration finished but UI is not ready yet', () => {
|
||||
expect(
|
||||
shouldPersistWorkspaceSession({
|
||||
workspaceSessionReady: false,
|
||||
hydrationSucceeded: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true only when both flags are set', () => {
|
||||
expect(
|
||||
shouldPersistWorkspaceSession({
|
||||
workspaceSessionReady: true,
|
||||
hydrationSucceeded: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toAppSshPtyId } from '../../../shared/ssh-pty-id'
|
||||
import type { SshConnectionState } from '../../../shared/ssh-types'
|
||||
import type { WorkspaceSessionSnapshot } from './workspace-session'
|
||||
import { buildActiveConnectionIdsAtShutdown } from './workspace-session-reconnect-targets'
|
||||
|
||||
function connected(targetId: string): [string, SshConnectionState] {
|
||||
return [targetId, { targetId, status: 'connected', error: null, reconnectAttempt: 0 }]
|
||||
}
|
||||
|
||||
describe('buildActiveConnectionIdsAtShutdown', () => {
|
||||
it('never asks startup to dial an Orca-owned target', () => {
|
||||
const owned = ['runtime-ssh-vm-1', 'machine-0123']
|
||||
const snapshot = {
|
||||
sshConnectionStates: new Map([connected('ssh-user'), ...owned.map(connected)])
|
||||
}
|
||||
const remoteSessions = Object.fromEntries(
|
||||
owned.map((id, index) => [`tab-${index}`, toAppSshPtyId(id, 'pty-1')])
|
||||
)
|
||||
expect(
|
||||
buildActiveConnectionIdsAtShutdown(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the builder reads only sshConnectionStates.
|
||||
snapshot as WorkspaceSessionSnapshot,
|
||||
remoteSessions
|
||||
)
|
||||
).toEqual(['ssh-user'])
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestStore, makeTab } from '@/store/slices/store-test-helpers'
|
||||
import { buildWorkspaceSessionPayload } from './workspace-session'
|
||||
|
||||
describe("the window's saved session", () => {
|
||||
it("never carries main's resume outcomes, which are live state", () => {
|
||||
const store = createTestStore()
|
||||
const tab = makeTab({
|
||||
id: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
ptyId: 'pty-1',
|
||||
title: 'restored',
|
||||
createdAt: 1
|
||||
})
|
||||
store.setState({
|
||||
tabsByWorktree: { 'wt-1': [tab] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null
|
||||
}
|
||||
},
|
||||
terminalPaneResumeOutcomesByPaneKey: {
|
||||
'tab-1:leaf-1': { ptyId: 'pty-1', ptyGeneration: 3, outcome: 'restored' }
|
||||
}
|
||||
})
|
||||
|
||||
const saved = buildWorkspaceSessionPayload(store.getState())
|
||||
|
||||
expect(saved).not.toHaveProperty('terminalPaneResumeOutcomesByPaneKey')
|
||||
expect(saved.tabsByWorktree).toEqual({ 'wt-1': [tab] })
|
||||
expect(saved.terminalLayoutsByTabId).toEqual(store.getState().terminalLayoutsByTabId)
|
||||
})
|
||||
})
|
||||
@@ -8,19 +8,12 @@ import type {
|
||||
WorkspaceSessionState
|
||||
} from '../../../shared/workspace-session-state-types'
|
||||
import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration'
|
||||
import {
|
||||
buildHostSessionRouting,
|
||||
patchWorkspaceSessionByHost,
|
||||
type HostPersistenceState
|
||||
} from './workspace-session-host-persistence'
|
||||
import { patchWorkspaceSessionByHost } from './workspace-session-host-persistence'
|
||||
|
||||
const TARGET_ID = 'target-1'
|
||||
const SSH_HOST_ID: ExecutionHostId = `ssh:${TARGET_ID}`
|
||||
const OTHER_TARGET_ID = 'target-2'
|
||||
const OTHER_SSH_HOST_ID: ExecutionHostId = `ssh:${OTHER_TARGET_ID}`
|
||||
const FOLDER_WORKSPACE_ID = 'fw-1'
|
||||
const FOLDER_KEY = `folder:${FOLDER_WORKSPACE_ID}`
|
||||
const PROJECT_GROUP_ID = 'pg-1'
|
||||
|
||||
function session(overrides: Partial<WorkspaceSessionState>): WorkspaceSessionState {
|
||||
return { ...getDefaultWorkspaceSession(), ...overrides }
|
||||
@@ -50,207 +43,9 @@ function partitionedApi(partitions: Partial<Record<string, WorkspaceSessionState
|
||||
}
|
||||
}
|
||||
|
||||
const sshFolderCatalog = {
|
||||
projectGroups: [{ id: PROJECT_GROUP_ID, executionHostId: SSH_HOST_ID }],
|
||||
folderWorkspaces: [
|
||||
{ id: FOLDER_WORKSPACE_ID, projectGroupId: PROJECT_GROUP_ID, executionHostId: SSH_HOST_ID }
|
||||
]
|
||||
}
|
||||
|
||||
describe('ssh folder workspace partition ownership', () => {
|
||||
it('routes a folder workspace to the partition main writes it to', () => {
|
||||
const routing = buildHostSessionRouting({
|
||||
repos: [],
|
||||
...sshFolderCatalog,
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
// RuntimeWorkspaceSessionController.getPreferredHostId answers `ssh:<targetId>` for this key;
|
||||
// 'local' here is the two-store split that made SSH tabs disappear on restart.
|
||||
expect(routing.hostIdByWorktreeId(FOLDER_KEY)).toBe(SSH_HOST_ID)
|
||||
})
|
||||
|
||||
it('keeps a folder row out of the local partition so a save cannot erase main’s copy', async () => {
|
||||
const patches: { hostId: ExecutionHostId | undefined; patch: WorkspaceSessionPatch }[] = []
|
||||
const state: HostPersistenceState = { repos: [], ...sshFolderCatalog, worktreesByRepo: {} }
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{
|
||||
get: async () => getDefaultWorkspaceSession(),
|
||||
patch: async (patch, hostId) => {
|
||||
patches.push({ hostId, patch })
|
||||
}
|
||||
},
|
||||
{ tabsByWorktree: { [FOLDER_KEY]: [tab('tab-desktop', FOLDER_KEY)] } },
|
||||
state
|
||||
).written
|
||||
|
||||
// Partition patches replace each supplied field; sending only the sibling folder row would erase main’s original row.
|
||||
const sshPatch = patches.find((entry) => entry.hostId === SSH_HOST_ID)?.patch
|
||||
expect(Object.keys(sshPatch?.tabsByWorktree ?? {})).toEqual([FOLDER_KEY])
|
||||
const localPatch = patches.find((entry) => entry.hostId === undefined)?.patch
|
||||
expect(localPatch?.tabsByWorktree?.[FOLDER_KEY]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads an ssh partition that only a folder workspace owns', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
local: session({}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [FOLDER_KEY]: [tab('tab-host', FOLDER_KEY)] }
|
||||
})
|
||||
}),
|
||||
// No repo names the target: the catalog-derived partition list cannot see this host at all.
|
||||
[]
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[FOLDER_KEY]?.map((entry) => entry.id)).toEqual(['tab-host'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh partition read source drives the write back', () => {
|
||||
const REPO_ID = 'repo-remote'
|
||||
const WORKTREE_ID = `${REPO_ID}::/remote/checkout`
|
||||
|
||||
it('returns adopted rows to the partition they were read from with no repo catalog', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
local: session({}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-runtime', WORKTREE_ID)] }
|
||||
})
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
expect(read.contestedPrimaryHostBySessionKey?.[WORKTREE_ID]).toBe(SSH_HOST_ID)
|
||||
const routing = buildHostSessionRouting({
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
contestedPrimaryHostBySessionKey: read.contestedPrimaryHostBySessionKey
|
||||
})
|
||||
// Preserve read-source routing without catalog owners or reunited rows get stranded local again.
|
||||
expect(routing.hostIdByWorktreeId(WORKTREE_ID)).toBe(SSH_HOST_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a bare workspace id two partitions both hold', () => {
|
||||
const REPO_ID = 'repo-duplicated'
|
||||
const WORKTREE_ID = `${REPO_ID}::/checkout`
|
||||
const DRAFT = 'unsaved draft that no other channel can recover'
|
||||
|
||||
function openFile(
|
||||
filePath: string,
|
||||
dirtyDraftContent?: string
|
||||
): NonNullable<WorkspaceSessionState['openFilesByWorktree']>[string][number] {
|
||||
return {
|
||||
filePath,
|
||||
relativePath: filePath.slice(filePath.lastIndexOf('/') + 1),
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript',
|
||||
dirtyDraftContent
|
||||
}
|
||||
}
|
||||
|
||||
function collidingPartitions(rivalHostId: ExecutionHostId) {
|
||||
return {
|
||||
local: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [] },
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [openFile('/checkout/a.ts', DRAFT)]
|
||||
}
|
||||
}),
|
||||
[rivalHostId]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-rival', WORKTREE_ID)] },
|
||||
openFilesByWorktree: { [WORKTREE_ID]: [openFile('/checkout/b.ts')] }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('gap-fills instead of replacing, so the local workspace keeps its unsaved draft', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(collidingPartitions(SSH_HOST_ID)),
|
||||
// Multi-host catalog repo IDs prove rivalry; local/SSH co-presence alone is the legacy split to repair.
|
||||
[
|
||||
{ id: REPO_ID, connectionId: null, executionHostId: 'local' },
|
||||
{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: SSH_HOST_ID }
|
||||
]
|
||||
)
|
||||
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(DRAFT)
|
||||
})
|
||||
|
||||
it('still reads an empty base tab row as a gap when the id is contested', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(collidingPartitions(SSH_HOST_ID)),
|
||||
[
|
||||
{ id: REPO_ID, connectionId: null, executionHostId: 'local' },
|
||||
{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: SSH_HOST_ID }
|
||||
]
|
||||
)
|
||||
|
||||
// Empty terminal lists remain adoptable gaps even for contested IDs; treating them as content would publish deletion over live host tabs (#12721).
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'tab-rival'
|
||||
])
|
||||
})
|
||||
|
||||
it('never names the rival partition as the write target for a contested id', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(collidingPartitions(SSH_HOST_ID)),
|
||||
// Multi-host catalog repo IDs prove rivalry; local/SSH co-presence alone is the legacy split to repair.
|
||||
[
|
||||
{ id: REPO_ID, connectionId: null, executionHostId: 'local' },
|
||||
{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: SSH_HOST_ID }
|
||||
]
|
||||
)
|
||||
|
||||
// Whole-ID routing to the rival would move the local workspace’s rows; gap-fill must preserve its owner.
|
||||
expect(read.contestedPrimaryHostBySessionKey?.[WORKTREE_ID]).not.toBe(SSH_HOST_ID)
|
||||
})
|
||||
|
||||
it('gap-fills a contested id from the same partition on every boot', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
// Reverse census insertion order versus host sorting to prove deterministic winners.
|
||||
[OTHER_SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-two', WORKTREE_ID)] }
|
||||
}),
|
||||
// Editor presence gives this contested key a read source while leaving terminal gaps both partitions can fill.
|
||||
local: session({ openFilesByWorktree: { [WORKTREE_ID]: [openFile('/checkout/a.ts')] } }),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-one', WORKTREE_ID)] }
|
||||
})
|
||||
}),
|
||||
// No repo evidence: only duplicate partition membership can prove this contest.
|
||||
[]
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-one'])
|
||||
// Contested ids are withheld from the read-source override, so the write cannot carry one
|
||||
// host's rows into the other's partition.
|
||||
expect(read.contestedPrimaryHostBySessionKey?.[WORKTREE_ID]).not.toBe(SSH_HOST_ID)
|
||||
expect(read.contestedPrimaryHostBySessionKey?.[WORKTREE_ID]).not.toBe(OTHER_SSH_HOST_ID)
|
||||
})
|
||||
|
||||
it('does not adopt a contested id the assembled session holds no row for', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
local: session({}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-one', WORKTREE_ID)] }
|
||||
}),
|
||||
[OTHER_SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-two', WORKTREE_ID)] }
|
||||
})
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
// Unknown contested IDs lack source overrides; adopting would default write ownership local and re-split storage.
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]).toBeUndefined()
|
||||
expect(read.contestedPrimaryHostBySessionKey?.[WORKTREE_ID]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a declined row in its own partition when a sibling workspace is written', async () => {
|
||||
const SIBLING_ID = 'repo-sibling::/checkout'
|
||||
@@ -293,110 +88,4 @@ describe('a bare workspace id two partitions both hold', () => {
|
||||
expect(Object.keys(sshTabs ?? {}).sort()).toEqual([SIBLING_ID, WORKTREE_ID].sort())
|
||||
expect(sshTabs?.[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-declined'])
|
||||
})
|
||||
|
||||
it('does not adopt a partition the catalog says does not own the workspace', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
local: session({}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-stale', WORKTREE_ID)] }
|
||||
}),
|
||||
[OTHER_SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-live', WORKTREE_ID)] }
|
||||
})
|
||||
}),
|
||||
[{ id: REPO_ID, connectionId: OTHER_TARGET_ID, executionHostId: OTHER_SSH_HOST_ID }]
|
||||
)
|
||||
|
||||
// Catalog ownership must defeat earlier-sorting stale partitions or old rows overwrite the live host’s data.
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live'])
|
||||
expect(read.contestedPrimaryHostBySessionKey?.[WORKTREE_ID]).toBe(OTHER_SSH_HOST_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a stray local copy beside a leftover in another ssh partition', () => {
|
||||
const REPO_ID = 'repo-remote'
|
||||
const WORKTREE_ID = `${REPO_ID}::/remote/checkout/feature`
|
||||
|
||||
it.each([
|
||||
[TARGET_ID, OTHER_SSH_HOST_ID],
|
||||
[OTHER_TARGET_ID, SSH_HOST_ID]
|
||||
])('keeps the rows of the partition the catalog names (owner %s)', async (owner, leftover) => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
local: session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-local', WORKTREE_ID)] } }),
|
||||
[`ssh:${owner}`]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-live', WORKTREE_ID)] }
|
||||
}),
|
||||
[leftover]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-leftover', WORKTREE_ID)] }
|
||||
})
|
||||
}),
|
||||
[{ id: REPO_ID, connectionId: owner, executionHostId: null }]
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live'])
|
||||
})
|
||||
})
|
||||
|
||||
describe("an ssh partition left over from before the host's managed server", () => {
|
||||
const REPO_ID = 'repo-managed'
|
||||
const WORKTREE_ID = `${REPO_ID}::/remote/checkout`
|
||||
const RUNTIME_HOST_ID: ExecutionHostId = 'runtime:managed-env'
|
||||
|
||||
it("keeps the runtime partition's rows when this desktop still catalogs the old ssh repo (#34376, #34416)", async () => {
|
||||
const runtimeLayout = {
|
||||
type: 'split' as const,
|
||||
direction: 'horizontal' as const,
|
||||
first: { type: 'leaf' as const, groupId: 'host-group' },
|
||||
second: { type: 'leaf' as const, groupId: 'editor-group' }
|
||||
}
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
local: session({}),
|
||||
[RUNTIME_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-host', WORKTREE_ID)] },
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: '/remote/checkout/new.ts',
|
||||
relativePath: 'new.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript'
|
||||
}
|
||||
]
|
||||
},
|
||||
tabGroupLayouts: { [WORKTREE_ID]: runtimeLayout }
|
||||
}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [tab('tab-closed', WORKTREE_ID)] },
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: '/remote/checkout/closed.ts',
|
||||
relativePath: 'closed.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript'
|
||||
}
|
||||
]
|
||||
},
|
||||
tabGroupLayouts: { [WORKTREE_ID]: { type: 'leaf', groupId: 'ssh-group' } }
|
||||
})
|
||||
}),
|
||||
[{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: null }],
|
||||
[RUNTIME_HOST_ID],
|
||||
new Map([[RUNTIME_HOST_ID, SSH_HOST_ID]])
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-host'])
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((f) => f.relativePath)).toEqual([
|
||||
'new.ts'
|
||||
])
|
||||
expect(read.session.tabGroupLayouts?.[WORKTREE_ID]).toEqual(runtimeLayout)
|
||||
expect(read.contestedPrimaryHostBySessionKey?.[WORKTREE_ID]).toBe(RUNTIME_HOST_ID)
|
||||
// Declined rows stay parked for their own partition so a downgraded client still finds them.
|
||||
expect(
|
||||
read.contestedHostWorkspaceSessions[SSH_HOST_ID]?.tabsByWorktree?.[WORKTREE_ID]?.[0]?.id
|
||||
).toBe('tab-closed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/** Restore sessions runtime wrote to SSH partitions, formerly unread by renderer local/runtime-only hydration (#12721/#18173). */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-resume'
|
||||
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
|
||||
import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration'
|
||||
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import '../../../shared/workspace-scope'
|
||||
import { makeUnifiedTab } from '@/store/slices/store-test-helpers'
|
||||
import { buildWorkspaceSessionHostSnapshots } from './workspace-session-host-persistence'
|
||||
|
||||
@@ -47,439 +47,6 @@ function partitionedApi(
|
||||
}
|
||||
}
|
||||
|
||||
/** Reproduce #12721: local has an empty terminal list while SSH owns the real rows. */
|
||||
function strandedPartitions(hostTabs: TerminalTab[], localTabs: TerminalTab[] = []) {
|
||||
return {
|
||||
local: session({ tabsByWorktree: { [WORKTREE_ID]: localTabs } }),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: hostTabs },
|
||||
activeTabIdByWorktree: { [WORKTREE_ID]: hostTabs[0]?.id ?? null }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('ssh host partition hydration', () => {
|
||||
it('hydrates tabs the runtime persisted into the ssh partition', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(strandedPartitions([tab('tab-runtime')])),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'tab-runtime'
|
||||
])
|
||||
})
|
||||
|
||||
it('adopts the stranded workspace rows alongside its tabs', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(strandedPartitions([tab('tab-runtime')])),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('tab-runtime')
|
||||
})
|
||||
|
||||
it('adopts a hibernated agent record the host partition alone holds', async () => {
|
||||
// Full SSH writes replace the partition; retain runtime-authored rows during reunification or repair deletes them.
|
||||
const partitions = strandedPartitions([tab('tab-runtime')])
|
||||
partitions[SSH_HOST_ID] = session({
|
||||
...partitions[SSH_HOST_ID],
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-runtime:leaf-1': {
|
||||
paneKey: 'tab-runtime:leaf-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'tab-runtime',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'resume me',
|
||||
state: 'done',
|
||||
capturedAt: 5,
|
||||
updatedAt: 5
|
||||
} satisfies SleepingAgentSessionRecord
|
||||
}
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(
|
||||
read.session.sleepingAgentSessionsByPaneKey?.['tab-runtime:leaf-1']?.providerSession.id
|
||||
).toBe('session-1')
|
||||
})
|
||||
|
||||
it('ignores a stray local copy of a workspace the catalog places on the ssh target', async () => {
|
||||
// Local SSH-workspace rows are residue; retaining them resurrected closed tabs while losing live host rows (#23390/#25616).
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(strandedPartitions([tab('tab-runtime')], [tab('tab-local')])),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'tab-runtime'
|
||||
])
|
||||
expect(read.session.activeTabIdByWorktree?.[WORKTREE_ID]).toBe('tab-runtime')
|
||||
expect(read.contestedPrimaryHostBySessionKey[WORKTREE_ID]).toBe(SSH_HOST_ID)
|
||||
})
|
||||
|
||||
it('keeps the ssh partition agent-resume records beside a stray local copy', async () => {
|
||||
const partitions = strandedPartitions([tab('tab-runtime')], [tab('tab-local')])
|
||||
partitions[SSH_HOST_ID] = session({
|
||||
...partitions[SSH_HOST_ID],
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-runtime:leaf-1': {
|
||||
paneKey: 'tab-runtime:leaf-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
tabId: 'tab-runtime',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'session-1' },
|
||||
prompt: 'resume me',
|
||||
state: 'done',
|
||||
capturedAt: 5,
|
||||
updatedAt: 5
|
||||
} satisfies SleepingAgentSessionRecord
|
||||
}
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(Object.keys(read.session.sleepingAgentSessionsByPaneKey ?? {})).toEqual([
|
||||
'tab-runtime:leaf-1'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps local tabs when the ssh partition names the workspace but holds no tabs', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(strandedPartitions([], [tab('tab-local')])),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'tab-local'
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores a stray local copy when the ssh partition keys its tabs by workspace key', async () => {
|
||||
const partitions = strandedPartitions([tab('tab-runtime')], [tab('tab-local')])
|
||||
partitions[SSH_HOST_ID] = session({
|
||||
tabsByWorktree: { [worktreeWorkspaceKey(WORKTREE_ID)]: [tab('tab-runtime')] }
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(
|
||||
Object.values(read.session.tabsByWorktree)
|
||||
.flat()
|
||||
.map((entry) => entry.id)
|
||||
).toEqual(['tab-runtime'])
|
||||
})
|
||||
|
||||
it("replaces a stray local copy's records for a tab id the ssh partition shares", async () => {
|
||||
// Relay reattach copied tab ids into `local`, so the stale and live rows can share a key.
|
||||
const partitions = strandedPartitions([tab('tab-shared')], [tab('tab-shared')])
|
||||
const layout = (activeLeafId: string) => ({ root: null, activeLeafId, expandedLeafId: null })
|
||||
partitions.local = session({
|
||||
...partitions.local,
|
||||
terminalLayoutsByTabId: { 'tab-shared': layout('leaf-stale') }
|
||||
})
|
||||
partitions[SSH_HOST_ID] = session({
|
||||
...partitions[SSH_HOST_ID],
|
||||
terminalLayoutsByTabId: { 'tab-shared': layout('leaf-live') }
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(read.session.terminalLayoutsByTabId?.['tab-shared']?.activeLeafId).toBe('leaf-live')
|
||||
})
|
||||
|
||||
it("keeps a stray local copy's unsaved draft beside the ssh partition's open files", async () => {
|
||||
const openFile = (relativePath: string, dirtyDraftContent?: string) => ({
|
||||
filePath: `${WORKTREE_PATH}/${relativePath}`,
|
||||
relativePath,
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript',
|
||||
...(dirtyDraftContent === undefined ? {} : { dirtyDraftContent })
|
||||
})
|
||||
const partitions = strandedPartitions([tab('tab-runtime')], [tab('tab-local')])
|
||||
partitions.local = session({
|
||||
...partitions.local,
|
||||
openFilesByWorktree: { [WORKTREE_ID]: [openFile('src/main.ts', 'unsaved work')] }
|
||||
})
|
||||
partitions[SSH_HOST_ID] = session({
|
||||
...partitions[SSH_HOST_ID],
|
||||
openFilesByWorktree: { [WORKTREE_ID]: [openFile('src/live.ts')] }
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'tab-runtime'
|
||||
])
|
||||
expect(
|
||||
read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => [
|
||||
file.relativePath,
|
||||
file.dirtyDraftContent
|
||||
])
|
||||
).toEqual([
|
||||
['src/live.ts', undefined],
|
||||
['src/main.ts', 'unsaved work']
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps a stray local copy's unsaved draft over the ssh partition's clean entry", async () => {
|
||||
const file = {
|
||||
filePath: `${WORKTREE_PATH}/src/main.ts`,
|
||||
relativePath: 'src/main.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript'
|
||||
}
|
||||
const partitions = strandedPartitions([tab('tab-runtime')], [tab('tab-local')])
|
||||
partitions.local = session({
|
||||
...partitions.local,
|
||||
openFilesByWorktree: { [WORKTREE_ID]: [{ ...file, dirtyDraftContent: 'unsaved work' }] }
|
||||
})
|
||||
partitions[SSH_HOST_ID] = session({
|
||||
...partitions[SSH_HOST_ID],
|
||||
openFilesByWorktree: { [WORKTREE_ID]: [file] }
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(
|
||||
read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((entry) => entry.dirtyDraftContent)
|
||||
).toEqual(['unsaved work'])
|
||||
})
|
||||
|
||||
it('leaves a populated local copy alone when the catalog cannot name its owner', async () => {
|
||||
// Without a repo row the local copy may be the live one, so the gap-filling rule still applies.
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(strandedPartitions([tab('tab-runtime')], [tab('tab-local')])),
|
||||
[]
|
||||
)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'tab-local'
|
||||
])
|
||||
expect(read.session.activeTabIdByWorktree?.[WORKTREE_ID]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes the reunited workspace back to the partition that owns it', async () => {
|
||||
const { buildHostIdByWorktreeId } = await import('./workspace-session-host-persistence')
|
||||
|
||||
const hostIdByWorktreeId = buildHostIdByWorktreeId({
|
||||
repos: [{ id: REPO_ID, connectionId: TARGET_ID, executionHostId: null }],
|
||||
worktreesByRepo: {}
|
||||
})
|
||||
|
||||
expect(hostIdByWorktreeId(WORKTREE_ID)).toBe(SSH_HOST_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition workspaces with no terminal tabs', () => {
|
||||
/** Editor-only SSH workspaces are valid: no terminal autocreate occurs beside editors, but all their state still belongs in SSH persistence. */
|
||||
function editorOnlyPartitions() {
|
||||
return {
|
||||
local: session({ tabsByWorktree: {} }),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [] },
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: '/remote/checkout/feature/src/main.ts',
|
||||
relativePath: 'src/main.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript',
|
||||
dirtyDraftContent: 'unsaved work'
|
||||
}
|
||||
]
|
||||
},
|
||||
activeFileIdByWorktree: { [WORKTREE_ID]: '/remote/checkout/feature/src/main.ts' },
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' },
|
||||
browserTabsByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: 'browser-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
label: 'Docs',
|
||||
url: 'https://docs.example',
|
||||
title: 'Docs',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
lastVisitedAtByWorktreeId: { [`${SSH_HOST_ID}|${WORKTREE_ID}`]: 4242 }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('restores the open editor files', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(
|
||||
read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => file.relativePath)
|
||||
).toEqual(['src/main.ts'])
|
||||
})
|
||||
|
||||
it('restores an unsaved hot-exit draft, which no other channel can recover', async () => {
|
||||
// No other channel carries editor state, so losing it here loses user-authored content outright.
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'unsaved work'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores browser workspaces and the active tab type', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.browserTabsByWorktree?.[WORKTREE_ID]?.map((entry) => entry.id)).toEqual([
|
||||
'browser-1'
|
||||
])
|
||||
expect(read.session.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor')
|
||||
})
|
||||
|
||||
it('restores a workspace the host partition names with no tabs row at all', async () => {
|
||||
// Never-terminal workspaces have no tabsByWorktree key; discover them from other session chrome too.
|
||||
const partitions = {
|
||||
local: session({ tabsByWorktree: {} }),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: {},
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: '/remote/checkout/feature/README.md',
|
||||
relativePath: 'README.md',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'markdown',
|
||||
dirtyDraftContent: 'never saved'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'never saved'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores host-qualified visit recency, which is keyed by host and not by bare id', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(editorOnlyPartitions()),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.lastVisitedAtByWorktreeId?.[`${SSH_HOST_ID}|${WORKTREE_ID}`]).toBe(4242)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition rows the host has nothing for', () => {
|
||||
/** Legacy local editor/draft state plus empty SSH rows is adoptable by terminal criteria; replacing populated base editor state with host emptiness would delete user content. */
|
||||
function emptyHostRowsOverBaseDraft(hostOpenFiles: boolean) {
|
||||
const draftFile = {
|
||||
filePath: `${WORKTREE_PATH}/src/main.ts`,
|
||||
relativePath: 'src/main.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript',
|
||||
dirtyDraftContent: 'unsaved work'
|
||||
}
|
||||
return {
|
||||
local: session({
|
||||
tabsByWorktree: {},
|
||||
openFilesByWorktree: { [WORKTREE_ID]: [draftFile] },
|
||||
activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' }
|
||||
}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [] },
|
||||
...(hostOpenFiles ? { openFilesByWorktree: { [WORKTREE_ID]: [] } } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('does not let an empty host row destroy an unsaved draft the base alone holds', async () => {
|
||||
// Host absence of editors proves no base deletion; lost dirtyDraftContent cannot be recovered.
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(emptyHostRowsOverBaseDraft(true)),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'unsaved work'
|
||||
)
|
||||
})
|
||||
|
||||
it('still adopts a populated host row over the base leftovers, keeping only their drafts', async () => {
|
||||
// Replace when the host has content, preserving base-only unsaved drafts; gating merely on populated base would disable split repair.
|
||||
const partitions = emptyHostRowsOverBaseDraft(false)
|
||||
partitions[SSH_HOST_ID] = session({
|
||||
...partitions[SSH_HOST_ID],
|
||||
openFilesByWorktree: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
filePath: `${WORKTREE_PATH}/src/host.ts`,
|
||||
relativePath: 'src/host.ts',
|
||||
worktreeId: WORKTREE_ID,
|
||||
language: 'typescript'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(
|
||||
read.session.openFilesByWorktree?.[WORKTREE_ID]?.map((file) => file.relativePath)
|
||||
).toEqual(['src/host.ts', 'src/main.ts'])
|
||||
})
|
||||
|
||||
it('adopts the layout of a tab the host slice names only in unifiedTabs', async () => {
|
||||
// Write routing indexes unified-only tabs too; read discovery must match or those tabs never round-trip.
|
||||
const partitions = {
|
||||
local: session({ tabsByWorktree: {} }),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: {},
|
||||
unifiedTabs: {
|
||||
[WORKTREE_ID]: [
|
||||
{
|
||||
id: 'tab-unified',
|
||||
entityId: 'tab-unified',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'terminal',
|
||||
label: 'tab-unified',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-unified': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(read.session.terminalLayoutsByTabId?.['tab-unified']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition adoption on a contested bare id', () => {
|
||||
/** SSH slices bypass contention splitting because local/SSH usually represents one legacy-split workspace. Same repoId::path across real rival hosts breaks that premise; adoption needs external contest evidence beyond base/host. */
|
||||
const RUNTIME_HOST_ID: ExecutionHostId = 'runtime:r1'
|
||||
@@ -524,18 +91,6 @@ describe('ssh host partition adoption on a contested bare id', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it("does not overwrite a contested workspace's own rows with the ssh workspace's", async () => {
|
||||
// Read names local primary; replacing its rows with SSH state would persist another workspace’s editor content and destroy drafts.
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi(contestedPartitions()),
|
||||
contestedRepos
|
||||
)
|
||||
|
||||
expect(read.session.openFilesByWorktree?.[WORKTREE_ID]?.[0]?.dirtyDraftContent).toBe(
|
||||
'local unsaved work'
|
||||
)
|
||||
})
|
||||
|
||||
it('still fills a gap on a contested id', async () => {
|
||||
// Declining to replace is not declining to repair: a row no claimant answered is still adopted.
|
||||
const partitions = contestedPartitions()
|
||||
@@ -596,43 +151,6 @@ describe('ssh host partition adoption on a contested bare id', () => {
|
||||
reloaded.contestedHostWorkspaceSessions['runtime:r2']?.unifiedTabs?.[WORKTREE_ID]?.[0]
|
||||
).toEqual(shadowTab)
|
||||
})
|
||||
|
||||
it('does not let a legacy bare recency key move another host workspace of the same id', async () => {
|
||||
// Only qualified recency keys prove hosts; replacing bare keys could permanently move another claimant’s Cmd+J history.
|
||||
const partitions = {
|
||||
local: session({
|
||||
tabsByWorktree: {},
|
||||
lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 1000 }
|
||||
}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [] },
|
||||
lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 9999 }
|
||||
})
|
||||
}
|
||||
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(read.session.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBe(1000)
|
||||
})
|
||||
|
||||
it('still adopts a host-qualified recency key, which names its own owner', async () => {
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
partitionedApi({
|
||||
local: session({
|
||||
tabsByWorktree: {},
|
||||
lastVisitedAtByWorktreeId: { [WORKTREE_ID]: 1000 }
|
||||
}),
|
||||
[SSH_HOST_ID]: session({
|
||||
tabsByWorktree: { [WORKTREE_ID]: [] },
|
||||
lastVisitedAtByWorktreeId: { [`${SSH_HOST_ID}|${WORKTREE_ID}`]: 9999 }
|
||||
})
|
||||
}),
|
||||
repos
|
||||
)
|
||||
|
||||
expect(read.session.lastVisitedAtByWorktreeId?.[`${SSH_HOST_ID}|${WORKTREE_ID}`]).toBe(9999)
|
||||
expect(read.session.lastVisitedAtByWorktreeId?.[WORKTREE_ID]).toBe(1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition write/read round trip', () => {
|
||||
@@ -676,14 +194,6 @@ describe('ssh host partition write/read round trip', () => {
|
||||
)
|
||||
expect(restored.activeTabTypeByWorktree?.[WORKTREE_ID]).toBe('editor')
|
||||
})
|
||||
|
||||
it('sends a terminal SSH workspace to its partition and reads it back', async () => {
|
||||
const restored = await roundTrip(
|
||||
session({ tabsByWorktree: { [WORKTREE_ID]: [tab('tab-live')] } })
|
||||
)
|
||||
|
||||
expect(restored.tabsByWorktree[WORKTREE_ID]?.map((entry) => entry.id)).toEqual(['tab-live'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssh host partition and the closed-last-terminal tombstone', () => {
|
||||
@@ -706,48 +216,6 @@ describe('ssh host partition and the closed-last-terminal tombstone', () => {
|
||||
return { restored: read.session, partitions }
|
||||
}
|
||||
|
||||
it('writes an SSH workspace emptied by this build into the partition that owns it', async () => {
|
||||
// Tombstones must persist only in owning SSH, leaving no local residue that could regenerate legacy repair input.
|
||||
const { partitions } = await roundTripSession(
|
||||
session({ tabsByWorktree: { [WORKTREE_ID]: [] } })
|
||||
)
|
||||
|
||||
expect(partitions[SSH_HOST_ID]?.tabsByWorktree?.[WORKTREE_ID]).toEqual([])
|
||||
expect(Object.hasOwn(partitions.local?.tabsByWorktree ?? {}, WORKTREE_ID)).toBe(false)
|
||||
})
|
||||
|
||||
it('restores that tombstone as an explicit empty row, not a deleted key', async () => {
|
||||
// Presence prevents reseeding; deleting the key means never initialized and recreates terminals on every launch.
|
||||
const { restored } = await roundTripSession(session({ tabsByWorktree: { [WORKTREE_ID]: [] } }))
|
||||
|
||||
expect(Object.hasOwn(restored.tabsByWorktree, WORKTREE_ID)).toBe(true)
|
||||
expect(restored.tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the restored workspace un-seeded by the shared seeding predicate', async () => {
|
||||
// Assert the real seeding predicate, not row shape alone, so reader disagreement fails.
|
||||
const { restored } = await roundTripSession(session({ tabsByWorktree: { [WORKTREE_ID]: [] } }))
|
||||
|
||||
expect(
|
||||
shouldAutoCreateInitialTerminal(
|
||||
restored.tabsByWorktree[WORKTREE_ID]?.length ?? 0,
|
||||
Object.hasOwn(restored.tabsByWorktree, WORKTREE_ID)
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not adopt a stale populated ssh row over a tombstone in the owning partition', async () => {
|
||||
// Owned SSH tombstones must neither resurrect stale tabs nor become adoptable gaps.
|
||||
const partitions = {
|
||||
local: session({ tabsByWorktree: {} }),
|
||||
[SSH_HOST_ID]: session({ tabsByWorktree: { [WORKTREE_ID]: [] } })
|
||||
}
|
||||
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(partitionedApi(partitions), repos)
|
||||
|
||||
expect(read.session.tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
expect(Object.hasOwn(read.session.tabsByWorktree, WORKTREE_ID)).toBe(true)
|
||||
})
|
||||
|
||||
it('resurrects the legacy-transition shape exactly once and not again', async () => {
|
||||
// Known gap must be one-shot: boot one repairs local’s empty legacy half; closing on this build moves tombstone solely to SSH, so boot two cannot resurrect tabs.
|
||||
const firstBoot = await fetchWorkspaceSessionWithRuntimeHostOwners(
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkspaceSessionSnapshot } from './workspace-session'
|
||||
import { withoutStagedBrowserTabs } from './workspace-session-staged-browser-tabs'
|
||||
|
||||
const WT = 'wt-1'
|
||||
const GROUP_ID = 'group-1'
|
||||
|
||||
function browserRow(suffix: string): { workspaceId: string; pageId: string; tabId: string } {
|
||||
return {
|
||||
workspaceId: `workspace-${suffix}`,
|
||||
pageId: `page-${suffix}`,
|
||||
tabId: `tab-${suffix}`
|
||||
}
|
||||
}
|
||||
|
||||
const ADOPTED = browserRow('adopted')
|
||||
const STAGED = browserRow('staged')
|
||||
|
||||
function createSnapshot(): Pick<
|
||||
WorkspaceSessionSnapshot,
|
||||
| 'activeBrowserTabIdByWorktree'
|
||||
| 'browserPagesByWorkspace'
|
||||
| 'browserTabsByWorktree'
|
||||
| 'remoteBrowserPageHandlesByPageId'
|
||||
| 'unifiedTabsByWorktree'
|
||||
| 'groupsByWorktree'
|
||||
> {
|
||||
return {
|
||||
activeBrowserTabIdByWorktree: { [WT]: STAGED.workspaceId },
|
||||
browserTabsByWorktree: {
|
||||
[WT]: [ADOPTED, STAGED].map(
|
||||
(row) =>
|
||||
({
|
||||
id: row.workspaceId,
|
||||
worktreeId: WT,
|
||||
activePageId: row.pageId,
|
||||
pageIds: [row.pageId]
|
||||
}) as never
|
||||
)
|
||||
},
|
||||
browserPagesByWorkspace: Object.fromEntries(
|
||||
[ADOPTED, STAGED].map((row) => [
|
||||
row.workspaceId,
|
||||
[{ id: row.pageId, workspaceId: row.workspaceId, worktreeId: WT } as never]
|
||||
])
|
||||
),
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
[ADOPTED.pageId]: { environmentId: 'env-1', remotePageId: 'remote-adopted' },
|
||||
[STAGED.pageId]: { environmentId: 'env-1', remotePageId: 'remote-staged', staged: true }
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[WT]: [ADOPTED, STAGED].map(
|
||||
(row) =>
|
||||
({
|
||||
id: row.tabId,
|
||||
entityId: row.workspaceId,
|
||||
groupId: GROUP_ID,
|
||||
worktreeId: WT,
|
||||
contentType: 'browser'
|
||||
}) as never
|
||||
)
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{
|
||||
id: GROUP_ID,
|
||||
worktreeId: WT,
|
||||
activeTabId: STAGED.tabId,
|
||||
tabOrder: [ADOPTED.tabId, STAGED.tabId]
|
||||
} as never
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('withoutStagedBrowserTabs', () => {
|
||||
it('drops a staged browser tab from every slice that would persist it', () => {
|
||||
const filtered = withoutStagedBrowserTabs(createSnapshot())
|
||||
|
||||
expect(filtered.browserTabsByWorktree[WT]?.map((workspace) => workspace.id)).toEqual([
|
||||
ADOPTED.workspaceId
|
||||
])
|
||||
expect(filtered.browserPagesByWorkspace).not.toHaveProperty(STAGED.workspaceId)
|
||||
expect(filtered.unifiedTabsByWorktree[WT]?.map((tab) => tab.id)).toEqual([ADOPTED.tabId])
|
||||
// A dangling active id would restore a workspace that is no longer there.
|
||||
expect(filtered.activeBrowserTabIdByWorktree[WT]).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps adopted browser tabs and returns the snapshot untouched when nothing is staged', () => {
|
||||
const snapshot = createSnapshot()
|
||||
delete snapshot.remoteBrowserPageHandlesByPageId[STAGED.pageId]
|
||||
|
||||
expect(withoutStagedBrowserTabs(snapshot)).toBe(snapshot)
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TWIN_REPO_HOST_CASES,
|
||||
TWIN_WORKTREE_ID,
|
||||
twinPartitionSessions
|
||||
} from '../../../shared/twin-repo-host-routing.test-fixture'
|
||||
import { mergeWorkspaceSessionsWithHostShadow } from './workspace-session-host-contention'
|
||||
import { buildHostIdByWorktreeId } from './workspace-session-host-persistence'
|
||||
|
||||
// Main's router is checked against the same table in runtime-workspace-session-controller-twin-repo.test.ts.
|
||||
describe("the window's save partition for a repo id on two hosts", () => {
|
||||
it.each(TWIN_REPO_HOST_CASES)('$name', ({ repos, catalog, saved, staleOn, expected }) => {
|
||||
// As startup does: read every partition, then route with the partition each row came from.
|
||||
const read = mergeWorkspaceSessionsWithHostShadow(
|
||||
Object.fromEntries(twinPartitionSessions(saved, staleOn))
|
||||
)
|
||||
const hostIdOf = buildHostIdByWorktreeId({
|
||||
repos,
|
||||
worktreesByRepo: { repo: catalog },
|
||||
contestedPrimaryHostBySessionKey: read.primaryHostBySessionKey
|
||||
})
|
||||
expect(hostIdOf(TWIN_WORKTREE_ID)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { buildPersistedUnifiedTabSessionData } from './workspace-session-unified-tabs'
|
||||
|
||||
const WT = 'repo1::/path/wt1'
|
||||
|
||||
const entry: Tab = {
|
||||
id: 'tab-a',
|
||||
entityId: 'tab-a',
|
||||
groupId: 'kept',
|
||||
worktreeId: WT,
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal 1',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
|
||||
// Main removes empty groups; saving renderer-only empty groups must not resurrect them.
|
||||
describe('buildPersistedUnifiedTabSessionData', () => {
|
||||
it('saves no group the window holds empty, nor its place in the group tree', () => {
|
||||
const saved = buildPersistedUnifiedTabSessionData({
|
||||
unifiedTabsByWorktree: { [WT]: [entry] },
|
||||
groupsByWorktree: {
|
||||
[WT]: [
|
||||
{ id: 'emptied', worktreeId: WT, activeTabId: null, tabOrder: [] },
|
||||
{ id: 'kept', worktreeId: WT, activeTabId: 'tab-a', tabOrder: ['tab-a'] }
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
[WT]: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
ratio: 0.5,
|
||||
first: { type: 'leaf', groupId: 'emptied' },
|
||||
second: { type: 'leaf', groupId: 'kept' }
|
||||
}
|
||||
},
|
||||
activeGroupIdByWorktree: { [WT]: 'emptied' }
|
||||
})
|
||||
|
||||
expect(saved.tabGroups?.[WT]?.map((group) => group.id)).toEqual(['kept'])
|
||||
expect(saved.tabGroupLayouts?.[WT]).toEqual({ type: 'leaf', groupId: 'kept' })
|
||||
expect(saved.activeGroupIdByWorktree?.[WT]).toBe('kept')
|
||||
})
|
||||
|
||||
it('saves nothing for a workspace whose only group the window holds empty', () => {
|
||||
const saved = buildPersistedUnifiedTabSessionData({
|
||||
unifiedTabsByWorktree: { [WT]: [] },
|
||||
groupsByWorktree: { [WT]: [{ id: 'root', worktreeId: WT, activeTabId: null, tabOrder: [] }] },
|
||||
layoutByWorktree: { [WT]: { type: 'leaf', groupId: 'root' } },
|
||||
activeGroupIdByWorktree: { [WT]: 'root' }
|
||||
})
|
||||
|
||||
expect(saved.tabGroups).toEqual({})
|
||||
expect(saved.tabGroupLayouts).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,535 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildSanitizedTabsByWorktree, buildWorkspaceSessionPayload } from './workspace-session'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { AppState } from '../store'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
|
||||
function createSnapshot(overrides: Partial<AppState> = {}): AppState {
|
||||
return {
|
||||
activeRepoId: 'repo-1',
|
||||
activeWorktreeId: 'wt-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', title: 'shell', ptyId: 'pty-1', worktreeId: 'wt-1' }],
|
||||
'wt-2': [{ id: 'tab-2', title: 'editor', ptyId: null, worktreeId: 'wt-2' }]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1'],
|
||||
'tab-2': []
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null }
|
||||
},
|
||||
activeTabIdByWorktree: { 'wt-1': 'tab-1', 'wt-2': 'tab-2' },
|
||||
editorDrafts: {},
|
||||
markdownFrontmatterVisible: {},
|
||||
openFiles: [
|
||||
{
|
||||
id: '/tmp/demo.ts',
|
||||
filePath: '/tmp/demo.ts',
|
||||
relativePath: 'demo.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
mode: 'edit',
|
||||
isDirty: false,
|
||||
isPreview: false,
|
||||
content: '',
|
||||
originalContent: ''
|
||||
},
|
||||
{
|
||||
id: '/tmp/demo.diff',
|
||||
filePath: '/tmp/demo.diff',
|
||||
relativePath: 'demo.diff',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'diff',
|
||||
mode: 'diff',
|
||||
isDirty: false,
|
||||
isPreview: false,
|
||||
content: '',
|
||||
originalContent: ''
|
||||
}
|
||||
],
|
||||
activeFileIdByWorktree: { 'wt-1': '/tmp/demo.ts' },
|
||||
activeTabTypeByWorktree: { 'wt-1': 'editor', 'wt-2': 'terminal' },
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: true,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
errorCode: null,
|
||||
errorDescription: null
|
||||
}
|
||||
]
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' },
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
sshConnectionStates: new Map(),
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
browserPagesByWorkspace: {
|
||||
'browser-1': [
|
||||
{
|
||||
id: 'page-1',
|
||||
workspaceId: 'browser-1',
|
||||
worktreeId: 'wt-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: true,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
]
|
||||
},
|
||||
browserUrlHistory: [],
|
||||
remoteBrowserPageHandlesByPageId: {},
|
||||
...overrides
|
||||
} as AppState
|
||||
}
|
||||
|
||||
function createRepo(id: string, connectionId: string | null): AppState['repos'][number] {
|
||||
return {
|
||||
id,
|
||||
path: `/${id}`,
|
||||
displayName: id,
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1,
|
||||
connectionId
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildWorkspaceSessionPayload', () => {
|
||||
it('preserves activeWorktreeIdsOnShutdown for full replacement writes', () => {
|
||||
const payload = buildWorkspaceSessionPayload(createSnapshot())
|
||||
|
||||
expect(payload.activeWorktreeIdsOnShutdown).toEqual(['wt-1'])
|
||||
})
|
||||
|
||||
it('persists the default-tab idempotency marker when present', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
defaultTerminalTabsAppliedByWorktreeId: { 'wt-1': true }
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.defaultTerminalTabsAppliedByWorktreeId).toEqual({ 'wt-1': true })
|
||||
})
|
||||
|
||||
it('persists floating terminal tabs for daemon reattach after restart', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
[FLOATING_TERMINAL_WORKTREE_ID]: [
|
||||
{
|
||||
id: 'floating-tab-1',
|
||||
title: 'Terminal 1',
|
||||
ptyId: 'floating-pty-1',
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID
|
||||
} as never
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'floating-tab-1': {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
buffersByLeafId: { 'pane:1': 'floating-scrollback' },
|
||||
ptyIdsByLeafId: { 'pane:1': 'floating-pty-1' }
|
||||
}
|
||||
},
|
||||
activeTabIdByWorktree: {
|
||||
[FLOATING_TERMINAL_WORKTREE_ID]: 'floating-tab-1'
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'floating-tab-1': ['floating-pty-1']
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toHaveLength(1)
|
||||
expect(payload.activeTabIdByWorktree?.[FLOATING_TERMINAL_WORKTREE_ID]).toBe('floating-tab-1')
|
||||
expect(payload.terminalLayoutsByTabId['floating-tab-1'].buffersByLeafId).toBeUndefined()
|
||||
expect(payload.terminalLayoutsByTabId['floating-tab-1'].ptyIdsByLeafId).toEqual({
|
||||
'pane:1': 'floating-pty-1'
|
||||
})
|
||||
expect(payload.activeWorktreeIdsOnShutdown).toEqual([FLOATING_TERMINAL_WORKTREE_ID])
|
||||
})
|
||||
|
||||
it('persists only edit-mode files and resets browser loading state', () => {
|
||||
const payload = buildWorkspaceSessionPayload(createSnapshot())
|
||||
|
||||
expect(payload.openFilesByWorktree).toEqual({
|
||||
'wt-1': [
|
||||
{
|
||||
filePath: '/tmp/demo.ts',
|
||||
relativePath: 'demo.ts',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
isPreview: undefined
|
||||
}
|
||||
]
|
||||
})
|
||||
expect(payload.browserTabsByWorktree?.['wt-1'][0].loading).toBe(false)
|
||||
})
|
||||
|
||||
it('persists front-matter hide overrides only for restored editor files', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
markdownFrontmatterVisible: {
|
||||
'/tmp/demo.ts': false,
|
||||
'/tmp/demo.diff': false,
|
||||
'/tmp/closed.md': false
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.markdownFrontmatterVisible).toEqual({ '/tmp/demo.ts': false })
|
||||
})
|
||||
|
||||
it('does not persist empty split groups from transient simulator tab creation', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'term-unified-1',
|
||||
entityId: 'tab-1',
|
||||
groupId: 'group-left',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
label: 'shell',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-left',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'term-unified-1',
|
||||
tabOrder: ['term-unified-1']
|
||||
},
|
||||
{
|
||||
id: 'group-right',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: null,
|
||||
tabOrder: []
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: {
|
||||
'wt-1': {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', groupId: 'group-left' },
|
||||
second: { type: 'leaf', groupId: 'group-right' },
|
||||
ratio: 0.5
|
||||
}
|
||||
},
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-right' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.tabGroups?.['wt-1']).toEqual([
|
||||
expect.objectContaining({ id: 'group-left', tabOrder: ['term-unified-1'] })
|
||||
])
|
||||
expect(payload.tabGroupLayouts?.['wt-1']).toEqual({ type: 'leaf', groupId: 'group-left' })
|
||||
expect(payload.activeGroupIdByWorktree?.['wt-1']).toBe('group-left')
|
||||
})
|
||||
|
||||
it('does not persist a browser tab whose create never materialized', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
browserTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-1',
|
||||
activePageId: 'page-1',
|
||||
pageIds: ['page-1'],
|
||||
worktreeId: 'wt-1'
|
||||
} as never,
|
||||
{
|
||||
id: 'staged-1',
|
||||
activePageId: 'staged-page',
|
||||
pageIds: ['staged-page'],
|
||||
worktreeId: 'wt-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
browserPagesByWorkspace: {
|
||||
'browser-1': [{ id: 'page-1', workspaceId: 'browser-1', worktreeId: 'wt-1' } as never],
|
||||
'staged-1': [{ id: 'staged-page', workspaceId: 'staged-1', worktreeId: 'wt-1' } as never]
|
||||
},
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': { environmentId: 'env-1', remotePageId: 'remote-1' },
|
||||
'staged-page': { environmentId: 'env-1', remotePageId: 'staged-page', staged: true }
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { 'wt-1': 'staged-1' },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'browser-unified-1',
|
||||
entityId: 'browser-1',
|
||||
groupId: 'group-left',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'browser',
|
||||
label: 'Example',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
},
|
||||
{
|
||||
id: 'staged-unified-1',
|
||||
entityId: 'staged-1',
|
||||
groupId: 'group-left',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'browser',
|
||||
label: 'New Tab',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-left',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'staged-unified-1',
|
||||
tabOrder: ['browser-unified-1', 'staged-unified-1']
|
||||
}
|
||||
]
|
||||
},
|
||||
layoutByWorktree: { 'wt-1': { type: 'leaf', groupId: 'group-left' } },
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-left' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.browserTabsByWorktree?.['wt-1']?.map((tab) => tab.id)).toEqual(['browser-1'])
|
||||
expect(payload.browserPagesByWorkspace).not.toHaveProperty('staged-1')
|
||||
expect(payload.unifiedTabs?.['wt-1']?.map((tab) => tab.id)).toEqual(['browser-unified-1'])
|
||||
// The group must not keep a slot pointing at a tab that no longer exists.
|
||||
expect(payload.tabGroups?.['wt-1']).toEqual([
|
||||
expect.objectContaining({ tabOrder: ['browser-unified-1'], activeTabId: null })
|
||||
])
|
||||
expect(payload.activeBrowserTabIdByWorktree?.['wt-1']).toBeNull()
|
||||
})
|
||||
|
||||
// Stamp remote page identity onto persisted rows; in-memory handles cannot rebuild after relaunch and would downgrade to fresh server pages.
|
||||
it('stamps the remote page identity of a client-hosted browser page onto its persisted row', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': {
|
||||
environmentId: 'env-1',
|
||||
remotePageId: 'remote-page-1',
|
||||
placement: {
|
||||
kind: 'client',
|
||||
browserHostClientId: 'host-a',
|
||||
browserHostGeneration: 2,
|
||||
pageHostGeneration: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.browserPagesByWorkspace?.['browser-1']?.[0]).toMatchObject({
|
||||
remoteBrowserPageId: 'remote-page-1',
|
||||
remoteBrowserPageClientHosted: true
|
||||
})
|
||||
})
|
||||
|
||||
it('marks a server-hosted remote page row with its remote page id but not client hosting', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': { environmentId: 'env-1', remotePageId: 'remote-page-1' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.browserPagesByWorkspace?.['browser-1']?.[0]).toMatchObject({
|
||||
remoteBrowserPageId: 'remote-page-1'
|
||||
})
|
||||
expect(
|
||||
payload.browserPagesByWorkspace?.['browser-1']?.[0].remoteBrowserPageClientHosted
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
// Restored rows can precede placement; persist their client-hosted marker through a second quit.
|
||||
it('keeps the client-hosted marker on a restored row the host has not republished yet', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
remoteBrowserPageHandlesByPageId: {
|
||||
'page-1': {
|
||||
environmentId: 'env-1',
|
||||
remotePageId: 'remote-page-1',
|
||||
restoredFromSession: true,
|
||||
restoredClientHosted: true
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.browserPagesByWorkspace?.['browser-1']?.[0]).toMatchObject({
|
||||
remoteBrowserPageId: 'remote-page-1',
|
||||
remoteBrowserPageClientHosted: true
|
||||
})
|
||||
})
|
||||
|
||||
it('drops local terminal scrollback buffers from session payloads', () => {
|
||||
const localWorktreeId = 'repo-1::/local/worktree'
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
[localWorktreeId]: [
|
||||
{
|
||||
id: 'tab-local',
|
||||
title: 'shell',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: localWorktreeId
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-local': ['pty-1']
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-local': {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
buffersByLeafId: { 'pane:1': 'serialized-local-scrollback' },
|
||||
scrollbackRefsByLeafId: { 'pane:1': 'v1-local' },
|
||||
ptyIdsByLeafId: { 'pane:1': 'pty-1' },
|
||||
titlesByLeafId: { 'pane:1': 'build' }
|
||||
}
|
||||
},
|
||||
repos: [createRepo('repo-1', null)]
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.terminalLayoutsByTabId['tab-local']).toEqual({
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { 'pane:1': 'pty-1' },
|
||||
titlesByLeafId: { 'pane:1': 'build' }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves SSH terminal scrollback buffers because relay teardown has no local history', () => {
|
||||
const sshWorktreeId = 'repo-ssh::/remote/worktree'
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
[sshWorktreeId]: [
|
||||
{
|
||||
id: 'tab-ssh',
|
||||
title: 'remote',
|
||||
ptyId: 'relay-pty-1',
|
||||
worktreeId: sshWorktreeId
|
||||
} as never
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-ssh': ['relay-pty-1']
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-ssh': {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
buffersByLeafId: { 'pane:1': 'serialized-remote-scrollback' },
|
||||
ptyIdsByLeafId: { 'pane:1': 'relay-pty-1' }
|
||||
}
|
||||
},
|
||||
repos: [createRepo('repo-ssh', 'conn-1')]
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.terminalLayoutsByTabId['tab-ssh'].buffersByLeafId).toEqual({
|
||||
'pane:1': 'serialized-remote-scrollback'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses lastKnownRelayPtyIdByTabId fallback for disconnected SSH worktrees', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [{ id: 'tab-1', title: 'shell', ptyId: 'pty-1', worktreeId: 'wt-1' } as never],
|
||||
'wt-ssh': [{ id: 'tab-ssh', title: 'remote', ptyId: null, worktreeId: 'wt-ssh' } as never]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1'],
|
||||
'tab-ssh': []
|
||||
},
|
||||
lastKnownRelayPtyIdByTabId: { 'tab-ssh': 'relay-sess-42' },
|
||||
repos: [createRepo('repo-ssh', 'conn-1')],
|
||||
worktreesByRepo: {
|
||||
'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' } as never]
|
||||
},
|
||||
sshConnectionStates: new Map([
|
||||
['conn-1', { status: 'connected', targetId: 'conn-1', error: null, reconnectAttempt: 0 }]
|
||||
]) as never
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.activeWorktreeIdsOnShutdown).toContain('wt-ssh')
|
||||
expect(payload.remoteSessionIdsByTabId).toEqual({ 'tab-ssh': 'relay-sess-42' })
|
||||
expect(payload.activeConnectionIdsAtShutdown).toEqual(['conn-1'])
|
||||
})
|
||||
|
||||
it('drops transient active editor markers that do not point at restored edit files', () => {
|
||||
const payload = buildWorkspaceSessionPayload(
|
||||
createSnapshot({
|
||||
activeFileIdByWorktree: { 'wt-1': '/tmp/demo.diff' },
|
||||
activeTabTypeByWorktree: { 'wt-1': 'editor', 'wt-2': 'terminal' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(payload.activeFileIdByWorktree).toEqual({})
|
||||
expect(payload.activeTabTypeByWorktree).toEqual({ 'wt-2': 'terminal' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('a launch pane in the saved session', () => {
|
||||
const tab = (agentLaunchPane: TerminalTab['agentLaunchPane']): TerminalTab => ({
|
||||
id: 'tab-1',
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'claude',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
agentLaunchPane
|
||||
})
|
||||
|
||||
// A launch's outcome is this session's notice, so a reload spawns the pane as a restart does;
|
||||
// a pending launch stays, so its verdict still lands after the reload.
|
||||
it('keeps a pending launch and drops an outcome', () => {
|
||||
const pending = { leafId: 'leaf-1', operationId: 'op-1' }
|
||||
const saved = buildSanitizedTabsByWorktree({
|
||||
'wt-1': [tab({ ...pending, outcome: { kind: 'not-started', code: 'boom' } })]
|
||||
})
|
||||
expect(saved['wt-1'][0].agentLaunchPane).toEqual(pending)
|
||||
expect(
|
||||
buildSanitizedTabsByWorktree({ 'wt-1': [tab(pending)] })['wt-1'][0].agentLaunchPane
|
||||
).toEqual(pending)
|
||||
})
|
||||
})
|
||||
@@ -1,46 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { markVisited } from './worktree-activation-visit'
|
||||
import {
|
||||
lastUserWorkspaceSwitchAt,
|
||||
noteUserWorkspaceSwitch,
|
||||
resetWorkspaceSwitchUserActsForTests,
|
||||
takeWorkspaceSwitchWake
|
||||
} from './workspace-switch-user-act'
|
||||
|
||||
describe("the user's switch to a workspace (E9)", () => {
|
||||
beforeEach(() => resetWorkspaceSwitchUserActsForTests())
|
||||
|
||||
it("is recorded only for an open its caller marked as the user's", () => {
|
||||
const state = {
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
markWorktreeVisited: vi.fn()
|
||||
}
|
||||
const previous = { worktreeId: null, hostId: null }
|
||||
markVisited(state, 'wt-1', previous)
|
||||
expect(lastUserWorkspaceSwitchAt('wt-1')).toBeUndefined()
|
||||
markVisited(state, 'wt-1', previous, undefined, 'user-open')
|
||||
expect(lastUserWorkspaceSwitchAt('wt-1')).toEqual(expect.any(Number))
|
||||
expect(lastUserWorkspaceSwitchAt('wt-2')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('counts a switch for a consumer only while it is recent, when asked', () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000)
|
||||
noteUserWorkspaceSwitch('wt-1')
|
||||
now.mockReturnValue(20_000)
|
||||
expect(takeWorkspaceSwitchWake('wt-1', 'pane-restore:a', { withinMs: 10_000 })).toBe(false)
|
||||
now.mockReturnValue(5_000)
|
||||
expect(takeWorkspaceSwitchWake('wt-1', 'pane-restore:b', { withinMs: 10_000 })).toBe(true)
|
||||
now.mockRestore()
|
||||
})
|
||||
|
||||
it('wakes each consumer once per switch; a remount with no new switch does not', () => {
|
||||
noteUserWorkspaceSwitch('wt-1')
|
||||
expect(takeWorkspaceSwitchWake('wt-1', 'explorer')).toBe(true)
|
||||
expect(takeWorkspaceSwitchWake('wt-1', 'explorer')).toBe(false)
|
||||
expect(takeWorkspaceSwitchWake('wt-1', 'git-status')).toBe(true)
|
||||
noteUserWorkspaceSwitch('wt-1')
|
||||
expect(takeWorkspaceSwitchWake('wt-1', 'explorer')).toBe(true)
|
||||
expect(takeWorkspaceSwitchWake('wt-2', 'explorer')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import {
|
||||
buildAgentMetadataTabIndex,
|
||||
collectAgentMetadataFromIndex,
|
||||
collectAgentMetadataForTerminal,
|
||||
maxAgentActivityAt,
|
||||
type AgentMetadata
|
||||
} from './workspace-tab-agent-metadata'
|
||||
|
||||
function makeEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'You are working inside Orca, a multi-agent IDE.',
|
||||
updatedAt: 1000,
|
||||
stateStartedAt: 900,
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
worktreeId: 'wt-1',
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('collectAgentMetadataForTerminal', () => {
|
||||
it('indexes orchestration task display metadata for tab search snippets', () => {
|
||||
const [metadata] = collectAgentMetadataForTerminal({
|
||||
terminalTabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agentStatusByPaneKey: {
|
||||
'tab-1:leaf-1': makeEntry({
|
||||
orchestration: {
|
||||
taskId: 'task-1',
|
||||
dispatchId: 'ctx-1',
|
||||
taskTitle: 'Checkout race',
|
||||
displayName: 'Fix checkout race'
|
||||
}
|
||||
})
|
||||
},
|
||||
retainedAgentsByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {}
|
||||
})
|
||||
|
||||
expect(metadata?.textParts).toContain('Fix checkout race')
|
||||
expect(metadata?.textParts).toContain('Checkout race')
|
||||
expect(metadata?.snippetCandidates).toContain('Fix checkout race')
|
||||
expect(metadata?.snippetCandidates).toContain('Checkout race')
|
||||
})
|
||||
|
||||
it('carries the live entry updatedAt through as lastActivityAt', () => {
|
||||
const [metadata] = collectAgentMetadataForTerminal({
|
||||
terminalTabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agentStatusByPaneKey: { 'tab-1:leaf-1': makeEntry({ updatedAt: 5000 }) },
|
||||
retainedAgentsByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {}
|
||||
})
|
||||
|
||||
expect(metadata?.lastActivityAt).toBe(5000)
|
||||
})
|
||||
|
||||
it('uses the reader clock for mirrored agent evidence and does not refresh replays', () => {
|
||||
const collect = (updatedAt: number) =>
|
||||
collectAgentMetadataForTerminal({
|
||||
terminalTabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agentStatusByPaneKey: {
|
||||
'tab-1:leaf-1': makeEntry({
|
||||
updatedAt,
|
||||
evidenceObservedAt: 100,
|
||||
mirroredEvidenceReceivedAt: 5_000
|
||||
})
|
||||
},
|
||||
retainedAgentsByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {}
|
||||
})[0]?.lastActivityAt
|
||||
|
||||
expect(collect(200)).toBe(5_000)
|
||||
expect(collect(50_000)).toBe(5_000)
|
||||
})
|
||||
|
||||
it('uses authority observation time for locally observed evidence', () => {
|
||||
const [metadata] = collectAgentMetadataForTerminal({
|
||||
terminalTabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
agentStatusByPaneKey: {
|
||||
'tab-1:leaf-1': makeEntry({ updatedAt: 50_000, evidenceObservedAt: 4_000 })
|
||||
},
|
||||
retainedAgentsByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {}
|
||||
})
|
||||
|
||||
expect(metadata?.lastActivityAt).toBe(4_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host-qualified agent metadata joins', () => {
|
||||
it('does not borrow snippets or activity between same-id worktrees', () => {
|
||||
const index = buildAgentMetadataTabIndex({
|
||||
agentStatusByPaneKey: {
|
||||
'shared-tab:local-pane': makeEntry({
|
||||
paneKey: 'shared-tab:local-pane',
|
||||
tabId: 'shared-tab',
|
||||
prompt: 'local atlas prompt',
|
||||
updatedAt: 1_000,
|
||||
connectionId: null
|
||||
}),
|
||||
'shared-tab:remote-pane': makeEntry({
|
||||
paneKey: 'shared-tab:remote-pane',
|
||||
tabId: 'shared-tab',
|
||||
prompt: 'remote atlas prompt',
|
||||
updatedAt: 2_000,
|
||||
connectionId: 'private-target'
|
||||
}),
|
||||
'shared-tab:unstamped-pane': makeEntry({
|
||||
paneKey: 'shared-tab:unstamped-pane',
|
||||
tabId: 'shared-tab',
|
||||
prompt: 'unknown owner prompt',
|
||||
updatedAt: 3_000
|
||||
})
|
||||
},
|
||||
retainedAgentsByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {}
|
||||
})
|
||||
const ambiguous = new Set(['wt-1'])
|
||||
const local = collectAgentMetadataFromIndex(
|
||||
index,
|
||||
'shared-tab',
|
||||
{ id: 'wt-1', hostId: 'local' },
|
||||
ambiguous
|
||||
)
|
||||
const remote = collectAgentMetadataFromIndex(
|
||||
index,
|
||||
'shared-tab',
|
||||
{
|
||||
id: 'wt-1',
|
||||
hostId: 'ssh:private-target',
|
||||
runtimeOwnerEnvironmentId: 'paired-host'
|
||||
},
|
||||
ambiguous
|
||||
)
|
||||
|
||||
expect(local.map((entry) => entry.snippetCandidates[0])).toEqual(['local atlas prompt'])
|
||||
expect(remote.map((entry) => entry.snippetCandidates[0])).toEqual(['remote atlas prompt'])
|
||||
expect(maxAgentActivityAt(local)).toBe(1_000)
|
||||
expect(maxAgentActivityAt(remote)).toBe(2_000)
|
||||
})
|
||||
})
|
||||
|
||||
function makeMetadata(overrides: Partial<AgentMetadata> = {}): AgentMetadata {
|
||||
return {
|
||||
paneKey: 'pane-1',
|
||||
textParts: [],
|
||||
snippetCandidates: [],
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('maxAgentActivityAt', () => {
|
||||
it('returns null when there is no metadata', () => {
|
||||
expect(maxAgentActivityAt([])).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the highest lastActivityAt across panes', () => {
|
||||
const metadata = [
|
||||
makeMetadata({ paneKey: 'pane-1', lastActivityAt: 1000 }),
|
||||
makeMetadata({ paneKey: 'pane-2', lastActivityAt: 3000 }),
|
||||
makeMetadata({ paneKey: 'pane-3', lastActivityAt: 2000 })
|
||||
]
|
||||
|
||||
expect(maxAgentActivityAt(metadata)).toBe(3000)
|
||||
})
|
||||
|
||||
it('ignores non-positive lastActivityAt values', () => {
|
||||
expect(maxAgentActivityAt([makeMetadata({ lastActivityAt: 0 })])).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -81,132 +81,3 @@ it('keeps the selected diff active when an editor for the same file shares its g
|
||||
expect(useAppStore.getState().groupsByWorktree.wt[0].activeTabId).toBe('diff')
|
||||
expect(useAppStore.getState().activeFileId).toBe('file')
|
||||
})
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> & Pick<Worktree, 'id'>): Worktree {
|
||||
return {
|
||||
repoId: 'repo',
|
||||
path: '/workspace',
|
||||
head: '',
|
||||
branch: 'main',
|
||||
isBare: false,
|
||||
isMainWorktree: true,
|
||||
displayName: 'Workspace',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const COLLIDING_WORKTREES = {
|
||||
local: [makeWorktree({ id: 'wt' })],
|
||||
remote: [makeWorktree({ id: 'wt', repoId: 'repo-remote', hostId: 'ssh:remote' })]
|
||||
}
|
||||
|
||||
it('refuses a hostless tab for a remote target whose worktree ID also exists locally', () => {
|
||||
const terminal: Tab = {
|
||||
id: 'unified-terminal',
|
||||
entityId: 'terminal',
|
||||
groupId: 'group',
|
||||
worktreeId: 'wt',
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
useAppStore.setState(
|
||||
{
|
||||
...initialState,
|
||||
worktreesByRepo: COLLIDING_WORKTREES,
|
||||
groupsByWorktree: {
|
||||
wt: [
|
||||
{
|
||||
id: 'group',
|
||||
worktreeId: 'wt',
|
||||
activeTabId: 'unified-terminal',
|
||||
tabOrder: ['unified-terminal']
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { wt: 'group' },
|
||||
unifiedTabsByWorktree: { wt: [terminal] }
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
expect(
|
||||
activateWorkspaceTabPaletteResult({
|
||||
worktreeId: 'wt',
|
||||
groupId: 'group',
|
||||
tabId: 'unified-terminal',
|
||||
entityId: 'terminal',
|
||||
contentType: 'terminal',
|
||||
executionHostId: 'ssh:remote'
|
||||
})
|
||||
).toEqual({ status: 'failed', reason: 'missing-tab' })
|
||||
})
|
||||
|
||||
it('refuses a hostless backing file for a remote target whose worktree ID also exists locally', () => {
|
||||
const editor: Tab = {
|
||||
id: 'unified-editor',
|
||||
entityId: 'file',
|
||||
groupId: 'group',
|
||||
worktreeId: 'wt',
|
||||
contentType: 'editor',
|
||||
executionHostId: 'ssh:remote',
|
||||
label: 'app.ts',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
useAppStore.setState(
|
||||
{
|
||||
...initialState,
|
||||
worktreesByRepo: COLLIDING_WORKTREES,
|
||||
groupsByWorktree: {
|
||||
wt: [
|
||||
{
|
||||
id: 'group',
|
||||
worktreeId: 'wt',
|
||||
activeTabId: 'unified-editor',
|
||||
tabOrder: ['unified-editor']
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { wt: 'group' },
|
||||
unifiedTabsByWorktree: { wt: [editor] },
|
||||
openFiles: [
|
||||
{
|
||||
id: 'file',
|
||||
worktreeId: 'wt',
|
||||
filePath: '/workspace/app.ts',
|
||||
relativePath: 'app.ts',
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
mode: 'edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
true
|
||||
)
|
||||
|
||||
expect(
|
||||
activateWorkspaceTabPaletteResult({
|
||||
worktreeId: 'wt',
|
||||
groupId: 'group',
|
||||
tabId: 'unified-editor',
|
||||
entityId: 'file',
|
||||
contentType: 'editor',
|
||||
executionHostId: 'ssh:remote'
|
||||
})
|
||||
).toEqual({ status: 'failed', reason: 'missing-file' })
|
||||
})
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorkspaceTabPaletteSearchResult } from './workspace-tab-palette-search'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
type MockStore = {
|
||||
worktreesByRepo: Record<string, { id: string; repoId: string; path: string }[]>
|
||||
groupsByWorktree: Record<string, Record<string, unknown>[]>
|
||||
unifiedTabsByWorktree: Record<string, Record<string, unknown>[]>
|
||||
openFiles: { id: string; worktreeId: string; externalSshTargetId?: string }[]
|
||||
repos: unknown[]
|
||||
settings: Record<string, unknown>
|
||||
activeGroupIdByWorktree: Record<string, string>
|
||||
getKnownWorktreeById: ReturnType<typeof vi.fn>
|
||||
focusGroup: ReturnType<typeof vi.fn>
|
||||
activateTab: ReturnType<typeof vi.fn>
|
||||
setActiveTab: ReturnType<typeof vi.fn>
|
||||
setActiveTabType: ReturnType<typeof vi.fn>
|
||||
setActiveFile: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const store: MockStore = {
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/tmp/wt-1' }]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'unified-terminal-1',
|
||||
tabOrder: ['unified-terminal-1']
|
||||
}
|
||||
]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'unified-terminal-1',
|
||||
entityId: 'terminal-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
openFiles: [],
|
||||
repos: [],
|
||||
settings: {},
|
||||
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
|
||||
getKnownWorktreeById: vi.fn(),
|
||||
focusGroup: vi.fn(),
|
||||
activateTab: vi.fn(),
|
||||
setActiveTab: vi.fn(),
|
||||
setActiveTabType: vi.fn(),
|
||||
setActiveFile: vi.fn()
|
||||
}
|
||||
return {
|
||||
store,
|
||||
activateAndRevealWorkspace: vi.fn(),
|
||||
activateWebRuntimeSessionTab: vi.fn(),
|
||||
focusTerminalTabSurface: vi.fn(),
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin: vi.fn(),
|
||||
isWebRuntimeSessionActive: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mocks.store
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
|
||||
focusTerminalTabSurface: mocks.focusTerminalTabSurface
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin: mocks.runtimeEnvironmentIdOrLocalUnlessTwin
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/web-runtime-session-tab-lifecycle', () => ({
|
||||
activateWebRuntimeSessionTab: mocks.activateWebRuntimeSessionTab
|
||||
}))
|
||||
vi.mock('@/runtime/web-runtime-session-environment', () => ({
|
||||
isWebRuntimeSessionActive: mocks.isWebRuntimeSessionActive
|
||||
}))
|
||||
|
||||
vi.mock('./worktree-activation', () => ({
|
||||
activateAndRevealWorkspace: mocks.activateAndRevealWorkspace
|
||||
}))
|
||||
|
||||
import { activateWorkspaceTabPaletteResult } from './workspace-tab-palette-activation'
|
||||
|
||||
function makeResult(
|
||||
overrides: Partial<WorkspaceTabPaletteSearchResult> = {}
|
||||
): WorkspaceTabPaletteSearchResult {
|
||||
return {
|
||||
tabId: 'unified-terminal-1',
|
||||
entityId: 'terminal-1',
|
||||
worktreeId: 'wt-1',
|
||||
groupId: 'group-1',
|
||||
contentType: 'terminal',
|
||||
occupantAgent: null,
|
||||
title: 'Terminal',
|
||||
secondaryText: '',
|
||||
secondaryMatches: [],
|
||||
repoName: 'repo/orca',
|
||||
worktreeName: 'Palette Worktree',
|
||||
branchName: 'main',
|
||||
titleRanges: [],
|
||||
secondaryRanges: [],
|
||||
repoRanges: [],
|
||||
worktreeRanges: [],
|
||||
branchRanges: [],
|
||||
typeAliasMatches: [],
|
||||
isCurrentTab: false,
|
||||
isCurrentWorktree: false,
|
||||
score: 0,
|
||||
qualityClass: null,
|
||||
rank: null,
|
||||
paletteIdentity: 'terminal\u0000wt-1\u0000group-1\u0000unified-terminal-1',
|
||||
lastActiveAt: null,
|
||||
activity: { ageBucket: null, timestamp: 0 },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function resetStore(): void {
|
||||
mocks.store.worktreesByRepo = {
|
||||
'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/tmp/wt-1' }]
|
||||
}
|
||||
mocks.store.groupsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'unified-terminal-1',
|
||||
tabOrder: ['unified-terminal-1']
|
||||
}
|
||||
]
|
||||
}
|
||||
mocks.store.unifiedTabsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'unified-terminal-1',
|
||||
entityId: 'terminal-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
label: 'Terminal',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
mocks.store.openFiles = []
|
||||
}
|
||||
|
||||
describe('activateWorkspaceTabPaletteResult', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetStore()
|
||||
mocks.store.getKnownWorktreeById.mockImplementation((worktreeId: string) =>
|
||||
Object.values(mocks.store.worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === worktreeId)
|
||||
)
|
||||
mocks.activateAndRevealWorkspace.mockReturnValue(true)
|
||||
mocks.runtimeEnvironmentIdOrLocalUnlessTwin.mockReturnValue('runtime-1')
|
||||
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('activates terminal tabs and focuses the terminal surface', () => {
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' })
|
||||
|
||||
expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1')
|
||||
expect(mocks.store.focusGroup).toHaveBeenCalledWith('wt-1', 'group-1')
|
||||
expect(mocks.store.activateTab).toHaveBeenCalledWith('unified-terminal-1', {
|
||||
worktreeId: 'wt-1'
|
||||
})
|
||||
expect(mocks.store.setActiveTab).toHaveBeenCalledWith('terminal-1')
|
||||
expect(mocks.store.setActiveTabType).toHaveBeenCalledWith('terminal', 'wt-1')
|
||||
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('terminal-1')
|
||||
})
|
||||
|
||||
it('reports blocked activation without changing the tab or focus', () => {
|
||||
mocks.activateAndRevealWorkspace.mockReturnValue(false)
|
||||
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'blocked' })
|
||||
expect(mocks.store.activateTab).not.toHaveBeenCalled()
|
||||
expect(mocks.store.focusGroup).not.toHaveBeenCalled()
|
||||
expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scopes activation to the host carried by the search result', () => {
|
||||
const executionHostId = 'runtime:host-1' as const
|
||||
mocks.store.getKnownWorktreeById.mockReturnValue({
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/wt-1',
|
||||
runtimeOwnerEnvironmentId: 'host-1'
|
||||
})
|
||||
mocks.store.unifiedTabsByWorktree['wt-1'][0].executionHostId = executionHostId
|
||||
|
||||
expect(activateWorkspaceTabPaletteResult({ ...makeResult(), executionHostId })).toEqual({
|
||||
status: 'activated'
|
||||
})
|
||||
|
||||
expect(mocks.store.getKnownWorktreeById).toHaveBeenCalledWith('wt-1', executionHostId)
|
||||
expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', { executionHostId })
|
||||
})
|
||||
|
||||
it('rejects colliding child ids before mutating either host', () => {
|
||||
const executionHostId = 'runtime:host-1' as const
|
||||
mocks.store.getKnownWorktreeById.mockImplementation((_worktreeId, hostId) =>
|
||||
hostId === executionHostId
|
||||
? {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/remote/wt-1',
|
||||
runtimeOwnerEnvironmentId: 'host-1'
|
||||
}
|
||||
: { id: 'wt-1', repoId: 'repo-1', path: '/local/wt-1', hostId: 'local' }
|
||||
)
|
||||
mocks.store.unifiedTabsByWorktree['wt-1'] = [
|
||||
{ ...mocks.store.unifiedTabsByWorktree['wt-1'][0], executionHostId: 'local' },
|
||||
{ ...mocks.store.unifiedTabsByWorktree['wt-1'][0], executionHostId }
|
||||
]
|
||||
|
||||
expect(activateWorkspaceTabPaletteResult({ ...makeResult(), executionHostId })).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'missing-tab'
|
||||
})
|
||||
expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.store.activateTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates a tab resolved by the known-workspace lookup when the repo catalog is empty', () => {
|
||||
mocks.store.worktreesByRepo = {}
|
||||
mocks.store.getKnownWorktreeById.mockReturnValue({ id: 'wt-1', repoId: 'repo-1' })
|
||||
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' })
|
||||
expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1')
|
||||
})
|
||||
|
||||
it('uses the web-runtime terminal activation path when active', () => {
|
||||
mocks.isWebRuntimeSessionActive.mockReturnValue(true)
|
||||
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({ status: 'activated' })
|
||||
|
||||
expect(mocks.activateWebRuntimeSessionTab).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'terminal-1',
|
||||
environmentId: 'runtime-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('activates editor-family tabs through the target split group and backing file', () => {
|
||||
mocks.store.unifiedTabsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'diff-tab-1',
|
||||
entityId: '/tmp/wt-1/src/app.ts',
|
||||
groupId: 'group-2',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'diff',
|
||||
label: 'app.ts (diff)',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
mocks.store.groupsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-2',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'diff-tab-1',
|
||||
tabOrder: ['diff-tab-1']
|
||||
}
|
||||
]
|
||||
}
|
||||
mocks.store.openFiles = [{ id: '/tmp/wt-1/src/app.ts', worktreeId: 'wt-1' }]
|
||||
|
||||
expect(
|
||||
activateWorkspaceTabPaletteResult(
|
||||
makeResult({
|
||||
tabId: 'diff-tab-1',
|
||||
entityId: '/tmp/wt-1/src/app.ts',
|
||||
groupId: 'group-2',
|
||||
contentType: 'diff'
|
||||
})
|
||||
)
|
||||
).toEqual({ status: 'activated' })
|
||||
|
||||
expect(mocks.store.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2')
|
||||
expect(mocks.store.setActiveFile).toHaveBeenCalledWith('/tmp/wt-1/src/app.ts')
|
||||
expect(mocks.store.activateTab).toHaveBeenLastCalledWith('diff-tab-1', { worktreeId: 'wt-1' })
|
||||
expect(mocks.store.setActiveTabType).toHaveBeenCalledWith('editor', 'wt-1')
|
||||
expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['editor' as const, 'editor-tab-1', '/tmp/wt-1/src/app.ts'],
|
||||
['conflict-review' as const, 'conflict-tab-1', 'wt-1::conflict-review'],
|
||||
['check-details' as const, 'check-tab-1', 'wt-1::check-details::check-run:42']
|
||||
])('activates %s tabs with their exact unified tab id', (contentType, tabId, entityId) => {
|
||||
mocks.store.unifiedTabsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: tabId,
|
||||
entityId,
|
||||
groupId: 'group-2',
|
||||
worktreeId: 'wt-1',
|
||||
contentType,
|
||||
label: 'Editor tab',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
mocks.store.groupsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'group-2',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: tabId,
|
||||
tabOrder: [tabId]
|
||||
}
|
||||
]
|
||||
}
|
||||
mocks.store.openFiles = [{ id: entityId, worktreeId: 'wt-1' }]
|
||||
|
||||
expect(
|
||||
activateWorkspaceTabPaletteResult(
|
||||
makeResult({
|
||||
tabId,
|
||||
entityId,
|
||||
groupId: 'group-2',
|
||||
contentType
|
||||
})
|
||||
)
|
||||
).toEqual({ status: 'activated' })
|
||||
|
||||
expect(mocks.store.focusGroup).toHaveBeenCalledWith('wt-1', 'group-2')
|
||||
expect(mocks.store.setActiveFile).toHaveBeenCalledWith(entityId)
|
||||
expect(mocks.store.activateTab).toHaveBeenLastCalledWith(tabId, { worktreeId: 'wt-1' })
|
||||
expect(mocks.store.setActiveTabType).toHaveBeenCalledWith('editor', 'wt-1')
|
||||
})
|
||||
|
||||
it('returns stale failures before focusing a removed group or tab', () => {
|
||||
mocks.store.groupsByWorktree = { 'wt-1': [] }
|
||||
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'missing-group'
|
||||
})
|
||||
expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled()
|
||||
expect(mocks.store.focusGroup).not.toHaveBeenCalled()
|
||||
|
||||
resetStore()
|
||||
mocks.store.unifiedTabsByWorktree = { 'wt-1': [] }
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'missing-tab'
|
||||
})
|
||||
expect(mocks.store.focusGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a sole backing file whose explicit owner differs from the target', () => {
|
||||
mocks.store.unifiedTabsByWorktree['wt-1'][0].contentType = 'editor'
|
||||
mocks.store.openFiles = [
|
||||
{ id: 'terminal-1', worktreeId: 'wt-1', externalSshTargetId: 'other-host' }
|
||||
]
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult({ contentType: 'editor' }))).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'missing-file'
|
||||
})
|
||||
expect(mocks.activateAndRevealWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats missing editor backing files and worktrees as stale', () => {
|
||||
mocks.store.unifiedTabsByWorktree = {
|
||||
'wt-1': [
|
||||
{
|
||||
id: 'editor-tab-1',
|
||||
entityId: '/tmp/wt-1/src/app.ts',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'editor',
|
||||
label: 'app.ts',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
expect(
|
||||
activateWorkspaceTabPaletteResult(
|
||||
makeResult({
|
||||
tabId: 'editor-tab-1',
|
||||
entityId: '/tmp/wt-1/src/app.ts',
|
||||
contentType: 'editor'
|
||||
})
|
||||
)
|
||||
).toEqual({ status: 'failed', reason: 'missing-file' })
|
||||
|
||||
resetStore()
|
||||
mocks.store.worktreesByRepo = {}
|
||||
expect(activateWorkspaceTabPaletteResult(makeResult())).toEqual({
|
||||
status: 'failed',
|
||||
reason: 'missing-worktree'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,377 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Tab, TabContentType } from '../../../shared/tab-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { buildPaletteTabDocument } from '../../../shared/palette-match/tab-document'
|
||||
import { searchWorkspaceTabs } from './workspace-tab-palette-results'
|
||||
import type { SearchableWorkspaceTab } from './workspace-tab-palette-search'
|
||||
import { createPaletteSearchContext } from '../../../shared/palette-match/palette-ranking'
|
||||
|
||||
const REPO_NAME = 'octo/rocket'
|
||||
const WORKTREE_NAME = 'Aurora Workspace'
|
||||
const BRANCH_NAME = 'main'
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/wt-1',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: WORKTREE_NAME,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeTab(id: string, contentType: TabContentType, createdAt: number): Tab {
|
||||
return {
|
||||
id,
|
||||
entityId: `${id}-entity`,
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType,
|
||||
label: id,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
|
||||
function makeEntry({
|
||||
id = 'tab-1',
|
||||
contentType = 'terminal',
|
||||
createdAt = 0,
|
||||
worktree = makeWorktree(),
|
||||
agentLastActivityAt,
|
||||
agentSnippet,
|
||||
title = id,
|
||||
secondaryText = ''
|
||||
}: {
|
||||
id?: string
|
||||
contentType?: 'terminal' | 'editor'
|
||||
createdAt?: number
|
||||
worktree?: Worktree
|
||||
agentLastActivityAt?: number
|
||||
agentSnippet?: string
|
||||
title?: string
|
||||
secondaryText?: string
|
||||
} = {}): SearchableWorkspaceTab {
|
||||
const secondarySearchTexts = secondaryText ? [secondaryText] : []
|
||||
return {
|
||||
tab: makeTab(id, contentType, createdAt) as SearchableWorkspaceTab['tab'],
|
||||
worktree,
|
||||
repoName: REPO_NAME,
|
||||
worktreeSortIndex: 0,
|
||||
groupSortIndex: 0,
|
||||
tabSortIndex: 0,
|
||||
occupantAgent: null,
|
||||
title,
|
||||
secondaryText,
|
||||
titleSearchText: title,
|
||||
secondarySearchTexts,
|
||||
document: buildPaletteTabDocument({
|
||||
id,
|
||||
title,
|
||||
secondaryTexts: secondarySearchTexts,
|
||||
worktreeName: WORKTREE_NAME,
|
||||
branch: BRANCH_NAME,
|
||||
repoName: REPO_NAME
|
||||
}),
|
||||
agentMetadata:
|
||||
agentLastActivityAt === undefined && !agentSnippet
|
||||
? []
|
||||
: [
|
||||
{
|
||||
paneKey: `${id}-pane`,
|
||||
textParts: [],
|
||||
snippetCandidates: agentSnippet ? [agentSnippet] : [],
|
||||
lastActivityAt: agentLastActivityAt ?? 0
|
||||
}
|
||||
],
|
||||
isCurrentTab: false,
|
||||
isCurrentWorktree: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('searchWorkspaceTabs lastActiveAt', () => {
|
||||
it('uses tab creation when no later activity is known', () => {
|
||||
const [result] = searchWorkspaceTabs([makeEntry({ createdAt: 4000 })], '')
|
||||
expect(result.lastActiveAt).toBe(4000)
|
||||
})
|
||||
|
||||
it('does not borrow worktree PTY activity for editor tabs', () => {
|
||||
const entry = makeEntry({
|
||||
contentType: 'editor',
|
||||
createdAt: 1000,
|
||||
worktree: makeWorktree({ lastActivityAt: 5000 })
|
||||
})
|
||||
const [result] = searchWorkspaceTabs([entry], '')
|
||||
expect(result.lastActiveAt).toBe(1000)
|
||||
})
|
||||
|
||||
it('prefers agent activity over worktree activity when agent activity is newer', () => {
|
||||
const entry = makeEntry({
|
||||
worktree: makeWorktree({ lastActivityAt: 1000 }),
|
||||
agentLastActivityAt: 9000
|
||||
})
|
||||
const [result] = searchWorkspaceTabs([entry], '')
|
||||
expect(result.lastActiveAt).toBe(9000)
|
||||
})
|
||||
|
||||
it('prefers agent activity even when it is older than worktree activity', () => {
|
||||
const entry = makeEntry({
|
||||
worktree: makeWorktree({ lastActivityAt: 9000 }),
|
||||
agentLastActivityAt: 1000
|
||||
})
|
||||
const [result] = searchWorkspaceTabs([entry], '')
|
||||
expect(result.lastActiveAt).toBe(1000)
|
||||
})
|
||||
|
||||
it('clamps to the tab creation time when the activity signal predates it', () => {
|
||||
const entry = makeEntry({
|
||||
createdAt: 4000,
|
||||
agentLastActivityAt: 1000,
|
||||
worktree: makeWorktree({ lastActivityAt: 1000 })
|
||||
})
|
||||
const [result] = searchWorkspaceTabs([entry], '')
|
||||
expect(result.lastActiveAt).toBe(4000)
|
||||
})
|
||||
|
||||
it('uses tab lastFocusedAt when no agent metadata is present', () => {
|
||||
const entry = makeEntry({
|
||||
id: 'tab-focused',
|
||||
createdAt: 1000
|
||||
})
|
||||
entry.tab.lastFocusedAt = 6000
|
||||
const [result] = searchWorkspaceTabs([entry], '')
|
||||
expect(result.lastActiveAt).toBe(6000)
|
||||
})
|
||||
|
||||
it('uses a newer tab focus instead of stale agent activity', () => {
|
||||
const entry = makeEntry({
|
||||
id: 'tab-refocused',
|
||||
createdAt: 1000,
|
||||
agentLastActivityAt: 3000
|
||||
})
|
||||
entry.tab.lastFocusedAt = 8000
|
||||
|
||||
const [result] = searchWorkspaceTabs([entry], '')
|
||||
|
||||
expect(result.lastActiveAt).toBe(8000)
|
||||
})
|
||||
|
||||
it('uses newer agent activity instead of an older tab focus', () => {
|
||||
const entry = makeEntry({
|
||||
id: 'tab-agent-active',
|
||||
createdAt: 1000,
|
||||
agentLastActivityAt: 8000
|
||||
})
|
||||
entry.tab.lastFocusedAt = 3000
|
||||
|
||||
const [result] = searchWorkspaceTabs([entry], '')
|
||||
|
||||
expect(result.lastActiveAt).toBe(8000)
|
||||
})
|
||||
|
||||
it('keeps valid creation when other activity signals are invalid', () => {
|
||||
const entry = makeEntry({ createdAt: 4_000, agentLastActivityAt: Number.POSITIVE_INFINITY })
|
||||
entry.tab.lastFocusedAt = Number.NaN
|
||||
|
||||
const [result] = searchWorkspaceTabs([entry], '', {
|
||||
context: createPaletteSearchContext(10_000)
|
||||
})
|
||||
|
||||
expect(result.lastActiveAt).toBe(4_000)
|
||||
})
|
||||
|
||||
it('uses the same future-clamped timestamp for rank activity and row display', () => {
|
||||
const [result] = searchWorkspaceTabs([makeEntry({ createdAt: 20_000 })], 'tab', {
|
||||
context: createPaletteSearchContext(10_000)
|
||||
})
|
||||
|
||||
expect(result.activity).toEqual({ ageBucket: 0, timestamp: 10_000 })
|
||||
expect(result.lastActiveAt).toBe(10_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchWorkspaceTabs ranking', () => {
|
||||
it.each(['atl', 'atlas'])('keeps the Atlas reference fixture order for %s', (query) => {
|
||||
const now = 100 * 24 * 60 * 60 * 1000
|
||||
const age = (milliseconds: number): number => now - milliseconds
|
||||
const entries = [
|
||||
makeEntry({
|
||||
id: 'old-prefix-2d',
|
||||
title: 'atlas-follow-up-draft-2026-09-01.md',
|
||||
createdAt: age(2 * 24 * 60 * 60 * 1000)
|
||||
}),
|
||||
makeEntry({
|
||||
id: 'old-prefix-3d',
|
||||
title: 'atlas-meeting-todo.md',
|
||||
createdAt: age(3 * 24 * 60 * 60 * 1000)
|
||||
}),
|
||||
makeEntry({
|
||||
id: 'recent-title',
|
||||
title: 'Clarify Atlas action items',
|
||||
createdAt: age(30_000)
|
||||
}),
|
||||
makeEntry({
|
||||
id: 'recent-path',
|
||||
title: 'questions-and-answers.md',
|
||||
secondaryText: 'notes/atlas/questions.md',
|
||||
createdAt: age(30 * 60 * 1000)
|
||||
}),
|
||||
makeEntry({
|
||||
id: 'older-path',
|
||||
title: 'worklog.md',
|
||||
secondaryText: 'notes/atlas/worklog.md',
|
||||
createdAt: age(9 * 60 * 60 * 1000)
|
||||
}),
|
||||
makeEntry({
|
||||
id: 'older-title',
|
||||
title: 'Advance Atlas security review',
|
||||
createdAt: age(19 * 60 * 60 * 1000)
|
||||
}),
|
||||
makeEntry({
|
||||
id: 'snippet',
|
||||
title: 'Agent conversation',
|
||||
agentSnippet: 'Discuss atlas rollout',
|
||||
createdAt: age(47 * 60 * 60 * 1000)
|
||||
})
|
||||
]
|
||||
|
||||
const results = searchWorkspaceTabs(entries, query, {
|
||||
context: createPaletteSearchContext(now)
|
||||
})
|
||||
|
||||
expect(results.map((result) => result.tabId)).toEqual([
|
||||
'recent-title',
|
||||
'older-title',
|
||||
'old-prefix-2d',
|
||||
'old-prefix-3d',
|
||||
'recent-path',
|
||||
'older-path',
|
||||
'snippet'
|
||||
])
|
||||
})
|
||||
|
||||
it('ranks a multi-token direct-plus-container hit above a container-only whole-query hit', () => {
|
||||
const directEntry = makeEntry({ id: 'direct-tab' })
|
||||
const containerEntry = makeEntry({ id: 'container-tab' })
|
||||
directEntry.document = buildPaletteTabDocument({
|
||||
id: 'direct',
|
||||
title: 'Auth notes',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'Aurora migration',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
containerEntry.document = buildPaletteTabDocument({
|
||||
id: 'container',
|
||||
title: 'Unrelated notes',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'Auth Aurora',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
|
||||
const results = searchWorkspaceTabs([containerEntry, directEntry], 'auth aurora')
|
||||
|
||||
expect(results.map((result) => result.tabId)).toEqual(['direct-tab', 'container-tab'])
|
||||
expect(results.map((result) => result.rank?.coverage)).toEqual([2, 2])
|
||||
expect(results.map((result) => result.rank?.containerOnlyTokenCount)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('ranks one recovered token above an otherwise-equal all-recovered match', () => {
|
||||
const oneRecovery = makeEntry({ id: 'one-recovery' })
|
||||
const twoRecoveries = makeEntry({ id: 'two-recoveries' })
|
||||
oneRecovery.document = buildPaletteTabDocument({
|
||||
id: 'one-recovery',
|
||||
title: 'alphx bravo',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'workspace',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
twoRecoveries.document = buildPaletteTabDocument({
|
||||
id: 'two-recoveries',
|
||||
title: 'alphx bravx',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'workspace',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
|
||||
const results = searchWorkspaceTabs([twoRecoveries, oneRecovery], 'alpha bravo')
|
||||
|
||||
expect(results.map((result) => result.tabId)).toEqual(['one-recovery', 'two-recoveries'])
|
||||
expect(results.map((result) => result.rank?.recoveryTokenCount)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('ranks direct tab title matches ahead of container-only worktree matches', () => {
|
||||
const directEntry = makeEntry({ id: 'README-4360' })
|
||||
const containerEntry = makeEntry({ id: 'unrelated-file' })
|
||||
// Both are in WORKTREE_NAME 'Aurora Workspace', but suppose worktree has 4360
|
||||
directEntry.document = buildPaletteTabDocument({
|
||||
id: 'tab-direct',
|
||||
title: 'STA-4360-fix.ts',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'STA-4360',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
containerEntry.document = buildPaletteTabDocument({
|
||||
id: 'tab-container',
|
||||
title: 'other-file.ts',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'STA-4360',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
|
||||
const results = searchWorkspaceTabs([containerEntry, directEntry], '4360')
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].tabId).toBe('README-4360')
|
||||
expect(results[0].rank?.coverage).toBe(0)
|
||||
expect(results[1].tabId).toBe('unrelated-file')
|
||||
expect(results[1].rank?.coverage).toBe(2)
|
||||
})
|
||||
|
||||
it('breaks tie between two container-matching tabs using lastActiveAt recency', () => {
|
||||
const olderTab = makeEntry({ id: 'older-tab' })
|
||||
const newerTab = makeEntry({ id: 'newer-tab' })
|
||||
olderTab.document = buildPaletteTabDocument({
|
||||
id: 'older',
|
||||
title: 'file-a.ts',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'STA-4360',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
newerTab.document = buildPaletteTabDocument({
|
||||
id: 'newer',
|
||||
title: 'file-b.ts',
|
||||
secondaryTexts: [],
|
||||
worktreeName: 'STA-4360',
|
||||
branch: 'main',
|
||||
repoName: 'repo'
|
||||
})
|
||||
olderTab.tab.lastFocusedAt = 1000
|
||||
newerTab.tab.lastFocusedAt = 5000
|
||||
|
||||
const results = searchWorkspaceTabs([olderTab, newerTab], '4360')
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0].tabId).toBe('newer-tab')
|
||||
expect(results[1].tabId).toBe('older-tab')
|
||||
})
|
||||
})
|
||||
@@ -1,650 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RetainedAgentEntry } from '@/store/slices/agent-status/agent-status-contract'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-resume'
|
||||
import type { Tab, TabGroup } from '../../../shared/tab-types'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { buildSearchableWorkspaceTabs, searchWorkspaceTabs } from './workspace-tab-palette-search'
|
||||
|
||||
const WT_ROOT = path.join('tmp', 'wt-1')
|
||||
const SRC_APP_RELATIVE_PATH = path.join('src', 'app.ts')
|
||||
const SRC_APP_PATH = path.join(WT_ROOT, SRC_APP_RELATIVE_PATH)
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: WT_ROOT,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/workspace-tab-search',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'Palette Worktree',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeUnifiedTab(overrides: Partial<Tab> = {}): Tab {
|
||||
return {
|
||||
id: 'unified-terminal-1',
|
||||
entityId: 'terminal-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal',
|
||||
label: 'Unified Label',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeTerminalTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
|
||||
return {
|
||||
id: 'terminal-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Raw Shell Title',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeOpenFile(overrides: Partial<OpenFile> = {}): OpenFile {
|
||||
return {
|
||||
id: SRC_APP_PATH,
|
||||
filePath: SRC_APP_PATH,
|
||||
relativePath: SRC_APP_RELATIVE_PATH,
|
||||
worktreeId: 'wt-1',
|
||||
language: 'typescript',
|
||||
isDirty: false,
|
||||
mode: 'edit',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeGroup(overrides: Partial<TabGroup> = {}): TabGroup {
|
||||
return {
|
||||
id: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
activeTabId: 'unified-terminal-1',
|
||||
tabOrder: ['unified-terminal-1'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeAgentEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'Implement the websocket retry loop',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
paneKey: 'terminal-1:leaf-a',
|
||||
tabId: 'terminal-1',
|
||||
worktreeId: 'wt-1',
|
||||
stateHistory: [],
|
||||
agentType: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sess-live' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function buildEntries(overrides: Partial<Parameters<typeof buildSearchableWorkspaceTabs>[0]> = {}) {
|
||||
const worktree = makeWorktree()
|
||||
const tab = makeUnifiedTab()
|
||||
return buildSearchableWorkspaceTabs({
|
||||
worktrees: [worktree],
|
||||
repoMap: new Map([[worktree.repoId, { displayName: 'repo/orca' }]]),
|
||||
worktreeOrder: new Map([[worktree.id, 0]]),
|
||||
unifiedTabsByWorktree: { [worktree.id]: [tab] },
|
||||
tabsByWorktree: { [worktree.id]: [makeTerminalTab()] },
|
||||
openFiles: [],
|
||||
agentStatusByPaneKey: {},
|
||||
retainedAgentsByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
activeGroupIdByWorktree: { [worktree.id]: 'group-1' },
|
||||
groupsByWorktree: { [worktree.id]: [makeGroup()] },
|
||||
activeWorktreeId: worktree.id,
|
||||
activeTabType: 'terminal',
|
||||
activeTabId: 'terminal-1',
|
||||
activeTabIdByWorktree: { [worktree.id]: 'terminal-1' },
|
||||
activeFileId: null,
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: { [worktree.id]: 'terminal' },
|
||||
generatedTitlesEnabled: true,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('workspace-tab-palette-search', () => {
|
||||
it('stamps the row execution host so activation never resolves by id alone', () => {
|
||||
// Host-blind activation opens the wrong same-ID workspace behind another host’s row label.
|
||||
const worktree = makeWorktree({ hostId: 'ssh:box' })
|
||||
const entries = buildEntries({ worktrees: [worktree] })
|
||||
const [result] = searchWorkspaceTabs(entries, '')
|
||||
expect(result.executionHostId).toBe('ssh:box')
|
||||
})
|
||||
|
||||
it('omits colliding tab ids even when only one record has an open file', () => {
|
||||
const orphaned = makeUnifiedTab({
|
||||
id: 'unified-editor-dup',
|
||||
contentType: 'editor',
|
||||
entityId: 'missing-file'
|
||||
})
|
||||
const resolvable = makeUnifiedTab({
|
||||
id: 'unified-editor-dup',
|
||||
contentType: 'editor',
|
||||
entityId: SRC_APP_PATH
|
||||
})
|
||||
const entries = buildEntries({
|
||||
unifiedTabsByWorktree: { 'wt-1': [orphaned, resolvable] },
|
||||
openFiles: [makeOpenFile()]
|
||||
})
|
||||
|
||||
expect(entries).toEqual([])
|
||||
})
|
||||
|
||||
it('omits an editor row whose explicit file host disagrees with its unique worktree', () => {
|
||||
const remote = makeWorktree({ hostId: 'ssh:remote' })
|
||||
const editor = makeUnifiedTab({
|
||||
id: 'remote-editor',
|
||||
entityId: SRC_APP_PATH,
|
||||
contentType: 'editor',
|
||||
executionHostId: 'ssh:remote'
|
||||
})
|
||||
const entries = buildEntries({
|
||||
worktrees: [remote],
|
||||
unifiedTabsByWorktree: { 'wt-1': [editor] },
|
||||
openFiles: [makeOpenFile({ externalSshTargetId: 'other-host' })]
|
||||
})
|
||||
|
||||
expect(entries).toEqual([])
|
||||
})
|
||||
it('omits a tab id when a session persisted it twice', () => {
|
||||
const duplicate = makeUnifiedTab({ id: 'unified-terminal-dup' })
|
||||
const entries = buildEntries({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [makeUnifiedTab(), duplicate, { ...duplicate }]
|
||||
}
|
||||
})
|
||||
|
||||
const tabIds = entries.map((entry) => entry.tab.id)
|
||||
expect(tabIds).toEqual(['unified-terminal-1'])
|
||||
|
||||
const results = searchWorkspaceTabs(entries, 'unified')
|
||||
expect(results.map((result) => result.tabId)).toEqual(tabIds)
|
||||
})
|
||||
it('uses the same title precedence as the tab strip and honors generated-title disabling', () => {
|
||||
const enabledEntries = buildEntries({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeTerminalTab({
|
||||
customTitle: 'Custom Title',
|
||||
quickCommandLabel: 'Quick Label',
|
||||
generatedTitle: 'Generated Label'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
// customTitle on the terminal record is merged into the unified resolve path.
|
||||
expect(searchWorkspaceTabs(enabledEntries, 'custom')[0]?.title).toBe('Custom Title')
|
||||
|
||||
const disabledEntries = buildEntries({
|
||||
generatedTitlesEnabled: false,
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeTerminalTab({
|
||||
title: 'Raw Shell Title',
|
||||
generatedTitle: 'Generated Label'
|
||||
})
|
||||
]
|
||||
},
|
||||
// Empty unified label so the live terminal title is the visible fallback.
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [makeUnifiedTab({ label: '' })]
|
||||
}
|
||||
})
|
||||
expect(searchWorkspaceTabs(disabledEntries, 'generated')).toHaveLength(0)
|
||||
expect(searchWorkspaceTabs(disabledEntries, 'raw')[0]?.title).toBe('Raw Shell Title')
|
||||
})
|
||||
|
||||
it('falls back to the unified terminal label when the legacy terminal record is gone', () => {
|
||||
const entries = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [makeUnifiedTab({ customLabel: 'Fallback Terminal' })]
|
||||
}
|
||||
})
|
||||
|
||||
expect(searchWorkspaceTabs(entries, 'fallback')[0]?.title).toBe('Fallback Terminal')
|
||||
})
|
||||
|
||||
// Unified labels follow visible OSC titles while terminal records can lag at Terminal N.
|
||||
it('indexes the unified label when the terminal record title is stale', () => {
|
||||
const entries = buildEntries({
|
||||
tabsByWorktree: {
|
||||
'wt-1': [makeTerminalTab({ title: 'Terminal 9', defaultTitle: 'Terminal 9' })]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [makeUnifiedTab({ label: '✳ Claude Code' })]
|
||||
}
|
||||
})
|
||||
|
||||
expect(searchWorkspaceTabs(entries, 'claude')[0]?.title).toBe('✳ Claude Code')
|
||||
expect(searchWorkspaceTabs(entries, 'terminal 9')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('indexes editor-family tabs through existing editor labels and paths', () => {
|
||||
const previewRelativePath = path.join('docs', 'readme.md')
|
||||
const previewPath = path.join(WT_ROOT, previewRelativePath)
|
||||
const file = makeOpenFile({
|
||||
id: `${previewPath}:preview`,
|
||||
filePath: previewPath,
|
||||
relativePath: previewRelativePath,
|
||||
mode: 'markdown-preview'
|
||||
})
|
||||
const entries = buildEntries({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeUnifiedTab({
|
||||
id: 'editor-preview',
|
||||
entityId: file.id,
|
||||
contentType: 'editor',
|
||||
label: 'ignored'
|
||||
}),
|
||||
makeUnifiedTab({
|
||||
id: 'missing-diff',
|
||||
entityId: path.join(WT_ROOT, 'missing.ts'),
|
||||
contentType: 'diff'
|
||||
})
|
||||
]
|
||||
},
|
||||
openFiles: [file],
|
||||
activeTabType: 'editor',
|
||||
activeTabId: null,
|
||||
activeTabIdByWorktree: {},
|
||||
activeFileId: file.id,
|
||||
activeFileIdByWorktree: { 'wt-1': file.id },
|
||||
activeTabTypeByWorktree: { 'wt-1': 'editor' },
|
||||
groupsByWorktree: {
|
||||
'wt-1': [makeGroup({ activeTabId: 'editor-preview', tabOrder: ['editor-preview'] })]
|
||||
}
|
||||
})
|
||||
|
||||
expect(entries.map((entry) => entry.tab.id)).toEqual(['editor-preview'])
|
||||
expect(searchWorkspaceTabs(entries, 'preview')[0]?.title).toBe('readme.md (preview)')
|
||||
expect(searchWorkspaceTabs(entries, path.join('docs', 'readme'))[0]?.secondaryRanges).toEqual([
|
||||
{ start: 0, end: 11 }
|
||||
])
|
||||
})
|
||||
|
||||
it('indexes all editor-family content types when their backing file is open', () => {
|
||||
const editorFile = makeOpenFile({
|
||||
id: SRC_APP_PATH,
|
||||
filePath: SRC_APP_PATH,
|
||||
relativePath: SRC_APP_RELATIVE_PATH,
|
||||
mode: 'edit'
|
||||
})
|
||||
const diffFile = makeOpenFile({
|
||||
id: 'wt-1::diff::staged::src/app.ts',
|
||||
filePath: SRC_APP_PATH,
|
||||
relativePath: SRC_APP_RELATIVE_PATH,
|
||||
mode: 'diff',
|
||||
diffSource: 'staged'
|
||||
})
|
||||
const conflictReviewFile = makeOpenFile({
|
||||
id: 'wt-1::conflict-review',
|
||||
filePath: WT_ROOT,
|
||||
relativePath: 'Conflict Review',
|
||||
mode: 'conflict-review'
|
||||
})
|
||||
const checkDetailsFile = makeOpenFile({
|
||||
id: 'wt-1::check-details::check-run:42',
|
||||
filePath: WT_ROOT,
|
||||
relativePath: 'CI / Typecheck',
|
||||
mode: 'check-details'
|
||||
})
|
||||
const files = [editorFile, diffFile, conflictReviewFile, checkDetailsFile]
|
||||
const entries = buildEntries({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeUnifiedTab({
|
||||
id: 'editor-tab',
|
||||
entityId: editorFile.id,
|
||||
contentType: 'editor'
|
||||
}),
|
||||
makeUnifiedTab({
|
||||
id: 'diff-tab',
|
||||
entityId: diffFile.id,
|
||||
contentType: 'diff'
|
||||
}),
|
||||
makeUnifiedTab({
|
||||
id: 'conflict-tab',
|
||||
entityId: conflictReviewFile.id,
|
||||
contentType: 'conflict-review'
|
||||
}),
|
||||
makeUnifiedTab({
|
||||
id: 'check-tab',
|
||||
entityId: checkDetailsFile.id,
|
||||
contentType: 'check-details'
|
||||
})
|
||||
]
|
||||
},
|
||||
openFiles: files,
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
makeGroup({
|
||||
activeTabId: 'editor-tab',
|
||||
tabOrder: ['editor-tab', 'diff-tab', 'conflict-tab', 'check-tab']
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(entries.map((entry) => entry.tab.contentType)).toEqual([
|
||||
'editor',
|
||||
'diff',
|
||||
'conflict-review',
|
||||
'check-details'
|
||||
])
|
||||
expect(searchWorkspaceTabs(entries, 'staged diff')[0]?.tabId).toBe('diff-tab')
|
||||
expect(searchWorkspaceTabs(entries, 'conflict review')[0]?.tabId).toBe('conflict-tab')
|
||||
expect(searchWorkspaceTabs(entries, 'typecheck')[0]?.tabId).toBe('check-tab')
|
||||
})
|
||||
|
||||
it('attaches live, retained, and sleeping agent metadata only to matching terminal tabs', () => {
|
||||
const retainedEntry = makeAgentEntry({
|
||||
paneKey: 'terminal-1:leaf-b',
|
||||
prompt: 'Retained branch cleanup',
|
||||
tabId: undefined,
|
||||
providerSession: { key: 'session_id', id: 'sess-retained' }
|
||||
})
|
||||
const retained: RetainedAgentEntry = {
|
||||
entry: retainedEntry,
|
||||
worktreeId: 'wt-1',
|
||||
tab: makeTerminalTab({ id: 'terminal-1', title: 'Retained Title' }),
|
||||
agentType: 'codex',
|
||||
startedAt: 1
|
||||
}
|
||||
const sleeping: SleepingAgentSessionRecord = {
|
||||
paneKey: 'terminal-1:leaf-c',
|
||||
tabId: 'terminal-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sess-sleeping' },
|
||||
prompt: 'Sleeping deployment notes',
|
||||
state: 'waiting',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'worktree-sleep'
|
||||
}
|
||||
const entries = buildEntries({
|
||||
agentStatusByPaneKey: {
|
||||
'terminal-1:leaf-a': makeAgentEntry(),
|
||||
'terminal-2:leaf-x': makeAgentEntry({
|
||||
paneKey: 'terminal-2:leaf-x',
|
||||
tabId: 'terminal-2',
|
||||
prompt: 'Wrong tab prompt'
|
||||
}),
|
||||
'terminal-1:leaf-y': makeAgentEntry({
|
||||
paneKey: 'terminal-1:leaf-y',
|
||||
worktreeId: 'wt-other',
|
||||
prompt: 'Wrong worktree prompt'
|
||||
})
|
||||
},
|
||||
retainedAgentsByPaneKey: { [retained.entry.paneKey]: retained },
|
||||
sleepingAgentSessionsByPaneKey: { [sleeping.paneKey]: sleeping }
|
||||
})
|
||||
|
||||
expect(searchWorkspaceTabs(entries, 'websocket')[0]?.secondaryText).toBe(
|
||||
'Implement the websocket retry loop'
|
||||
)
|
||||
expect(searchWorkspaceTabs(entries, 'retained')[0]?.secondaryText).toBe(
|
||||
'Retained branch cleanup'
|
||||
)
|
||||
expect(searchWorkspaceTabs(entries, 'sess-sleeping')).toHaveLength(1)
|
||||
expect(searchWorkspaceTabs(entries, 'wrong tab')).toHaveLength(0)
|
||||
expect(searchWorkspaceTabs(entries, 'wrong worktree')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates live, retained, and sleeping metadata by pane key with live preferred', () => {
|
||||
const retainedEntry = makeAgentEntry({
|
||||
paneKey: 'terminal-1:leaf-a',
|
||||
prompt: 'Retained duplicate prompt'
|
||||
})
|
||||
const entries = buildEntries({
|
||||
agentStatusByPaneKey: {
|
||||
'terminal-1:leaf-a': makeAgentEntry({ prompt: 'Live duplicate prompt' })
|
||||
},
|
||||
retainedAgentsByPaneKey: {
|
||||
'terminal-1:leaf-a': {
|
||||
entry: retainedEntry,
|
||||
worktreeId: 'wt-1',
|
||||
tab: makeTerminalTab(),
|
||||
agentType: 'codex',
|
||||
startedAt: 1
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'terminal-1:leaf-a': {
|
||||
paneKey: 'terminal-1:leaf-a',
|
||||
tabId: 'terminal-1',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sess-sleeping' },
|
||||
prompt: 'Sleeping duplicate prompt',
|
||||
state: 'waiting',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(searchWorkspaceTabs(entries, 'live duplicate')[0]?.secondaryText).toBe(
|
||||
'Live duplicate prompt'
|
||||
)
|
||||
expect(searchWorkspaceTabs(entries, 'retained duplicate')).toHaveLength(0)
|
||||
expect(searchWorkspaceTabs(entries, 'sleeping duplicate')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('orders empty-query results by current tab, current worktree, and tab position', () => {
|
||||
const current = makeUnifiedTab({ id: 'tab-current', entityId: 'terminal-current' })
|
||||
const sibling = makeUnifiedTab({
|
||||
id: 'tab-sibling',
|
||||
entityId: 'terminal-sibling',
|
||||
sortOrder: 1
|
||||
})
|
||||
const other = makeUnifiedTab({
|
||||
id: 'tab-other',
|
||||
entityId: 'terminal-other',
|
||||
worktreeId: 'wt-2',
|
||||
groupId: 'group-2'
|
||||
})
|
||||
const entries = buildEntries({
|
||||
worktrees: [
|
||||
makeWorktree({ id: 'wt-1', displayName: 'Current WT' }),
|
||||
makeWorktree({ id: 'wt-2', repoId: 'repo-2', displayName: 'Other WT' })
|
||||
],
|
||||
repoMap: new Map([
|
||||
['repo-1', { displayName: 'repo/current' }],
|
||||
['repo-2', { displayName: 'repo/other' }]
|
||||
]),
|
||||
worktreeOrder: new Map([
|
||||
['wt-1', 1],
|
||||
['wt-2', 2]
|
||||
]),
|
||||
unifiedTabsByWorktree: { 'wt-1': [sibling, current], 'wt-2': [other] },
|
||||
tabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeTerminalTab({ id: 'terminal-sibling', title: 'Sibling' }),
|
||||
makeTerminalTab({ id: 'terminal-current', title: 'Current' })
|
||||
],
|
||||
'wt-2': [makeTerminalTab({ id: 'terminal-other', worktreeId: 'wt-2', title: 'Other' })]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [
|
||||
makeGroup({
|
||||
activeTabId: 'tab-current',
|
||||
tabOrder: ['tab-current', 'tab-sibling']
|
||||
})
|
||||
],
|
||||
'wt-2': [makeGroup({ id: 'group-2', worktreeId: 'wt-2', tabOrder: ['tab-other'] })]
|
||||
},
|
||||
activeTabId: 'terminal-current',
|
||||
activeTabIdByWorktree: { 'wt-1': 'terminal-current' }
|
||||
})
|
||||
|
||||
expect(searchWorkspaceTabs(entries, '').map((result) => result.entityId)).toEqual([
|
||||
'terminal-current',
|
||||
'terminal-sibling',
|
||||
'terminal-other'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the branch label when a cleared display name left it undefined', () => {
|
||||
// Cmd+J receives the same possibly corrupt stored display names as workspace search.
|
||||
const cleared = makeWorktree({
|
||||
displayName: undefined as unknown as string,
|
||||
branch: 'refs/heads/feature/workspace-tab-search'
|
||||
})
|
||||
const entries = buildEntries({ worktrees: [cleared] })
|
||||
|
||||
expect(searchWorkspaceTabs(entries, 'workspace-tab-search')[0]).toMatchObject({
|
||||
worktreeName: 'feature/workspace-tab-search',
|
||||
worktreeRanges: [{ start: 'feature/'.length, end: 'feature/workspace-tab-search'.length }]
|
||||
})
|
||||
})
|
||||
|
||||
it('lists a branch-less row on the empty query without throwing', () => {
|
||||
const cleared = makeWorktree({
|
||||
displayName: undefined as unknown as string,
|
||||
branch: undefined as unknown as string,
|
||||
path: path.join('repos', 'design-review')
|
||||
})
|
||||
const entries = buildEntries({ worktrees: [cleared] })
|
||||
|
||||
expect(searchWorkspaceTabs(entries, '')[0]).toMatchObject({
|
||||
worktreeName: 'design-review',
|
||||
worktreeRanges: []
|
||||
})
|
||||
})
|
||||
|
||||
it('omits the fixed Terminal tab secondary on terminals', () => {
|
||||
const entries = buildEntries()
|
||||
const emptyQuery = searchWorkspaceTabs(entries, '')[0]
|
||||
expect(emptyQuery).toMatchObject({
|
||||
contentType: 'terminal',
|
||||
secondaryText: '',
|
||||
secondaryRanges: []
|
||||
})
|
||||
expect(entries[0]?.secondarySearchTexts).toEqual([])
|
||||
})
|
||||
|
||||
it('still finds terminals via type aliases without showing a secondary', () => {
|
||||
// Title is agent-named so the hit has to come from the type alias, not the title.
|
||||
const entries = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'Fix login race' })] }
|
||||
})
|
||||
const hit = searchWorkspaceTabs(entries, 'terminal')[0]
|
||||
expect(hit).toMatchObject({
|
||||
contentType: 'terminal',
|
||||
title: 'Fix login race',
|
||||
secondaryText: '',
|
||||
secondaryRanges: [],
|
||||
// Equal-strength aliases use the builder's stable display order.
|
||||
typeAliasMatch: { text: 'terminal tab', ranges: [{ start: 0, end: 8 }] }
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a query spanning the tab title and the worktree branch', () => {
|
||||
const entries = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'Fix login race' })] }
|
||||
})
|
||||
|
||||
const hit = searchWorkspaceTabs(entries, 'login workspace-tab-search')[0]
|
||||
expect(hit?.titleRanges).toEqual([{ start: 4, end: 9 }])
|
||||
expect(hit?.branchRanges).toEqual([
|
||||
{ start: 'feature/'.length, end: 'feature/workspace-tab-search'.length }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps an agent-snippet-only hit but sorts it after every structured hit', () => {
|
||||
const entries = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
makeUnifiedTab({ id: 'tab-agent', entityId: 'terminal-agent', label: 'Claude Code' }),
|
||||
makeUnifiedTab({ id: 'tab-title', entityId: 'terminal-title', label: 'Websocket notes' })
|
||||
]
|
||||
},
|
||||
agentStatusByPaneKey: {
|
||||
'terminal-agent:leaf-a': makeAgentEntry({ tabId: 'terminal-agent' })
|
||||
},
|
||||
groupsByWorktree: {
|
||||
'wt-1': [makeGroup({ activeTabId: 'tab-agent', tabOrder: ['tab-agent', 'tab-title'] })]
|
||||
}
|
||||
})
|
||||
|
||||
const results = searchWorkspaceTabs(entries, 'websocket')
|
||||
expect(results.map((result) => result.tabId)).toEqual(['tab-title', 'tab-agent'])
|
||||
expect(results[1].secondaryText).toBe('Implement the websocket retry loop')
|
||||
})
|
||||
|
||||
it('rejects a query with more unique tokens than the matcher accepts', () => {
|
||||
const query = 't0 t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12 t13 t14 t15 t16'
|
||||
const entries = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: query })] }
|
||||
})
|
||||
expect(
|
||||
searchWorkspaceTabs(entries, 't0 t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12 t13 t14 t15')
|
||||
).toHaveLength(1)
|
||||
expect(searchWorkspaceTabs(entries, query)).toEqual([])
|
||||
})
|
||||
|
||||
it('stamps grok occupancy from the idle OSC title the sidebar already shows', () => {
|
||||
const titledOnly = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [makeTerminalTab({ title: 'grok' })] },
|
||||
unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'grok' })] }
|
||||
})
|
||||
expect(titledOnly[0]?.occupantAgent).toBe('grok')
|
||||
expect(searchWorkspaceTabs(titledOnly, 'grok')[0]?.occupantAgent).toBe('grok')
|
||||
})
|
||||
|
||||
it('stamps occupancy from the live unified label when the terminal record title is stale', () => {
|
||||
const staleRecord = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [makeTerminalTab({ title: 'Terminal 1' })] },
|
||||
unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'grok' })] }
|
||||
})
|
||||
expect(staleRecord[0]?.title).toBe('grok')
|
||||
expect(staleRecord[0]?.occupantAgent).toBe('grok')
|
||||
})
|
||||
|
||||
it('does not stamp grok occupancy from a hyphenated filename-style title', () => {
|
||||
const hyphenated = buildEntries({
|
||||
tabsByWorktree: { 'wt-1': [makeTerminalTab({ title: 'session-scanner-grok-parser' })] },
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [makeUnifiedTab({ label: 'session-scanner-grok-parser' })]
|
||||
}
|
||||
})
|
||||
expect(hyphenated[0]?.occupantAgent).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,10 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { createTestStore, makeWorktree } from '@/store/slices/store-test-helpers'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import {
|
||||
resolveWorkspaceTerminalHostAuthority,
|
||||
type WorkspaceTerminalHostAuthorityState
|
||||
} from '@/lib/workspace-terminal-host-authority'
|
||||
import { resolveWorkspaceTerminalHostAuthority } from '@/lib/workspace-terminal-host-authority'
|
||||
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them.
|
||||
@@ -256,138 +253,4 @@ describe('workspace terminal seeding authority', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const WT = 'repo::/workspace'
|
||||
const FOLDER = 'folder:folder-1'
|
||||
const TARGET = 'ssh:target' as const
|
||||
const base = {
|
||||
settings: { activeRuntimeEnvironmentId: 'unrelated' },
|
||||
runtimeEnvironments: [{ id: 'unrelated' }],
|
||||
runtimeEnvironmentCatalogHydrated: false
|
||||
} satisfies WorkspaceTerminalHostAuthorityState
|
||||
const sshRow = { id: WT, repoId: 'repo', hostId: TARGET }
|
||||
const hubA = { id: 'repo', connectionId: 'target', executionHostId: 'runtime:hub-a' as const }
|
||||
const hubB = { id: 'repo', connectionId: 'target', executionHostId: 'runtime:hub-b' as const }
|
||||
|
||||
const cases: {
|
||||
name: string
|
||||
id: string
|
||||
state: WorkspaceTerminalHostAuthorityState
|
||||
expected: 'live' | 'unverifiable' | 'none'
|
||||
}[] = [
|
||||
{
|
||||
name: 'repo-owned runtime before worktree hydration',
|
||||
id: WT,
|
||||
expected: 'live',
|
||||
state: { repos: [{ id: 'repo', executionHostId: 'runtime:owner' }] }
|
||||
},
|
||||
{
|
||||
name: 'runtime HUB behind an SSH worktree',
|
||||
id: WT,
|
||||
expected: 'live',
|
||||
state: { repos: [hubA], worktreesByRepo: { repo: [sshRow] } }
|
||||
},
|
||||
{
|
||||
name: 'runtime project group owns a folder',
|
||||
id: FOLDER,
|
||||
expected: 'live',
|
||||
state: {
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group' }],
|
||||
projectGroups: [{ id: 'group', executionHostId: 'runtime:owner' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'restored runtime folder before catalog hydration',
|
||||
id: FOLDER,
|
||||
expected: 'live',
|
||||
state: {
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: { [FOLDER]: 'runtime:restored' }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'disconnected direct SSH folder is outside replacement scope',
|
||||
id: FOLDER,
|
||||
expected: 'none',
|
||||
state: {
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group', connectionId: 'target' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'conflicting folder owners stay unverifiable',
|
||||
id: FOLDER,
|
||||
expected: 'unverifiable',
|
||||
state: {
|
||||
folderWorkspaces: [
|
||||
{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'local' },
|
||||
{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'runtime:owner' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'conflicting folder project-group owners stay unverifiable',
|
||||
id: FOLDER,
|
||||
expected: 'unverifiable',
|
||||
state: {
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group' }],
|
||||
projectGroups: [
|
||||
{ id: 'group', executionHostId: 'local' },
|
||||
{ id: 'group', executionHostId: 'runtime:owner' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'unroutable detected publication stays unverifiable',
|
||||
id: WT,
|
||||
expected: 'unverifiable',
|
||||
state: { detectedWorktreesByRepo: { repo: { worktrees: [{ id: WT, repoId: 'repo' }] } } }
|
||||
}
|
||||
]
|
||||
it.each(cases)('$name', ({ id, state, expected }) => {
|
||||
expect(resolveWorkspaceTerminalHostAuthority({ ...base, ...state }, id)).toBe(expected)
|
||||
})
|
||||
|
||||
it('lets the explicit same-workspace host disambiguate rival catalog owners', () => {
|
||||
const state: WorkspaceTerminalHostAuthorityState = {
|
||||
...base,
|
||||
activeWorktreeId: WT,
|
||||
repos: [
|
||||
{ id: 'repo', executionHostId: 'local' },
|
||||
{ id: 'repo', executionHostId: 'runtime:owner' }
|
||||
]
|
||||
}
|
||||
expect(
|
||||
resolveWorkspaceTerminalHostAuthority({ ...state, activeWorkspaceExecutionHostId: 'local' }, WT)
|
||||
).toBe('none')
|
||||
expect(
|
||||
resolveWorkspaceTerminalHostAuthority(
|
||||
{ ...state, activeWorkspaceExecutionHostId: 'runtime:owner' },
|
||||
WT
|
||||
)
|
||||
).toBe('live')
|
||||
})
|
||||
|
||||
it('waits for rival SSH HUB owners, then accepts the surviving runtime owner', () => {
|
||||
const state: WorkspaceTerminalHostAuthorityState = {
|
||||
...base,
|
||||
activeWorktreeId: WT,
|
||||
activeWorkspaceExecutionHostId: TARGET,
|
||||
worktreesByRepo: { repo: [sshRow] }
|
||||
}
|
||||
expect(resolveWorkspaceTerminalHostAuthority({ ...state, repos: [hubA, hubB] }, WT)).toBe(
|
||||
'unverifiable'
|
||||
)
|
||||
expect(resolveWorkspaceTerminalHostAuthority({ ...state, repos: [hubA] }, WT)).toBe('live')
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
it.each([WT, FOLDER])('leaves local terminal ownership with the real paired web host: %s', (id) => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
const state: WorkspaceTerminalHostAuthorityState = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
runtimeEnvironments: [{ id: 'paired-host' }],
|
||||
worktreesByRepo: { repo: [{ id: WT, repoId: 'repo', hostId: 'local' }] },
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'local' }]
|
||||
}
|
||||
expect(resolveWorkspaceTerminalHostAuthority(state, id)).toBe('live')
|
||||
})
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import { EVERY_AGENT_CHAT } from '../../../shared/native-chat/every-agent-chat.test-fixture'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import {
|
||||
createMockStore,
|
||||
registerWorktreeActivationReset,
|
||||
createdTabStartups
|
||||
} from './worktree-activation.test-fixture'
|
||||
|
||||
registerWorktreeActivationReset()
|
||||
|
||||
describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
it('queues a startup command when agent launch is provided', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude "Fix this bug"' },
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true
|
||||
})
|
||||
expect(store.setActiveTab).toHaveBeenCalledWith('tab-1')
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual({
|
||||
command: 'claude "Fix this bug"'
|
||||
})
|
||||
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens new agent workspace terminals in native chat when configured', () => {
|
||||
const store = createMockStore({
|
||||
settings: {
|
||||
nativeChatProviders: EVERY_AGENT_CHAT
|
||||
}
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{
|
||||
command: 'claude',
|
||||
launchAgent: 'claude'
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual({
|
||||
command: 'claude',
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['mirrorable', 'https://github.com/o/r/issues/12', {}],
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}],
|
||||
['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}]
|
||||
])('opens a %s draft startup payload accordingly', (_label, draftPrompt, expectedViewMode) => {
|
||||
const store = createMockStore({
|
||||
settings: {
|
||||
nativeChatProviders: EVERY_AGENT_CHAT
|
||||
}
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{
|
||||
command: 'claude',
|
||||
launchAgent: 'claude',
|
||||
draftPrompt
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true,
|
||||
launchAgent: 'claude',
|
||||
...expectedViewMode
|
||||
})
|
||||
})
|
||||
|
||||
// An argv-prefill launch carries its draft inside the terminal command.
|
||||
it.each([
|
||||
['mirrorable', 'https://github.com/o/r/issues/12', {}],
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}],
|
||||
['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}]
|
||||
])(
|
||||
'keeps a %s argv-prefill draft in the terminal',
|
||||
(_label, launchDraftText, expectedViewMode) => {
|
||||
const store = createMockStore({
|
||||
settings: {
|
||||
nativeChatProviders: EVERY_AGENT_CHAT
|
||||
}
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{
|
||||
command: `claude --prefill '${launchDraftText}'`,
|
||||
launchAgent: 'claude'
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true,
|
||||
launchAgent: 'claude',
|
||||
...expectedViewMode
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('opens the startup default tab in the terminal', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({
|
||||
createTab,
|
||||
settings: {
|
||||
nativeChatProviders: EVERY_AGENT_CHAT
|
||||
}
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude', launchAgent: 'claude' },
|
||||
undefined,
|
||||
undefined,
|
||||
{ runCommands: true, tabs: [{ title: 'Claude', command: 'claude' }] }
|
||||
)
|
||||
|
||||
expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true,
|
||||
recordInteraction: false,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['mirrorable', 'https://github.com/o/r/issues/12', {}],
|
||||
['multi-line', 'Review this\n\nhttps://github.com/o/r/issues/12', {}],
|
||||
['unsupported-separator', 'Review this\u2028https://github.com/o/r/issues/12', {}]
|
||||
])(
|
||||
'opens a %s draft startup default tab accordingly',
|
||||
(_label, draftPrompt, expectedViewMode) => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({
|
||||
createTab,
|
||||
settings: {
|
||||
nativeChatProviders: EVERY_AGENT_CHAT
|
||||
}
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude', launchAgent: 'claude', draftPrompt },
|
||||
undefined,
|
||||
undefined,
|
||||
{ runCommands: true, tabs: [{ title: 'Claude', command: 'claude' }] }
|
||||
)
|
||||
|
||||
expect(createTab).toHaveBeenNthCalledWith(1, 'wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true,
|
||||
recordInteraction: false,
|
||||
launchAgent: 'claude',
|
||||
...expectedViewMode
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('forwards telemetry on the queued startup so main can fire agent_started', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{
|
||||
command: 'claude',
|
||||
telemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true,
|
||||
launchAgent: 'claude'
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual({
|
||||
command: 'claude',
|
||||
telemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps the tab agent from startup launchAgent without telemetry', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{
|
||||
command: 'codex',
|
||||
launchAgent: 'codex'
|
||||
},
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingStartup: expect.anything(),
|
||||
pendingActivationSpawn: true,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual({
|
||||
command: 'codex',
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealWorkspace, activateAndRevealWorktree } from './worktree-activation'
|
||||
import {
|
||||
lastUserWorkspaceSwitchAt,
|
||||
resetWorkspaceSwitchUserActsForTests
|
||||
} from './workspace-switch-user-act'
|
||||
import './workspace-switch-user-act'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
@@ -86,23 +83,6 @@ function seedAutomationWorktreeState(
|
||||
})
|
||||
}
|
||||
|
||||
// e9 review: only an open its caller marks as the user's may later start a stopped WSL distro.
|
||||
describe("activateAndRevealWorktree and the user's own switch (E9)", () => {
|
||||
it("notes a switch only for 'user-open', not for programmatic activations", () => {
|
||||
resetWorkspaceSwitchUserActsForTests()
|
||||
const worktree = makeAutomationWorktree()
|
||||
seedAutomationWorktreeState(worktree, { revealWorktreeInSidebar: vi.fn() })
|
||||
|
||||
// What the CLI/agent worktree event and the delete successor pass: no navigation intent.
|
||||
activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
|
||||
activateAndRevealWorktree(worktree.id, { revealInSidebar: false })
|
||||
expect(lastUserWorkspaceSwitchAt(worktree.id)).toBeUndefined()
|
||||
|
||||
activateAndRevealWorktree(worktree.id, { navigationIntent: 'user-open' })
|
||||
expect(lastUserWorkspaceSwitchAt(worktree.id)).toEqual(expect.any(Number))
|
||||
})
|
||||
})
|
||||
|
||||
describe('activateAndRevealWorktree automation filters', () => {
|
||||
it('keeps workspace-list filters while the activity view is showing', () => {
|
||||
const worktree = makeAutomationWorktree()
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import '../../../shared/constants'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getWorktreeVisitTimestamp } from './worktree-visit-recency'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake'
|
||||
import './web-runtime-worktree-terminal-after-wake'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync/tracking-lifecycle'
|
||||
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
import { rememberEmptyHostAnswerForTests } from '@/runtime/web-session-tabs-sync/host-terminal-count-fixture'
|
||||
import '@/runtime/web-session-tabs-sync/host-terminal-count-fixture'
|
||||
import {
|
||||
makeCreatedAgentWorktree as makeWorktree,
|
||||
seedAlreadyActiveWorktree,
|
||||
seedEmptyActivatableWorktree
|
||||
} from '@/lib/worktree-activation-created-agent.test-fixture'
|
||||
import { stubRuntimeEnvironmentBridge } from '../runtime/runtime-environment-bridge-fixture'
|
||||
import '../runtime/runtime-environment-bridge-fixture'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
@@ -23,14 +23,6 @@ vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
|
||||
function makeWebRuntimeWorktree() {
|
||||
return {
|
||||
...makeWorktree(),
|
||||
hostId: 'local' as const,
|
||||
runtimeOwnerEnvironmentId: 'web-runtime-1'
|
||||
}
|
||||
}
|
||||
|
||||
/** Activates and asserts a focusable tab appeared with no queued startup, returning its id. */
|
||||
function activateAndExpectNoRelaunch(
|
||||
worktreeId: string,
|
||||
@@ -90,16 +82,6 @@ describe('activateAndRevealWorktree', () => {
|
||||
expect(state.worktreeNavHistory).toEqual([worktree.id])
|
||||
})
|
||||
|
||||
it('adds the activated project to an active project filter', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
useAppStore.setState({ filterRepoIds: ['repo-2'] })
|
||||
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
|
||||
expect(useAppStore.getState().filterRepoIds).toEqual(['repo-2', worktree.repoId])
|
||||
})
|
||||
|
||||
it('does not relaunch the creation-time agent when reopening an empty worktree', () => {
|
||||
const worktree = makeWorktree()
|
||||
const { revealWorktreeInSidebar } = seedEmptyActivatableWorktree(worktree)
|
||||
@@ -127,23 +109,6 @@ describe('activateAndRevealWorktree', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not relaunch when activating a sibling worktree the user never opened', () => {
|
||||
const sibling = makeWorktree()
|
||||
const target = { ...makeWorktree(), id: 'wt-handoff', displayName: 'handoff' }
|
||||
seedEmptyActivatableWorktree(target, { extraWorktrees: [sibling] })
|
||||
|
||||
// The shape the removal successor produces: no startup payload.
|
||||
activateAndExpectNoRelaunch(target.id, { revealInSidebar: false })
|
||||
})
|
||||
|
||||
it('does not relaunch when activation opts carry no startup payload', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
// Match CLI/relay/notification option shapes pinned in useIpcEvents; backend didSpawnStartup is main-only.
|
||||
activateAndExpectNoRelaunch(worktree.id, { notifyHostRuntime: false })
|
||||
})
|
||||
|
||||
it('still queues an explicit startup supplied by the caller', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
@@ -159,535 +124,4 @@ describe('activateAndRevealWorktree', () => {
|
||||
expect.objectContaining({ command: 'codex' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not duplicate a sleeping agent session owned by a preserved slept pane', () => {
|
||||
const worktree = makeWorktree()
|
||||
const revealWorktreeInSidebar = vi.fn()
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'slept-tab',
|
||||
ptyId: 'wake-hint',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
pendingStartupByTabId: {},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'slept-tab:0': {
|
||||
paneKey: 'slept-tab:0',
|
||||
tabId: 'slept-tab',
|
||||
worktreeId: worktree.id,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' },
|
||||
prompt: 'resume prior task',
|
||||
state: 'working',
|
||||
capturedAt: 1000,
|
||||
updatedAt: 1000,
|
||||
terminalTitle: 'Codex'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
...getDefaultSettings(''),
|
||||
agentCmdOverrides: {}
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id)
|
||||
const state = useAppStore.getState()
|
||||
const resumedTab = state.tabsByWorktree[worktree.id]?.find((tab) => tab.id !== 'slept-tab')
|
||||
|
||||
expect(result).toEqual({ primaryTabId: null })
|
||||
expect(resumedTab).toBeUndefined()
|
||||
expect(state.pendingStartupByTabId).toEqual({})
|
||||
expect(state.sleepingAgentSessionsByPaneKey['slept-tab:0']).toMatchObject({
|
||||
paneKey: 'slept-tab:0',
|
||||
providerSession: { key: 'session_id', id: 'codex-session-1' }
|
||||
})
|
||||
expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id)
|
||||
})
|
||||
|
||||
it('forwards an explicit sidebar reveal behavior', () => {
|
||||
const worktree = makeWorktree()
|
||||
const revealWorktreeInSidebar = vi.fn()
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
pendingStartupByTabId: {},
|
||||
settings: {
|
||||
...getDefaultSettings(''),
|
||||
agentCmdOverrides: {}
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, { sidebarRevealBehavior: 'auto' })
|
||||
|
||||
expect(result).toEqual({ primaryTabId: expect.any(String) })
|
||||
expect(revealWorktreeInSidebar).toHaveBeenCalledWith(worktree.id, { behavior: 'auto' })
|
||||
})
|
||||
|
||||
it('asks the host runtime to activate the worktree in the paired web client', async () => {
|
||||
const worktree = makeWebRuntimeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment })
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
settings: {
|
||||
...getDefaultSettings(''),
|
||||
agentCmdOverrides: {},
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(result).toEqual({ primaryTabId: null })
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe(worktree.id)
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith({
|
||||
selector: 'web-runtime-1',
|
||||
method: 'worktree.activate',
|
||||
params: {
|
||||
worktree: `id:${worktree.id}`,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
},
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('activates the explicit owner runtime when another runtime is focused', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment })
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:owner-runtime'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
settings: {
|
||||
...getDefaultSettings(''),
|
||||
agentCmdOverrides: {},
|
||||
activeRuntimeEnvironmentId: 'focused-runtime'
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(result).toEqual({ primaryTabId: null })
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'owner-runtime',
|
||||
method: 'worktree.activate'
|
||||
})
|
||||
)
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'focused-runtime',
|
||||
method: 'worktree.activate'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not echo host-originated runtime activation events back to the host', async () => {
|
||||
const worktree = makeWebRuntimeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment })
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
settings: {
|
||||
...getDefaultSettings('/workspace/.orca-workspaces'),
|
||||
agentCmdOverrides: {},
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
})
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(result).toEqual({ primaryTabId: null })
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe(worktree.id)
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not respawn when the host snapshot still has terminal tabs', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn()
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
settings: {
|
||||
...getDefaultSettings('/workspace/.orca-workspaces'),
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 1,
|
||||
activeRenderableTabId: 'tab-1'
|
||||
}))
|
||||
})
|
||||
|
||||
const { shouldApplyWebSessionTabsSnapshot } =
|
||||
await import('@/runtime/web-session-tabs-sync/tracking-decisions')
|
||||
shouldApplyWebSessionTabsSnapshot(
|
||||
{
|
||||
worktree: worktree.id,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: 'host-tab-1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'host-tab-1::leaf',
|
||||
title: 'Terminal',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: '11111111-1111-4111-8111-111111111111',
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'term_host'
|
||||
}
|
||||
]
|
||||
},
|
||||
'web-runtime-1'
|
||||
)
|
||||
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('respawns a host terminal when waking a slept web workspace with dead local PTYs', async () => {
|
||||
const worktree = makeWebRuntimeWorktree()
|
||||
const callRuntimeEnvironment = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { repoId: worktree.repoId, worktreeId: worktree.id, activated: true }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tabId: 'host-tab-1', terminal: 'term_host' }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({
|
||||
call: callRuntimeEnvironment,
|
||||
subscribe: vi.fn()
|
||||
})
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
settings: {
|
||||
...getDefaultSettings('/workspace/.orca-workspaces'),
|
||||
agentCmdOverrides: {},
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn(),
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 1,
|
||||
activeRenderableTabId: 'tab-1'
|
||||
}))
|
||||
})
|
||||
|
||||
rememberEmptyHostAnswerForTests('web-runtime-1', worktree.id)
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'web-runtime-1',
|
||||
method: 'session.tabs.createTerminal'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('respawns wake terminals on the explicit owner runtime when focus changed', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { tabId: 'host-tab-1', terminal: 'term_host' }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({
|
||||
call: callRuntimeEnvironment,
|
||||
subscribe: vi.fn()
|
||||
})
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: '/workspace/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:owner-runtime'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'tab-1',
|
||||
ptyId: 'pty-1',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Terminal 1',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
settings: {
|
||||
...getDefaultSettings('/workspace/.orca-workspaces'),
|
||||
activeRuntimeEnvironmentId: 'focused-runtime'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 1,
|
||||
activeRenderableTabId: 'tab-1'
|
||||
}))
|
||||
})
|
||||
|
||||
rememberEmptyHostAnswerForTests('owner-runtime', worktree.id)
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'owner-runtime',
|
||||
method: 'session.tabs.createTerminal'
|
||||
})
|
||||
)
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'focused-runtime',
|
||||
method: 'session.tabs.createTerminal'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import * as activationGate from './worktree-agent-activation-gate'
|
||||
import {
|
||||
ensureWorktreeHasInitialTerminal,
|
||||
reseedGatedEmptyWorkspace
|
||||
} from './worktree-initial-terminal-seeding'
|
||||
import { reseedGatedEmptyWorkspace } from './worktree-initial-terminal-seeding'
|
||||
import {
|
||||
isEmptyWorkspaceDefaultSurfacePending,
|
||||
releaseEmptyWorkspaceDefaultSurface
|
||||
@@ -15,10 +12,7 @@ import {
|
||||
makeCreatedAgentWorktree as makeWorktree,
|
||||
seedEmptyActivatableWorktree
|
||||
} from '@/lib/worktree-activation-created-agent.test-fixture'
|
||||
import {
|
||||
createMockStore,
|
||||
registerWorktreeActivationReset
|
||||
} from './worktree-activation.test-fixture'
|
||||
import { registerWorktreeActivationReset } from './worktree-activation.test-fixture'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
@@ -66,34 +60,6 @@ function useElectronActivationGate(): ReturnType<typeof vi.spyOn> {
|
||||
return vi.spyOn(activationGate, 'gateWorktreeAgentActivation').mockResolvedValue('empty')
|
||||
}
|
||||
|
||||
function seedEmptyFolderWorkspace(): void {
|
||||
useAppStore.setState({
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: FOLDER_ID,
|
||||
projectGroupId: 'group-1',
|
||||
name: 'notes',
|
||||
folderPath: '/local/notes',
|
||||
executionHostId: 'local',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0
|
||||
}
|
||||
],
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
getFreshFolderWorkspacePathStatus: () => ({ exists: true }),
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
} as unknown as Partial<ReturnType<typeof useAppStore.getState>>)
|
||||
}
|
||||
|
||||
function tabCount(workspaceKey: string): number {
|
||||
return (useAppStore.getState().tabsByWorktree[workspaceKey] ?? []).length
|
||||
}
|
||||
@@ -102,163 +68,7 @@ async function flushPromises(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
// Chat-default users should get chat immediately on empty activation, without converting an unwanted shell.
|
||||
describe('empty workspace seeding with a default agent chat', () => {
|
||||
it('opens the default agent chat instead of a shell', () => {
|
||||
defaultChat.open.mockReturnValue({ primaryTabId: 'chat-tab' })
|
||||
const createTab = vi.fn(() => ({ id: 'shell-tab' }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
const primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ seedUserDefaultSurface: true }
|
||||
)
|
||||
|
||||
expect(defaultChat.open).toHaveBeenCalledWith('wt-1', expect.any(Function))
|
||||
expect(primaryTabId).toBe('chat-tab')
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// A host's "no" to the chat seeds the shell this workspace would have had, not the agent's TUI.
|
||||
it('seeds the plain shell when the host declines the default chat', () => {
|
||||
defaultChat.open.mockImplementation((_worktreeId: string, seedShell: () => boolean) => {
|
||||
seedShell()
|
||||
return { primaryTabId: null }
|
||||
})
|
||||
const createTab = vi.fn(() => ({ id: 'shell-tab' }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, undefined, {
|
||||
seedUserDefaultSurface: true
|
||||
})
|
||||
|
||||
expect(defaultChat.open).toHaveBeenCalledOnce()
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(createTab).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
expect.not.objectContaining({ launchAgent: expect.anything() })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a shell when no chat can open', () => {
|
||||
defaultChat.open.mockReturnValue(null)
|
||||
const createTab = vi.fn(() => ({ id: 'shell-tab' }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
const primaryTabId = ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ seedUserDefaultSurface: true }
|
||||
)
|
||||
|
||||
expect(primaryTabId).toBe('shell-tab')
|
||||
expect(createTab).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the shell for seeds that did not ask for the default surface', () => {
|
||||
const store = createMockStore({ createTab: vi.fn(() => ({ id: 'shell-tab' })) })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1')
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-2', undefined, undefined, undefined, undefined, {
|
||||
seedUserDefaultSurface: true,
|
||||
activateCreatedTabs: false
|
||||
})
|
||||
|
||||
expect(defaultChat.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a user open of an empty workspace asks for the default surface', () => {
|
||||
defaultChat.open.mockReturnValue({ primaryTabId: null })
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
activateAndRevealWorktree(worktree.id, { ...USER_OPEN, notifyHostRuntime: false })
|
||||
|
||||
expect(defaultChat.open).toHaveBeenCalledWith(worktree.id, expect.any(Function))
|
||||
})
|
||||
|
||||
it('back/forward navigation asks for the default surface', () => {
|
||||
defaultChat.open.mockReturnValue({ primaryTabId: null })
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
useAppStore.setState({
|
||||
worktreeNavHistory: [worktree.id, 'repo-1::/elsewhere'],
|
||||
worktreeNavHistoryIndex: 1
|
||||
})
|
||||
|
||||
useAppStore.getState().goBackWorktree()
|
||||
|
||||
expect(defaultChat.open).toHaveBeenCalledWith(worktree.id, expect.any(Function))
|
||||
})
|
||||
|
||||
// Activation without deliberate user intent (CLI/phone/failure/delete fallback) must not start an unrequested agent.
|
||||
it.each([
|
||||
['no navigation intent', {}],
|
||||
['a Blank Terminal pick', { ...USER_OPEN, agent: null }]
|
||||
])('keeps the shell for %s', (_label, selection) => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
activateAndRevealWorktree(worktree.id, { ...selection, notifyHostRuntime: false })
|
||||
|
||||
expect(defaultChat.open).not.toHaveBeenCalled()
|
||||
expect(tabCount(worktree.id)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the gated reseed that seeds an empty workspace in Electron', () => {
|
||||
it('opens the default agent chat for a user open', async () => {
|
||||
defaultChat.open.mockReturnValue({ primaryTabId: null })
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
const gate = useElectronActivationGate()
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, {
|
||||
...USER_OPEN,
|
||||
notifyHostRuntime: false
|
||||
})
|
||||
expect(result === false ? 'failed' : result.primaryTabId).toBeNull()
|
||||
await gate.mock.results[0]?.value
|
||||
|
||||
expect(defaultChat.open).toHaveBeenCalledWith(worktree.id, expect.any(Function))
|
||||
expect(tabCount(worktree.id)).toBe(0)
|
||||
})
|
||||
|
||||
it('seeds a shell for an activation without a user open', async () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
const gate = useElectronActivationGate()
|
||||
|
||||
activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
|
||||
await gate.mock.results[0]?.value
|
||||
|
||||
expect(defaultChat.open).not.toHaveBeenCalled()
|
||||
expect(tabCount(worktree.id)).toBe(1)
|
||||
})
|
||||
|
||||
it('opens the default agent chat for a user-opened folder workspace', async () => {
|
||||
defaultChat.open.mockReturnValue({ primaryTabId: null })
|
||||
seedEmptyFolderWorkspace()
|
||||
const gate = useElectronActivationGate()
|
||||
|
||||
activateAndRevealFolderWorkspace(FOLDER_ID, { ...USER_OPEN, executionHostId: 'local' })
|
||||
await gate.mock.results[0]?.value
|
||||
|
||||
expect(defaultChat.open).toHaveBeenCalledWith(FOLDER_KEY, expect.any(Function))
|
||||
expect(tabCount(FOLDER_KEY)).toBe(0)
|
||||
})
|
||||
|
||||
it('waits for the host agent list and lets no other reseed seed a shell meanwhile', async () => {
|
||||
defaultChat.open.mockReturnValue({ primaryTabId: null })
|
||||
defaultChat.awaitsDetection.mockReturnValue(true)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
activateAndRevealFolderWorkspace,
|
||||
activateAndRevealWorkspace,
|
||||
activateAndRevealWorktree
|
||||
} from './worktree-activation'
|
||||
import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation'
|
||||
import * as activationGate from './worktree-agent-activation-gate'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
@@ -42,80 +38,6 @@ function seedClosedLastTerminal(worktreeId: string): void {
|
||||
}
|
||||
|
||||
describe('activating a workspace whose last terminal was closed', () => {
|
||||
it.each([
|
||||
['Blank Terminal', null, 1],
|
||||
['an agent', 'codex' as const, 0]
|
||||
])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, {
|
||||
agent,
|
||||
notifyHostRuntime: false
|
||||
})
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
expect(result === false ? null : result.primaryTabId === null).toBe(expectedTabCount === 0)
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabCount)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'forwards providesInitialSurface=%s through the async activation gate',
|
||||
async (providesInitialSurface) => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
seedClosedLastTerminal(worktree.id)
|
||||
useAppStore.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'pane-1': { worktreeId: worktree.id }
|
||||
} as never
|
||||
})
|
||||
const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation')
|
||||
gate.mockResolvedValue('empty')
|
||||
|
||||
activateAndRevealWorktree(worktree.id, {
|
||||
providesInitialSurface,
|
||||
notifyHostRuntime: false
|
||||
})
|
||||
await gate.mock.results[0]?.value
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(
|
||||
providesInitialSurface ? 0 : 1
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// An empty gate invalidates agent-based suppression; only an explicit caller surface survives.
|
||||
it.each([
|
||||
['an agent selection', { agent: 'codex' as const }, 1],
|
||||
[
|
||||
'an agent selection whose caller promised its own surface',
|
||||
{ agent: 'codex' as const, providesInitialSurface: true },
|
||||
0
|
||||
]
|
||||
])('re-seeds a gate-reported empty workspace for %s', async (_label, selection, expectedTabs) => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
seedClosedLastTerminal(worktree.id)
|
||||
useAppStore.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'pane-1': { worktreeId: worktree.id }
|
||||
} as never
|
||||
})
|
||||
const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation')
|
||||
gate.mockResolvedValue('empty')
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, {
|
||||
...selection,
|
||||
notifyHostRuntime: false
|
||||
})
|
||||
await gate.mock.results[0]?.value
|
||||
|
||||
// The gate owns seeding for this activation, so the synchronous call never returns a tab.
|
||||
expect(result === false ? null : result.primaryTabId).toBeNull()
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(expectedTabs)
|
||||
})
|
||||
|
||||
it('lets the latest caller surface supersede an older callback on the shared gate', async () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
@@ -174,18 +96,6 @@ describe('activating a workspace whose last terminal was closed', () => {
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.id).toBe(recoveredTabId)
|
||||
})
|
||||
|
||||
it('re-seeds a terminal when the workspace is opened from elsewhere', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
seedClosedLastTerminal(worktree.id)
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
expect(result === false ? null : result.primaryTabId).toBeTruthy()
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Hydration can restore an emptied workspace active; clicking that row must reseed instead of preserving the blank surface.
|
||||
it('re-seeds when the restored active workspace is reopened on the same host', () => {
|
||||
const worktree = makeWorktree()
|
||||
@@ -205,40 +115,6 @@ describe('activating a workspace whose last terminal was closed', () => {
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Sidebar passes local while Cmd+J omits host; reseeding must remain independent of that difference.
|
||||
it('re-seeds identically whether or not the caller passes an execution host', () => {
|
||||
for (const opts of [{}, { executionHostId: 'local' as const }]) {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
seedClosedLastTerminal(worktree.id)
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: worktree.id,
|
||||
activeWorkspaceExecutionHostId: 'local',
|
||||
activeView: 'terminal'
|
||||
})
|
||||
|
||||
activateAndRevealWorktree(worktree.id, { ...opts, notifyHostRuntime: false })
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
// File links/check annotations provide editors; shell seeding would add an unrequested terminal and erase the empty tombstone.
|
||||
it('leaves the row empty when the caller opens its own surface', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
seedClosedLastTerminal(worktree.id)
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, {
|
||||
providesInitialSurface: true,
|
||||
notifyHostRuntime: false
|
||||
})
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
expect(result === false ? null : result.primaryTabId).toBeNull()
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves the row empty for startup hydration, which never opts into re-seeding', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
@@ -344,65 +220,6 @@ function seedEmptiedFolderWorkspaceOnTwoHosts(): void {
|
||||
}
|
||||
|
||||
describe('activating a folder workspace whose last terminal was closed', () => {
|
||||
it.each([
|
||||
['Blank Terminal', null, 1],
|
||||
['an agent', 'codex' as const, 0]
|
||||
])('seeds a default shell for %s selection only', (_label, agent, expectedTabCount) => {
|
||||
seedEmptiedFolderWorkspaceOnTwoHosts()
|
||||
|
||||
const result = activateAndRevealFolderWorkspace(FOLDER_ID, {
|
||||
agent,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY)
|
||||
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(expectedTabCount)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'forwards providesInitialSurface=%s through the async activation gate',
|
||||
async (providesInitialSurface) => {
|
||||
seedEmptiedFolderWorkspaceOnTwoHosts()
|
||||
useAppStore.setState({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'pane-1': { worktreeId: FOLDER_KEY }
|
||||
} as never
|
||||
})
|
||||
const gate = vi.spyOn(activationGate, 'gateWorktreeAgentActivation')
|
||||
gate.mockResolvedValue('empty')
|
||||
|
||||
activateAndRevealFolderWorkspace(FOLDER_ID, {
|
||||
executionHostId: 'local',
|
||||
providesInitialSurface
|
||||
})
|
||||
await gate.mock.results[0]?.value
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(
|
||||
providesInitialSurface ? 0 : 1
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['local', SSH_HOST_ID] as const)(
|
||||
'opens a notification on %s without revealing the folder',
|
||||
(executionHostId) => {
|
||||
seedEmptiedFolderWorkspaceOnTwoHosts()
|
||||
useAppStore.setState({ sidebarBody: 'agents' })
|
||||
|
||||
const result = activateAndRevealWorkspace(FOLDER_KEY, {
|
||||
executionHostId,
|
||||
revealInSidebar: false,
|
||||
clearSidebarFilters: false
|
||||
})
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe(FOLDER_KEY)
|
||||
expect(useAppStore.getState().sidebarBody).toBe('agents')
|
||||
expect(useAppStore.getState().revealWorktreeInSidebar).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
// This must use the folder entry point; setActiveWorktree(folderWorkspaceKey(...)) takes the git path.
|
||||
it.each([
|
||||
['a local agent selection', { agent: 'codex' as const }, 'local' as const, 1],
|
||||
@@ -434,27 +251,6 @@ describe('activating a folder workspace whose last terminal was closed', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('re-seeds a terminal when the workspace is opened', () => {
|
||||
seedEmptiedFolderWorkspaceOnTwoHosts()
|
||||
|
||||
const result = activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' })
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('re-seeds when the restored active folder workspace is reopened on the same host', () => {
|
||||
seedEmptiedFolderWorkspaceOnTwoHosts()
|
||||
useAppStore.setState({
|
||||
activeWorktreeId: FOLDER_KEY,
|
||||
activeWorkspaceExecutionHostId: 'local'
|
||||
})
|
||||
|
||||
activateAndRevealFolderWorkspace(FOLDER_ID, { executionHostId: 'local' })
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[FOLDER_KEY]).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Own-surface opt-out must also cover folders or file-link routing there reseeds shells.
|
||||
it('leaves the row empty when the caller opens its own surface', async () => {
|
||||
seedEmptiedFolderWorkspaceOnTwoHosts()
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync/tracking-lifecycle'
|
||||
import { rememberEmptyHostAnswerForTests } from '@/runtime/web-session-tabs-sync/host-terminal-count-fixture'
|
||||
import { useAppStore } from '@/store'
|
||||
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake'
|
||||
import { toast } from 'sonner'
|
||||
import { stubRuntimeEnvironmentBridge } from '../runtime/runtime-environment-bridge-fixture'
|
||||
import { loadWebRuntimeSessionTabsApplyModulesForTests } from '@/runtime/web-runtime-session-snapshot'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
const WORKTREE_PATH = path.join('workspace', 'feature')
|
||||
const REPO_PATH = path.join('workspace', 'repo')
|
||||
const ORCA_WORKSPACES_PATH = path.join('workspace', '.orca-workspaces')
|
||||
|
||||
// The post-create refresh lazy-loads real session-tabs modules; load them before vi.waitFor's 1s budget starts.
|
||||
beforeAll(loadWebRuntimeSessionTabsApplyModulesForTests)
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
|
||||
vi.unstubAllGlobals()
|
||||
resetWebSessionTabsSnapshotFreshnessForTests()
|
||||
resetWebRuntimeWakeTerminalRespawnForTests()
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
function makeWorktree(): Worktree {
|
||||
return {
|
||||
id: `repo-1::${WORKTREE_PATH}`,
|
||||
repoId: 'repo-1',
|
||||
path: WORKTREE_PATH,
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdWithAgent: 'codex',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: 'web-runtime-1'
|
||||
}
|
||||
}
|
||||
|
||||
describe('empty remote worktree activation', () => {
|
||||
it('creates a host terminal when waking an empty remote workspace', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
tab: {
|
||||
type: 'terminal',
|
||||
id: 'host-tab-1::leaf-1',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: 'leaf-1',
|
||||
title: 'Terminal 1',
|
||||
terminal: 'term_host',
|
||||
status: 'ready',
|
||||
isActive: true
|
||||
},
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1
|
||||
}
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({
|
||||
call: callRuntimeEnvironment,
|
||||
subscribe: vi.fn()
|
||||
})
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
settings: {
|
||||
...getDefaultSettings(ORCA_WORKSPACES_PATH),
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 0,
|
||||
activeRenderableTabId: null
|
||||
}))
|
||||
})
|
||||
|
||||
rememberEmptyHostAnswerForTests('web-runtime-1', worktree.id)
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: 'web-runtime-1',
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: expect.objectContaining({
|
||||
worktree: `id:${worktree.id}`,
|
||||
activate: false,
|
||||
select: true,
|
||||
navigation: 'caller'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
// Why: drain the post-create snapshot refresh so it cannot land on the next test's bridge.
|
||||
await vi.waitFor(() =>
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'session.tabs.list' })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces a failed host terminal request without retrying ambiguously', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { code: 'terminal_create_failed', message: 'Host refused the terminal' }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({
|
||||
call: callRuntimeEnvironment,
|
||||
subscribe: vi.fn()
|
||||
})
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
settings: {
|
||||
...getDefaultSettings(ORCA_WORKSPACES_PATH),
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 0,
|
||||
activeRenderableTabId: null
|
||||
}))
|
||||
})
|
||||
|
||||
rememberEmptyHostAnswerForTests('web-runtime-1', worktree.id)
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(toast.error).toHaveBeenCalledWith('Host refused the terminal', {
|
||||
id: `web-runtime-worktree-terminal:web-runtime-1:${worktree.id}`
|
||||
})
|
||||
)
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not create a terminal for a workspace the user emptied', async () => {
|
||||
// Empty mirror workspaces use this activation door without stream rows. Honor explicit empty tombstones here too or every focus reseeds terminals.
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn()
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment, subscribe: vi.fn() })
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: { [worktree.id]: [] },
|
||||
ptyIdsByTabId: {},
|
||||
settings: {
|
||||
...getDefaultSettings(ORCA_WORKSPACES_PATH),
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 0,
|
||||
activeRenderableTabId: null
|
||||
}))
|
||||
})
|
||||
|
||||
rememberEmptyHostAnswerForTests('web-runtime-1', worktree.id)
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('declines when tab rows exist but none of them render', async () => {
|
||||
// Present-but-unrenderable rows indicate mid-reconcile; seed must not duplicate their pending panes.
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn()
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment, subscribe: vi.fn() })
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'local-tab',
|
||||
ptyId: 'dead-pty',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {},
|
||||
settings: {
|
||||
...getDefaultSettings(ORCA_WORKSPACES_PATH),
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 0,
|
||||
activeRenderableTabId: null
|
||||
}))
|
||||
})
|
||||
|
||||
rememberEmptyHostAnswerForTests('web-runtime-1', worktree.id)
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(callRuntimeEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still respawns a woke workspace whose tab rows outlived their PTYs', async () => {
|
||||
// Renderable rows with gone host PTYs need separate recovery; tombstone guards must not swallow it.
|
||||
const worktree = makeWorktree()
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValueOnce({ ok: true, result: {} })
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment, subscribe: vi.fn() })
|
||||
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: REPO_PATH,
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'local-tab',
|
||||
ptyId: 'dead-pty',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {},
|
||||
settings: {
|
||||
...getDefaultSettings(ORCA_WORKSPACES_PATH),
|
||||
activeRuntimeEnvironmentId: 'web-runtime-1'
|
||||
},
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({
|
||||
renderableTabCount: 1,
|
||||
activeRenderableTabId: 'local-tab'
|
||||
}))
|
||||
})
|
||||
|
||||
rememberEmptyHostAnswerForTests('web-runtime-1', worktree.id)
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake(worktree.id)
|
||||
await vi.waitFor(() => {
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import {
|
||||
createMockStore,
|
||||
registerWorktreeActivationReset,
|
||||
setLegacySetupScriptLaunchMode,
|
||||
createdTabStartups
|
||||
} from './worktree-activation.test-fixture'
|
||||
|
||||
registerWorktreeActivationReset()
|
||||
|
||||
describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
it('queues an issue command split through returned WSL shell metadata', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\issue-command-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: { ORCA_ROOT_PATH: 'C:\\repo' }
|
||||
})
|
||||
|
||||
expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', {
|
||||
command: 'bash /mnt/c/repo/.git/orca/issue-command-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: 'C:\\repo' }
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a Setup tab and queues issue automation on the primary terminal', () => {
|
||||
setLegacySetupScriptLaunchMode('split-vertical')
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
{
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
},
|
||||
{
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/issue-command-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', {
|
||||
command: 'bash /tmp/repo/.git/orca/issue-command-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,110 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
listActivationPtySessions,
|
||||
resolveActivationPtyListScope
|
||||
} from './worktree-activation-pty-inventory'
|
||||
|
||||
const worktreeId = 'repo::/workspace'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function stubListSessions(impl: (scope?: unknown) => Promise<unknown[]>) {
|
||||
const listSessions = vi.fn(impl)
|
||||
vi.stubGlobal('window', { api: { pty: { listSessions } } })
|
||||
return listSessions
|
||||
}
|
||||
|
||||
describe('activation inventory execution scope', () => {
|
||||
it('keeps a known local repo usable before unrelated runtime catalogs hydrate', () => {
|
||||
expect(
|
||||
resolveActivationPtyListScope(
|
||||
{
|
||||
repos: [{ id: 'repo' }],
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: false,
|
||||
activeWorktreeId: 'other::/remote',
|
||||
activeWorkspaceExecutionHostId: 'ssh:other'
|
||||
},
|
||||
worktreeId
|
||||
)
|
||||
).toEqual({ connectionId: null })
|
||||
})
|
||||
|
||||
it('does not use the native repo fallback for an explicitly owned worktree', () => {
|
||||
expect(
|
||||
resolveActivationPtyListScope(
|
||||
{
|
||||
repos: [{ id: 'repo' }],
|
||||
worktreesByRepo: { repo: [{ id: worktreeId, repoId: 'repo', hostId: 'runtime:hub' }] }
|
||||
},
|
||||
worktreeId
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves folder workspaces without a Git worktree row', () => {
|
||||
expect(
|
||||
resolveActivationPtyListScope(
|
||||
{
|
||||
folderWorkspaces: [{ id: 'folder', projectGroupId: 'group', connectionId: 'box' }]
|
||||
},
|
||||
folderWorkspaceKey('folder')
|
||||
)
|
||||
).toEqual({ connectionId: 'box' })
|
||||
})
|
||||
|
||||
it('does not choose a provider for missing or ambiguous ownership', () => {
|
||||
expect(resolveActivationPtyListScope({}, worktreeId)).toBeUndefined()
|
||||
expect(
|
||||
resolveActivationPtyListScope(
|
||||
{
|
||||
repos: [
|
||||
{ id: 'repo', executionHostId: 'local' },
|
||||
{ id: 'repo', executionHostId: 'ssh:box' }
|
||||
]
|
||||
},
|
||||
worktreeId
|
||||
)
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never routes a paired host or its nested SSH target through client providers', () => {
|
||||
for (const hostId of ['runtime:hub', 'ssh:nested'] as const) {
|
||||
expect(
|
||||
resolveActivationPtyListScope(
|
||||
{
|
||||
worktreesByRepo: {
|
||||
repo: [{ id: worktreeId, repoId: 'repo', hostId, runtimeOwnerEnvironmentId: 'hub' }]
|
||||
}
|
||||
},
|
||||
worktreeId
|
||||
)
|
||||
).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('activation inventory census', () => {
|
||||
it('rejects paired ownership without consulting the client inventory', async () => {
|
||||
const listSessions = stubListSessions(async () => [])
|
||||
await expect(
|
||||
listActivationPtySessions(
|
||||
{
|
||||
worktreesByRepo: {
|
||||
repo: [
|
||||
{
|
||||
id: worktreeId,
|
||||
repoId: 'repo',
|
||||
hostId: 'runtime:hub',
|
||||
runtimeOwnerEnvironmentId: 'hub'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
worktreeId
|
||||
)
|
||||
).rejects.toThrow('Activation PTY inventory is unverifiable')
|
||||
expect(listSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,354 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV,
|
||||
SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV
|
||||
} from '../../../shared/setup-agent-sequencing'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import {
|
||||
createMockStore,
|
||||
registerWorktreeActivationReset,
|
||||
setLegacySetupScriptLaunchMode,
|
||||
createdTabStartups
|
||||
} from './worktree-activation.test-fixture'
|
||||
|
||||
registerWorktreeActivationReset()
|
||||
|
||||
function sequencedSetup(env: Record<string, string>, runnerCommand: string): unknown {
|
||||
return {
|
||||
command: `bash -lc 'eval "\${${SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV}:?missing}"'`,
|
||||
env: { ...env, [SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]: expect.stringContaining(runnerCommand) }
|
||||
}
|
||||
}
|
||||
|
||||
describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
it('creates a background Setup tab for newly created worktrees by default', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(createTab).toHaveBeenCalledTimes(2)
|
||||
expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1')
|
||||
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/worktrees/wt-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('queues setup through returned POSIX shell metadata on native Windows paths', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash /c/repo/.git/orca/setup-runner.sh',
|
||||
env: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('queues returned setup on an existing terminal tab when startup was already adopted', () => {
|
||||
let createdIndex = 1
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
createTab,
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 }))
|
||||
})
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
command: 'bash -lc wrapped-setup',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
|
||||
expect(result).toBe('tab-1')
|
||||
expect(createTab).toHaveBeenCalledTimes(1)
|
||||
expect(store.setActiveTab).toHaveBeenCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash -lc wrapped-setup',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
})
|
||||
|
||||
it('queues wrapped setup on an existing terminal tab when setup gates startup', () => {
|
||||
let createdIndex = 1
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
createTab,
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 }))
|
||||
})
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude' },
|
||||
{
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' },
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe('tab-1')
|
||||
expect(createTab).toHaveBeenCalledTimes(1)
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual(
|
||||
sequencedSetup({ ORCA_ROOT_PATH: '/tmp/repo' }, 'bash /tmp/repo/.git/orca/setup-runner.sh')
|
||||
)
|
||||
})
|
||||
|
||||
it('queues WSL setup launch commands with WSL path conversion on native Windows paths', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash /mnt/c/repo/.git/orca/setup-runner.sh',
|
||||
env: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'keeps a blank shell and queues setup-gated Codex in a new terminal (activate=%s)',
|
||||
(activateCreatedTabs) => {
|
||||
let createdIndex = 1
|
||||
const store = createMockStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
createTab: vi.fn(() => ({ id: `tab-${++createdIndex}` })),
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 }))
|
||||
})
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'codex', launchAgent: 'codex' },
|
||||
{ runnerScriptPath: '/tmp/setup.sh', envVars: {}, waitForAgentStartup: true },
|
||||
undefined,
|
||||
undefined,
|
||||
{ createNewTerminalForStartup: true, activateCreatedTabs }
|
||||
)
|
||||
|
||||
expect(result).toBe('tab-2')
|
||||
const startups = createdTabStartups(store)
|
||||
expect(Object.keys(startups)).toEqual(['tab-2', 'tab-3'])
|
||||
expect(startups['tab-2']).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining('exec codex')
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(startups['tab-3']).toEqual(sequencedSetup({}, 'bash /tmp/setup.sh'))
|
||||
if (!activateCreatedTabs) {
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('gates startup behind setup completion when both are provided in new-tab mode', () => {
|
||||
setLegacySetupScriptLaunchMode('new-tab')
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude' },
|
||||
{
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' },
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining(
|
||||
'Timed out waiting for setup before starting agent.'
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining('exec claude')
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual(
|
||||
sequencedSetup({ ORCA_ROOT_PATH: '/tmp/repo' }, 'bash /tmp/repo/.git/orca/setup-runner.sh')
|
||||
)
|
||||
})
|
||||
|
||||
it('starts setup and agent side by side by default', () => {
|
||||
setLegacySetupScriptLaunchMode('new-tab')
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude' },
|
||||
{
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual({
|
||||
command: 'claude'
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps setup sequencing after retiring a saved split preference', () => {
|
||||
setLegacySetupScriptLaunchMode('split-vertical')
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude' },
|
||||
{
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' },
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining('exec claude')
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual(
|
||||
sequencedSetup({ ORCA_ROOT_PATH: '/tmp/repo' }, 'bash /tmp/repo/.git/orca/setup-runner.sh')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps WSL setup shell metadata when gating startup behind setup completion', () => {
|
||||
setLegacySetupScriptLaunchMode('split-vertical')
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude' },
|
||||
{
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: { ORCA_ROOT_PATH: 'C:\\repo' },
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
[SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV]: expect.stringContaining(
|
||||
'/mnt/c/repo/.git/orca/setup-runner.sh'
|
||||
)
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual(
|
||||
sequencedSetup({ ORCA_ROOT_PATH: 'C:\\repo' }, 'bash /mnt/c/repo/.git/orca/setup-runner.sh')
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['split-vertical', 'split-horizontal', 'new-tab', null] as const)(
|
||||
'creates a dedicated Setup tab for retired location %s',
|
||||
(mode) => {
|
||||
setLegacySetupScriptLaunchMode(mode)
|
||||
const store = createMockStore()
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
expect(store.createTab).toHaveBeenCalledTimes(2)
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('creates a background Setup tab when setupScriptLaunchMode is new-tab', () => {
|
||||
setLegacySetupScriptLaunchMode('new-tab')
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
|
||||
expect(createTab).toHaveBeenCalledTimes(2)
|
||||
// Main tab is activated first (new terminal), then setup tab is created,
|
||||
// and the helper re-activates the main tab so focus stays on tab-1.
|
||||
expect(store.setActiveTab).toHaveBeenNthCalledWith(1, 'tab-1')
|
||||
expect(store.setActiveTab).toHaveBeenLastCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: 'bash /tmp/repo/.git/orca/setup-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: '/tmp/repo' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -31,20 +31,6 @@ const setup = {
|
||||
|
||||
// Returned setup scripts must not force Terminal 1 beside a new native chat.
|
||||
describe('seeding beside a caller-provided chat surface', () => {
|
||||
it('activation forwards providesInitialSurface so setup alone adds one tab', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
const result = activateAndRevealWorktree(worktree.id, {
|
||||
providesInitialSurface: true,
|
||||
notifyHostRuntime: false,
|
||||
setup
|
||||
})
|
||||
|
||||
expect(result).not.toBe(false)
|
||||
expect(result === false ? 'unused' : result.primaryTabId).toBeNull()
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
})
|
||||
it('does not enqueue setup again when its consumed tab is reactivated', () => {
|
||||
const worktree = makeWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV } from '../../../shared/setup-agent-sequencing'
|
||||
import { activateAndRevealFolderWorkspace, activateAndRevealWorktree } from './worktree-activation'
|
||||
import '../../../shared/setup-agent-sequencing'
|
||||
import './worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import type { AppStoreState } from './worktree-activation.test-fixture'
|
||||
import {
|
||||
createMockStore,
|
||||
registerWorktreeActivationReset,
|
||||
createdTabStartups
|
||||
registerWorktreeActivationReset
|
||||
} from './worktree-activation.test-fixture'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
makeCreatedAgentWorktree,
|
||||
seedEmptyActivatableWorktree
|
||||
} from './worktree-activation-created-agent.test-fixture'
|
||||
import {
|
||||
resetWebRuntimeWakeTerminalRespawnForTests,
|
||||
shouldSkipWebRuntimeWakeTerminalRespawn
|
||||
} from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
import { stubRuntimeEnvironmentBridge } from '../runtime/runtime-environment-bridge-fixture'
|
||||
import { rememberEmptyHostAnswerForTests } from '@/runtime/web-session-tabs-sync/host-terminal-count-fixture'
|
||||
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
|
||||
import '../runtime/runtime-environment-bridge-fixture'
|
||||
import '@/runtime/web-session-tabs-sync/host-terminal-count-fixture'
|
||||
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync/tracking-lifecycle'
|
||||
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
@@ -46,60 +42,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('activateAndRevealWorktree', () => {
|
||||
it.each(['local', 'runtime:recorded'] as const)(
|
||||
'historical reveal contract retains explicit %s owner and supplies no substitute terminal',
|
||||
async (hostId) => {
|
||||
const worktree = { ...makeCreatedAgentWorktree(), hostId }
|
||||
const twin = {
|
||||
...worktree,
|
||||
hostId: hostId === 'local' ? ('runtime:other' as const) : ('local' as const)
|
||||
}
|
||||
seedEmptyActivatableWorktree(worktree, { extraWorktrees: [twin] })
|
||||
const createTab = vi.fn()
|
||||
const setActiveWorktree = vi.fn<typeof originalSetActiveWorktree>()
|
||||
useAppStore.setState((state) => ({
|
||||
settings: { ...state.settings!, activeRuntimeEnvironmentId: 'ambient-other' },
|
||||
createTab,
|
||||
setActiveWorktree,
|
||||
activeWorktreeId: 'unrelated',
|
||||
activeWorkspaceExecutionHostId: 'ssh:unrelated'
|
||||
}))
|
||||
const runtimeCall = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, error: { code: 'test', message: 'record request only' } })
|
||||
stubRuntimeEnvironmentBridge({ call: runtimeCall })
|
||||
const environmentId = hostId === 'local' ? null : 'recorded'
|
||||
expect(
|
||||
activateAndRevealWorktree(worktree.id, {
|
||||
executionHostId: hostId,
|
||||
runtimeEnvironmentId: environmentId,
|
||||
providesInitialSurface: true
|
||||
})
|
||||
).not.toBe(false)
|
||||
expect(setActiveWorktree).toHaveBeenCalledWith(worktree.id, hostId, {
|
||||
stateTransition: expect.any(Function)
|
||||
})
|
||||
const transition = setActiveWorktree.mock.calls[0]?.[2]?.stateTransition
|
||||
expect(transition?.(useAppStore.getState())).toMatchObject({ activate: true })
|
||||
if (environmentId) {
|
||||
await vi.waitFor(() =>
|
||||
expect(runtimeCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
selector: environmentId,
|
||||
method: 'worktree.activate'
|
||||
})
|
||||
)
|
||||
)
|
||||
} else {
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
}
|
||||
expect(
|
||||
runtimeCall.mock.calls.some(([request]) => request.method === 'session.tabs.createTerminal')
|
||||
).toBe(false)
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['local', 'ssh:box', 'inherited-ssh'] as const)(
|
||||
'real historical folder action selects exact %s pane through the store without a replacement shell',
|
||||
(kind) => {
|
||||
@@ -226,288 +168,9 @@ describe('activateAndRevealWorktree', () => {
|
||||
useAppStore.setState(before, true)
|
||||
}
|
||||
})
|
||||
|
||||
it('asks the paired host for the prepared agent terminal when backend startup did not spawn', async () => {
|
||||
const worktree = {
|
||||
...makeCreatedAgentWorktree(),
|
||||
hostId: 'local' as const,
|
||||
runtimeOwnerEnvironmentId: 'web-runtime-1'
|
||||
}
|
||||
const callRuntimeEnvironment = vi.fn(
|
||||
async (request: { method: string; params?: Record<string, unknown> }) =>
|
||||
request.method === 'session.tabs.createTerminal'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
tab: { id: 'host-agent-tab', leafId: 'host-agent-leaf' },
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1
|
||||
}
|
||||
}
|
||||
: { ok: false, error: { code: 'test', message: 'stop after recording the request' } }
|
||||
)
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment })
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
const settings = useAppStore.getState().settings
|
||||
useAppStore.setState({
|
||||
settings: settings
|
||||
? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' }
|
||||
: ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings)
|
||||
})
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'stale-local-agent-tab',
|
||||
ptyId: 'stale-local-agent-pty',
|
||||
worktreeId: worktree.id,
|
||||
title: 'Codex',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1,
|
||||
launchAgent: 'codex'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
activateAndRevealWorktree(worktree.id, {
|
||||
agent: 'codex',
|
||||
startup: {
|
||||
command: "codex 'fix the ownership race'",
|
||||
env: { ORCA_AGENT_PROFILE: 'review' },
|
||||
launchAgent: 'codex',
|
||||
launchToken: 'launch-1'
|
||||
}
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'worktree.activate' })
|
||||
)
|
||||
)
|
||||
|
||||
const createRequests = callRuntimeEnvironment.mock.calls.filter(
|
||||
([request]) => request.method === 'session.tabs.createTerminal'
|
||||
)
|
||||
expect(createRequests).toHaveLength(1)
|
||||
expect(createRequests[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
command: "codex 'fix the ownership race'",
|
||||
env: { ORCA_AGENT_PROFILE: 'review' },
|
||||
launchAgent: 'codex',
|
||||
launchToken: 'launch-1'
|
||||
})
|
||||
})
|
||||
)
|
||||
await vi.waitFor(() => expect(shouldSkipWebRuntimeWakeTerminalRespawn(worktree.id)).toBe(false))
|
||||
})
|
||||
|
||||
it('does not request another host terminal when backend startup already spawned', async () => {
|
||||
const worktree = {
|
||||
...makeCreatedAgentWorktree(),
|
||||
hostId: 'local' as const,
|
||||
runtimeOwnerEnvironmentId: 'web-runtime-1'
|
||||
}
|
||||
const callRuntimeEnvironment = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: { code: 'test', message: 'stop after recording the request' }
|
||||
})
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment })
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' }
|
||||
: ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings)
|
||||
}))
|
||||
|
||||
activateAndRevealWorktree(worktree.id, {
|
||||
agent: 'codex',
|
||||
backendStartupTerminalSpawned: true
|
||||
})
|
||||
await vi.waitFor(() =>
|
||||
expect(callRuntimeEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'worktree.activate' })
|
||||
)
|
||||
)
|
||||
|
||||
expect(
|
||||
callRuntimeEnvironment.mock.calls.filter(
|
||||
([request]) => request.method === 'session.tabs.createTerminal'
|
||||
)
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('activateAndRevealFolderWorkspace', () => {
|
||||
it.each([
|
||||
[
|
||||
'the selected agent',
|
||||
{
|
||||
agent: 'codex' as const,
|
||||
startup: { command: 'codex', launchAgent: 'codex' as const, launchToken: 'launch-1' }
|
||||
},
|
||||
{ command: 'codex', launchAgent: 'codex', launchToken: 'launch-1' }
|
||||
],
|
||||
['Blank Terminal', { agent: null }, { command: undefined }]
|
||||
])('asks the runtime owner for exactly one %s surface', async (_label, activation, expected) => {
|
||||
const callRuntimeEnvironment = vi.fn(
|
||||
async (request: { method: string; params?: Record<string, unknown> }) =>
|
||||
request.method === 'session.tabs.createTerminal'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
tab: { id: 'host-tab', leafId: 'host-leaf' },
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1
|
||||
}
|
||||
}
|
||||
: { ok: false, error: { code: 'test', message: 'stop after recording the request' } }
|
||||
)
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
stubRuntimeEnvironmentBridge({ call: callRuntimeEnvironment })
|
||||
const settings = useAppStore.getState().settings
|
||||
useAppStore.setState({
|
||||
activeView: 'terminal',
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-1',
|
||||
projectGroupId: 'group-1',
|
||||
name: 'runtime folder',
|
||||
folderPath: '/workspace/runtime-folder',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
executionHostId: 'runtime:web-runtime-1'
|
||||
}
|
||||
],
|
||||
getFreshFolderWorkspacePathStatus: vi.fn(() => ({ exists: true })),
|
||||
tabsByWorktree: {},
|
||||
settings: settings
|
||||
? { ...settings, activeRuntimeEnvironmentId: 'web-runtime-1' }
|
||||
: ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof settings),
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
} as unknown as Partial<AppStoreState>)
|
||||
|
||||
rememberEmptyHostAnswerForTests('web-runtime-1', folderWorkspaceKey('folder-1'))
|
||||
activateAndRevealFolderWorkspace('folder-1', {
|
||||
...activation,
|
||||
runtimeEnvironmentId: 'web-runtime-1'
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
callRuntimeEnvironment.mock.calls.filter(
|
||||
([request]) => request.method === 'session.tabs.createTerminal'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
)
|
||||
const createRequest = callRuntimeEnvironment.mock.calls.find(
|
||||
([request]) => request.method === 'session.tabs.createTerminal'
|
||||
)?.[0]
|
||||
expect(createRequest).toEqual(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining(expected)
|
||||
})
|
||||
)
|
||||
if (activation.agent === null) {
|
||||
expect(createRequest?.params).not.toHaveProperty('launchAgent')
|
||||
}
|
||||
await vi.waitFor(() =>
|
||||
expect(shouldSkipWebRuntimeWakeTerminalRespawn('folder:folder-1')).toBe(false)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
it('does not create a local fallback tab in the paired web runtime client', () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' }
|
||||
: ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings),
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: 'web-runtime-1'
|
||||
}
|
||||
] as never
|
||||
}
|
||||
}))
|
||||
const store = createMockStore()
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(store, 'wt-1')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues returned setup fallback on an existing web runtime tab', () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' }
|
||||
: ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings),
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local',
|
||||
runtimeOwnerEnvironmentId: 'web-runtime-1'
|
||||
}
|
||||
] as never
|
||||
}
|
||||
}))
|
||||
let createdIndex = 1
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1' }] },
|
||||
createTab,
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime-1' },
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 }))
|
||||
})
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude' },
|
||||
{
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
envVars: { ORCA_ROOT_PATH: '/tmp/repo' },
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toBe('tab-1')
|
||||
expect(createTab).toHaveBeenCalledTimes(1)
|
||||
expect(store.setActiveTab).toHaveBeenCalledWith('tab-1')
|
||||
expect(store.setTabCustomTitle).toHaveBeenCalledWith('tab-2', 'Setup', {
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(createdTabStartups(store)['tab-2']).toEqual({
|
||||
command: `bash -lc 'eval "\${${SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV}:?missing}"'`,
|
||||
env: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
[SETUP_AGENT_SEQUENCE_SETUP_SCRIPT_ENV]: expect.stringContaining(
|
||||
'bash /tmp/repo/.git/orca/setup-runner.sh'
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('holds the issue command for the first mirrored web runtime tab when none exists yet', () => {
|
||||
;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true
|
||||
useAppStore.setState((state) => ({
|
||||
@@ -583,25 +246,4 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
||||
command: 'gh issue view 42'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a local initial terminal for explicitly local worktrees while a runtime is focused', () => {
|
||||
useAppStore.setState((state) => ({
|
||||
settings: state.settings
|
||||
? { ...state.settings, activeRuntimeEnvironmentId: 'web-runtime-1' }
|
||||
: ({ activeRuntimeEnvironmentId: 'web-runtime-1' } as unknown as typeof state.settings)
|
||||
}))
|
||||
const store = createMockStore({
|
||||
settings: { activeRuntimeEnvironmentId: 'web-runtime-1' },
|
||||
repos: [{ id: 'repo-1', executionHostId: 'local', connectionId: null }],
|
||||
worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }
|
||||
})
|
||||
|
||||
const result = ensureWorktreeHasInitialTerminal(store, 'wt-1')
|
||||
|
||||
expect(result).toBe('tab-1')
|
||||
expect(store.createTab).toHaveBeenCalledWith('wt-1', undefined, undefined, {
|
||||
pendingActivationSpawn: true
|
||||
})
|
||||
expect(store.setActiveTab).toHaveBeenCalledWith('tab-1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorktreeIdsWithLiveAgent, isInactiveWorkspace } from './worktree-activity-state'
|
||||
import type { TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import type { AgentStatusEntry } from '../../../shared/agent-status-types'
|
||||
|
||||
const NOW = 10_000_000
|
||||
|
||||
function makeTab(id: string): Pick<TerminalTab, 'id'> {
|
||||
return { id }
|
||||
}
|
||||
|
||||
function makeAgentEntry(
|
||||
overrides: Partial<AgentStatusEntry> & { paneKey: string }
|
||||
): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: NOW,
|
||||
stateStartedAt: NOW,
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree activity state', () => {
|
||||
it('keeps a workspace with a running agent active even without a live pty (#7197)', () => {
|
||||
const worktreeIdsWithLiveAgent = new Set(['wt-1'])
|
||||
expect(
|
||||
isInactiveWorkspace(
|
||||
'wt-1',
|
||||
{ 'wt-1': [makeTab('tab-1')] },
|
||||
{ 'tab-1': [] },
|
||||
{},
|
||||
worktreeIdsWithLiveAgent
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorktreeIdsWithLiveAgent', () => {
|
||||
it('falls back to the paneKey tabId when worktreeId is absent', () => {
|
||||
const entries = {
|
||||
'tab-1:00000000-0000-4000-8000-000000000000': makeAgentEntry({
|
||||
paneKey: 'tab-1:00000000-0000-4000-8000-000000000000'
|
||||
})
|
||||
}
|
||||
expect(getWorktreeIdsWithLiveAgent(entries, { 'wt-1': [makeTab('tab-1')] }, NOW)).toEqual(
|
||||
new Set(['wt-1'])
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores completed headless agents without an open session', () => {
|
||||
const entries = {
|
||||
'tab-1:leaf-1': makeAgentEntry({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
worktreeId: 'wt-1',
|
||||
state: 'done'
|
||||
})
|
||||
}
|
||||
|
||||
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('ignores stale status left behind after an SSH disconnect', () => {
|
||||
const entries = {
|
||||
'tab-1:leaf-1': makeAgentEntry({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
worktreeId: 'wt-1',
|
||||
updatedAt: 0
|
||||
})
|
||||
}
|
||||
|
||||
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set())
|
||||
})
|
||||
|
||||
it.each(['working', 'blocked', 'waiting'] as const)(
|
||||
'keeps a fresh %s agent visible during a PTY gap',
|
||||
(state) => {
|
||||
const entries = {
|
||||
'tab-1:leaf-1': makeAgentEntry({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
worktreeId: 'wt-1',
|
||||
state
|
||||
})
|
||||
}
|
||||
|
||||
expect(getWorktreeIdsWithLiveAgent(entries, {}, NOW)).toEqual(new Set(['wt-1']))
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
/** Main places unshown live terminals and owns panes/bindings; renderer sweeps only read its frames, never mint another surface. Frame drawing is covered by CLI-tab/layout-owner adoption suites. */
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PublishedTab } from '../../../shared/workspace-layout/workspace-layout-published'
|
||||
import { provideLayoutFrameTerminalPanes } from '@/runtime/layout-frame-terminal-panes'
|
||||
import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
|
||||
import {
|
||||
type GateTestStore,
|
||||
listed,
|
||||
LIVE_LEAF_ID,
|
||||
seedExistingSurface,
|
||||
SIBLING_LEAF_ID,
|
||||
sleepingRecord,
|
||||
testDeps,
|
||||
UNOWNED,
|
||||
WORKTREE_ID
|
||||
} from './worktree-agent-activation-gate.test-fixture'
|
||||
import type { LiveTerminalSurfaceOwner } from './worktree-live-terminal-surface-owners'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
const LIVE_PTY = `${WORKTREE_ID}@@live-pty`
|
||||
const MAIN_TAB = 'tab-main'
|
||||
|
||||
type HeldPane = { worktreeId?: string; tabId: string; leafId: string; ptyId: string }
|
||||
|
||||
/** Main's frames hold these terminal panes, drawn by the window yet or not. */
|
||||
function mainHolds(...panes: HeldPane[]): void {
|
||||
const held = (worktreeId: string) =>
|
||||
panes.filter((pane) => (pane.worktreeId ?? WORKTREE_ID) === worktreeId)
|
||||
provideLayoutFrameTerminalPanes({
|
||||
holdsTerminalPane: (worktreeId, pane) =>
|
||||
held(worktreeId).some(
|
||||
(h) => h.tabId === pane.tabId && h.leafId === pane.leafId && h.ptyId === pane.ptyId
|
||||
),
|
||||
terminalTab: (worktreeId, tabId): PublishedTab | undefined => {
|
||||
const tabPanes = held(worktreeId).filter((pane) => pane.tabId === tabId)
|
||||
return tabPanes.length === 0
|
||||
? undefined
|
||||
: {
|
||||
id: tabId,
|
||||
kind: 'terminal',
|
||||
entityId: tabId,
|
||||
executionHostId: 'local',
|
||||
createdAt: 1,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
terminal: {
|
||||
root: { type: 'leaf', leafId: tabPanes[0]!.leafId },
|
||||
panes: tabPanes.map(({ leafId, ptyId }) => ({ leafId, ptyId }))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The pane main placed a terminal in, as the host's census records it. */
|
||||
function placed(ptyId: string, tabId = MAIN_TAB, leafId = LIVE_LEAF_ID): LiveTerminalSurfaceOwner {
|
||||
return { paneKey: `${tabId}:${leafId}`, ptyId, tabId }
|
||||
}
|
||||
|
||||
/** The window wrote nothing of its own: main's frames are what show the terminal. */
|
||||
function expectNoWindowWrite(store: GateTestStore): void {
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setTabLayout).not.toHaveBeenCalled()
|
||||
expect(store.updateTabPtyId).not.toHaveBeenCalled()
|
||||
expect(store.replaceTerminalLayoutPanePtyId).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mainHolds()
|
||||
})
|
||||
|
||||
describe('worktree agent activation gate, layout-owner build', () => {
|
||||
it('uses immediately ready development restore inventory', async () => {
|
||||
mainHolds({ tabId: MAIN_TAB, leafId: LIVE_LEAF_ID, ptyId: LIVE_PTY })
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, { unowned: true, recorded: placed(LIVE_PTY) }]])
|
||||
})
|
||||
const awaitReady = vi.fn(async () => true)
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady })
|
||||
).resolves.toBe('adopted')
|
||||
|
||||
expect(awaitReady).toHaveBeenCalledOnce()
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for packaged restore hydration before reading daemon inventory', async () => {
|
||||
mainHolds({ tabId: MAIN_TAB, leafId: LIVE_LEAF_ID, ptyId: LIVE_PTY })
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, { unowned: true, recorded: placed(LIVE_PTY) }]])
|
||||
})
|
||||
let releaseReady!: (ready: boolean) => void
|
||||
const awaitReady = vi.fn(() => new Promise<boolean>((resolve) => (releaseReady = resolve)))
|
||||
|
||||
const activation = runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady })
|
||||
await vi.waitFor(() => expect(awaitReady).toHaveBeenCalledOnce())
|
||||
expect(deps.listSessions).not.toHaveBeenCalled()
|
||||
|
||||
releaseReady(true)
|
||||
await expect(activation).resolves.toBe('adopted')
|
||||
expect(deps.listSessions).toHaveBeenCalledOnce()
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts the live daemon PTY main placed before activation can resume another agent', async () => {
|
||||
mainHolds({ tabId: MAIN_TAB, leafId: LIVE_LEAF_ID, ptyId: LIVE_PTY })
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, { unowned: true, recorded: placed(LIVE_PTY) }]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never surfaces a PTY its host is killing', async () => {
|
||||
const closingPtyId = `${WORKTREE_ID}@@closed-pane`
|
||||
const keptPtyId = `${WORKTREE_ID}@@kept-pane`
|
||||
mainHolds({ tabId: MAIN_TAB, leafId: LIVE_LEAF_ID, ptyId: keptPtyId })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { deps } = testDeps({
|
||||
sessions: [
|
||||
{ ...listed(closingPtyId), title: 'zsh', agentOwnership: 'absent', exiting: true },
|
||||
{ ...listed(keptPtyId), title: 'zsh', agentOwnership: 'absent' }
|
||||
],
|
||||
surfaceOwners: new Map([
|
||||
[closingPtyId, UNOWNED],
|
||||
[keptPtyId, { unowned: true, recorded: placed(keptPtyId) }]
|
||||
]),
|
||||
resumeCount: 0
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
// The closing PTY is not even a candidate: a candidate main does not hold is reported declined.
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('leaves an unowned PTY to the recorded pane this renderer still holds, bound by main', async () => {
|
||||
const recorded = placed(LIVE_PTY, 'tab-live')
|
||||
mainHolds({ tabId: 'tab-live', leafId: LIVE_LEAF_ID, ptyId: LIVE_PTY })
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, { unowned: true, recorded }]])
|
||||
})
|
||||
seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
// No second tab: main's frame binds the pane the renderer holds.
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[string, (store: GateTestStore) => void]>([
|
||||
[
|
||||
'the recorded pane now holds another PTY',
|
||||
(store) =>
|
||||
seedExistingSurface(store, {
|
||||
tabId: 'tab-live',
|
||||
leafId: LIVE_LEAF_ID,
|
||||
boundPtyId: `${WORKTREE_ID}@@other-pty`
|
||||
})
|
||||
],
|
||||
['the recorded tab is gone', () => {}],
|
||||
[
|
||||
'the recorded leaf is no longer in the tab layout',
|
||||
(store) => seedExistingSurface(store, { tabId: 'tab-live', leafId: SIBLING_LEAF_ID })
|
||||
],
|
||||
[
|
||||
'the recorded tab belongs to another worktree',
|
||||
(store) =>
|
||||
seedExistingSurface(store, {
|
||||
tabId: 'tab-live',
|
||||
leafId: LIVE_LEAF_ID,
|
||||
worktreeId: 'repo::/other-worktree'
|
||||
})
|
||||
]
|
||||
])('shows main’s new tab for an unowned PTY when %s', async (_case, seed) => {
|
||||
// Main placed the PTY in a new tab of its own and recorded that pane, not the stale one.
|
||||
mainHolds({ tabId: MAIN_TAB, leafId: LIVE_LEAF_ID, ptyId: LIVE_PTY })
|
||||
const { deps } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, { unowned: true, recorded: placed(LIVE_PTY) }]])
|
||||
})
|
||||
seed(deps.getState())
|
||||
const recordedLayoutBefore = structuredClone(deps.getState().terminalLayoutsByTabId['tab-live'])
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(deps.getState().terminalLayoutsByTabId['tab-live']).toEqual(recordedLayoutBefore)
|
||||
expect(deps.getState().ptyIdsByTabId['tab-live']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adopts a daemon PTY main placed for a folder workspace', async () => {
|
||||
const folderWorkspaceId = 'folder:plain-workspace'
|
||||
const ptyId = `${folderWorkspaceId}@@live-pty`
|
||||
mainHolds({ worktreeId: folderWorkspaceId, tabId: MAIN_TAB, leafId: LIVE_LEAF_ID, ptyId })
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [listed(ptyId)],
|
||||
surfaceOwners: new Map([[ptyId, { unowned: true, recorded: placed(ptyId) }]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads the host tab of a surface this renderer never mounted from main’s frames', async () => {
|
||||
const hostTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
|
||||
mainHolds({ tabId: hostTabId, leafId: LIVE_LEAF_ID, ptyId: LIVE_PTY })
|
||||
const { deps } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, placed(LIVE_PTY, hostTabId)]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
})
|
||||
|
||||
it('reports adopted only for the PTYs that actually landed on a surface', async () => {
|
||||
const adoptedPtyId = `${WORKTREE_ID}@@orphan-agent`
|
||||
const unverifiablePtyId = `${WORKTREE_ID}@@ambiguous-agent`
|
||||
mainHolds({ tabId: MAIN_TAB, leafId: LIVE_LEAF_ID, ptyId: adoptedPtyId })
|
||||
const { deps } = testDeps({
|
||||
sessions: [listed(adoptedPtyId), listed(unverifiablePtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[adoptedPtyId, { unowned: true, recorded: placed(adoptedPtyId) }],
|
||||
[unverifiablePtyId, null]
|
||||
]),
|
||||
resumeCount: 0
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
})
|
||||
|
||||
it('leaves every host pane of an unmounted tab to main’s frames', async () => {
|
||||
const siblingPtyId = `${WORKTREE_ID}@@live-sibling`
|
||||
const hostTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
|
||||
mainHolds(
|
||||
{ tabId: hostTabId, leafId: LIVE_LEAF_ID, ptyId: LIVE_PTY },
|
||||
{ tabId: hostTabId, leafId: SIBLING_LEAF_ID, ptyId: siblingPtyId }
|
||||
)
|
||||
const { deps } = testDeps({
|
||||
sessions: [listed(LIVE_PTY), listed(siblingPtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[LIVE_PTY, placed(LIVE_PTY, hostTabId)],
|
||||
[siblingPtyId, placed(siblingPtyId, hostTabId, SIBLING_LEAF_ID)]
|
||||
])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
})
|
||||
|
||||
it('makes no surface for a live PTY main has not placed yet, and resumes no agent over it', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, UNOWNED]]),
|
||||
sleeping: [sleepingRecord('tab-slept', SIBLING_LEAF_ID, 'slept-session')]
|
||||
})
|
||||
|
||||
// The live agent may own the sleeping session: resuming it would start a second writer.
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[worktree-activation] live PTYs left without a surface',
|
||||
expect.objectContaining({ declinedPtyIds: [LIVE_PTY] })
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -1,83 +0,0 @@
|
||||
/** Ask main to place live unshown terminals before inventory reply; frames draw its panes later. Window writes nothing, while surfaced still chooses resume versus shell seeding. */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { provideLayoutFrameTerminalPanes } from '@/runtime/layout-frame-terminal-panes'
|
||||
import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
|
||||
import {
|
||||
type GateTestStore,
|
||||
listed,
|
||||
LIVE_LEAF_ID,
|
||||
SIBLING_LEAF_ID,
|
||||
sleepingRecord,
|
||||
testDeps,
|
||||
WORKTREE_ID
|
||||
} from './worktree-agent-activation-gate.test-fixture'
|
||||
import type { LiveTerminalSurfaceOwner } from './worktree-live-terminal-surface-owners'
|
||||
|
||||
vi.mock('../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
|
||||
|
||||
const LIVE_PTY = `${WORKTREE_ID}@@live-pty`
|
||||
const MAIN_TAB = '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
|
||||
|
||||
/** The pane main's listing names for a terminal it just placed. */
|
||||
function placedByMain(ptyId: string, tabId = MAIN_TAB): LiveTerminalSurfaceOwner {
|
||||
return { paneKey: `${tabId}:${LIVE_LEAF_ID}`, ptyId, tabId }
|
||||
}
|
||||
|
||||
function expectNoWindowWrite(store: GateTestStore): void {
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setTabLayout).not.toHaveBeenCalled()
|
||||
expect(store.updateTabPtyId).not.toHaveBeenCalled()
|
||||
expect(store.replaceTerminalLayoutPanePtyId).not.toHaveBeenCalled()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Main's frames have not reached this window yet.
|
||||
provideLayoutFrameTerminalPanes({ holdsTerminalPane: () => false, terminalTab: () => undefined })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('the activation sweep reads main’s placement, layout-owner build', () => {
|
||||
it('counts a terminal main placed as shown before its frame arrives: no shell is seeded', async () => {
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [listed(LIVE_PTY)],
|
||||
surfaceOwners: new Map([[LIVE_PTY, placedByMain(LIVE_PTY)]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expectNoWindowWrite(deps.getState())
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still resumes the workspace’s sleeping agent beside the terminal main placed', async () => {
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [{ ...listed(LIVE_PTY), title: 'zsh', agentOwnership: 'absent' }],
|
||||
surfaceOwners: new Map([[LIVE_PTY, placedByMain(LIVE_PTY)]]),
|
||||
sleeping: [sleepingRecord('tab-slept', SIBLING_LEAF_ID, 'slept-session')]
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed')
|
||||
|
||||
expect(resume).toHaveBeenCalledOnce()
|
||||
expectNoWindowWrite(deps.getState())
|
||||
})
|
||||
|
||||
it('seeds no shell when nothing resumes beside the terminal main placed', async () => {
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [{ ...listed(LIVE_PTY), title: 'zsh', agentOwnership: 'absent' }],
|
||||
surfaceOwners: new Map([[LIVE_PTY, placedByMain(LIVE_PTY)]]),
|
||||
sleeping: [sleepingRecord('tab-slept', SIBLING_LEAF_ID, 'slept-session')],
|
||||
resumeCount: 0
|
||||
})
|
||||
|
||||
// 'empty' would tell the caller to seed a fresh shell beside the live one.
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(resume).toHaveBeenCalledOnce()
|
||||
expectNoWindowWrite(deps.getState())
|
||||
})
|
||||
})
|
||||
@@ -1,181 +0,0 @@
|
||||
// The activation gate's fake store and inventory, shared by its window-owned and layout-owner tests.
|
||||
import type { ClosedTerminalTabTombstonesByTabId } from '../../../shared/closed-terminal-tab-tombstones'
|
||||
import { vi } from 'vitest'
|
||||
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-resume'
|
||||
import type { PtyListedSession } from '../../../shared/pty-listed-session'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../shared/terminal-tab-types'
|
||||
import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
||||
import type { TerminalSlice } from '@/store/slices/terminals'
|
||||
import type {
|
||||
LiveTerminalSurfaceOwnerIndex,
|
||||
UnownedLiveTerminal
|
||||
} from './worktree-live-terminal-surface-owners'
|
||||
|
||||
export const WORKTREE_ID = 'repo::/worktree'
|
||||
export const LIVE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
export const DEAD_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
export const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
export const UNOWNED: UnownedLiveTerminal = { unowned: true, recorded: null }
|
||||
|
||||
export function listed(id: string): PtyListedSession {
|
||||
return { id, cwd: '/worktree', title: 'Codex', agentOwnership: 'present' }
|
||||
}
|
||||
|
||||
export function sleepingRecord(
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
providerSessionId: string
|
||||
): SleepingAgentSessionRecord {
|
||||
return {
|
||||
paneKey: `${tabId}:${leafId}`,
|
||||
tabId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: providerSessionId },
|
||||
prompt: 'resume',
|
||||
state: 'working',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
/** The gate's own fixtures index panes by leaf, so keep that map materialized. */
|
||||
export type SeededLayout = TerminalLayoutSnapshot & { ptyIdsByLeafId: Record<string, string> }
|
||||
|
||||
export function testDeps(args: {
|
||||
sessions?: PtyListedSession[]
|
||||
sleeping?: SleepingAgentSessionRecord[]
|
||||
structured?: boolean
|
||||
resumeCount?: number
|
||||
/** Host census of PTY→surface ownership; null models a host that could not answer. */
|
||||
surfaceOwners?: LiveTerminalSurfaceOwnerIndex | null
|
||||
}) {
|
||||
const resume = vi.fn(() => args.resumeCount ?? 1)
|
||||
const sleeping = args.sleeping ?? []
|
||||
const ptyIdsByTabId: Record<string, string[]> = {}
|
||||
const tabsByWorktree: Record<string, TerminalTab[]> = { [WORKTREE_ID]: [] }
|
||||
const closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId = {}
|
||||
const terminalLayoutsByTabId: Record<string, SeededLayout> = Object.fromEntries(
|
||||
sleeping.map((record) => {
|
||||
const leafId = record.paneKey.slice(record.paneKey.indexOf(':') + 1)
|
||||
return [
|
||||
record.tabId!,
|
||||
{
|
||||
root: { type: 'leaf' as const, leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: {}
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const setTabLayout: TerminalSlice['setTabLayout'] = vi.fn((tabId, layout) => {
|
||||
if (!layout) {
|
||||
delete terminalLayoutsByTabId[tabId]
|
||||
return
|
||||
}
|
||||
terminalLayoutsByTabId[tabId] = { ...layout, ptyIdsByLeafId: { ...layout.ptyIdsByLeafId } }
|
||||
})
|
||||
const replaceTerminalLayoutPanePtyId = vi.fn((tabId: string, leafId: string, ptyId: string) => {
|
||||
const layout = terminalLayoutsByTabId[tabId]
|
||||
if (layout) {
|
||||
layout.ptyIdsByLeafId[leafId] = ptyId
|
||||
}
|
||||
})
|
||||
let createdCount = 0
|
||||
const createTab: TerminalSlice['createTab'] = vi.fn((worktreeId, _group, _shell, options) => {
|
||||
createdCount += 1
|
||||
const id = options?.id ?? `created-${createdCount}`
|
||||
const tab: TerminalTab = {
|
||||
id,
|
||||
ptyId: options?.initialPtyId ?? null,
|
||||
worktreeId,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: tabsByWorktree[worktreeId]?.length ?? 0,
|
||||
createdAt: 1
|
||||
}
|
||||
tabsByWorktree[worktreeId] ??= []
|
||||
tabsByWorktree[worktreeId].push(tab)
|
||||
ptyIdsByTabId[tab.id] = tab.ptyId ? [tab.ptyId] : []
|
||||
if (options?.initialLeafId) {
|
||||
setTabLayout(id, singlePaneLayoutSnapshot(options.initialLeafId, options.initialPtyId))
|
||||
}
|
||||
return tab
|
||||
})
|
||||
const updateTabPtyId = vi.fn((tabId: string, ptyId: string) => {
|
||||
ptyIdsByTabId[tabId] = [...new Set([...(ptyIdsByTabId[tabId] ?? []), ptyId])]
|
||||
})
|
||||
const store = {
|
||||
closedTerminalTabTombstonesByTabId,
|
||||
createTab,
|
||||
ptyIdsByTabId,
|
||||
setTabLayout,
|
||||
tabsByWorktree,
|
||||
sleepingAgentSessionsByPaneKey: Object.fromEntries(
|
||||
sleeping.map((record) => [record.paneKey, record])
|
||||
),
|
||||
updateTabPtyId,
|
||||
replaceTerminalLayoutPanePtyId,
|
||||
terminalLayoutsByTabId,
|
||||
unifiedTabsByWorktree: {
|
||||
[WORKTREE_ID]: args.structured
|
||||
? [
|
||||
{
|
||||
id: 'structured-1',
|
||||
entityId: 'session-1',
|
||||
groupId: 'group-1',
|
||||
worktreeId: WORKTREE_ID,
|
||||
contentType: 'agent-session' as const,
|
||||
label: 'Codex',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
}
|
||||
return {
|
||||
createTab,
|
||||
resume,
|
||||
deps: {
|
||||
getState: () => store,
|
||||
listSessions: vi.fn(async () => args.sessions ?? []),
|
||||
listSurfaceOwners: vi.fn(async () =>
|
||||
args.surfaceOwners === undefined ? new Map() : args.surfaceOwners
|
||||
),
|
||||
resume
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GateTestStore = ReturnType<ReturnType<typeof testDeps>['deps']['getState']>
|
||||
|
||||
export function seedExistingSurface(
|
||||
store: GateTestStore,
|
||||
args: { tabId: string; leafId: string; boundPtyId?: string; worktreeId?: string }
|
||||
): void {
|
||||
const worktreeId = args.worktreeId ?? WORKTREE_ID
|
||||
store.tabsByWorktree[worktreeId] = [
|
||||
...(store.tabsByWorktree[worktreeId] ?? []),
|
||||
{
|
||||
id: args.tabId,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: 'Terminal',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
store.terminalLayoutsByTabId[args.tabId] = {
|
||||
root: { type: 'leaf' as const, leafId: args.leafId },
|
||||
activeLeafId: args.leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: args.boundPtyId ? { [args.leafId]: args.boundPtyId } : {}
|
||||
}
|
||||
}
|
||||
@@ -1,776 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { structuredAgentSessionTabId } from '../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import type { PtyListedSession } from '../../../shared/pty-listed-session'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { runWorktreeAgentActivationGate } from './worktree-agent-activation-gate'
|
||||
import {
|
||||
DEAD_LEAF_ID,
|
||||
type GateTestStore,
|
||||
listed,
|
||||
LIVE_LEAF_ID,
|
||||
seedExistingSurface,
|
||||
SIBLING_LEAF_ID,
|
||||
sleepingRecord,
|
||||
testDeps,
|
||||
UNOWNED,
|
||||
WORKTREE_ID
|
||||
} from './worktree-agent-activation-gate.test-fixture'
|
||||
import {
|
||||
indexLiveTerminalSurfaceOwners,
|
||||
type UnownedLiveTerminal
|
||||
} from './worktree-live-terminal-surface-owners'
|
||||
|
||||
const TAB_CLOSED_AT = 1_700_000_000_000
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
// Main places live terminals in a layout-owner build; those tests' twins are in
|
||||
// worktree-agent-activation-gate-layout-owner.test.ts, under the same name unless noted.
|
||||
const windowOwnedIt = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)
|
||||
|
||||
/** Layout-owner build: the sweep only reads main's listing; it binds no pane of its own. */
|
||||
function expectNoBindingWrite(store: GateTestStore): void {
|
||||
expect(store.setTabLayout).not.toHaveBeenCalled()
|
||||
expect(store.updateTabPtyId).not.toHaveBeenCalled()
|
||||
expect(store.replaceTerminalLayoutPanePtyId).not.toHaveBeenCalled()
|
||||
}
|
||||
const STALE_STRUCTURED_SESSION_ID = 'structured-session-stale'
|
||||
|
||||
function runtimeSnapshot(
|
||||
tabId: string,
|
||||
leafId: string,
|
||||
ptyIds: string[]
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
return {
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'packaged-run-31759132745',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabs: [
|
||||
...ptyIds.map((ptyId, index) => ({
|
||||
type: 'terminal' as const,
|
||||
id: `${tabId}:${leafId}:${index}`,
|
||||
title: 'Codex',
|
||||
parentTabId: tabId,
|
||||
leafId,
|
||||
ptyId,
|
||||
status: 'ready' as const,
|
||||
terminal: `term-${index}`,
|
||||
isActive: false
|
||||
})),
|
||||
{
|
||||
type: 'agent-session' as const,
|
||||
id: 'structured-agent-session-live-session',
|
||||
title: 'Codex Chat',
|
||||
sessionId: 'live-session',
|
||||
agent: 'codex' as const,
|
||||
isActive: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('worktree agent activation gate', () => {
|
||||
windowOwnedIt(
|
||||
'waits for packaged restore hydration before reading daemon inventory',
|
||||
async () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-pty`
|
||||
const { deps, createTab, resume } = testDeps({
|
||||
sessions: [listed(ptyId)],
|
||||
surfaceOwners: new Map([[ptyId, UNOWNED]])
|
||||
})
|
||||
let releaseReady!: (ready: boolean) => void
|
||||
const awaitReady = vi.fn(() => new Promise<boolean>((resolve) => (releaseReady = resolve)))
|
||||
|
||||
const activation = runWorktreeAgentActivationGate(WORKTREE_ID, { ...deps, awaitReady })
|
||||
await vi.waitFor(() => expect(awaitReady).toHaveBeenCalledOnce())
|
||||
expect(deps.listSessions).not.toHaveBeenCalled()
|
||||
|
||||
releaseReady(true)
|
||||
await expect(activation).resolves.toBe('adopted')
|
||||
expect(deps.listSessions).toHaveBeenCalledOnce()
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('blocks seeding while the queried snapshot names a live launch', async () => {
|
||||
const { deps, resume } = testDeps({})
|
||||
const snapshot = {
|
||||
...runtimeSnapshot('tab', LIVE_LEAF_ID, []),
|
||||
tabs: [],
|
||||
initialSurfaceLaunch: { launchId: 'launch-1', agent: 'claude' as const }
|
||||
}
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, {
|
||||
...deps,
|
||||
hasStructuredSession: async () => ({ snapshot, ownerBySessionId: new Map() })
|
||||
})
|
||||
).resolves.toBe('blocked')
|
||||
expect(deps.listSessions).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks automatic resume when packaged restore never becomes ready', async () => {
|
||||
const { deps, createTab, resume } = testDeps({})
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, {
|
||||
...deps,
|
||||
awaitReady: async () => false
|
||||
})
|
||||
).resolves.toBe('blocked')
|
||||
|
||||
expect(deps.listSessions).not.toHaveBeenCalled()
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Layout-owner build: 'adopts the live daemon PTY main placed before activation can resume another agent'.
|
||||
windowOwnedIt('adopts a live daemon PTY before activation can resume another agent', async () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-pty`
|
||||
const { deps, createTab, resume } = testDeps({
|
||||
sessions: [listed(ptyId)],
|
||||
surfaceOwners: new Map([[ptyId, UNOWNED]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
|
||||
initialPtyId: ptyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Layout-owner build: 'never surfaces a PTY its host is killing'.
|
||||
windowOwnedIt('never gives a surface to a PTY its host is killing', async () => {
|
||||
// A pane closed just before quit is still dying when the relaunched app lists it.
|
||||
const closingPtyId = `${WORKTREE_ID}@@closed-pane`
|
||||
const livePtyId = `${WORKTREE_ID}@@kept-pane`
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [
|
||||
{ ...listed(closingPtyId), title: 'zsh', agentOwnership: 'absent', exiting: true },
|
||||
{ ...listed(livePtyId), title: 'zsh', agentOwnership: 'absent' }
|
||||
],
|
||||
surfaceOwners: new Map([
|
||||
[closingPtyId, UNOWNED],
|
||||
[livePtyId, UNOWNED]
|
||||
])
|
||||
})
|
||||
|
||||
await runWorktreeAgentActivationGate(WORKTREE_ID, deps)
|
||||
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
|
||||
initialPtyId: livePtyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
})
|
||||
|
||||
// Layout-owner build: 'leaves an unowned PTY to the recorded pane this renderer still holds, bound by main'.
|
||||
windowOwnedIt(
|
||||
'rebinds an unowned PTY to the recorded pane this renderer still holds',
|
||||
async () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-pty`
|
||||
const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' }
|
||||
const { deps, createTab, resume } = testDeps({
|
||||
sessions: [listed(ptyId)],
|
||||
surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]])
|
||||
})
|
||||
seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
// The host's graph omits unmounted panes; minting here forked the agent onto a second tab.
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(deps.getState().terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({
|
||||
[LIVE_LEAF_ID]: ptyId
|
||||
})
|
||||
expect(deps.getState().ptyIdsByTabId['tab-live']).toEqual([ptyId])
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
// Layout-owner build: 'shows main’s new tab for an unowned PTY when %s'.
|
||||
windowOwnedIt.each<[string, (store: GateTestStore) => void]>([
|
||||
[
|
||||
'the recorded pane now holds another PTY',
|
||||
(store) =>
|
||||
seedExistingSurface(store, {
|
||||
tabId: 'tab-live',
|
||||
leafId: LIVE_LEAF_ID,
|
||||
boundPtyId: `${WORKTREE_ID}@@other-pty`
|
||||
})
|
||||
],
|
||||
[
|
||||
'the recorded tab was closed',
|
||||
(store) => {
|
||||
// Pin the clock so the close record is inside its TTL.
|
||||
vi.setSystemTime(TAB_CLOSED_AT)
|
||||
store.closedTerminalTabTombstonesByTabId['tab-live'] = {
|
||||
closedAt: TAB_CLOSED_AT,
|
||||
worktreeId: WORKTREE_ID,
|
||||
reason: 'user'
|
||||
}
|
||||
}
|
||||
],
|
||||
// A closed split or a replaced layout leaves the record naming a leaf the tab no longer has.
|
||||
[
|
||||
'the recorded leaf is no longer in the tab layout',
|
||||
(store) => seedExistingSurface(store, { tabId: 'tab-live', leafId: SIBLING_LEAF_ID })
|
||||
],
|
||||
[
|
||||
'the recorded tab belongs to another worktree',
|
||||
(store) =>
|
||||
seedExistingSurface(store, {
|
||||
tabId: 'tab-live',
|
||||
leafId: LIVE_LEAF_ID,
|
||||
worktreeId: 'repo::/other-worktree'
|
||||
})
|
||||
]
|
||||
])('mints a tab for an unowned PTY when %s', async (_case, seed) => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-pty`
|
||||
const recorded = { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId, tabId: 'tab-live' }
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(ptyId)],
|
||||
surfaceOwners: new Map([[ptyId, { unowned: true, recorded }]])
|
||||
})
|
||||
seed(deps.getState())
|
||||
const recordedLayoutBefore = structuredClone(deps.getState().terminalLayoutsByTabId['tab-live'])
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
|
||||
initialPtyId: ptyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
// Nothing may be bound to the recorded surface the gate refused.
|
||||
expect(deps.getState().terminalLayoutsByTabId['tab-live']).toEqual(recordedLayoutBefore)
|
||||
expect(deps.getState().ptyIdsByTabId['tab-live']).toBeUndefined()
|
||||
})
|
||||
|
||||
// Another client's pane spawned these PTYs for its own tab, which this window never had.
|
||||
windowOwnedIt(
|
||||
'adopts unowned PTYs under the tab they were recorded for when this window lacks it',
|
||||
async () => {
|
||||
const firstPtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const secondPtyId = `${WORKTREE_ID}@@live-sibling`
|
||||
const unownedAt = (ptyId: string, leafId: string): UnownedLiveTerminal => ({
|
||||
unowned: true,
|
||||
recorded: { paneKey: `tab-live:${leafId}`, ptyId, tabId: 'tab-live' }
|
||||
})
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(firstPtyId), listed(secondPtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[firstPtyId, unownedAt(firstPtyId, LIVE_LEAF_ID)],
|
||||
[secondPtyId, unownedAt(secondPtyId, SIBLING_LEAF_ID)]
|
||||
])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
// Fresh ids left that client showing each agent in a second tab.
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(deps.getState().terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({
|
||||
[LIVE_LEAF_ID]: firstPtyId,
|
||||
[SIBLING_LEAF_ID]: secondPtyId
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Two live PTYs naming one pane: the pane binds once and the other PTY keeps a tab of its own.
|
||||
windowOwnedIt.each([
|
||||
['the host names the second PTY as its owner', false],
|
||||
['the second PTY only recorded it too', true]
|
||||
] as const)('binds a recorded pane once when %s', async (_case, secondUnowned) => {
|
||||
const [first, second] = [`${WORKTREE_ID}@@first`, `${WORKTREE_ID}@@second`]
|
||||
const pane = { paneKey: `tab-live:${LIVE_LEAF_ID}`, tabId: 'tab-live' }
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(first), listed(second)],
|
||||
surfaceOwners: new Map([
|
||||
[first, { unowned: true, recorded: { ...pane, ptyId: first } }],
|
||||
[
|
||||
second,
|
||||
secondUnowned
|
||||
? { unowned: true, recorded: { ...pane, ptyId: second } }
|
||||
: { ...pane, ptyId: second }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
const [bound, minted] = secondUnowned ? [first, second] : [second, first]
|
||||
expect(deps.getState().terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual({
|
||||
[LIVE_LEAF_ID]: bound
|
||||
})
|
||||
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
|
||||
initialPtyId: minted,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
})
|
||||
|
||||
// Layout-owner build: 'adopts a daemon PTY main placed for a folder workspace'.
|
||||
windowOwnedIt('adopts a daemon PTY minted for a folder workspace', async () => {
|
||||
const folderWorkspaceId = 'folder:plain-workspace'
|
||||
const ptyId = `${folderWorkspaceId}@@live-pty`
|
||||
const { deps, createTab, resume } = testDeps({
|
||||
sessions: [listed(ptyId)],
|
||||
surfaceOwners: new Map([[ptyId, UNOWNED]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(folderWorkspaceId, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).toHaveBeenCalledWith(folderWorkspaceId, undefined, undefined, {
|
||||
initialPtyId: ptyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not resume when the workspace has only a structured session', async () => {
|
||||
const { deps, createTab, resume } = testDeps({ structured: true })
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('structured')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not activate a terminal for a stale structured sleeping projection', async () => {
|
||||
const tabId = structuredAgentSessionTabId(STALE_STRUCTURED_SESSION_ID)
|
||||
const stale = sleepingRecord(tabId, LIVE_LEAF_ID, STALE_STRUCTURED_SESSION_ID)
|
||||
const { deps, createTab, resume } = testDeps({ structured: true, sleeping: [stale] })
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resumes a dead agent when the workspace only has a non-agent PTY', async () => {
|
||||
const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session')
|
||||
const plainPtyId = `${WORKTREE_ID}@@plain-shell`
|
||||
const { deps, resume } = testDeps({
|
||||
sessions: [{ ...listed(plainPtyId), title: 'zsh', agentOwnership: 'absent' }],
|
||||
sleeping: [dead]
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed')
|
||||
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, { skipClaimKeys: new Set() })
|
||||
})
|
||||
|
||||
it('suppresses only the exact live agent session while resuming a dead sibling', async () => {
|
||||
const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session')
|
||||
const dead = sleepingRecord('tab-dead', DEAD_LEAF_ID, 'dead-session')
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live, dead] })
|
||||
const store = deps.getState()
|
||||
store.terminalLayoutsByTabId['tab-live']!.ptyIdsByLeafId[LIVE_LEAF_ID] = livePtyId
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('resumed')
|
||||
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`])
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['present', 'unknown'] as const)(
|
||||
'does not resume OMP when a surfaced %s agent lacks conversation ownership',
|
||||
async (agentOwnership) => {
|
||||
const record = {
|
||||
...sleepingRecord('old-tab', DEAD_LEAF_ID, 'omp-session'),
|
||||
agent: 'omp' as const
|
||||
}
|
||||
const ptyId = `${WORKTREE_ID}@@current-omp`
|
||||
const { deps, resume, createTab } = testDeps({
|
||||
sessions: [{ ...listed(ptyId), title: 'OMP', agentOwnership }],
|
||||
sleeping: [record],
|
||||
surfaceOwners: new Map([
|
||||
[
|
||||
ptyId,
|
||||
{
|
||||
ptyId,
|
||||
tabId: 'current-tab',
|
||||
paneKey: `current-tab:${LIVE_LEAF_ID}`
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
seedExistingSurface(deps.getState(), {
|
||||
tabId: 'current-tab',
|
||||
leafId: LIVE_LEAF_ID,
|
||||
boundPtyId: ptyId
|
||||
})
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(deps.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not use an ambiguous tab binding as a live session claim', async () => {
|
||||
const live = sleepingRecord('tab-live', LIVE_LEAF_ID, 'live-session')
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] })
|
||||
deps.getState().ptyIdsByTabId['tab-live'] = [livePtyId, `${WORKTREE_ID}@@other-agent`]
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked')
|
||||
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks when the host names no chat owner for a structured tab', async () => {
|
||||
// An older host can still answer that a terminal owns the session; that is no chat owner.
|
||||
const tabId = 'structured-agent-session-live-session'
|
||||
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, resume } = testDeps({ sessions: [listed(livePtyId)], sleeping: [live] })
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, {
|
||||
...deps,
|
||||
hasStructuredSession: async () => ({
|
||||
snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, [livePtyId]),
|
||||
ownerBySessionId: new Map()
|
||||
})
|
||||
})
|
||||
).resolves.toBe('blocked')
|
||||
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses the exact structured session when native already owns it', async () => {
|
||||
const tabId = 'structured-agent-session-live-session'
|
||||
const live = sleepingRecord(tabId, LIVE_LEAF_ID, 'live-session')
|
||||
const { deps, resume } = testDeps({ sleeping: [live], resumeCount: 0 })
|
||||
|
||||
await expect(
|
||||
runWorktreeAgentActivationGate(WORKTREE_ID, {
|
||||
...deps,
|
||||
hasStructuredSession: async () => ({
|
||||
snapshot: runtimeSnapshot(tabId, LIVE_LEAF_ID, []),
|
||||
ownerBySessionId: new Map([['live-session', { owner: 'native' }]])
|
||||
})
|
||||
})
|
||||
).resolves.toBe('structured')
|
||||
|
||||
expect(resume).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
skipClaimKeys: new Set([`${WORKTREE_ID}\0codex\0session_id\0live-session`])
|
||||
})
|
||||
})
|
||||
it('does not mint a second surface for a PTY bound only in the persisted layout', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)] })
|
||||
seedExistingSurface(deps.getState(), {
|
||||
tabId: 'tab-live',
|
||||
leafId: LIVE_LEAF_ID,
|
||||
boundPtyId: livePtyId
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(deps.listSurfaceOwners).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not mint a second surface for a PTY recorded only on the tab row', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({ sessions: [listed(livePtyId)] })
|
||||
const store = deps.getState()
|
||||
seedExistingSurface(store, { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
store.tabsByWorktree[WORKTREE_ID]![0]!.ptyId = livePtyId
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the host-owned surface when the renderer projection lost every binding', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[
|
||||
livePtyId,
|
||||
{
|
||||
paneKey: `tab-live:${LIVE_LEAF_ID}`,
|
||||
ptyId: livePtyId,
|
||||
tabId: 'tab-live'
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
const store = deps.getState()
|
||||
seedExistingSurface(store, { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Main's listing names the pane it holds and its frames rebind it; the window writes nothing.
|
||||
expectNoBindingWrite(store)
|
||||
return
|
||||
}
|
||||
expect(store.updateTabPtyId).toHaveBeenCalledWith('tab-live', livePtyId)
|
||||
expect(store.replaceTerminalLayoutPanePtyId).toHaveBeenCalledWith(
|
||||
'tab-live',
|
||||
LIVE_LEAF_ID,
|
||||
livePtyId
|
||||
)
|
||||
})
|
||||
|
||||
// Layout-owner build: 'reads the host tab of a surface this renderer never mounted from main’s frames'.
|
||||
windowOwnedIt(
|
||||
'materializes the host tab id for a surface this renderer never mounted',
|
||||
async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const hostTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[
|
||||
livePtyId,
|
||||
{
|
||||
paneKey: `${hostTabId}:${LIVE_LEAF_ID}`,
|
||||
ptyId: livePtyId,
|
||||
tabId: hostTabId
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(createTab).toHaveBeenCalledWith(WORKTREE_ID, undefined, undefined, {
|
||||
id: hostTabId,
|
||||
initialLeafId: LIVE_LEAF_ID,
|
||||
initialPtyId: livePtyId,
|
||||
activate: false,
|
||||
recordInteraction: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Unreadable census must not claim adopted without a surface; fail-closed still returns caller seeding responsibility.
|
||||
it('declines to mint but still asks for a seed when the host cannot answer', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: null,
|
||||
resumeCount: 0
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('declines to mint but still asks for a seed when two host surfaces claim one live PTY', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: new Map([[livePtyId, null]]),
|
||||
resumeCount: 0
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a decline when the host names a leaf the persisted layout does not have', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[livePtyId, { paneKey: `tab-live:${SIBLING_LEAF_ID}`, ptyId: livePtyId, tabId: 'tab-live' }]
|
||||
]),
|
||||
resumeCount: 0
|
||||
})
|
||||
seedExistingSurface(deps.getState(), { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
|
||||
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
// Main holds the layout: the leaf it names is shown by its frames, so nothing is declined.
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expectNoBindingWrite(deps.getState())
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
return
|
||||
}
|
||||
// The seam re-checks its own guard, so an existing tab is not re-seeded by 'empty'.
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('empty')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[worktree-activation] live PTYs left without a surface',
|
||||
expect.objectContaining({ worktreeId: WORKTREE_ID, declinedPtyIds: [livePtyId] })
|
||||
)
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
windowOwnedIt('reports adopted only for the PTYs that actually landed on a surface', async () => {
|
||||
const adoptedPtyId = `${WORKTREE_ID}@@orphan-agent`
|
||||
const unverifiablePtyId = `${WORKTREE_ID}@@ambiguous-agent`
|
||||
const { deps } = testDeps({
|
||||
sessions: [listed(adoptedPtyId), listed(unverifiablePtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[adoptedPtyId, UNOWNED],
|
||||
[unverifiablePtyId, null]
|
||||
]),
|
||||
resumeCount: 0
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ssh:box@@pty-1', WORKTREE_ID],
|
||||
[`${WORKTREE_ID}@@legacy`, ''],
|
||||
// Host ORCA_WORKTREE_ID may seed a foreign relay worktreeId; prefix-minted IDs must still include this workspace’s sessions.
|
||||
[`${WORKTREE_ID}@@stale-env`, 'other-repo::/elsewhere']
|
||||
])(
|
||||
'adopts %s using workspace metadata or the legacy ID fallback',
|
||||
async (livePtyId, worktreeId) => {
|
||||
const { deps, createTab, resume } = testDeps({
|
||||
sessions: [{ ...listed(livePtyId), worktreeId }],
|
||||
surfaceOwners: new Map([
|
||||
[livePtyId, { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId: livePtyId, tabId: 'tab-live' }]
|
||||
])
|
||||
})
|
||||
const store = deps.getState()
|
||||
seedExistingSurface(store, { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual(
|
||||
// Layout-owner build: main's frames bind the pane, not the window.
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? {} : { [LIVE_LEAF_ID]: livePtyId }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('adopts a host surface hydrated under a differently spelled workspace id', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[livePtyId, { paneKey: `tab-live:${LIVE_LEAF_ID}`, ptyId: livePtyId, tabId: 'tab-live' }]
|
||||
])
|
||||
})
|
||||
const store = deps.getState()
|
||||
seedExistingSurface(store, { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
// Packaged hydration can key the same workspace by an equivalent path spelling.
|
||||
store.tabsByWorktree[`${WORKTREE_ID}/`] = store.tabsByWorktree[WORKTREE_ID]!
|
||||
store.tabsByWorktree[WORKTREE_ID] = []
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId).toEqual(
|
||||
// Layout-owner build: main's frames bind the pane, not the window.
|
||||
ORCA_LAYOUT_OWNER_IS_MAIN ? {} : { [LIVE_LEAF_ID]: livePtyId }
|
||||
)
|
||||
})
|
||||
|
||||
it('re-reads local ownership after the census before minting', async () => {
|
||||
const livePtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(livePtyId)],
|
||||
surfaceOwners: new Map([[livePtyId, UNOWNED]])
|
||||
})
|
||||
const store = deps.getState()
|
||||
seedExistingSurface(store, { tabId: 'tab-live', leafId: LIVE_LEAF_ID })
|
||||
// The pane mounts while the census is in flight, binding the PTY behind the sweep.
|
||||
deps.listSurfaceOwners.mockImplementation(async () => {
|
||||
store.ptyIdsByTabId['tab-live'] = [livePtyId]
|
||||
return new Map([[livePtyId, UNOWNED]])
|
||||
})
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not adopt a predecessor after the relay restarts between activation inventories', async () => {
|
||||
const predecessor = 'ssh:target@@pty2:old-relay:1'
|
||||
const replacement = 'ssh:target@@pty2:new-relay:1'
|
||||
const { deps, createTab } = testDeps({ resumeCount: 0 })
|
||||
const store = deps.getState()
|
||||
seedExistingSurface(store, {
|
||||
tabId: 'tab-live',
|
||||
leafId: LIVE_LEAF_ID,
|
||||
boundPtyId: predecessor
|
||||
})
|
||||
let replyWithOldInventory!: (sessions: PtyListedSession[]) => void
|
||||
deps.listSessions.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
replyWithOldInventory = resolve
|
||||
})
|
||||
)
|
||||
const activation = runWorktreeAgentActivationGate(WORKTREE_ID, deps)
|
||||
await vi.waitFor(() => expect(deps.listSessions).toHaveBeenCalledOnce())
|
||||
|
||||
// Recovery rebinds the same pane before the old relay's inventory response arrives.
|
||||
store.terminalLayoutsByTabId['tab-live']!.ptyIdsByLeafId[LIVE_LEAF_ID] = replacement
|
||||
store.ptyIdsByTabId['tab-live'] = [replacement]
|
||||
deps.listSurfaceOwners.mockResolvedValueOnce(indexLiveTerminalSurfaceOwners([], WORKTREE_ID))
|
||||
replyWithOldInventory([{ ...listed(predecessor), worktreeId: WORKTREE_ID }])
|
||||
await activation
|
||||
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(store.tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual(['tab-live'])
|
||||
expect(store.terminalLayoutsByTabId['tab-live']?.ptyIdsByLeafId[LIVE_LEAF_ID]).toBe(replacement)
|
||||
})
|
||||
|
||||
// Layout-owner build: 'leaves every host pane of an unmounted tab to main’s frames'.
|
||||
windowOwnedIt('gives every host pane of an unmounted tab its own leaf', async () => {
|
||||
const firstPtyId = `${WORKTREE_ID}@@live-agent`
|
||||
const secondPtyId = `${WORKTREE_ID}@@live-sibling`
|
||||
const hostTabId = '3359f7c8-9bd8-4931-8104-52b6bdbd108d'
|
||||
const { deps, createTab } = testDeps({
|
||||
sessions: [listed(firstPtyId), listed(secondPtyId)],
|
||||
surfaceOwners: new Map([
|
||||
[
|
||||
firstPtyId,
|
||||
{ paneKey: `${hostTabId}:${LIVE_LEAF_ID}`, ptyId: firstPtyId, tabId: hostTabId }
|
||||
],
|
||||
[
|
||||
secondPtyId,
|
||||
{ paneKey: `${hostTabId}:${SIBLING_LEAF_ID}`, ptyId: secondPtyId, tabId: hostTabId }
|
||||
]
|
||||
])
|
||||
})
|
||||
const store = deps.getState()
|
||||
|
||||
await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('adopted')
|
||||
|
||||
expect(createTab).toHaveBeenCalledOnce()
|
||||
expect(store.terminalLayoutsByTabId[hostTabId]?.root).toEqual({
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: LIVE_LEAF_ID },
|
||||
second: { type: 'leaf', leafId: SIBLING_LEAF_ID }
|
||||
})
|
||||
expect(store.terminalLayoutsByTabId[hostTabId]?.ptyIdsByLeafId).toEqual({
|
||||
[LIVE_LEAF_ID]: firstPtyId,
|
||||
[SIBLING_LEAF_ID]: secondPtyId
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
RuntimeMobileSessionTabsResult,
|
||||
RuntimeTerminalSummary
|
||||
} from '../../../shared/runtime-types'
|
||||
import * as sleepingResume from './resume-sleeping-agent-session'
|
||||
import './resume-sleeping-agent-session'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
|
||||
import { makeCreatedAgentWorktree as makeWorktree } from './worktree-activation-created-agent.test-fixture'
|
||||
@@ -185,49 +185,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('worktree agent activation seam', () => {
|
||||
it('keeps a projected chat-only workspace terminal-free', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const groupId = 'chat-group'
|
||||
useAppStore.setState({
|
||||
...baseState(),
|
||||
unifiedTabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'structured-agent-session-chat-1',
|
||||
entityId: 'chat-1',
|
||||
groupId,
|
||||
worktreeId: worktree.id,
|
||||
contentType: 'agent-session',
|
||||
label: 'Codex Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: groupId,
|
||||
worktreeId: worktree.id,
|
||||
activeTabId: 'structured-agent-session-chat-1',
|
||||
tabOrder: ['structured-agent-session-chat-1']
|
||||
}
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [worktree.id]: groupId }
|
||||
})
|
||||
const { runtimeCall, listSessions } = stubInventory()
|
||||
|
||||
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
expect(listSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts a live terminal without spawning a fallback', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const livePtyId = `${worktree.id}@@live-codex`
|
||||
@@ -256,99 +213,6 @@ describe('worktree agent activation seam', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('spawns a fallback when the workspace has no agent', async () => {
|
||||
const worktree = makeWorktree()
|
||||
useAppStore.setState(baseState())
|
||||
stubInventory()
|
||||
|
||||
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0]?.ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('re-seeds an explicitly activated workspace with a closed terminal tombstone', async () => {
|
||||
const worktree = makeWorktree()
|
||||
useAppStore.setState({
|
||||
...baseState(),
|
||||
// An empty row is persisted after the user closes the last terminal.
|
||||
tabsByWorktree: { [worktree.id]: [] }
|
||||
})
|
||||
stubInventory()
|
||||
|
||||
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(1)
|
||||
// A fresh shell, never a second surface forked onto the live agent's PTY.
|
||||
expect(tabs[0]?.ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not race an explicitly promised surface with a fallback terminal', async () => {
|
||||
const worktree = makeWorktree()
|
||||
useAppStore.setState(baseState())
|
||||
stubInventory()
|
||||
|
||||
expect(activateAndRevealWorktree(worktree.id, { providesInitialSurface: true })).toEqual({
|
||||
primaryTabId: null
|
||||
})
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
|
||||
})
|
||||
|
||||
// Paired-runtime/never-answered relay inventories omit owners; decline minting but still provide the user a usable surface.
|
||||
it('still seeds a usable pane when the census cannot prove who owns a live PTY', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const livePtyId = `${worktree.id}@@live-codex`
|
||||
useAppStore.setState(baseState())
|
||||
stubInventory({ livePtyId, unverifiableCensus: true })
|
||||
|
||||
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0]?.ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not spawn before a structured chat tab hydrates', async () => {
|
||||
const worktree = makeWorktree()
|
||||
useAppStore.setState(baseState())
|
||||
const { runtimeCall, listSessions } = stubInventory({ structured: true })
|
||||
|
||||
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
expect(useAppStore.getState().unifiedTabsByWorktree[worktree.id] ?? []).toHaveLength(0)
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id] ?? []).toHaveLength(0)
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
method: 'session.tabs.list',
|
||||
params: { worktree: `id:${worktree.id}` }
|
||||
})
|
||||
expect(listSessions).toHaveBeenCalledExactlyOnceWith({ connectionId: null })
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
method: 'agentSession.handoffStatus',
|
||||
params: { sessionId: 'chat-1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not authorize client-side recovery for a paired-runtime-owned workspace', async () => {
|
||||
const worktree = makeWorktree()
|
||||
useAppStore.setState({
|
||||
...baseState(),
|
||||
worktreesByRepo: { [worktree.repoId]: [{ ...worktree, hostId: 'runtime:env-1' }] }
|
||||
})
|
||||
const { listSessions } = stubInventory()
|
||||
|
||||
expect(activateAndRevealWorktree(worktree.id)).toEqual({ primaryTabId: null })
|
||||
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('blocked')
|
||||
expect(listSessions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks seeding while detached and allows activation once the SSH target is connected', async () => {
|
||||
const worktree = makeWorktree()
|
||||
useAppStore.setState({
|
||||
@@ -380,45 +244,4 @@ describe('worktree agent activation seam', () => {
|
||||
)
|
||||
expect(useAppStore.getState().tabsByWorktree[worktree.id]?.[0]?.ptyId).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves a sleeping OMP session until its execution host can answer', async () => {
|
||||
const worktree = makeWorktree()
|
||||
const record = {
|
||||
paneKey: 'saved-tab:11111111-1111-4111-8111-111111111111',
|
||||
tabId: 'saved-tab',
|
||||
worktreeId: worktree.id,
|
||||
agent: 'omp' as const,
|
||||
providerSession: { key: 'session_id' as const, id: 'saved-omp-session' },
|
||||
prompt: 'resume',
|
||||
state: 'working' as const,
|
||||
capturedAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
useAppStore.setState({
|
||||
...baseState(),
|
||||
worktreesByRepo: { [worktree.repoId]: [{ ...worktree, hostId: 'ssh:box' }] },
|
||||
sleepingAgentSessionsByPaneKey: { [record.paneKey]: record }
|
||||
})
|
||||
const { listSessions } = stubInventory()
|
||||
const resume = vi
|
||||
.spyOn(sleepingResume, 'resumeSleepingAgentSessionsForWorktree')
|
||||
.mockReturnValue(1)
|
||||
listSessions.mockImplementation(async (scope?: unknown) => {
|
||||
if (scope) {
|
||||
throw new Error('No PTY provider for connection "box": the SSH relay is not attached')
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('blocked')
|
||||
expect(resume).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toEqual(record)
|
||||
|
||||
listSessions.mockResolvedValue([])
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
await expect(waitForWorktreeAgentActivationGateForTests(worktree.id)).resolves.toBe('resumed')
|
||||
expect(resume).toHaveBeenCalledExactlyOnceWith(worktree.id, { skipClaimKeys: new Set() })
|
||||
expect(listSessions.mock.calls).toEqual([[{ connectionId: 'box' }], [{ connectionId: 'box' }]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -162,37 +162,6 @@ describe('a CLI terminal the window never mounted, layout-owner build', () => {
|
||||
expect(terminalTabIds(store)).toEqual(['tab-cli'])
|
||||
})
|
||||
|
||||
it('main’s binding frame arrives after the census: the window draws nothing, and that frame draws main’s tab', async () => {
|
||||
const store = await bootWindow()
|
||||
const before = loadWorkspaceLayoutWithoutReport('local', emptySession(), nameBasedLoadContext())
|
||||
stream.onFrame!(frameOf(before.layout))
|
||||
store.getState().ensureWorktreeRootGroup(GIT_KEY)
|
||||
// Main admitted the CLI pane before its spawn bound it; no frame of it has arrived yet.
|
||||
model = loadWorkspaceLayoutWithoutReport(
|
||||
'local',
|
||||
cliSession(false),
|
||||
nameBasedLoadContext()
|
||||
).layout
|
||||
streamUp = false
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
store.getState,
|
||||
GIT_KEY,
|
||||
[CLI_PTY],
|
||||
async () => unowned
|
||||
)
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(adoption).toEqual({ surfaced: false, declinedPtyIds: [CLI_PTY] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(applied).toEqual([])
|
||||
streamUp = true
|
||||
stream.onFrame!(frameOf(model))
|
||||
await flushMicrotasks()
|
||||
expect(terminalTabIds(store)).toEqual(['tab-cli'])
|
||||
})
|
||||
|
||||
it('a background wake on a workspace the window does not hold: the visit shows main’s one tab', async () => {
|
||||
const store = await bootWindow()
|
||||
stream.onFrame!(frameOf(model))
|
||||
|
||||
@@ -142,55 +142,6 @@ describe('adopting live terminals in a layout-owner build', () => {
|
||||
).resolves.toEqual({ surfaced: true, declinedPtyIds: [] })
|
||||
})
|
||||
|
||||
// Main placed the terminals while answering the sweep's listing, so the listing is fresher than
|
||||
// any frame this window has drawn: it is the verdict, and the frames catch up.
|
||||
it.each([
|
||||
['binds to another terminal', [`${GIT_KEY}@@aaaa0008`, `${GIT_KEY}@@aaaa0009`]],
|
||||
['leaves unbound', [undefined, undefined]]
|
||||
] satisfies [string, [string?, string?]][])(
|
||||
'counts the pane main’s listing names, though the last frame drawn %s, and writes nothing',
|
||||
async (_, ptyIds) => {
|
||||
const { window: windowSession, main } = sessions(ptyIds)
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync)
|
||||
frames.onFrame!(snapshotOf(main))
|
||||
await flushMicrotasks()
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
const setTabLayout = vi.spyOn(store.getState(), 'setTabLayout')
|
||||
|
||||
await expect(
|
||||
adoptLiveWorkspacePtySurfaces(store.getState, GIT_KEY, [LEFT_PTY], async () => owners)
|
||||
).resolves.toEqual({ surfaced: true, declinedPtyIds: [] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(setTabLayout).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('counts a tab main’s listing names before its frame arrives, rather than making one', async () => {
|
||||
const { window: windowSession } = sessions()
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: windowSession }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync)
|
||||
sync.start()
|
||||
frames.onFrame!(snapshotOf(windowSession))
|
||||
await flushMicrotasks()
|
||||
const createTab = vi.spyOn(store.getState(), 'createTab')
|
||||
|
||||
const adoption = await adoptLiveWorkspacePtySurfaces(
|
||||
store.getState,
|
||||
GIT_KEY,
|
||||
[LEFT_PTY],
|
||||
async () => owners
|
||||
)
|
||||
|
||||
expect(adoption).toEqual({ surfaced: true, declinedPtyIds: [] })
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.id)).toEqual(['tab-other'])
|
||||
})
|
||||
|
||||
it('counts a terminal shown, then the snapshot arriving after the sweep draws its tab with no new sweep', async () => {
|
||||
const { window: windowSession, main } = sessions()
|
||||
const store = createTestStore()
|
||||
@@ -215,20 +166,4 @@ describe('adopting live terminals in a layout-owner build', () => {
|
||||
expect(createTab).not.toHaveBeenCalled()
|
||||
expect(readOwners).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('declines a terminal in a workspace the window does not show yet, which frames do not draw', async () => {
|
||||
const { main } = sessions()
|
||||
const store = createTestStore()
|
||||
await bootWindowFromPartitions(store, [{ hostId: 'local', session: emptySession() }])
|
||||
sync = openWorkspaceLayoutFrameSync(store)
|
||||
provideLayoutFrameTerminalPanes(sync)
|
||||
sync.start()
|
||||
frames.onFrame!(snapshotOf(main))
|
||||
await flushMicrotasks()
|
||||
|
||||
expect(
|
||||
sync.holdsTerminalPane(GIT_KEY, { tabId: 'tab-split', leafId: leaf(2), ptyId: LEFT_PTY })
|
||||
).toBe(false)
|
||||
expect(store.getState().terminalLayoutsByTabId['tab-split']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readWorktreeStructuredActivationInventory } from './worktree-agent-structured-inventory'
|
||||
|
||||
const worktree = 'repo::/workspace'
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function install(result: unknown, ok = true) {
|
||||
const call = vi.fn(async ({ method }: { method: string }) => {
|
||||
if (method === 'agentSession.handoffStatus') {
|
||||
return { ok: true, result: { owner: 'native' } }
|
||||
}
|
||||
return { ok, result }
|
||||
})
|
||||
vi.stubGlobal('window', { api: { runtime: { call } } })
|
||||
return call
|
||||
}
|
||||
|
||||
describe('structured activation inventory scope', () => {
|
||||
it.each([null, {}, { worktree: 'other::/workspace', tabs: [] }, { worktree }])(
|
||||
'rejects unreadable or wrong-workspace replies',
|
||||
async (result) => {
|
||||
install(result)
|
||||
await expect(readWorktreeStructuredActivationInventory(worktree)).rejects.toThrow(
|
||||
'scope unavailable'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not interpret an RPC failure as an empty workspace', async () => {
|
||||
install(null, false)
|
||||
await expect(readWorktreeStructuredActivationInventory(worktree)).rejects.toThrow(
|
||||
'inventory unavailable'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { getWorktreeAttachmentLabel } from './worktree-attachment-label'
|
||||
|
||||
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/tmp/repo-1/wt-1',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature/workspace-attachment',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'Workspace',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('getWorktreeAttachmentLabel', () => {
|
||||
it('prefers a trimmed display name', () => {
|
||||
expect(getWorktreeAttachmentLabel(worktree({ displayName: ' Named workspace ' }))).toBe(
|
||||
'Named workspace'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to a branch without the local ref prefix', () => {
|
||||
expect(
|
||||
getWorktreeAttachmentLabel(worktree({ displayName: '', branch: 'refs/heads/fix-ci' }))
|
||||
).toBe('fix-ci')
|
||||
})
|
||||
|
||||
it('falls back to the cross-platform path basename', () => {
|
||||
expect(
|
||||
getWorktreeAttachmentLabel(
|
||||
worktree({ displayName: '', branch: '', path: 'C:\\repo\\workspace-tail' })
|
||||
)
|
||||
).toBe('workspace-tail')
|
||||
})
|
||||
})
|
||||
@@ -1,309 +0,0 @@
|
||||
import { EVERY_AGENT_CHAT } from '../../../shared/native-chat/every-agent-chat.test-fixture'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery'
|
||||
import { seedAgentTabStateAfterWorktreeCreate } from './worktree-creation-agent-seeds'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
seedNativeChatAppliedSessionOptions: vi.fn(),
|
||||
seedNativeChatLaunchDraftForAgentTab: vi.fn(),
|
||||
setWebRuntimeTabProps: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../../shared/native-chat/input/native-chat-session-option-cache', () => ({
|
||||
seedNativeChatAppliedSessionOptions: mocks.seedNativeChatAppliedSessionOptions
|
||||
}))
|
||||
vi.mock('@/lib/agent-launch-prompt-delivery', () => ({
|
||||
seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab
|
||||
}))
|
||||
vi.mock('@/runtime/web-runtime-terminal-actions', () => ({
|
||||
setWebRuntimeTabProps: mocks.setWebRuntimeTabProps
|
||||
}))
|
||||
|
||||
type AppState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
const initialTabsByWorktree = useAppStore.getState().tabsByWorktree
|
||||
const initialUnifiedTabsByWorktree = useAppStore.getState().unifiedTabsByWorktree
|
||||
const initialGetKnownWorktreeById = useAppStore.getState().getKnownWorktreeById
|
||||
const initialSettings = useAppStore.getState().settings!
|
||||
const initialWorktreesByRepo = useAppStore.getState().worktreesByRepo
|
||||
const initialRepos = useAppStore.getState().repos
|
||||
|
||||
const DRAFT = 'https://github.com/o/r/issues/12'
|
||||
|
||||
const request = {
|
||||
agent: 'claude' as const,
|
||||
startupPlan: { agent: 'claude', launchCommand: 'claude' } as never,
|
||||
launchDraftPrompt: DRAFT
|
||||
}
|
||||
|
||||
function setTabs(
|
||||
tabs: { id: string; launchAgent?: string; viewMode?: 'terminal' | 'chat' }[],
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
): void {
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: { 'wt-1': tabs },
|
||||
terminalLayoutsByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt-1',
|
||||
...(runtimeOwnerEnvironmentId ? { runtimeOwnerEnvironmentId } : {})
|
||||
}
|
||||
]
|
||||
},
|
||||
repos: [{ id: 'repo-1', path: '/repo', connectionId: null }],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': tabs.map((tab, index) => ({
|
||||
id: tab.id,
|
||||
entityId: tab.id,
|
||||
groupId: 'group-1',
|
||||
worktreeId: 'wt-1',
|
||||
contentType: 'terminal' as const,
|
||||
label: `Terminal ${index + 1}`,
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: index,
|
||||
isPreview: false,
|
||||
isPinned: false,
|
||||
...(tab.viewMode ? { viewMode: tab.viewMode } : {})
|
||||
}))
|
||||
},
|
||||
getKnownWorktreeById: ((id: string) =>
|
||||
id === 'wt-1' ? { id } : undefined) as unknown as AppState['getKnownWorktreeById']
|
||||
} as unknown as Partial<AppState>)
|
||||
}
|
||||
|
||||
function seededTabIds(): string[] {
|
||||
return mocks.seedNativeChatLaunchDraftForAgentTab.mock.calls.map((call) => call[0].tabId)
|
||||
}
|
||||
|
||||
function tabViewMode(tabId: string): 'terminal' | 'chat' | undefined {
|
||||
return useAppStore.getState().unifiedTabsByWorktree['wt-1']?.find((tab) => tab.id === tabId)
|
||||
?.viewMode
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
...initialSettings,
|
||||
nativeChatProviders: EVERY_AGENT_CHAT
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetHookCommandDelayedDeliveryForTests()
|
||||
useAppStore.setState({
|
||||
tabsByWorktree: initialTabsByWorktree,
|
||||
unifiedTabsByWorktree: initialUnifiedTabsByWorktree,
|
||||
getKnownWorktreeById: initialGetKnownWorktreeById,
|
||||
settings: initialSettings,
|
||||
worktreesByRepo: initialWorktreesByRepo,
|
||||
repos: initialRepos
|
||||
} as Partial<AppState>)
|
||||
})
|
||||
|
||||
describe('seedAgentTabStateAfterWorktreeCreate', () => {
|
||||
it('seeds the backend-spawned startup tab, not the first default terminal tab', () => {
|
||||
// Repo default tabs (dev server / logs / shell) run no agent; on the
|
||||
// backend-spawn path none of them carries launchAgent either.
|
||||
setTabs([{ id: 'dev-server' }, { id: 'logs' }, { id: 'agent-tab' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'dev-server',
|
||||
startupTerminalTabId: 'agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
|
||||
expect(seededTabIds()).toEqual(['agent-tab'])
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith(
|
||||
'agent-tab',
|
||||
'claude',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves an explicit chat view while seeding a backend draft', () => {
|
||||
setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'chat' }])
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { ...request, launchDraftPrompt: 'note\u2028https://github.com/o/r/issues/12' },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'agent-tab',
|
||||
startupTerminalTabId: 'agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
expect(tabViewMode('agent-tab')).toBe('chat')
|
||||
})
|
||||
|
||||
it('does not turn a backend terminal draft into terminal-backed chat', () => {
|
||||
setTabs([{ id: 'agent-tab', launchAgent: 'claude', viewMode: 'terminal' }])
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'agent-tab',
|
||||
startupTerminalTabId: 'agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
expect(tabViewMode('agent-tab')).toBe('terminal')
|
||||
})
|
||||
|
||||
it('seeds a raw backend tab id without setting a default view on the host', () => {
|
||||
setTabs([], 'runtime-1')
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: 'host-agent-tab',
|
||||
backendSpawned: true
|
||||
})
|
||||
expect(seededTabIds()).toEqual(['web-terminal-host-agent-tab'])
|
||||
expect(mocks.setWebRuntimeTabProps).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds the launchAgent-stamped tab when the renderer owns startup', () => {
|
||||
setTabs([{ id: 'dev-server' }, { id: 'agent-tab', launchAgent: 'claude' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'dev-server',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(seededTabIds()).toEqual(['agent-tab'])
|
||||
})
|
||||
|
||||
it('still seeds primaryTabId on the ordinary local path', () => {
|
||||
setTabs([{ id: 'tab-1' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(seededTabIds()).toEqual(['tab-1'])
|
||||
})
|
||||
|
||||
it('never falls back to an unrelated first tab', () => {
|
||||
setTabs([{ id: 'dev-server' }, { id: 'logs' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers the seed to the first mirrored tab on runtime-owned worktrees', () => {
|
||||
// Runtime-owned (web session) worktrees mirror their session tabs async, so
|
||||
// the worktree has no tab at all at seed time.
|
||||
setTabs([])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
|
||||
setTabs([{ id: 'mirror-tab-1' }])
|
||||
|
||||
expect(seededTabIds()).toEqual(['mirror-tab-1'])
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledWith(
|
||||
'mirror-tab-1',
|
||||
'claude',
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers the agent-stamped tab over the first mirrored tab when both land', () => {
|
||||
setTabs([])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
setTabs([{ id: 'mirror-shell' }, { id: 'mirror-agent', launchAgent: 'claude' }])
|
||||
|
||||
expect(seededTabIds()).toEqual(['mirror-agent'])
|
||||
})
|
||||
|
||||
it('drops the deferred seed when the mirrored tabs are ambiguous', () => {
|
||||
// Repo default tabs mirror together and none is stamped: `tabs[0]` here is
|
||||
// "dev server". Seeding it would be withheld from mobile by the agent check
|
||||
// and ignored on desktop — the same invariant the synchronous path holds.
|
||||
setTabs([])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request,
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: null,
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
setTabs([{ id: 'mirror-dev-server' }, { id: 'mirror-logs' }])
|
||||
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds session options but no draft without launch draft context', () => {
|
||||
setTabs([{ id: 'tab-1' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { agent: 'claude', startupPlan: request.startupPlan },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).toHaveBeenCalledOnce()
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing without an agent or a startup plan', () => {
|
||||
setTabs([{ id: 'tab-1' }])
|
||||
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { agent: null, startupPlan: request.startupPlan, launchDraftPrompt: DRAFT },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
seedAgentTabStateAfterWorktreeCreate({
|
||||
request: { agent: 'claude', startupPlan: null, launchDraftPrompt: DRAFT },
|
||||
worktreeId: 'wt-1',
|
||||
primaryTabId: 'tab-1',
|
||||
startupTerminalTabId: undefined,
|
||||
backendSpawned: false
|
||||
})
|
||||
|
||||
expect(mocks.seedNativeChatAppliedSessionOptions).not.toHaveBeenCalled()
|
||||
expect(mocks.seedNativeChatLaunchDraftForAgentTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,227 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
|
||||
import type * as BackgroundMountModule from '@/components/terminal/background-terminal-worktree-mount'
|
||||
|
||||
const { requestMount, mainStarts } = vi.hoisted(() => {
|
||||
const started: unknown[] = []
|
||||
return { requestMount: vi.fn(), mainStarts: started }
|
||||
})
|
||||
|
||||
vi.mock('@/components/terminal/background-terminal-worktree-mount', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof BackgroundMountModule>()),
|
||||
requestBackgroundTerminalWorktreeMount: requestMount
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({
|
||||
ensureAgentStartupInTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() }
|
||||
}))
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
import {
|
||||
makeCreatedAgentWorktree,
|
||||
seedEmptyActivatableWorktree
|
||||
} from './worktree-activation-created-agent.test-fixture'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them, and the first
|
||||
// terminals it starts for the window's new tabs.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
|
||||
const client = (
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
return {
|
||||
...client,
|
||||
callRuntimeRpc: (...args: Parameters<typeof client.callRuntimeRpc>) => {
|
||||
if (args[1] !== 'terminal.create') {
|
||||
return client.callRuntimeRpc(...args)
|
||||
}
|
||||
mainStarts.push(args[2])
|
||||
return new Promise(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** Whether the tab's startup runs: main runs it in a layout-owner build, else the window's mount. */
|
||||
function startupRuns(tabId: string): boolean {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
return useAppStore.getState().pendingStartupByTabId[tabId] !== undefined
|
||||
}
|
||||
return mainStarts.some(
|
||||
(params) =>
|
||||
typeof params === 'object' && params !== null && 'tabId' in params && params.tabId === tabId
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether the user sees the terminal titled Setup: a layout-owner build sends the title to main,
|
||||
* whose frame (not modelled here) draws it; otherwise the window titles the row itself. */
|
||||
function titledSetup(terminal: { id: string; customTitle?: string | null }): boolean {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
return terminal.customTitle === 'Setup'
|
||||
}
|
||||
const tabBarId = useAppStore
|
||||
.getState()
|
||||
.unifiedTabsByWorktree[worktree.id]?.find((tab) => tab.entityId === terminal.id)?.id
|
||||
return layoutApplyTestTransport
|
||||
.sentOfType('renameTab')
|
||||
.some((rename) => rename.tabId === tabBarId && rename.title === 'Setup')
|
||||
}
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
const worktree = makeCreatedAgentWorktree()
|
||||
|
||||
function makeAgentRequest(): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: worktree.repoId,
|
||||
name: 'feature',
|
||||
setupDecision: 'run',
|
||||
agent: 'codex',
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: {
|
||||
agent: 'codex',
|
||||
launchCommand: 'codex',
|
||||
expectedProcess: 'codex',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
},
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null
|
||||
}
|
||||
}
|
||||
|
||||
function makeBlankTerminalRequest(
|
||||
overrides: Partial<WorktreeCreationRequest> = {}
|
||||
): WorktreeCreationRequest {
|
||||
return { ...makeAgentRequest(), agent: null, startupPlan: null, ...overrides }
|
||||
}
|
||||
|
||||
// Submits from the composer, then moves the user off the creation panel before the create lands.
|
||||
async function createAfterUserSwitchedAway(
|
||||
result: CreateWorktreeResult,
|
||||
request: WorktreeCreationRequest = makeAgentRequest()
|
||||
): Promise<void> {
|
||||
const createWorktree = vi.fn(async () => result)
|
||||
useAppStore.setState({ createWorktree })
|
||||
const creationId = runBackgroundWorktreeCreation(request)
|
||||
useAppStore.setState({ activePendingCreationId: null })
|
||||
await vi.waitFor(() =>
|
||||
expect(useAppStore.getState().pendingWorktreeCreations[creationId]).toBeUndefined()
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mainStarts.length = 0
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
describe('a create finishing after the user switched away', () => {
|
||||
it('mounts the seeded agent and setup tabs in the background so their startup runs', async () => {
|
||||
// SSH-like: the host started nothing, so the renderer seeds the agent and setup terminals.
|
||||
await createAfterUserSwitchedAway({
|
||||
worktree,
|
||||
setup: { runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', envVars: {} }
|
||||
})
|
||||
|
||||
const state = useAppStore.getState()
|
||||
const tabs = state.tabsByWorktree[worktree.id] ?? []
|
||||
expect(new Set(tabs.map((tab) => tab.id)).size).toBe(2)
|
||||
expect(tabs.every((tab) => startupRuns(tab.id))).toBe(true)
|
||||
expect(state.activeWorktreeId).toBeNull()
|
||||
expect(requestMount).toHaveBeenCalledWith({
|
||||
worktreeId: worktree.id,
|
||||
tabIds: tabs.map((tab) => tab.id)
|
||||
})
|
||||
})
|
||||
|
||||
it('mounts a fallback setup tab but not the agent terminal the host already started', async () => {
|
||||
const hostTab = useAppStore.getState().createTab(worktree.id, undefined, undefined, {
|
||||
initialPtyId: 'pty-agent',
|
||||
activate: false,
|
||||
launchAgent: 'codex'
|
||||
})
|
||||
|
||||
// The host started the agent but could not start setup, so it hands setup back.
|
||||
await createAfterUserSwitchedAway({
|
||||
worktree,
|
||||
startupTerminal: { spawned: true, surface: 'visible' },
|
||||
setup: { runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', envVars: {} }
|
||||
})
|
||||
|
||||
const tabs = useAppStore.getState().tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(2)
|
||||
const setupTab = tabs.find((tab) => tab.id !== hostTab.id)
|
||||
expect(requestMount).toHaveBeenCalledTimes(1)
|
||||
expect(requestMount).toHaveBeenCalledWith({ worktreeId: worktree.id, tabIds: [setupTab?.id] })
|
||||
})
|
||||
|
||||
it('mounts the dedicated Setup tab beside an idle primary terminal', async () => {
|
||||
await createAfterUserSwitchedAway(
|
||||
{
|
||||
worktree,
|
||||
setup: { runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh', envVars: {} }
|
||||
},
|
||||
makeBlankTerminalRequest()
|
||||
)
|
||||
const next = useAppStore.getState()
|
||||
const tabs = next.tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(2)
|
||||
const setupTab = tabs.find(titledSetup)
|
||||
expect(setupTab).toBeDefined()
|
||||
expect(requestMount).toHaveBeenCalledExactlyOnceWith({
|
||||
worktreeId: worktree.id,
|
||||
tabIds: [setupTab?.id]
|
||||
})
|
||||
expect(next.activeWorktreeId).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['split-vertical', 'split-horizontal', 'new-tab'] as const)(
|
||||
'keeps setup-only creation to one tab with retired location %s',
|
||||
async (setupScriptLaunchMode) => {
|
||||
const currentSettings = useAppStore.getState().settings
|
||||
if (!currentSettings) {
|
||||
throw new Error('missing creation settings')
|
||||
}
|
||||
const settings = { ...currentSettings, setupScriptLaunchMode }
|
||||
useAppStore.setState({ settings })
|
||||
await createAfterUserSwitchedAway(
|
||||
{
|
||||
worktree,
|
||||
setup: { runnerScriptPath: '/workspace/setup.sh', envVars: {} },
|
||||
defaultTabs: { tabs: [{ title: 'Dev', command: 'pnpm dev' }], runCommands: true }
|
||||
},
|
||||
makeBlankTerminalRequest({ setupOnly: true })
|
||||
)
|
||||
const next = useAppStore.getState()
|
||||
expect(next.tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
expect(titledSetup(next.tabsByWorktree[worktree.id]![0]!)).toBe(true)
|
||||
expect(next.activeWorktreeId).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('mounts a plain terminal whose only work is a queued issue-command split', async () => {
|
||||
await createAfterUserSwitchedAway(
|
||||
{ worktree },
|
||||
makeBlankTerminalRequest({ issueCommand: { command: 'echo issue' } })
|
||||
)
|
||||
|
||||
const next = useAppStore.getState()
|
||||
const tabs = next.tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(1)
|
||||
const tabId = tabs[0]?.id ?? ''
|
||||
expect(next.pendingStartupByTabId[tabId]).toBeUndefined()
|
||||
expect(next.pendingIssueCommandSplitByTabId[tabId]).toBeDefined()
|
||||
expect(requestMount).toHaveBeenCalledWith({ worktreeId: worktree.id, tabIds: [tabId] })
|
||||
})
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { executeWorktreeCreation } from './worktree-creation-flow-execute'
|
||||
import { launchStructuredWorktreeSession } from './worktree-creation-structured-session'
|
||||
import {
|
||||
makeCreatedAgentWorktree,
|
||||
seedEmptyActivatableWorktree
|
||||
} from './worktree-activation-created-agent.test-fixture'
|
||||
import { registerWorktreeActivationReset } from './worktree-activation.test-fixture'
|
||||
import type { WorktreeCreationRequest } from './pending-worktree-creation'
|
||||
|
||||
vi.mock('./worktree-creation-structured-session', () => ({
|
||||
launchStructuredWorktreeSession: vi.fn(async (args) => ({
|
||||
accepted: true,
|
||||
cancelled: false,
|
||||
visibilityUnknown: false,
|
||||
activation: args.activation,
|
||||
primaryTabId: args.primaryTabId
|
||||
}))
|
||||
}))
|
||||
vi.mock('./worktree-creation-completion', () => ({ completeWorktreeCreation: vi.fn() }))
|
||||
|
||||
const initialState = useAppStore.getState()
|
||||
registerWorktreeActivationReset()
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
useAppStore.setState(initialState, true)
|
||||
})
|
||||
|
||||
describe('native chat creation completed in the background', () => {
|
||||
it.each(['claude', 'codex'] as const)(
|
||||
'runs setup once without an idle shell or focus change for %s',
|
||||
async (agent) => {
|
||||
const worktree = makeCreatedAgentWorktree()
|
||||
seedEmptyActivatableWorktree(worktree)
|
||||
const request: WorktreeCreationRequest = {
|
||||
repoId: worktree.repoId,
|
||||
name: 'feature',
|
||||
setupDecision: 'run',
|
||||
agent,
|
||||
agentLaunchRoute: 'structured-native-chat',
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null
|
||||
}
|
||||
const setup = { runnerScriptPath: '/tmp/setup-runner.sh', envVars: {} }
|
||||
useAppStore.setState({
|
||||
activeView: 'tasks',
|
||||
activeWorktreeId: 'previous-worktree',
|
||||
activeTabId: 'previous-tab',
|
||||
createWorktree: vi.fn().mockResolvedValue({ worktree, setup }),
|
||||
pendingWorktreeCreations: {
|
||||
'creation-1': {
|
||||
creationId: 'creation-1',
|
||||
phase: 'fetching',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
const createArgs = vi.mocked(useAppStore.getState().createWorktree).mock.calls[0]
|
||||
expect(createArgs[10]).toBe(agent)
|
||||
expect(createArgs[16]).toBeUndefined()
|
||||
expect(createArgs[25]).not.toHaveProperty('startupDraft')
|
||||
const state = useAppStore.getState()
|
||||
const tabs = state.tabsByWorktree[worktree.id]
|
||||
expect(tabs).toHaveLength(1)
|
||||
expect(tabs[0].customTitle).toBe('Setup')
|
||||
expect(state.pendingStartupByTabId[tabs[0].id]).toMatchObject({
|
||||
command: 'bash /tmp/setup-runner.sh'
|
||||
})
|
||||
expect(state.activeView).toBe('tasks')
|
||||
expect(state.activeWorktreeId).toBe('previous-worktree')
|
||||
expect(state.activeTabId).toBe('previous-tab')
|
||||
expect(launchStructuredWorktreeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ primaryTabId: null, shouldActivateOnCompletion: false })
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,315 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
import type { CreateWorktreeResult } from '../../../shared/worktree/create-types'
|
||||
import type * as EphemeralVmWorktreeCreationModule from '@/lib/ephemeral-vm-worktree-creation'
|
||||
|
||||
type ReadyToastCall = { message: string; onClick: () => void }
|
||||
|
||||
const { readyToast, attachVmRuntime } = vi.hoisted(() => {
|
||||
const calls: ReadyToastCall[] = []
|
||||
return {
|
||||
readyToast: { calls },
|
||||
attachVmRuntime: vi.fn<() => Promise<void>>(async () => {})
|
||||
}
|
||||
})
|
||||
|
||||
type NavigationState = {
|
||||
activeView: 'terminal' | 'tasks'
|
||||
activePendingCreationId: string | null
|
||||
activeWorktreeId: string | null
|
||||
pendingWorktreeCreations: Record<string, PendingWorktreeCreation>
|
||||
}
|
||||
const navigation: NavigationState = {
|
||||
activeView: 'terminal',
|
||||
activePendingCreationId: null,
|
||||
activeWorktreeId: null,
|
||||
pendingWorktreeCreations: {}
|
||||
}
|
||||
|
||||
// Pending-creation actions mirror the real slice: begin points the panel at the entry, remove clears it.
|
||||
const store = Object.assign(navigation, {
|
||||
settings: {},
|
||||
repos: [],
|
||||
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
|
||||
store.pendingWorktreeCreations[entry.creationId] = entry
|
||||
store.activePendingCreationId = entry.creationId
|
||||
}),
|
||||
updatePendingWorktreeCreation: vi.fn(
|
||||
(creationId: string, patch: Partial<PendingWorktreeCreation>) => {
|
||||
const entry = store.pendingWorktreeCreations[creationId]
|
||||
if (entry) {
|
||||
store.pendingWorktreeCreations[creationId] = { ...entry, ...patch }
|
||||
}
|
||||
}
|
||||
),
|
||||
removePendingWorktreeCreation: vi.fn((creationId: string) => {
|
||||
delete store.pendingWorktreeCreations[creationId]
|
||||
if (store.activePendingCreationId === creationId) {
|
||||
store.activePendingCreationId = null
|
||||
}
|
||||
}),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
setActivePendingWorktreeCreation: vi.fn(),
|
||||
setActiveView: vi.fn((view: NavigationState['activeView']) => {
|
||||
store.activeView = view
|
||||
}),
|
||||
setSidebarOpen: vi.fn(),
|
||||
createWorktree: vi.fn<() => Promise<CreateWorktreeResult>>(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
setTabViewMode: vi.fn(),
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => store,
|
||||
subscribe: vi.fn(() => () => {})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/browser-uuid', () => ({
|
||||
createBrowserUuid: () => 'creation-1'
|
||||
}))
|
||||
|
||||
// Agent creates return no primary tab: the agent's own surface is the worktree's active tab.
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(() => ({ primaryTabId: null })),
|
||||
activateAndRevealWorkspace: vi.fn(() => ({ primaryTabId: null }))
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
|
||||
ensureWorktreeHasInitialTerminal: vi.fn(() => 'tab-1')
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
|
||||
queueWorkspaceActivationTerminalFocus: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({
|
||||
ensureAgentStartupInTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-creation-agent-seeds', () => ({
|
||||
seedAgentTabStateAfterWorktreeCreate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn((message: string, options: { action: { onClick: () => void } }) => {
|
||||
readyToast.calls.push({ message, onClick: options.action.onClick })
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
|
||||
prepareEphemeralVmWorkspaceTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-worktree-creation', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof EphemeralVmWorktreeCreationModule>()),
|
||||
attachEphemeralVmRuntimeToWorkspace: attachVmRuntime
|
||||
}))
|
||||
|
||||
import { activateAndRevealWorkspace, activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
|
||||
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
|
||||
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeCreateResult(overrides: Partial<CreateWorktreeResult> = {}): CreateWorktreeResult {
|
||||
return {
|
||||
worktree: makeWorktree({ id: 'wt-1', repoId: 'repo-1', displayName: 'Feature' }),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Submits like the composer does, holding createWorktree open until the test resolves it.
|
||||
async function submitCreate(
|
||||
result: CreateWorktreeResult = makeCreateResult(),
|
||||
request: WorktreeCreationRequest = makeRequest()
|
||||
): Promise<() => void> {
|
||||
let resolve!: (value: CreateWorktreeResult) => void
|
||||
store.createWorktree.mockReturnValueOnce(new Promise((r) => (resolve = r)))
|
||||
expect(runBackgroundWorktreeCreation(request)).toBe('creation-1')
|
||||
expect(store.activePendingCreationId).toBe('creation-1')
|
||||
await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalledTimes(1))
|
||||
return () => resolve(result)
|
||||
}
|
||||
|
||||
// setActiveWorktree clears the pending-creation pointer when the user picks a workspace.
|
||||
function selectWorkspace(worktreeId: string): void {
|
||||
store.activePendingCreationId = null
|
||||
store.activeWorktreeId = worktreeId
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
readyToast.calls.length = 0
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = null
|
||||
store.activeWorktreeId = null
|
||||
store.pendingWorktreeCreations = {}
|
||||
})
|
||||
|
||||
describe('a creation that finishes after the user moved on (#9944)', () => {
|
||||
it('keeps the captured runtime host when its create response calls itself local', async () => {
|
||||
const finish = await submitCreate(
|
||||
makeCreateResult({
|
||||
worktree: makeWorktree({ id: 'wt-1', repoId: 'repo-1', hostId: 'local' })
|
||||
}),
|
||||
makeRequest({ executionHostId: 'runtime:remote-a' })
|
||||
)
|
||||
selectWorkspace('wt-other')
|
||||
finish()
|
||||
await vi.waitFor(() => expect(readyToast.calls).toHaveLength(1))
|
||||
readyToast.calls[0]?.onClick()
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
navigationIntent: 'user-open',
|
||||
executionHostId: 'runtime:remote-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the user on the workspace they switched to and offers the new one in a toast', async () => {
|
||||
const finish = await submitCreate()
|
||||
selectWorkspace('wt-other')
|
||||
finish()
|
||||
await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled())
|
||||
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(activateAndRevealWorkspace).not.toHaveBeenCalled()
|
||||
expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled()
|
||||
expect(readyToast.calls).toHaveLength(1)
|
||||
expect(readyToast.calls[0]?.message).toBe('Worktree Feature is ready')
|
||||
|
||||
readyToast.calls[0]?.onClick()
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith('wt-1', {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
navigationIntent: 'user-open'
|
||||
})
|
||||
})
|
||||
|
||||
it('names the server a create went to when it activates or seeds the new workspace (L277)', async () => {
|
||||
const watching = await submitCreate(
|
||||
makeCreateResult(),
|
||||
makeRequest({ executionHostId: 'runtime:remote-a' })
|
||||
)
|
||||
watching()
|
||||
await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled())
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
expect.objectContaining({ executionHostId: 'runtime:remote-a' })
|
||||
)
|
||||
|
||||
vi.clearAllMocks()
|
||||
store.pendingWorktreeCreations = {}
|
||||
const away = await submitCreate(
|
||||
makeCreateResult(),
|
||||
makeRequest({ executionHostId: 'runtime:remote-a' })
|
||||
)
|
||||
selectWorkspace('wt-other')
|
||||
away()
|
||||
await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled())
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ executionHostId: 'runtime:remote-a' })
|
||||
)
|
||||
})
|
||||
|
||||
it('toasts when the user left for another app view', async () => {
|
||||
const finish = await submitCreate()
|
||||
store.activeView = 'tasks'
|
||||
finish()
|
||||
await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled())
|
||||
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(readyToast.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hands a backend-started agent workspace to a user still watching, without a toast', async () => {
|
||||
const finish = await submitCreate(
|
||||
makeCreateResult({ startupTerminal: { spawned: true, surface: 'visible' } }),
|
||||
makeRequest({
|
||||
agent: 'claude',
|
||||
startupPlan: {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
})
|
||||
)
|
||||
finish()
|
||||
await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled())
|
||||
|
||||
// The host already adopted the agent tab, so activation must seed nothing beside it;
|
||||
// worktree-creation-backend-startup-focus.test.ts pins that this lands focus on that tab.
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
|
||||
worktreeCreationId: 'creation-1',
|
||||
sidebarRevealBehavior: 'auto',
|
||||
agent: 'claude',
|
||||
backendStartupTerminalSpawned: true
|
||||
})
|
||||
expect(queueWorkspaceActivationTerminalFocus).toHaveBeenCalledWith('wt-1', {
|
||||
primaryTabId: null
|
||||
})
|
||||
expect(readyToast.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not toast a creation cancelled after the workspace was created', async () => {
|
||||
// Cancel lands while the created workspace is still being wired up, past the post-create cancel check.
|
||||
attachVmRuntime.mockImplementationOnce(async () => {
|
||||
store.removePendingWorktreeCreation('creation-1')
|
||||
})
|
||||
const finish = await submitCreate()
|
||||
finish()
|
||||
await vi.waitFor(() => expect(attachVmRuntime).toHaveBeenCalledTimes(1))
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(readyToast.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('hands the workspace over without a toast to a user who already opened it', async () => {
|
||||
const finish = await submitCreate()
|
||||
// The created row is listed before completion; clicking it opens the new workspace.
|
||||
selectWorkspace('wt-1')
|
||||
finish()
|
||||
await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled())
|
||||
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith('wt-1', {
|
||||
worktreeCreationId: 'creation-1',
|
||||
sidebarRevealBehavior: 'auto'
|
||||
})
|
||||
expect(queueWorkspaceActivationTerminalFocus).toHaveBeenCalledWith('wt-1', {
|
||||
primaryTabId: null
|
||||
})
|
||||
expect(readyToast.calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,425 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
import { shouldShowWorktreeCreationSurface } from '@/lib/worktree-creation-surface'
|
||||
|
||||
// Contain each fire-and-forget post-create step and still complete/tear down UI. Escaped preflight failures must hit the caller catch, showing error/toast instead of stuck creating.
|
||||
|
||||
type TestActiveView = 'terminal' | 'tasks'
|
||||
|
||||
const store = {
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null as string | null,
|
||||
nativeChatProviders: { claude: false, codex: false }
|
||||
},
|
||||
activeView: 'terminal' as TestActiveView,
|
||||
activePendingCreationId: 'creation-1' as string | null,
|
||||
repos: [] as { id: string; connectionId: string | null }[],
|
||||
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
|
||||
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
|
||||
store.pendingWorktreeCreations[entry.creationId] = entry
|
||||
store.activePendingCreationId = entry.creationId
|
||||
}),
|
||||
updatePendingWorktreeCreation: vi.fn(
|
||||
(creationId: string, patch: Partial<PendingWorktreeCreation>) => {
|
||||
const entry = store.pendingWorktreeCreations[creationId]
|
||||
if (entry) {
|
||||
store.pendingWorktreeCreations[creationId] = { ...entry, ...patch }
|
||||
}
|
||||
}
|
||||
),
|
||||
// Mirrors pending-worktree-creation.ts: drop the entry and the active pointer.
|
||||
removePendingWorktreeCreation: vi.fn((creationId: string) => {
|
||||
delete store.pendingWorktreeCreations[creationId]
|
||||
if (store.activePendingCreationId === creationId) {
|
||||
store.activePendingCreationId = null
|
||||
}
|
||||
}),
|
||||
setActivePendingWorktreeCreation: vi.fn((creationId: string | null) => {
|
||||
store.activePendingCreationId = creationId
|
||||
}),
|
||||
setActiveView: vi.fn((view: TestActiveView) => {
|
||||
store.activeView = view
|
||||
}),
|
||||
setSidebarOpen: vi.fn(),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
createWorktree: vi.fn(),
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>,
|
||||
unifiedTabsByWorktree: {}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => store
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() }
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/web-runtime-worktree-terminal-after-wake', () => ({
|
||||
ensureWebRuntimeWorktreeTerminalAfterWake: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
|
||||
queueWorkspaceActivationTerminalFocus: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({
|
||||
ensureAgentStartupInTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-creation-agent-seeds', () => ({
|
||||
seedAgentTabStateAfterWorktreeCreate: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
|
||||
prepareEphemeralVmWorkspaceTarget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-worktree-creation', () => ({
|
||||
prepareRequestForCreate: vi.fn(
|
||||
async (_creationId: string, request: WorktreeCreationRequest) => request
|
||||
),
|
||||
attachEphemeralVmRuntimeToWorkspace: vi.fn(async () => undefined),
|
||||
cleanupEphemeralVmRuntimeForFailedCreate: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-creation-structured-recovery', () => ({
|
||||
markStructuredWorktreeLaunchUnconfirmed: vi.fn(),
|
||||
retryStructuredWorktreeLaunch: vi.fn()
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
|
||||
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
|
||||
import { prepareRequestForCreate } from '@/lib/ephemeral-vm-worktree-creation'
|
||||
import { executeWorktreeCreation } from './worktree-creation-flow-execute'
|
||||
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
|
||||
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
} as WorktreeCreationRequest
|
||||
}
|
||||
|
||||
function seedPendingCreation(request: WorktreeCreationRequest): void {
|
||||
store.pendingWorktreeCreations = {
|
||||
'creation-1': {
|
||||
creationId: 'creation-1',
|
||||
phase: 'fetching',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request
|
||||
}
|
||||
}
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
}
|
||||
|
||||
function surfaceInput(activeView: TestActiveView): {
|
||||
activeView: TestActiveView
|
||||
activePendingCreationId: string | null
|
||||
hasActivePendingCreation: boolean
|
||||
} {
|
||||
return {
|
||||
activeView,
|
||||
activePendingCreationId: store.activePendingCreationId,
|
||||
hasActivePendingCreation:
|
||||
store.activePendingCreationId !== null &&
|
||||
store.pendingWorktreeCreations[store.activePendingCreationId] !== undefined
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// resetAllMocks: implementations from prior tests (the injected throws) must not leak.
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
store.activeView = 'terminal'
|
||||
store.repos = [{ id: 'repo-1', connectionId: null }]
|
||||
store.tabsByWorktree = {}
|
||||
store.pendingWorktreeCreations = {}
|
||||
store.activePendingCreationId = null
|
||||
store.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1' }
|
||||
})
|
||||
})
|
||||
|
||||
describe('a throw after createWorktree succeeds no longer strands the creation surface', () => {
|
||||
it.each(['active', 'background', 'activation-failure'] as const)(
|
||||
'%s creation preserves Codex startup when a blank terminal already exists',
|
||||
async (scenario) => {
|
||||
const request = makeRequest({
|
||||
agent: 'codex',
|
||||
startupPlan: {
|
||||
agent: 'codex',
|
||||
launchCommand: 'codex',
|
||||
expectedProcess: 'codex',
|
||||
followupPrompt: '',
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
})
|
||||
const setup = {
|
||||
runnerScriptPath: '/tmp/setup.sh',
|
||||
envVars: {},
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
seedPendingCreation(request)
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'existing-shell' }] }
|
||||
store.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1' },
|
||||
setup
|
||||
})
|
||||
if (scenario === 'background') {
|
||||
store.activeView = 'tasks'
|
||||
}
|
||||
if (scenario === 'activation-failure') {
|
||||
vi.mocked(activateAndRevealWorktree).mockImplementation(() => {
|
||||
throw new Error('reveal failed with a blank terminal already present')
|
||||
})
|
||||
}
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
if (scenario === 'active') {
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
expect.objectContaining({
|
||||
startup: expect.objectContaining({ command: 'codex' }),
|
||||
setup,
|
||||
createNewTerminalForStartup: true
|
||||
})
|
||||
)
|
||||
} else {
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
expect.objectContaining({ command: 'codex' }),
|
||||
setup,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ createNewTerminalForStartup: true })
|
||||
)
|
||||
}
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('activating branch: a planless agent throw recovers a terminal and completes', async () => {
|
||||
const request = makeRequest({ agent: 'claude' })
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('recovered-tab')
|
||||
vi.mocked(activateAndRevealWorktree).mockImplementation(() => {
|
||||
throw new Error('activation exploded')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'worktree create: activate-and-reveal failed',
|
||||
'wt-1',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ worktreeCreationId: 'creation-1' }
|
||||
)
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined()
|
||||
expect(store.activePendingCreationId).toBeNull()
|
||||
expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false)
|
||||
})
|
||||
|
||||
it('activating branch: leaves existing default tabs untouched after a partial failure', async () => {
|
||||
const request = makeRequest({ issueCommand: { command: 'echo setup' } })
|
||||
seedPendingCreation(request)
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'existing-tab' }] }
|
||||
vi.mocked(activateAndRevealWorktree).mockImplementation(() => {
|
||||
throw new Error('reveal exploded after tab creation')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
})
|
||||
|
||||
it('activating branch: routes draft and follow-up delivery to the stamped agent tab', async () => {
|
||||
const request = makeRequest({
|
||||
agent: 'codex',
|
||||
startupPlan: {
|
||||
agent: 'codex',
|
||||
launchCommand: 'codex',
|
||||
expectedProcess: 'codex',
|
||||
draftPrompt: 'draft context',
|
||||
followupPrompt: 'follow-up context',
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
})
|
||||
seedPendingCreation(request)
|
||||
store.tabsByWorktree = {
|
||||
'wt-1': [{ id: 'default-tab' }, { id: 'agent-tab', launchAgent: 'codex' }]
|
||||
}
|
||||
vi.mocked(activateAndRevealWorktree).mockImplementation(() => {
|
||||
throw new Error('reveal exploded after default tabs were created')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(ensureWorktreeHasInitialTerminal).not.toHaveBeenCalled()
|
||||
expect(ensureAgentStartupInTerminal).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ primaryTabId: 'agent-tab' })
|
||||
)
|
||||
})
|
||||
|
||||
it('background branch: a throw in after-wake seeding is contained after tabs are seeded', async () => {
|
||||
// User left the terminal view mid-create, so the non-activating branch runs.
|
||||
store.activeView = 'tasks'
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => {
|
||||
throw new Error('after-wake exploded')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ activateCreatedTabs: false })
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'worktree create: after-wake terminal seeding failed',
|
||||
'wt-1',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined()
|
||||
expect(store.activePendingCreationId).toBeNull()
|
||||
expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false)
|
||||
})
|
||||
|
||||
it('concurrent create: a throw completing a backgrounded creation still tears its entry down', async () => {
|
||||
// A newer create owns the pending pointer; complete this one in background without stealing terminal view.
|
||||
store.activeView = 'terminal'
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
store.activePendingCreationId = 'creation-2'
|
||||
store.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1' },
|
||||
setup: { runnerScriptPath: '/tmp/setup.sh' }
|
||||
})
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
vi.mocked(ensureWebRuntimeWorktreeTerminalAfterWake).mockImplementation(() => {
|
||||
throw new Error('after-wake exploded')
|
||||
})
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
{ runnerScriptPath: '/tmp/setup.sh' },
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ activateCreatedTabs: false })
|
||||
)
|
||||
expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined()
|
||||
expect(store.activePendingCreationId).toBe('creation-2')
|
||||
expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false)
|
||||
})
|
||||
|
||||
it('control: with no throw the same flow completes and tears the surface down', async () => {
|
||||
store.activeView = 'tasks'
|
||||
const request = makeRequest()
|
||||
seedPendingCreation(request)
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
expect(store.pendingWorktreeCreations['creation-1']).toBeUndefined()
|
||||
expect(store.activePendingCreationId).toBeNull()
|
||||
expect(shouldShowWorktreeCreationSurface(surfaceInput('terminal'))).toBe(false)
|
||||
})
|
||||
|
||||
it('backstop: a rejection that escapes the execute promise becomes a visible inline error', async () => {
|
||||
// Pre-create preparation runs before the in-function try/catch.
|
||||
vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded'))
|
||||
|
||||
const creationId = runBackgroundWorktreeCreation(makeRequest())
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(store.pendingWorktreeCreations[creationId]).toMatchObject({
|
||||
status: 'error',
|
||||
error: 'prepare exploded'
|
||||
})
|
||||
})
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
expect(store.removePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
'worktree create: unhandled failure',
|
||||
creationId,
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('backstop: a rejection after leaving the panel is announced with a toast', async () => {
|
||||
store.activeView = 'tasks'
|
||||
vi.mocked(prepareRequestForCreate).mockRejectedValue(new Error('prepare exploded'))
|
||||
|
||||
const creationId = runBackgroundWorktreeCreation(makeRequest())
|
||||
// Navigate after synchronous pending reveal but before rejected preparation reaches its catch backstop.
|
||||
store.activeView = 'tasks'
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('prepare exploded')
|
||||
})
|
||||
expect(store.pendingWorktreeCreations[creationId]).toMatchObject({ status: 'error' })
|
||||
})
|
||||
})
|
||||
@@ -1,492 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
|
||||
const { prepareEphemeralVmWorkspaceTargetMock } = vi.hoisted(() => ({
|
||||
prepareEphemeralVmWorkspaceTargetMock: vi.fn()
|
||||
}))
|
||||
|
||||
type TestActiveView = 'terminal' | 'tasks'
|
||||
|
||||
const store = {
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null as string | null,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The fixture mutates this setting across test cases.
|
||||
nativeChatProviders: { claude: false, codex: false } as Record<string, boolean>
|
||||
},
|
||||
activeView: 'terminal' as TestActiveView,
|
||||
activePendingCreationId: 'creation-1' as string | null,
|
||||
repos: [{ id: 'repo-runtime', connectionId: null }],
|
||||
pendingWorktreeCreations: {} as Record<string, PendingWorktreeCreation>,
|
||||
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
|
||||
store.pendingWorktreeCreations[entry.creationId] = entry
|
||||
store.activePendingCreationId = entry.creationId
|
||||
}),
|
||||
updatePendingWorktreeCreation: vi.fn(
|
||||
(creationId: string, patch: Partial<PendingWorktreeCreation>) => {
|
||||
const entry = store.pendingWorktreeCreations[creationId]
|
||||
if (entry) {
|
||||
store.pendingWorktreeCreations[creationId] = { ...entry, ...patch }
|
||||
}
|
||||
}
|
||||
),
|
||||
removePendingWorktreeCreation: vi.fn((creationId: string) => {
|
||||
delete store.pendingWorktreeCreations[creationId]
|
||||
}),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
setActivePendingWorktreeCreation: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
createWorktree: vi.fn(() => new Promise(() => {})),
|
||||
setupProjectExistingFolder: vi.fn(),
|
||||
refreshRuntimeEnvironmentStatus: vi.fn(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
setTabViewMode: vi.fn(),
|
||||
tabsByWorktree: {} as Record<string, { id: string; launchAgent?: string }[]>,
|
||||
unifiedTabsByWorktree: {}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => store
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/browser-uuid', () => ({
|
||||
createBrowserUuid: () => 'creation-1'
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(() => false)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
|
||||
queueWorkspaceActivationTerminalFocus: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({
|
||||
ensureAgentStartupInTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
|
||||
prepareEphemeralVmWorkspaceTarget: prepareEphemeralVmWorkspaceTargetMock
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
|
||||
import {
|
||||
continueBackgroundWorktreeCreation,
|
||||
runBackgroundWorktreeCreation
|
||||
} from './worktree-creation-flow'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.settings.activeRuntimeEnvironmentId = null
|
||||
store.settings.nativeChatProviders = { claude: false, codex: false }
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.repos = []
|
||||
store.pendingWorktreeCreations = { 'creation-1': makePendingCreation(makeRequest()) }
|
||||
store.createWorktree.mockImplementation(() => new Promise(() => {}))
|
||||
store.tabsByWorktree = {}
|
||||
store.unifiedTabsByWorktree = {}
|
||||
vi.mocked(ensureWorktreeHasInitialTerminal).mockReturnValue('tab-1')
|
||||
})
|
||||
|
||||
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makePendingCreation(request: WorktreeCreationRequest): PendingWorktreeCreation {
|
||||
return {
|
||||
creationId: 'creation-1',
|
||||
phase: 'preparing',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
async function flushAsyncWorktreeCreation(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('runBackgroundWorktreeCreation', () => {
|
||||
beforeEach(() => {
|
||||
store.settings.activeRuntimeEnvironmentId = null
|
||||
store.repos = [{ id: 'repo-runtime', connectionId: null }]
|
||||
store.pendingWorktreeCreations = {}
|
||||
store.activePendingCreationId = null
|
||||
store.beginPendingWorktreeCreation.mockClear()
|
||||
store.updatePendingWorktreeCreation.mockClear()
|
||||
store.removePendingWorktreeCreation.mockClear()
|
||||
store.setActiveView.mockClear()
|
||||
store.setSidebarOpen.mockClear()
|
||||
store.createWorktree.mockReset().mockImplementation(() => new Promise(() => {}))
|
||||
store.setupProjectExistingFolder.mockReset()
|
||||
store.refreshRuntimeEnvironmentStatus.mockReset()
|
||||
prepareEphemeralVmWorkspaceTargetMock.mockReset()
|
||||
globalThis.window = {
|
||||
api: {
|
||||
ephemeralVm: {
|
||||
attachWorkspace: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
onProvisionEvent: vi.fn(() => vi.fn())
|
||||
}
|
||||
}
|
||||
} as never
|
||||
})
|
||||
|
||||
it('shows a VM provisioning phase and creates the worktree on the prepared runtime repo', async () => {
|
||||
store.repos = [
|
||||
{
|
||||
id: 'repo-1',
|
||||
connectionId: null,
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/stablyai/orca',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@github.com:stablyai/orca.git'
|
||||
}
|
||||
} as never
|
||||
]
|
||||
prepareEphemeralVmWorkspaceTargetMock.mockResolvedValue({
|
||||
ok: true,
|
||||
runtimeId: 'runtime-1',
|
||||
environmentId: 'env-1',
|
||||
stderr: '',
|
||||
warnings: [],
|
||||
setup: {
|
||||
project: { id: 'project-1' },
|
||||
setup: {
|
||||
id: 'setup-runtime',
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1'
|
||||
},
|
||||
repo: { id: 'repo-runtime', path: '/workspace/repo' }
|
||||
}
|
||||
})
|
||||
store.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'repo-runtime::/workspace/repo/worktree', repoId: 'repo-runtime' }
|
||||
})
|
||||
|
||||
runBackgroundWorktreeCreation(
|
||||
makeRequest({
|
||||
ephemeralVmRecipe: {
|
||||
sourceRepoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
projectId: 'project-1'
|
||||
},
|
||||
baseBranch: 'Jinwoo-H/setup-vercel-sandbox',
|
||||
worktreeCreateProgressMode: 'indeterminate'
|
||||
})
|
||||
)
|
||||
|
||||
expect(store.beginPendingWorktreeCreation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ phase: 'provisioning-vm' })
|
||||
)
|
||||
await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalled())
|
||||
expect(prepareEphemeralVmWorkspaceTargetMock).toHaveBeenCalledWith({
|
||||
repoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
projectId: 'github:stablyai/orca',
|
||||
workspaceName: 'feature',
|
||||
provisionId: 'creation-1',
|
||||
setupExistingFolder: store.setupProjectExistingFolder
|
||||
})
|
||||
const createCall = store.createWorktree.mock.calls[0] as unknown[]
|
||||
expect(createCall[0]).toBe('repo-runtime')
|
||||
expect(createCall[1]).toBe('feature')
|
||||
expect(createCall[2]).toBeUndefined()
|
||||
expect(createCall).toContain('creation-1')
|
||||
expect(window.api.ephemeralVm.attachWorkspace).toHaveBeenCalledWith({
|
||||
runtimeId: 'runtime-1',
|
||||
workspaceId: 'repo-runtime::/workspace/repo/worktree'
|
||||
})
|
||||
expect(store.refreshRuntimeEnvironmentStatus).toHaveBeenCalledWith('env-1')
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
})
|
||||
|
||||
it('appends stderr provisioning events for the active VM recipe create', async () => {
|
||||
let provisionEventCallback:
|
||||
| ((event: { provisionId: string; stream: 'stdout' | 'stderr'; chunk: string }) => void)
|
||||
| null = null
|
||||
const unsubscribe = vi.fn()
|
||||
window.api.ephemeralVm.onProvisionEvent = vi.fn((callback) => {
|
||||
provisionEventCallback = callback
|
||||
return unsubscribe
|
||||
})
|
||||
prepareEphemeralVmWorkspaceTargetMock.mockImplementation(async () => {
|
||||
provisionEventCallback?.({
|
||||
provisionId: 'creation-1',
|
||||
stream: 'stderr',
|
||||
chunk: 'creating sandbox\n'
|
||||
})
|
||||
provisionEventCallback?.({
|
||||
provisionId: 'other-create',
|
||||
stream: 'stderr',
|
||||
chunk: 'ignore me\n'
|
||||
})
|
||||
provisionEventCallback?.({
|
||||
provisionId: 'creation-1',
|
||||
stream: 'stdout',
|
||||
chunk: '{"pairingCode":"secret"}'
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
runtimeId: 'runtime-1',
|
||||
environmentId: 'env-1',
|
||||
stderr: '',
|
||||
warnings: [
|
||||
{
|
||||
id: 'recipe.result.endpoint.public_ws',
|
||||
message: 'Recipe pairing endpoint uses insecure public ws:// transport.',
|
||||
remediation: 'Use wss://.'
|
||||
}
|
||||
],
|
||||
setup: {
|
||||
project: { id: 'project-1' },
|
||||
setup: {
|
||||
id: 'setup-runtime',
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1'
|
||||
},
|
||||
repo: { id: 'repo-runtime', path: '/workspace/repo' }
|
||||
}
|
||||
}
|
||||
})
|
||||
store.createWorktree.mockResolvedValue({
|
||||
worktree: { id: 'repo-runtime::/workspace/repo/worktree', repoId: 'repo-runtime' }
|
||||
})
|
||||
|
||||
runBackgroundWorktreeCreation(
|
||||
makeRequest({
|
||||
ephemeralVmRecipe: {
|
||||
sourceRepoId: 'repo-1',
|
||||
recipeId: 'cloud-sandbox',
|
||||
projectId: 'project-1'
|
||||
},
|
||||
worktreeCreateProgressMode: 'indeterminate'
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalled())
|
||||
expect(window.api.ephemeralVm.onProvisionEvent).toHaveBeenCalled()
|
||||
expect(unsubscribe).toHaveBeenCalled()
|
||||
expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith(
|
||||
'creation-1',
|
||||
expect.objectContaining({ provisioningLog: 'creating sandbox\n' })
|
||||
)
|
||||
expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith(
|
||||
'creation-1',
|
||||
expect.objectContaining({
|
||||
provisioningLog: expect.stringContaining(
|
||||
'Warning: Recipe pairing endpoint uses insecure public ws:// transport.'
|
||||
)
|
||||
})
|
||||
)
|
||||
expect(JSON.stringify(store.updatePendingWorktreeCreation.mock.calls)).not.toContain(
|
||||
'pairingCode'
|
||||
)
|
||||
expect(JSON.stringify(store.updatePendingWorktreeCreation.mock.calls)).not.toContain(
|
||||
'ignore me'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('staged background worktree creation', () => {
|
||||
it('can continue without revealing a staged create after background preflight', async () => {
|
||||
store.updatePendingWorktreeCreation.mockClear()
|
||||
store.createWorktree.mockClear()
|
||||
store.setActivePendingWorktreeCreation.mockClear()
|
||||
store.setActiveView.mockClear()
|
||||
store.setSidebarOpen.mockClear()
|
||||
|
||||
const request = makeRequest({ setupDecision: 'run' })
|
||||
const started = continueBackgroundWorktreeCreation('creation-1', request, {
|
||||
revealCreationSurface: false
|
||||
})
|
||||
|
||||
expect(started).toBe(true)
|
||||
expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith(
|
||||
'creation-1',
|
||||
expect.objectContaining({
|
||||
phase: 'fetching',
|
||||
request
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
expect(store.createWorktree).toHaveBeenCalledTimes(1)
|
||||
expect(store.setActivePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
expect(store.setActiveView).not.toHaveBeenCalled()
|
||||
expect(store.setSidebarOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a backend startup terminal in the background after the user leaves', async () => {
|
||||
store.activeView = 'tasks'
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1'
|
||||
},
|
||||
startupTerminal: { tabId: 'agent-tab', spawned: true }
|
||||
})
|
||||
|
||||
const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), {
|
||||
revealCreationSurface: false
|
||||
})
|
||||
|
||||
expect(started).toBe(true)
|
||||
await flushAsyncWorktreeCreation()
|
||||
expect(activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ activateCreatedTabs: false, backendStartupTerminalSpawned: true })
|
||||
)
|
||||
expect(queueWorkspaceActivationTerminalFocus).not.toHaveBeenCalled()
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1', {
|
||||
cleanupVm: false
|
||||
})
|
||||
})
|
||||
|
||||
it('opens only the setup tab despite templates when the composer choice is cleared', async () => {
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
const setup = { runnerScriptPath: '/repo/.orca/setup.sh', envVars: {} }
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' },
|
||||
setup,
|
||||
defaultTabs: { tabs: [{ title: 'Dev', command: 'pnpm dev' }], runCommands: true }
|
||||
})
|
||||
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: null })
|
||||
|
||||
continueBackgroundWorktreeCreation('creation-1', makeRequest({ setupOnly: true }))
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(activateAndRevealWorktree).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
expect.objectContaining({ setup, providesInitialSurface: true })
|
||||
)
|
||||
)
|
||||
expect(store.createWorktree.mock.calls[0]?.at(-1)).toMatchObject({ setupOnly: true })
|
||||
expect(vi.mocked(activateAndRevealWorktree).mock.calls[0]?.[1]).not.toHaveProperty(
|
||||
'defaultTabs'
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds only setup despite templates when the user moved on before completion', async () => {
|
||||
store.activeView = 'tasks'
|
||||
const setup = { runnerScriptPath: '/repo/.orca/setup.sh', envVars: {} }
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' },
|
||||
setup,
|
||||
defaultTabs: { tabs: [{ title: 'Dev', command: 'pnpm dev' }], runCommands: true }
|
||||
})
|
||||
|
||||
continueBackgroundWorktreeCreation('creation-1', makeRequest({ setupOnly: true }), {
|
||||
revealCreationSurface: false
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(ensureWorktreeHasInitialTerminal).toHaveBeenCalledWith(
|
||||
store,
|
||||
'wt-1',
|
||||
undefined,
|
||||
setup,
|
||||
undefined,
|
||||
undefined,
|
||||
expect.objectContaining({ activateCreatedTabs: false, callerProvidesSurface: true })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('seeds the backend-spawned agent tab, not the worktree default terminal tab', async () => {
|
||||
// Default tabs can be dev/log shells; backend startup terminal, not activation primaryTabId, identifies the agent.
|
||||
store.activeView = 'terminal'
|
||||
store.activePendingCreationId = 'creation-1'
|
||||
store.tabsByWorktree = { 'wt-1': [{ id: 'dev-server' }, { id: 'agent-tab' }] }
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' },
|
||||
startupTerminal: { tabId: 'agent-tab', spawned: true }
|
||||
})
|
||||
vi.mocked(activateAndRevealWorktree).mockReturnValueOnce({ primaryTabId: 'dev-server' })
|
||||
|
||||
continueBackgroundWorktreeCreation(
|
||||
'creation-1',
|
||||
makeRequest({
|
||||
agent: 'claude',
|
||||
startupPlan: {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude --prefill x',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
},
|
||||
launchDraftPrompt: 'https://github.com/o/r/issues/12'
|
||||
})
|
||||
)
|
||||
|
||||
await vi.waitFor(() => expect(store.seedNativeChatLaunchDraft).toHaveBeenCalled())
|
||||
expect(store.seedNativeChatLaunchDraft).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tabId: 'agent-tab' })
|
||||
)
|
||||
const createCall = store.createWorktree.mock.calls[0] as unknown[] | undefined
|
||||
expect(createCall?.[25]).toEqual({
|
||||
startupDraft: 'https://github.com/o/r/issues/12'
|
||||
})
|
||||
})
|
||||
|
||||
it('toasts a staged create error after the user leaves the creation surface', async () => {
|
||||
store.activeView = 'tasks'
|
||||
store.createWorktree.mockRejectedValueOnce(new Error('create failed'))
|
||||
|
||||
const started = continueBackgroundWorktreeCreation('creation-1', makeRequest(), {
|
||||
revealCreationSurface: false
|
||||
})
|
||||
|
||||
expect(started).toBe(true)
|
||||
await flushAsyncWorktreeCreation()
|
||||
expect(store.updatePendingWorktreeCreation).toHaveBeenCalledWith(
|
||||
'creation-1',
|
||||
expect.objectContaining({
|
||||
status: 'error'
|
||||
})
|
||||
)
|
||||
expect(toast.error).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PendingWorktreeCreation, WorktreeCreationRequest } from './pending-worktree-creation'
|
||||
|
||||
const pendingWorktreeCreations: Record<string, PendingWorktreeCreation> = {}
|
||||
const store = {
|
||||
pendingWorktreeCreations,
|
||||
activeView: 'terminal',
|
||||
activePendingCreationId: 'creation-1',
|
||||
settings: null,
|
||||
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
|
||||
store.pendingWorktreeCreations[entry.creationId] = entry
|
||||
}),
|
||||
updatePendingWorktreeCreation: vi.fn((id: string, patch: Partial<PendingWorktreeCreation>) => {
|
||||
const entry = store.pendingWorktreeCreations[id]
|
||||
if (entry) {
|
||||
store.pendingWorktreeCreations[id] = { ...entry, ...patch }
|
||||
}
|
||||
}),
|
||||
removePendingWorktreeCreation: vi.fn((id: string) => {
|
||||
delete store.pendingWorktreeCreations[id]
|
||||
}),
|
||||
setActiveView: vi.fn(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
setActivePendingWorktreeCreation: vi.fn(),
|
||||
createWorktree: vi.fn(() => new Promise(() => {}))
|
||||
}
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
|
||||
vi.mock('@/lib/browser-uuid', () => ({ createBrowserUuid: () => 'creation-1' }))
|
||||
vi.mock('@/lib/ensure-hooks-confirmed', () => ({
|
||||
ensureHooksConfirmed: vi.fn(),
|
||||
readAndConfirmRuntimeIssueCommand: vi.fn()
|
||||
}))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
import { ensureHooksConfirmed } from './ensure-hooks-confirmed'
|
||||
import { runBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
|
||||
function makeRequest(overrides: Partial<WorktreeCreationRequest> = {}): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: null,
|
||||
worktreeCreateProgressMode: 'stepped',
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.pendingWorktreeCreations = {}
|
||||
})
|
||||
|
||||
async function flushAsyncWorktreeCreation(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('background script preparation', () => {
|
||||
it('shows preparation immediately and waits for trust before creating', async () => {
|
||||
const trust = Promise.withResolvers<'run' | 'skip'>()
|
||||
vi.mocked(ensureHooksConfirmed).mockReturnValueOnce(trust.promise)
|
||||
const request = makeRequest({ hookPreparation: { executionHostId: 'ssh:remote-1' } })
|
||||
|
||||
runBackgroundWorktreeCreation(request)
|
||||
|
||||
expect(store.pendingWorktreeCreations['creation-1']).toMatchObject({
|
||||
phase: 'preparing',
|
||||
loaderVisible: true,
|
||||
status: 'creating',
|
||||
request
|
||||
})
|
||||
expect(store.createWorktree).not.toHaveBeenCalled()
|
||||
expect(ensureHooksConfirmed).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
'repo-1',
|
||||
'setup',
|
||||
'ssh:remote-1',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
|
||||
trust.resolve('run')
|
||||
await vi.waitFor(() => expect(store.createWorktree).toHaveBeenCalledOnce())
|
||||
expect(store.pendingWorktreeCreations['creation-1'].phase).toBe('fetching')
|
||||
expect(store.createWorktree.mock.calls[0]).toContainEqual(
|
||||
expect.objectContaining({ executionHostId: 'ssh:remote-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not create after cancellation during slow script inspection', async () => {
|
||||
const trust = Promise.withResolvers<'run' | 'skip'>()
|
||||
vi.mocked(ensureHooksConfirmed).mockReturnValueOnce(trust.promise)
|
||||
runBackgroundWorktreeCreation(makeRequest({ hookPreparation: { executionHostId: 'local' } }))
|
||||
|
||||
store.removePendingWorktreeCreation('creation-1')
|
||||
trust.resolve('run')
|
||||
await flushAsyncWorktreeCreation()
|
||||
expect(store.createWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,155 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { PendingWorktreeCreation, WorktreeCreationRequest } from './pending-worktree-creation'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureHooksConfirmed: vi.fn(),
|
||||
readAndConfirmRuntimeIssueCommand: vi.fn(),
|
||||
buildTrustedComposerIssueCommand: vi.fn()
|
||||
}))
|
||||
vi.mock('./ensure-hooks-confirmed', () => mocks)
|
||||
vi.mock('./composer-issue-command', () => mocks)
|
||||
|
||||
const pendingWorktreeCreations: Record<string, PendingWorktreeCreation> = {}
|
||||
const store = {
|
||||
pendingWorktreeCreations,
|
||||
updatePendingWorktreeCreation: vi.fn(),
|
||||
removePendingWorktreeCreation: vi.fn()
|
||||
}
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
|
||||
|
||||
import { prepareWorktreeCreationHooks } from './worktree-creation-hook-preparation'
|
||||
|
||||
function makeRequest(): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'issue-42',
|
||||
agent: null,
|
||||
setupDecision: 'run',
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: '',
|
||||
quickTelemetry: null,
|
||||
hookPreparation: {
|
||||
executionHostId: 'ssh:remote-1',
|
||||
issueCommand: { provider: 'github', issueNumber: 42, artifactUrl: 'https://example.test/42' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
const request = makeRequest()
|
||||
store.pendingWorktreeCreations = {
|
||||
'creation-1': {
|
||||
creationId: 'creation-1',
|
||||
phase: 'preparing',
|
||||
status: 'creating',
|
||||
startedAt: 0,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
request
|
||||
}
|
||||
}
|
||||
mocks.ensureHooksConfirmed.mockResolvedValue('run')
|
||||
})
|
||||
|
||||
describe('worktree script preparation', () => {
|
||||
it('reads and confirms issue commands on the selected host before advancing', async () => {
|
||||
const read = Promise.withResolvers<{ template: string; trustDecision: 'run' }>()
|
||||
mocks.readAndConfirmRuntimeIssueCommand.mockReturnValue(read.promise)
|
||||
mocks.buildTrustedComposerIssueCommand.mockReturnValue({ command: 'issue 42' })
|
||||
const preparing = prepareWorktreeCreationHooks('creation-1', makeRequest())
|
||||
await vi.waitFor(() => expect(mocks.readAndConfirmRuntimeIssueCommand).toHaveBeenCalled())
|
||||
expect(store.updatePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
expect(mocks.readAndConfirmRuntimeIssueCommand).toHaveBeenCalledWith(
|
||||
store,
|
||||
'repo-1',
|
||||
'ssh:remote-1',
|
||||
expect.any(Function)
|
||||
)
|
||||
read.resolve({ template: 'issue {{number}}', trustDecision: 'run' })
|
||||
expect(await preparing).toMatchObject({
|
||||
issueCommand: { command: 'issue 42' },
|
||||
executionHostId: 'ssh:remote-1',
|
||||
hookPreparation: undefined
|
||||
})
|
||||
expect(mocks.buildTrustedComposerIssueCommand).toHaveBeenCalledWith({
|
||||
enabled: true,
|
||||
provider: 'github',
|
||||
issueNumber: 42,
|
||||
artifactUrl: 'https://example.test/42',
|
||||
template: 'issue {{number}}',
|
||||
trustDecision: 'run'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an explicit Skip distinct from setup trust so issue-command preparation still runs', async () => {
|
||||
const request = { ...makeRequest(), setupDecision: 'skip' as const }
|
||||
mocks.readAndConfirmRuntimeIssueCommand.mockResolvedValue({
|
||||
template: 'issue {{number}}',
|
||||
trustDecision: 'run'
|
||||
})
|
||||
const prepared = await prepareWorktreeCreationHooks('creation-1', request)
|
||||
expect(prepared?.setupDecision).toBe('skip')
|
||||
expect(mocks.readAndConfirmRuntimeIssueCommand).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves an omitted decision and quietly settles a late cancelled trust failure', async () => {
|
||||
const request: WorktreeCreationRequest = JSON.parse(
|
||||
JSON.stringify({
|
||||
...makeRequest(),
|
||||
setupDecision: undefined,
|
||||
hookPreparation: { executionHostId: 'ssh:remote-1' }
|
||||
})
|
||||
)
|
||||
expect(request).not.toHaveProperty('setupDecision')
|
||||
expect(
|
||||
(await prepareWorktreeCreationHooks('creation-1', request))?.setupDecision
|
||||
).toBeUndefined()
|
||||
store.updatePendingWorktreeCreation.mockClear()
|
||||
const trust = Promise.withResolvers<'run' | 'skip'>()
|
||||
mocks.ensureHooksConfirmed.mockReturnValueOnce(trust.promise)
|
||||
const preparing = prepareWorktreeCreationHooks('creation-1', request)
|
||||
delete store.pendingWorktreeCreations['creation-1']
|
||||
trust.reject(new Error('late trust failure'))
|
||||
await expect(preparing).resolves.toBeNull()
|
||||
expect(store.updatePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not inspect issue commands when setup trust was refused', async () => {
|
||||
mocks.ensureHooksConfirmed.mockResolvedValue('skip')
|
||||
expect(await prepareWorktreeCreationHooks('creation-1', makeRequest())).toMatchObject({
|
||||
setupDecision: 'skip'
|
||||
})
|
||||
expect(mocks.readAndConfirmRuntimeIssueCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not advance after cancelling an issue-command confirmation', async () => {
|
||||
const read = Promise.withResolvers<{ template: string; trustDecision: 'run' }>()
|
||||
mocks.readAndConfirmRuntimeIssueCommand.mockReturnValue(read.promise)
|
||||
const preparing = prepareWorktreeCreationHooks('creation-1', makeRequest())
|
||||
await vi.waitFor(() => expect(mocks.readAndConfirmRuntimeIssueCommand).toHaveBeenCalled())
|
||||
delete store.pendingWorktreeCreations['creation-1']
|
||||
read.resolve({ template: 'issue {{number}}', trustDecision: 'run' })
|
||||
expect(await preparing).toBeNull()
|
||||
expect(store.updatePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the preparation when the VM recipe is refused', async () => {
|
||||
mocks.ensureHooksConfirmed.mockResolvedValueOnce('run').mockResolvedValueOnce('skip')
|
||||
const request = makeRequest()
|
||||
request.hookPreparation = { executionHostId: 'ssh:remote-1', confirmVmRecipe: true }
|
||||
expect(await prepareWorktreeCreationHooks('creation-1', request)).toBeNull()
|
||||
expect(mocks.ensureHooksConfirmed).toHaveBeenLastCalledWith(
|
||||
expect.any(Function),
|
||||
'repo-1',
|
||||
'vmRecipe',
|
||||
'ssh:remote-1',
|
||||
undefined,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(store.removePendingWorktreeCreation).toHaveBeenCalledWith('creation-1')
|
||||
expect(store.updatePendingWorktreeCreation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
PendingWorktreeCreation,
|
||||
WorktreeCreationRequest
|
||||
} from '@/lib/pending-worktree-creation'
|
||||
|
||||
const pendingWorktreeCreations: Record<string, PendingWorktreeCreation> = {}
|
||||
const store = {
|
||||
settings: {},
|
||||
activeView: 'terminal',
|
||||
activePendingCreationId: 'creation-1',
|
||||
repos: [],
|
||||
pendingWorktreeCreations,
|
||||
beginPendingWorktreeCreation: vi.fn((entry: PendingWorktreeCreation) => {
|
||||
pendingWorktreeCreations[entry.creationId] = entry
|
||||
}),
|
||||
updatePendingWorktreeCreation: vi.fn(),
|
||||
removePendingWorktreeCreation: vi.fn((creationId: string) => {
|
||||
delete pendingWorktreeCreations[creationId]
|
||||
}),
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
setActivePendingWorktreeCreation: vi.fn(),
|
||||
setActiveView: vi.fn(),
|
||||
setSidebarOpen: vi.fn(),
|
||||
createWorktree: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
|
||||
seedNativeChatLaunchDraft: vi.fn(),
|
||||
setTabViewMode: vi.fn(),
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {}
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => store } }))
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: vi.fn(() => ({ primaryTabId: 'tab-1' }))
|
||||
}))
|
||||
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
|
||||
ensureWorktreeHasInitialTerminal: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/workspace-activation-terminal-focus', () => ({
|
||||
queueWorkspaceActivationTerminalFocus: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/new-workspace', () => ({ ensureAgentStartupInTerminal: vi.fn() }))
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
|
||||
import { continueBackgroundWorktreeCreation } from './worktree-creation-flow'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function promptedClaudeRequest(): WorktreeCreationRequest {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
name: 'feature',
|
||||
setupDecision: 'inherit',
|
||||
agent: 'claude',
|
||||
agentLaunchRoute: 'terminal-tui',
|
||||
pendingFirstAgentMessageRename: false,
|
||||
note: '',
|
||||
quickPrompt: 'fix the bug',
|
||||
quickTelemetry: {
|
||||
agent_kind: 'claude-code',
|
||||
launch_source: 'new_workspace_composer',
|
||||
request_kind: 'new'
|
||||
},
|
||||
startupPlan: {
|
||||
agent: 'claude',
|
||||
launchCommand: '/desktop/claude',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: 'fix the bug',
|
||||
launchConfig: { agentCommand: '/desktop/claude', agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('never runs the desktop plan when the host that planned the launch did not start it', async () => {
|
||||
store.createWorktree.mockResolvedValueOnce({
|
||||
worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' },
|
||||
warning: 'Host could not start claude.',
|
||||
hostPlannedStartup: true
|
||||
})
|
||||
|
||||
const request = promptedClaudeRequest()
|
||||
pendingWorktreeCreations['creation-1'] = {
|
||||
creationId: 'creation-1',
|
||||
phase: 'fetching',
|
||||
status: 'creating',
|
||||
startedAt: 1,
|
||||
indeterminate: true,
|
||||
loaderVisible: true,
|
||||
request
|
||||
}
|
||||
continueBackgroundWorktreeCreation('creation-1', request)
|
||||
|
||||
await vi.waitFor(() => expect(store.removePendingWorktreeCreation).toHaveBeenCalled())
|
||||
expect(store.createWorktree.mock.calls[0]?.[25]).toMatchObject({
|
||||
hostAgentLaunch: {
|
||||
agent: 'claude',
|
||||
prompt: 'fix the bug',
|
||||
launchSource: 'new_workspace_composer'
|
||||
}
|
||||
})
|
||||
const activation = vi.mocked(activateAndRevealWorktree).mock.calls[0]?.[1]
|
||||
// Neither the desktop plan nor the window's agent recovery, which would drop the prompt.
|
||||
expect(activation).not.toHaveProperty('startup')
|
||||
expect(activation).not.toHaveProperty('agent')
|
||||
expect(ensureAgentStartupInTerminal).not.toHaveBeenCalled()
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Couldn't confirm Claude started. Check its terminal before trying again."
|
||||
)
|
||||
})
|
||||
@@ -3,12 +3,8 @@ import { useAppStore } from '@/store'
|
||||
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import type { PendingWorktreeCreation } from './pending-worktree-creation'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import {
|
||||
createMockStore,
|
||||
createdTabStartups,
|
||||
registerWorktreeActivationReset
|
||||
} from './worktree-activation.test-fixture'
|
||||
import './worktree-initial-terminal-seeding'
|
||||
import { registerWorktreeActivationReset } from './worktree-activation.test-fixture'
|
||||
|
||||
registerWorktreeActivationReset()
|
||||
|
||||
@@ -35,40 +31,7 @@ function creatingEntry(): PendingWorktreeCreation {
|
||||
}
|
||||
}
|
||||
|
||||
function creatingStore() {
|
||||
return createMockStore({
|
||||
settings: {},
|
||||
pendingWorktreeCreations: { 'creation-1': creatingEntry() }
|
||||
})
|
||||
}
|
||||
|
||||
describe('initial surface during worktree creation', () => {
|
||||
it('does not seed a fallback shell when an unfinished checkout is opened', () => {
|
||||
const store = creatingStore()
|
||||
expect(ensureWorktreeHasInitialTerminal(store, 'wt-1')).toBeNull()
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets the matching creation owner complete a blank-terminal request', () => {
|
||||
const store = creatingStore()
|
||||
expect(
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, undefined, undefined, {
|
||||
worktreeCreationId: 'creation-1'
|
||||
})
|
||||
).toBe('tab-1')
|
||||
expect(store.createTab).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not block another workspace or an explicit agent launch', () => {
|
||||
expect(ensureWorktreeHasInitialTerminal(creatingStore(), 'wt-other')).toBe('tab-1')
|
||||
const store = creatingStore()
|
||||
expect(
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', { command: 'codex', launchAgent: 'codex' })
|
||||
).toBe('tab-1')
|
||||
expect(store.createTab).toHaveBeenCalledOnce()
|
||||
expect(createdTabStartups(store)['tab-1']).toEqual({ command: 'codex', launchAgent: 'codex' })
|
||||
})
|
||||
|
||||
it('shows the creation panel instead of an empty workspace when the row is opened early', () => {
|
||||
useAppStore.setState({
|
||||
activeView: 'tasks',
|
||||
|
||||
@@ -1,346 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
|
||||
type ReadyToast = {
|
||||
id: number
|
||||
title: string
|
||||
duration?: number
|
||||
label: ReactNode
|
||||
onClick: () => void
|
||||
onDismiss: () => void
|
||||
onAutoClose: () => void
|
||||
}
|
||||
|
||||
const { toasts, store } = await vi.hoisted(async () => {
|
||||
const { createStore } = await import('zustand/vanilla')
|
||||
const toasts: ReadyToast[] = []
|
||||
const store = createStore<{
|
||||
repos: Pick<Repo, 'id' | 'kind'>[]
|
||||
activeView: 'terminal' | 'tasks'
|
||||
activeWorktreeId: string | null
|
||||
activePendingCreationId: string | null
|
||||
activeWorkspaceExecutionHostId: ExecutionHostId | null
|
||||
worktreesByRepo: Record<string, Pick<Worktree, 'id' | 'repoId' | 'hostId'>[]>
|
||||
settings: { activeRuntimeEnvironmentId: string | null }
|
||||
}>(() => ({
|
||||
repos: [],
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
activePendingCreationId: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
worktreesByRepo: {},
|
||||
settings: { activeRuntimeEnvironmentId: null }
|
||||
}))
|
||||
return { toasts, store }
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
dismiss: vi.fn(),
|
||||
success: (
|
||||
title: string,
|
||||
options: {
|
||||
duration?: number
|
||||
action: { label: ReactNode; onClick: () => void }
|
||||
onDismiss: () => void
|
||||
onAutoClose: () => void
|
||||
}
|
||||
) => {
|
||||
const id = toasts.length + 1
|
||||
toasts.push({
|
||||
id,
|
||||
title,
|
||||
duration: options.duration,
|
||||
...options.action,
|
||||
onDismiss: options.onDismiss,
|
||||
onAutoClose: options.onAutoClose
|
||||
})
|
||||
return id
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: store
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorkspace: vi.fn()
|
||||
}))
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { activateAndRevealWorkspace } from '@/lib/worktree-activation'
|
||||
import { showWorktreeCreationReadyToast } from './worktree-creation-ready-toast'
|
||||
|
||||
const worktree = {
|
||||
id: 'repo-1::/workspace/feature',
|
||||
repoId: 'repo-1',
|
||||
displayName: 'Feature',
|
||||
branch: 'refs/heads/feature',
|
||||
path: '/workspace/feature'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
for (const entry of toasts) {
|
||||
entry.onDismiss()
|
||||
}
|
||||
toasts.length = 0
|
||||
store.setState({
|
||||
repos: [],
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: null,
|
||||
activePendingCreationId: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
worktreesByRepo: {},
|
||||
settings: { activeRuntimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
describe('showWorktreeCreationReadyToast', () => {
|
||||
it('names a git worktree and offers to go to it', () => {
|
||||
store.setState({ repos: [{ id: 'repo-1', kind: 'git' }] })
|
||||
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
|
||||
expect(toasts).toHaveLength(1)
|
||||
expect(toasts[0]?.title).toBe('Worktree Feature is ready')
|
||||
expect(toasts[0]?.duration).toBe(Infinity)
|
||||
const label = renderToStaticMarkup(<>{toasts[0]?.label}</>)
|
||||
expect(label).toContain('lucide-external-link')
|
||||
expect(label).toContain('Go to worktree')
|
||||
|
||||
toasts[0]?.onClick()
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
navigationIntent: 'user-open'
|
||||
})
|
||||
})
|
||||
|
||||
it('calls a folder repo workspace a workspace, not a worktree', () => {
|
||||
store.setState({ repos: [{ id: 'repo-1', kind: 'folder' }] })
|
||||
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
|
||||
expect(toasts[0]?.title).toBe('Workspace Feature is ready')
|
||||
const label = renderToStaticMarkup(<>{toasts[0]?.label}</>)
|
||||
expect(label).toContain('lucide-external-link')
|
||||
expect(label).toContain('Go to workspace')
|
||||
expect(label).not.toContain('Go to worktree')
|
||||
})
|
||||
|
||||
it('keeps the worktree wording when the repo is unknown', () => {
|
||||
store.setState({ repos: [] })
|
||||
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
|
||||
expect(toasts[0]?.title).toBe('Worktree Feature is ready')
|
||||
expect(renderToStaticMarkup(<>{toasts[0]?.label}</>)).toContain('Go to worktree')
|
||||
})
|
||||
|
||||
it('dismisses only the opened worktree notification and releases its subscription', async () => {
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
showWorktreeCreationReadyToast({ ...worktree, id: 'repo-1::/workspace/other' })
|
||||
|
||||
store.setState({ activeWorktreeId: 'repo-1::/workspace/unrelated' })
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
|
||||
store.setState({ activeWorktreeId: null })
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledTimes(1)
|
||||
store.setState({ activeWorktreeId: 'repo-1::/workspace/other' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenLastCalledWith(toasts[1]?.id)
|
||||
})
|
||||
|
||||
it('waits until the worktree is shown instead of another view or creation panel', async () => {
|
||||
store.setState({ activeView: 'tasks', activeWorktreeId: worktree.id })
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
store.setState({ activeView: 'terminal', activePendingCreationId: 'creation-2' })
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
|
||||
store.setState({ activePendingCreationId: null })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it('does not show a late notification for a worktree already being viewed', () => {
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
expect(toasts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('also dismisses when a folder workspace is opened', async () => {
|
||||
store.setState({ repos: [{ id: 'repo-1', kind: 'folder' }] })
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it('matches equivalent remote workspace paths', async () => {
|
||||
const remote = { ...worktree, id: 'ssh-repo::C:\\workspace\\feature' }
|
||||
showWorktreeCreationReadyToast(remote)
|
||||
store.setState({ activeWorktreeId: 'ssh-repo::c:/workspace/feature' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it.each(['onDismiss', 'onAutoClose', 'onClick'] as const)(
|
||||
'releases the navigation subscription on %s',
|
||||
(callback) => {
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
toasts[0]?.[callback]()
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps a remote notification until that host is opened, even when IDs collide', async () => {
|
||||
const remote = { ...worktree, hostId: 'runtime:remote-a' as const }
|
||||
store.setState({ activeWorktreeId: worktree.id, activeWorkspaceExecutionHostId: 'local' })
|
||||
showWorktreeCreationReadyToast(remote)
|
||||
expect(toasts).toHaveLength(1)
|
||||
store.setState({ activeWorktreeId: null })
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
store.setState({ activeWorkspaceExecutionHostId: remote.hostId })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it('resolves the selected host from the catalog when selection is unqualified', async () => {
|
||||
const local = { ...worktree, hostId: 'local' as const }
|
||||
store.setState({ worktreesByRepo: { [worktree.repoId]: [local] } })
|
||||
showWorktreeCreationReadyToast(local)
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it('routes an unqualified selection by the owner row, not the Active Server setting', async () => {
|
||||
store.setState({
|
||||
repos: [{ id: 'repo-1', kind: 'git' }],
|
||||
worktreesByRepo: { [worktree.repoId]: [{ id: worktree.id, repoId: worktree.repoId }] },
|
||||
settings: { activeRuntimeEnvironmentId: 'remote-a' }
|
||||
})
|
||||
showWorktreeCreationReadyToast(worktree, 'local')
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it('opens the notification target on its captured host', () => {
|
||||
showWorktreeCreationReadyToast({ ...worktree, hostId: 'runtime:remote-a' })
|
||||
toasts[0]?.onClick()
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
navigationIntent: 'user-open',
|
||||
executionHostId: 'runtime:remote-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the notification when navigation passes through its remembered workspace', async () => {
|
||||
store.setState({ activeView: 'tasks', activeWorktreeId: worktree.id })
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
store.setState({ activeView: 'terminal' })
|
||||
store.setState({ activeWorktreeId: 'repo-1::/workspace/unrelated' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it('cancels a queued dismissal when the notification is closed manually', async () => {
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
store.setState({ activeWorktreeId: worktree.id })
|
||||
toasts[0]?.onDismiss()
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([undefined, 'runtime:remote-a'] as const)(
|
||||
'projects a paired response into the renderer host with creation host %s',
|
||||
async (creationHostId) => {
|
||||
const raw = { ...worktree, hostId: 'local' as const }
|
||||
store.setState({
|
||||
worktreesByRepo: { [worktree.repoId]: [{ ...worktree, hostId: 'runtime:remote-a' }] }
|
||||
})
|
||||
showWorktreeCreationReadyToast(raw, creationHostId)
|
||||
store.setState({ activeWorktreeId: worktree.id, activeWorkspaceExecutionHostId: 'local' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
toasts[0]?.onClick()
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
navigationIntent: 'user-open',
|
||||
executionHostId: 'runtime:remote-a'
|
||||
})
|
||||
store.setState({ activeWorktreeId: null })
|
||||
showWorktreeCreationReadyToast(raw, creationHostId)
|
||||
store.setState({
|
||||
activeWorktreeId: worktree.id,
|
||||
activeWorkspaceExecutionHostId: 'runtime:remote-a'
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[1]?.id)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves nested SSH ownership reached through a paired runtime', async () => {
|
||||
showWorktreeCreationReadyToast({ ...worktree, hostId: 'ssh:remote-a' }, 'runtime:hub')
|
||||
store.setState({ activeWorktreeId: worktree.id, activeWorkspaceExecutionHostId: 'runtime:hub' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
store.setState({ activeWorkspaceExecutionHostId: 'ssh:remote-a' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[0]?.id)
|
||||
})
|
||||
|
||||
it('keeps an unqualified notification when the catalog cannot prove its host', async () => {
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
[worktree.repoId]: [
|
||||
{ ...worktree, hostId: 'local' },
|
||||
{ ...worktree, hostId: 'runtime:remote-a' }
|
||||
]
|
||||
}
|
||||
})
|
||||
showWorktreeCreationReadyToast(worktree)
|
||||
store.setState({ activeWorktreeId: worktree.id, activeWorkspaceExecutionHostId: 'local' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps captured local ownership for legacy rows without a host', async () => {
|
||||
showWorktreeCreationReadyToast(worktree, 'local')
|
||||
store.setState({
|
||||
activeWorktreeId: worktree.id,
|
||||
activeWorkspaceExecutionHostId: 'runtime:remote-a'
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).not.toHaveBeenCalled()
|
||||
toasts[0]?.onClick()
|
||||
expect(activateAndRevealWorkspace).toHaveBeenCalledWith(worktree.id, {
|
||||
sidebarRevealBehavior: 'auto',
|
||||
navigationIntent: 'user-open',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
store.setState({ activeWorktreeId: null })
|
||||
showWorktreeCreationReadyToast(worktree, 'local')
|
||||
store.setState({ activeWorktreeId: worktree.id, activeWorkspaceExecutionHostId: 'local' })
|
||||
await Promise.resolve()
|
||||
expect(toast.dismiss).toHaveBeenCalledExactlyOnceWith(toasts[1]?.id)
|
||||
})
|
||||
})
|
||||
@@ -1,219 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const mainStarts: unknown[] = []
|
||||
return { createSupport: vi.fn(), mainStarts }
|
||||
})
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() } }))
|
||||
vi.mock('@/runtime/structured-agent-session-client', () => ({
|
||||
callStructuredAgentSession: vi.fn(async (_target: unknown, method: string) =>
|
||||
method === 'agentSession.createSupport' ? mocks.createSupport() : new Promise(() => undefined)
|
||||
)
|
||||
}))
|
||||
|
||||
import { useAppStore } from '@/store'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import { launchStructuredWorktreeSession } from './worktree-creation-structured-session'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them, and the first
|
||||
// terminals it starts for the window's new tabs.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) => {
|
||||
const client = (
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
return {
|
||||
...client,
|
||||
callRuntimeRpc: (...args: Parameters<typeof client.callRuntimeRpc>) => {
|
||||
if (args[1] !== 'terminal.create') {
|
||||
return client.callRuntimeRpc(...args)
|
||||
}
|
||||
mocks.mainStarts.push(args[2])
|
||||
return new Promise(() => undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** The startup the tab's terminal runs: main's start in a layout-owner build, else the window's. */
|
||||
function startupOf(tabId: string): unknown {
|
||||
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
|
||||
return useAppStore.getState().pendingStartupByTabId[tabId]
|
||||
}
|
||||
return mocks.mainStarts.find(
|
||||
(params) =>
|
||||
typeof params === 'object' && params !== null && 'tabId' in params && params.tabId === tabId
|
||||
)
|
||||
}
|
||||
|
||||
const initial = useAppStore.getState()
|
||||
|
||||
function worktree(name: string, host: 'paired' | 'local' = 'paired'): Worktree {
|
||||
const worktreePath = path.join('workspace', name)
|
||||
const owner: Pick<Worktree, 'hostId' | 'runtimeOwnerEnvironmentId'> =
|
||||
host === 'paired'
|
||||
? { hostId: 'runtime:server-1', runtimeOwnerEnvironmentId: 'server-1' }
|
||||
: { hostId: 'local' }
|
||||
return {
|
||||
id: `repo-1::${worktreePath}`,
|
||||
repoId: 'repo-1',
|
||||
path: worktreePath,
|
||||
head: 'abc',
|
||||
branch: `refs/heads/${name}`,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: name,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdWithAgent: 'claude',
|
||||
...owner
|
||||
}
|
||||
}
|
||||
|
||||
const OTHER = worktree('other')
|
||||
const CREATED = worktree('created')
|
||||
|
||||
function seedPairedServer(): ReturnType<typeof vi.fn> {
|
||||
const call = vi.fn(() => new Promise(() => undefined))
|
||||
vi.stubGlobal('window', { ...window, api: { runtimeEnvironments: { call, subscribe: vi.fn() } } })
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: path.join('workspace', 'repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
executionHostId: 'runtime:server-1'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [OTHER, CREATED] },
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only checks the creation is still pending.
|
||||
pendingWorktreeCreations: { 'creation-1': {} as never },
|
||||
settings: {
|
||||
...getDefaultSettings(path.join('workspace', '.orca')),
|
||||
activeRuntimeEnvironmentId: 'server-1'
|
||||
}
|
||||
})
|
||||
// The user moved to another workspace while the create ran.
|
||||
useAppStore.getState().setActiveWorktree(OTHER.id, 'runtime:server-1')
|
||||
return call
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only these request fields.
|
||||
const REQUEST = {
|
||||
agent: 'claude',
|
||||
quickPrompt: 'fix the flaky test',
|
||||
promptDelivery: 'auto-submit',
|
||||
startupPlan: {
|
||||
launchCommand: "claude --model opus 'fix the flaky test'",
|
||||
launchConfig: { agent: 'claude' }
|
||||
}
|
||||
} as unknown as WorktreeCreationRequest
|
||||
|
||||
afterEach(() => {
|
||||
mocks.mainStarts.length = 0
|
||||
vi.unstubAllGlobals()
|
||||
useAppStore.setState(initial, true)
|
||||
})
|
||||
|
||||
describe('a background worktree create whose paired server declines the chat', () => {
|
||||
it("opens the request's agent terminal without pulling the user onto the new workspace", async () => {
|
||||
mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' })
|
||||
const call = seedPairedServer()
|
||||
|
||||
await launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request: REQUEST,
|
||||
agentLaunchRoute: 'structured-native-chat',
|
||||
worktreeId: CREATED.id,
|
||||
shouldActivateOnCompletion: false,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'session.tabs.createTerminal',
|
||||
params: expect.objectContaining({
|
||||
command: "claude --model opus 'fix the flaky test'",
|
||||
select: false
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(useAppStore.getState().activeWorktreeId).toBe(OTHER.id)
|
||||
})
|
||||
})
|
||||
|
||||
const LOCAL_OTHER = worktree('local-other', 'local')
|
||||
const LOCAL_CREATED = worktree('local-created', 'local')
|
||||
|
||||
function seedThisMachine(): void {
|
||||
useAppStore.setState({
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: path.join('workspace', 'repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [LOCAL_OTHER, LOCAL_CREATED] },
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch only checks the creation is still pending.
|
||||
pendingWorktreeCreations: { 'creation-1': {} as never },
|
||||
settings: getDefaultSettings(path.join('workspace', '.orca')),
|
||||
// The user moved to another workspace while the create ran.
|
||||
activeWorktreeId: LOCAL_OTHER.id
|
||||
})
|
||||
}
|
||||
|
||||
describe('a background worktree create whose own machine declines the chat', () => {
|
||||
it("opens the request's agent terminal in place, with no chat, leaving the user where they are", async () => {
|
||||
mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' })
|
||||
seedThisMachine()
|
||||
|
||||
await launchStructuredWorktreeSession({
|
||||
creationId: 'creation-1',
|
||||
request: REQUEST,
|
||||
agentLaunchRoute: 'structured-native-chat',
|
||||
worktreeId: LOCAL_CREATED.id,
|
||||
shouldActivateOnCompletion: false,
|
||||
activation: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(useAppStore.getState().tabsByWorktree[LOCAL_CREATED.id]).toHaveLength(1)
|
||||
)
|
||||
const state = useAppStore.getState()
|
||||
const tab = state.tabsByWorktree[LOCAL_CREATED.id]![0]!
|
||||
expect(startupOf(tab.id)).toMatchObject({
|
||||
command: "claude --model opus 'fix the flaky test'"
|
||||
})
|
||||
expect(
|
||||
(state.unifiedTabsByWorktree[LOCAL_CREATED.id] ?? []).filter(
|
||||
(candidate) => candidate.contentType === 'agent-session'
|
||||
)
|
||||
).toEqual([])
|
||||
expect(state.activeWorktreeId).toBe(LOCAL_OTHER.id)
|
||||
})
|
||||
})
|
||||
@@ -288,20 +288,6 @@ describe('creation chat handoff', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('finishes creation when the host opens a terminal instead of a chat', async () => {
|
||||
mocks.admission.mockResolvedValue({ kind: 'declined' })
|
||||
|
||||
await executeWorktreeCreation('creation-1', request)
|
||||
await main.settle()
|
||||
|
||||
const state = useAppStore.getState()
|
||||
expect(state.activeWorktreeId).toBe(worktree.id)
|
||||
expect(state.pendingWorktreeCreations['creation-1']).toBeUndefined()
|
||||
expect(state.getActiveTab(worktree.id)?.contentType).toBe('terminal')
|
||||
expect(state.tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
expect(mocks.createSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dismisses a pending admission without opening a late chat', async () => {
|
||||
const admission = Promise.withResolvers<StructuredLaunchAdmission>()
|
||||
mocks.admission.mockReturnValue(admission.promise)
|
||||
|
||||
@@ -1,418 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
clearNativeChatComposerDraftsForTests,
|
||||
readNativeChatComposerDraft,
|
||||
updateNativeChatComposerDraft
|
||||
} from '@/components/native-chat/native-chat-composer-draft-store'
|
||||
|
||||
type PendingCreationState = { pendingWorktreeCreations: Record<string, unknown> }
|
||||
type PendingCreationListener = (state: PendingCreationState) => void
|
||||
type BeginArgs = {
|
||||
beforeOpen?: (sessionId: string) => boolean | void
|
||||
hooks?: { signal?: AbortSignal }
|
||||
onTabOpenSettled?: (tabId: string | null, reason?: 'layout-refused') => void
|
||||
plan?: { prompt?: string; promptDelivery?: string; draftFromScope?: string }
|
||||
}
|
||||
const mocks = vi.hoisted(() => {
|
||||
let listener: PendingCreationListener | null = null
|
||||
const state: PendingCreationState & {
|
||||
activeView: string
|
||||
activePendingCreationId: string | null
|
||||
unifiedTabsByWorktree: Record<string, never[]>
|
||||
updatePendingWorktreeCreation: (id: string, patch: unknown) => void
|
||||
getTab: (id: string) => {
|
||||
id: string
|
||||
contentType: 'agent-session'
|
||||
entityId: string
|
||||
executionHostId: 'local'
|
||||
} | null
|
||||
} = {
|
||||
activeView: 'terminal',
|
||||
activePendingCreationId: 'creation-1',
|
||||
unifiedTabsByWorktree: {},
|
||||
pendingWorktreeCreations: Object.fromEntries([['creation-1', {}]]),
|
||||
updatePendingWorktreeCreation: vi.fn<(id: string, patch: unknown) => void>(),
|
||||
getTab: (id) =>
|
||||
id === 'agent-session:session-1'
|
||||
? { id, contentType: 'agent-session', entityId: 'session-1', executionHostId: 'local' }
|
||||
: null
|
||||
}
|
||||
return {
|
||||
state,
|
||||
get listener() {
|
||||
return listener
|
||||
},
|
||||
set listener(value: PendingCreationListener | null) {
|
||||
listener = value
|
||||
},
|
||||
unsubscribe: vi.fn<() => void>(),
|
||||
launchAgentInNewTabWithOutcome: vi.fn(),
|
||||
waitForComposerMount: vi.fn<
|
||||
(_source: string, _destination: string, signal: AbortSignal) => Promise<void>
|
||||
>(async () => {}),
|
||||
beginStructuredAgentSessionProvisionalLaunch: vi.fn<
|
||||
(args: BeginArgs) => {
|
||||
sessionId: string
|
||||
tab: { id: string }
|
||||
settlement: Promise<{ kind: 'structured'; sessionId: string }>
|
||||
cancel: () => void
|
||||
} | null
|
||||
>(),
|
||||
activateAndRevealWorktree: vi.fn<(worktreeId: string, options?: unknown) => unknown>()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(vi.fn<() => unknown>(), {
|
||||
getState: () => mocks.state,
|
||||
subscribe: vi.fn<(listener: PendingCreationListener) => () => void>(
|
||||
(listener: PendingCreationListener) => {
|
||||
mocks.listener = listener
|
||||
return mocks.unsubscribe
|
||||
}
|
||||
)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/agent-session/structured-agent-session-provisional-tab', () => ({
|
||||
beginStructuredAgentSessionProvisionalLaunch: mocks.beginStructuredAgentSessionProvisionalLaunch
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/launch-agent-in-new-tab', () => ({
|
||||
launchAgentInNewTabWithOutcome: mocks.launchAgentInNewTabWithOutcome
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/worktree-activation', () => ({
|
||||
activateAndRevealWorktree: mocks.activateAndRevealWorktree
|
||||
}))
|
||||
vi.mock('@/components/native-chat/native-chat-composer-mount', () => ({
|
||||
nativeChatComposerSelection: () => undefined,
|
||||
waitForNativeChatComposerHandoff: mocks.waitForComposerMount
|
||||
}))
|
||||
vi.mock('@/components/terminal/background-terminal-worktree-mount', () => ({
|
||||
requestBackgroundTerminalWorktreeMount: vi.fn()
|
||||
}))
|
||||
|
||||
import { launchStructuredWorktreeSession } from './worktree-creation-structured-session'
|
||||
import { requestBackgroundTerminalWorktreeMount } from '@/components/terminal/background-terminal-worktree-mount'
|
||||
|
||||
const request = {
|
||||
repoId: 'repo-1',
|
||||
name: 'routing-recovery',
|
||||
setupDecision: 'run' as const,
|
||||
agent: 'codex' as const,
|
||||
agentLaunchRoute: 'structured-native-chat' as const,
|
||||
pendingFirstAgentMessageRename: true,
|
||||
note: '',
|
||||
startupPlan: null,
|
||||
quickPrompt: 'Fix the route',
|
||||
quickTelemetry: null
|
||||
}
|
||||
|
||||
const baseArgs = {
|
||||
creationId: 'creation-1',
|
||||
request,
|
||||
agentLaunchRoute: 'structured-native-chat' as const,
|
||||
worktreeId: 'worktree-1',
|
||||
shouldActivateOnCompletion: true,
|
||||
activation: false as const,
|
||||
primaryTabId: null
|
||||
}
|
||||
|
||||
function launchHandle() {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
tab: { id: 'agent-session:session-1' },
|
||||
settlement: new Promise<{ kind: 'structured'; sessionId: string }>(() => {}),
|
||||
cancel: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('launchStructuredWorktreeSession', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.waitForComposerMount.mockResolvedValue(undefined)
|
||||
clearNativeChatComposerDraftsForTests()
|
||||
mocks.state.pendingWorktreeCreations = Object.fromEntries([['creation-1', {}]])
|
||||
mocks.launchAgentInNewTabWithOutcome.mockReturnValue(null)
|
||||
mocks.listener = null
|
||||
mocks.state.activePendingCreationId = 'creation-1'
|
||||
mocks.state.activeView = 'terminal'
|
||||
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: null })
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
|
||||
args.beforeOpen?.('session-1')
|
||||
args.onTabOpenSettled?.('agent-session:session-1')
|
||||
return launchHandle()
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the normal workspace surface when no chat tab is published', async () => {
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
|
||||
args.onTabOpenSettled?.(null)
|
||||
return launchHandle()
|
||||
})
|
||||
mocks.activateAndRevealWorktree.mockReturnValue({ primaryTabId: 'fallback-terminal' })
|
||||
|
||||
await expect(launchStructuredWorktreeSession(baseArgs)).resolves.toMatchObject({
|
||||
primaryTabId: 'fallback-terminal'
|
||||
})
|
||||
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith('worktree-1', {
|
||||
providesInitialSurface: false,
|
||||
worktreeCreationId: 'creation-1'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([true, false])('recovers a held prompt after no tab, visible: %s', async (visible) => {
|
||||
mocks.state.pendingWorktreeCreations = {
|
||||
'creation-1': { phase: 'creating', heldChatMessage: 'Add a test', request }
|
||||
}
|
||||
if (!visible) {
|
||||
mocks.state.activePendingCreationId = null
|
||||
}
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
|
||||
args.onTabOpenSettled?.(null, 'layout-refused')
|
||||
return launchHandle()
|
||||
})
|
||||
mocks.launchAgentInNewTabWithOutcome.mockReturnValue({
|
||||
surface: { kind: 'local-terminal', tabId: 'recovered-terminal' }
|
||||
})
|
||||
|
||||
await expect(launchStructuredWorktreeSession(baseArgs)).resolves.toMatchObject({
|
||||
primaryTabId: 'recovered-terminal'
|
||||
})
|
||||
expect(mocks.launchAgentInNewTabWithOutcome).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: 'Add a test',
|
||||
promptDelivery: 'auto-submit',
|
||||
activate: visible,
|
||||
agentSessionLaunchPlan: expect.objectContaining({ route: 'terminal-tui' })
|
||||
})
|
||||
)
|
||||
if (!visible) {
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['workspace', 'view'] as const)(
|
||||
'preserves %s navigation while waiting for the replacement chat',
|
||||
async (navigation) => {
|
||||
let openTab: BeginArgs['onTabOpenSettled']
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
|
||||
openTab = args.onTabOpenSettled
|
||||
return launchHandle()
|
||||
})
|
||||
|
||||
const completion = launchStructuredWorktreeSession(baseArgs)
|
||||
if (navigation === 'workspace') {
|
||||
mocks.state.activePendingCreationId = null
|
||||
} else {
|
||||
mocks.state.activeView = 'tasks'
|
||||
}
|
||||
openTab?.('agent-session:session-1')
|
||||
await completion
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('cancels a launch dismissed while waiting for its chat tab', async () => {
|
||||
const launch = launchHandle()
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue(launch)
|
||||
|
||||
const completion = launchStructuredWorktreeSession(baseArgs)
|
||||
mocks.state.pendingWorktreeCreations = {}
|
||||
mocks.listener?.(mocks.state)
|
||||
|
||||
await expect(completion).resolves.toMatchObject({ cancelled: true })
|
||||
expect(launch.cancel).toHaveBeenCalled()
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not launch after the pending creation was cancelled', async () => {
|
||||
mocks.state.pendingWorktreeCreations = {}
|
||||
|
||||
await expect(launchStructuredWorktreeSession(baseArgs)).resolves.toMatchObject({
|
||||
accepted: true,
|
||||
cancelled: true,
|
||||
primaryTabId: null
|
||||
})
|
||||
expect(mocks.beginStructuredAgentSessionProvisionalLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not open a tab when cancellation races the reveal callback', async () => {
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
|
||||
const pending = mocks.state.pendingWorktreeCreations
|
||||
mocks.state.pendingWorktreeCreations = {}
|
||||
mocks.listener?.(mocks.state)
|
||||
mocks.state.pendingWorktreeCreations = pending
|
||||
const allowed = args.beforeOpen?.('session-1')
|
||||
return allowed === false ? null : launchHandle()
|
||||
})
|
||||
|
||||
await expect(launchStructuredWorktreeSession(baseArgs)).resolves.toMatchObject({
|
||||
accepted: true,
|
||||
cancelled: false,
|
||||
primaryTabId: null
|
||||
})
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('logs a chat tab that throws while opening instead of dropping it silently', async () => {
|
||||
const failure = new Error('tab store unavailable')
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation(() => {
|
||||
throw failure
|
||||
})
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await expect(launchStructuredWorktreeSession(baseArgs)).resolves.toMatchObject({
|
||||
accepted: true,
|
||||
primaryTabId: null
|
||||
})
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
'worktree create: structured chat tab failed to open',
|
||||
'worktree-1',
|
||||
failure
|
||||
)
|
||||
expect(mocks.unsubscribe).toHaveBeenCalled()
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
|
||||
it('sends what the user wrote while the worktree was created as the first message', async () => {
|
||||
mocks.state.pendingWorktreeCreations = {
|
||||
'creation-1': { phase: 'creating', heldChatMessage: 'Add a test', request }
|
||||
}
|
||||
|
||||
await launchStructuredWorktreeSession(baseArgs)
|
||||
|
||||
expect(mocks.beginStructuredAgentSessionProvisionalLaunch.mock.calls[0][0].plan).toMatchObject({
|
||||
prompt: 'Add a test',
|
||||
promptDelivery: 'auto-submit'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a linked item draft to the pending composer draft the launch moves over', async () => {
|
||||
const linked = {
|
||||
...request,
|
||||
quickPrompt: '',
|
||||
launchDraftPrompt: 'Issue #12',
|
||||
promptDelivery: 'draft' as const
|
||||
}
|
||||
mocks.state.pendingWorktreeCreations = { 'creation-1': { phase: 'creating', request: linked } }
|
||||
|
||||
await launchStructuredWorktreeSession({ ...baseArgs, request: linked })
|
||||
|
||||
expect(mocks.beginStructuredAgentSessionProvisionalLaunch.mock.calls[0][0].plan).toMatchObject({
|
||||
prompt: ''
|
||||
})
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'moves the latest unsent typing only when the replacement is published, foreground: %s',
|
||||
async (foreground) => {
|
||||
const source = 'pending-worktree-creation:creation-1'
|
||||
const destination = 'agent-session:session-1'
|
||||
let openTab: BeginArgs['onTabOpenSettled']
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
|
||||
openTab = args.onTabOpenSettled
|
||||
return launchHandle()
|
||||
})
|
||||
updateNativeChatComposerDraft(source, { text: 'Half a prompt' }, 'immediate')
|
||||
const completion = launchStructuredWorktreeSession({
|
||||
...baseArgs,
|
||||
shouldActivateOnCompletion: foreground
|
||||
})
|
||||
|
||||
expect(readNativeChatComposerDraft(source).text).toBe('Half a prompt')
|
||||
expect(readNativeChatComposerDraft(destination).text).toBe('')
|
||||
updateNativeChatComposerDraft(
|
||||
source,
|
||||
{ text: 'Half a prompt, finished while opening' },
|
||||
'immediate'
|
||||
)
|
||||
openTab?.('agent-session:session-1')
|
||||
await completion
|
||||
|
||||
expect(readNativeChatComposerDraft(destination).text).toBe(
|
||||
'Half a prompt, finished while opening'
|
||||
)
|
||||
expect(readNativeChatComposerDraft(source).text).toBe('')
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps unsent text when the replacement tab is refused', async () => {
|
||||
const source = 'pending-worktree-creation:creation-1'
|
||||
updateNativeChatComposerDraft(source, { text: 'Keep this draft' }, 'immediate')
|
||||
mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => {
|
||||
args.onTabOpenSettled?.(null, 'layout-refused')
|
||||
return launchHandle()
|
||||
})
|
||||
|
||||
await launchStructuredWorktreeSession(baseArgs)
|
||||
|
||||
expect(readNativeChatComposerDraft(source).text).toBe('Keep this draft')
|
||||
})
|
||||
|
||||
it('keeps the pending draft editable until the published chat editor mounts', async () => {
|
||||
const mounted = Promise.withResolvers<void>()
|
||||
mocks.waitForComposerMount.mockReturnValue(mounted.promise)
|
||||
const source = 'pending-worktree-creation:creation-1'
|
||||
updateNativeChatComposerDraft(source, { text: 'Before publication' }, 'immediate')
|
||||
|
||||
const completion = launchStructuredWorktreeSession(baseArgs)
|
||||
await Promise.resolve()
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(readNativeChatComposerDraft(source).text).toBe('Before publication')
|
||||
updateNativeChatComposerDraft(
|
||||
source,
|
||||
{ text: 'Still typing while the editor mounts' },
|
||||
'immediate'
|
||||
)
|
||||
expect(requestBackgroundTerminalWorktreeMount).toHaveBeenCalledWith({
|
||||
worktreeId: 'worktree-1',
|
||||
tabIds: [expect.any(String)]
|
||||
})
|
||||
mounted.resolve()
|
||||
await completion
|
||||
|
||||
expect(readNativeChatComposerDraft('agent-session:session-1').text).toBe(
|
||||
'Still typing while the editor mounts'
|
||||
)
|
||||
expect(mocks.activateAndRevealWorktree).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('finishes in the background after navigation while the replacement mounts', async () => {
|
||||
mocks.waitForComposerMount.mockImplementation(
|
||||
(_source, _destination, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => resolve(), { once: true })
|
||||
})
|
||||
)
|
||||
const completion = launchStructuredWorktreeSession(baseArgs)
|
||||
await Promise.resolve()
|
||||
mocks.state.activeView = 'tasks'
|
||||
mocks.listener?.(mocks.state)
|
||||
|
||||
await completion
|
||||
|
||||
expect(mocks.activateAndRevealWorktree).not.toHaveBeenCalled()
|
||||
expect(mocks.unsubscribe).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps both newer typing and a first message returned by a failed launch', async () => {
|
||||
updateNativeChatComposerDraft(
|
||||
'agent-session:session-1',
|
||||
{ text: 'Returned first message' },
|
||||
'immediate'
|
||||
)
|
||||
updateNativeChatComposerDraft(
|
||||
'pending-worktree-creation:creation-1',
|
||||
{ text: 'Newer follow-up' },
|
||||
'immediate'
|
||||
)
|
||||
|
||||
await launchStructuredWorktreeSession(baseArgs)
|
||||
|
||||
expect(readNativeChatComposerDraft('agent-session:session-1').text).toBe(
|
||||
'Returned first message\n\nNewer follow-up'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveWorktreeBranchLabel,
|
||||
resolveWorktreeDisplayName
|
||||
} from './worktree-default-display-name'
|
||||
|
||||
describe('resolveWorktreeBranchLabel', () => {
|
||||
it('returns empty instead of throwing when branch is absent at runtime', () => {
|
||||
// Empty-query rows reach render before branch search; branchName() must tolerate missing branches there too.
|
||||
expect(resolveWorktreeBranchLabel({ branch: undefined as unknown as string })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorktreeDisplayName', () => {
|
||||
it('falls back to the branch when a cleared name left the field undefined', () => {
|
||||
expect(
|
||||
resolveWorktreeDisplayName({
|
||||
displayName: undefined as unknown as string,
|
||||
branch: 'refs/heads/main',
|
||||
path: '/repos/orca'
|
||||
})
|
||||
).toBe('main')
|
||||
})
|
||||
|
||||
it('falls back to the folder name for a branch-less folder workspace', () => {
|
||||
expect(
|
||||
resolveWorktreeDisplayName({ displayName: '', branch: '', path: '/repos/design-review' })
|
||||
).toBe('design-review')
|
||||
})
|
||||
|
||||
it('resolves the folder name from a Windows path', () => {
|
||||
expect(
|
||||
resolveWorktreeDisplayName({
|
||||
displayName: '',
|
||||
branch: '',
|
||||
path: 'C:\\Users\\alice\\repos\\design-review'
|
||||
})
|
||||
).toBe('design-review')
|
||||
})
|
||||
|
||||
it('returns empty rather than throwing when every source is missing', () => {
|
||||
expect(
|
||||
resolveWorktreeDisplayName({
|
||||
displayName: undefined as unknown as string,
|
||||
branch: undefined as unknown as string,
|
||||
path: undefined as unknown as string
|
||||
})
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { compareWorktreeDisplayName } from './worktree-display-name-order'
|
||||
|
||||
// Persisted/discovered rows can lack required displayName (crash 99657ab1).
|
||||
function worktree(id: string, displayName: string | undefined, lastActivityAt = 0): Worktree {
|
||||
return {
|
||||
id,
|
||||
repoId: 'repo',
|
||||
path: `/tmp/${id}`,
|
||||
head: 'head',
|
||||
branch: displayName ?? id,
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: displayName as string,
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
describe('compareWorktreeDisplayName', () => {
|
||||
it('does not throw when a displayName is undefined (crash 99657ab1)', () => {
|
||||
const a = worktree('a', undefined)
|
||||
const b = worktree('b', 'Beta')
|
||||
expect(() => compareWorktreeDisplayName(a, b)).not.toThrow()
|
||||
// undefined coalesces to '' which sorts before a real name.
|
||||
expect(compareWorktreeDisplayName(a, b)).toBeLessThan(0)
|
||||
expect(compareWorktreeDisplayName(b, a)).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -1,275 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { SshConnectionStatus } from '../../../shared/ssh-types'
|
||||
import {
|
||||
resolveWorktreeHostConnection,
|
||||
selectWorktreeHostConnectionPhase
|
||||
} from './worktree-host-connection-phase'
|
||||
|
||||
function makeState(overrides: Record<string, unknown>): AppState {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the phase selector reads only the repo, worktree, runtime, and SSH fields set here.
|
||||
return {
|
||||
activeWorktreeId: null,
|
||||
detectedWorktreesByRepo: {},
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
removedSshTargetLabels: new Map(),
|
||||
repos: [],
|
||||
runtimeStatusByEnvironmentId: new Map(),
|
||||
sshConnectionStates: new Map(),
|
||||
sshStateByEnvironment: new Map(),
|
||||
sshTargetLabels: new Map(),
|
||||
sshTargetsHydrated: true,
|
||||
terminalStartupRestorationReady: true,
|
||||
worktreesByRepo: {},
|
||||
...overrides
|
||||
} as unknown as AppState
|
||||
}
|
||||
|
||||
function makeSshState(
|
||||
status: SshConnectionStatus | null,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): AppState {
|
||||
return makeState({
|
||||
repos: [{ id: 'repo-ssh', connectionId: 'ssh-a' }],
|
||||
sshConnectionStates: new Map(
|
||||
status
|
||||
? [
|
||||
[
|
||||
'ssh-a',
|
||||
{
|
||||
targetId: 'ssh-a',
|
||||
status,
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 7
|
||||
}
|
||||
]
|
||||
]
|
||||
: []
|
||||
),
|
||||
worktreesByRepo: { 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' }] },
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('selectWorktreeHostConnectionPhase', () => {
|
||||
it('reads the local deployment tunnel for a managed browser route', () => {
|
||||
const state = makeSshState('connected', {
|
||||
repos: [{ id: 'repo-ssh', executionHostId: 'runtime:env-a' }]
|
||||
})
|
||||
expect(resolveWorktreeHostConnection(state, 'wt-ssh', 'ssh-a').phase).toBe('unverifiable')
|
||||
expect(resolveWorktreeHostConnection(state, 'wt-ssh', 'ssh-a', null)).toEqual({
|
||||
phase: 'connected',
|
||||
targetId: 'ssh-a',
|
||||
environmentId: null,
|
||||
publishedStatus: 'connected',
|
||||
connectedEpoch: 'ssh-a:7'
|
||||
})
|
||||
})
|
||||
it('reports a local workspace, and no worktree, as local', () => {
|
||||
const state = makeState({
|
||||
repos: [{ id: 'repo-local' }],
|
||||
worktreesByRepo: { 'repo-local': [{ id: 'wt-local', repoId: 'repo-local' }] }
|
||||
})
|
||||
|
||||
const local = {
|
||||
phase: 'local',
|
||||
targetId: null,
|
||||
environmentId: null,
|
||||
publishedStatus: null,
|
||||
connectedEpoch: null
|
||||
}
|
||||
expect(selectWorktreeHostConnectionPhase(state, 'wt-local')).toEqual(local)
|
||||
expect(selectWorktreeHostConnectionPhase(state, null)).toEqual(local)
|
||||
})
|
||||
|
||||
it('reports a runtime-owned target as local: this client never dials it', () => {
|
||||
const state = makeState({
|
||||
repos: [{ id: 'repo-ephemeral', connectionId: 'runtime-ssh-vm-a' }],
|
||||
worktreesByRepo: { 'repo-ephemeral': [{ id: 'wt-ephemeral', repoId: 'repo-ephemeral' }] }
|
||||
})
|
||||
|
||||
expect(selectWorktreeHostConnectionPhase(state, 'wt-ephemeral').phase).toBe('local')
|
||||
})
|
||||
|
||||
it('reads an unpublished target as connecting only until startup restoration finishes', () => {
|
||||
expect(
|
||||
selectWorktreeHostConnectionPhase(
|
||||
makeSshState(null, { terminalStartupRestorationReady: false }),
|
||||
'wt-ssh'
|
||||
)
|
||||
).toMatchObject({ phase: 'connecting', targetId: 'ssh-a', publishedStatus: 'disconnected' })
|
||||
expect(selectWorktreeHostConnectionPhase(makeSshState(null), 'wt-ssh')).toMatchObject({
|
||||
phase: 'unavailable',
|
||||
targetId: 'ssh-a',
|
||||
publishedStatus: 'disconnected'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps published statuses; only a connected host names its connection', () => {
|
||||
expect(selectWorktreeHostConnectionPhase(makeSshState('connecting'), 'wt-ssh')).toEqual({
|
||||
phase: 'connecting',
|
||||
targetId: 'ssh-a',
|
||||
environmentId: null,
|
||||
publishedStatus: 'connecting',
|
||||
connectedEpoch: null
|
||||
})
|
||||
expect(selectWorktreeHostConnectionPhase(makeSshState('reconnecting'), 'wt-ssh').phase).toBe(
|
||||
'connecting'
|
||||
)
|
||||
expect(selectWorktreeHostConnectionPhase(makeSshState('deploying-relay'), 'wt-ssh').phase).toBe(
|
||||
'connecting'
|
||||
)
|
||||
expect(selectWorktreeHostConnectionPhase(makeSshState('connected'), 'wt-ssh')).toEqual({
|
||||
phase: 'connected',
|
||||
targetId: 'ssh-a',
|
||||
environmentId: null,
|
||||
publishedStatus: 'connected',
|
||||
connectedEpoch: 'ssh-a:7'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports failed and disconnected targets as unavailable once restoration is done', () => {
|
||||
for (const status of ['reconnection-failed', 'disconnected', 'auth-failed', 'error'] as const) {
|
||||
expect(selectWorktreeHostConnectionPhase(makeSshState(status), 'wt-ssh').phase).toBe(
|
||||
'unavailable'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a published failure unavailable even before restoration finishes', () => {
|
||||
expect(
|
||||
selectWorktreeHostConnectionPhase(
|
||||
makeSshState('auth-failed', { terminalStartupRestorationReady: false }),
|
||||
'wt-ssh'
|
||||
).phase
|
||||
).toBe('unavailable')
|
||||
})
|
||||
|
||||
it("reads a runtime environment's nested target from that environment's SSH state", () => {
|
||||
const state = makeState({
|
||||
repos: [{ id: 'repo-runtime', connectionId: 'ssh-nested', executionHostId: 'runtime:env-a' }],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['env-a', { status: { runtimeId: 'runtime-a' }, checkedAt: 1 }]
|
||||
]),
|
||||
sshStateByEnvironment: new Map([
|
||||
[
|
||||
'env-a',
|
||||
{
|
||||
connectionStates: new Map([
|
||||
[
|
||||
'ssh-nested',
|
||||
{
|
||||
targetId: 'ssh-nested',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 3
|
||||
}
|
||||
]
|
||||
]),
|
||||
targetLabels: new Map([['ssh-nested', 'build box']]),
|
||||
removedTargetLabels: new Map(),
|
||||
targetsHydrated: true
|
||||
}
|
||||
]
|
||||
]),
|
||||
terminalStartupRestorationReady: false,
|
||||
worktreesByRepo: {
|
||||
'repo-runtime': [
|
||||
{
|
||||
id: 'wt-runtime',
|
||||
repoId: 'repo-runtime',
|
||||
hostId: 'runtime:env-a',
|
||||
runtimeOwnerEnvironmentId: 'env-a'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(selectWorktreeHostConnectionPhase(state, 'wt-runtime')).toEqual({
|
||||
phase: 'connected',
|
||||
targetId: 'ssh-nested',
|
||||
environmentId: 'env-a',
|
||||
publishedStatus: 'connected',
|
||||
connectedEpoch: 'ssh-nested:3'
|
||||
})
|
||||
})
|
||||
|
||||
it("reads a paired server's folder workspace on its nested target like a project there", () => {
|
||||
const state = makeState({
|
||||
// As fetched from the server: its own SSH target, stamped with the server's host.
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'fw-runtime',
|
||||
projectGroupId: 'group-runtime',
|
||||
folderPath: '/srv/notes',
|
||||
connectionId: 'ssh-nested',
|
||||
executionHostId: 'runtime:env-a'
|
||||
}
|
||||
],
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'group-runtime',
|
||||
kind: 'folder',
|
||||
connectionId: 'ssh-nested',
|
||||
executionHostId: 'runtime:env-a'
|
||||
}
|
||||
],
|
||||
runtimeStatusByEnvironmentId: new Map([
|
||||
['env-a', { status: { runtimeId: 'runtime-a' }, checkedAt: 1 }]
|
||||
])
|
||||
})
|
||||
|
||||
expect(selectWorktreeHostConnectionPhase(state, 'folder:fw-runtime')).toMatchObject({
|
||||
phase: 'unverifiable',
|
||||
targetId: 'ssh-nested',
|
||||
environmentId: 'env-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('names a new connection on every reconnect', () => {
|
||||
const connected = makeSshState('connected')
|
||||
const reconnected = makeSshState('connected', {
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'ssh-a',
|
||||
{
|
||||
targetId: 'ssh-a',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0,
|
||||
connectionGeneration: 8
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
expect(selectWorktreeHostConnectionPhase(reconnected, 'wt-ssh').connectedEpoch).not.toBe(
|
||||
selectWorktreeHostConnectionPhase(connected, 'wt-ssh').connectedEpoch
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a nested target whose runtime is unreachable as unverifiable, not down', () => {
|
||||
const state = makeState({
|
||||
repos: [{ id: 'repo-runtime', connectionId: 'ssh-nested', executionHostId: 'runtime:env-a' }],
|
||||
worktreesByRepo: {
|
||||
'repo-runtime': [
|
||||
{
|
||||
id: 'wt-runtime',
|
||||
repoId: 'repo-runtime',
|
||||
hostId: 'runtime:env-a',
|
||||
runtimeOwnerEnvironmentId: 'env-a'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(selectWorktreeHostConnectionPhase(state, 'wt-runtime')).toMatchObject({
|
||||
phase: 'unverifiable',
|
||||
targetId: 'ssh-nested',
|
||||
publishedStatus: null
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
|
||||
import {
|
||||
createMockStore,
|
||||
registerWorktreeActivationReset
|
||||
} from './worktree-activation.test-fixture'
|
||||
import {
|
||||
CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS,
|
||||
type ClosedTerminalTabTombstonesByTabId
|
||||
} from '../../../shared/closed-terminal-tab-tombstones'
|
||||
|
||||
registerWorktreeActivationReset()
|
||||
|
||||
const WT = 'wt-1'
|
||||
|
||||
/** Startup hydration's seeding decision: no reseed opt-in, so only the row and records decide. */
|
||||
function seeds(
|
||||
tabsByWorktree: Record<string, { id: string }[]>,
|
||||
closedTerminalTabTombstonesByTabId?: ClosedTerminalTabTombstonesByTabId,
|
||||
opts?: { reseedEmptiedWorkspace?: boolean }
|
||||
): boolean {
|
||||
const store = createMockStore({ tabsByWorktree, closedTerminalTabTombstonesByTabId })
|
||||
ensureWorktreeHasInitialTerminal(store, WT, undefined, undefined, undefined, undefined, opts)
|
||||
return store.createTab.mock.calls.length > 0
|
||||
}
|
||||
|
||||
function closedAt(closedAtMs: number): ClosedTerminalTabTombstonesByTabId {
|
||||
return { 'closed-tab': { closedAt: closedAtMs, worktreeId: WT, reason: 'user' } }
|
||||
}
|
||||
|
||||
describe('seeding an empty workspace reads close records', () => {
|
||||
it('an empty row whose only record is past its TTL seeds', () => {
|
||||
expect(
|
||||
seeds({ [WT]: [] }, closedAt(Date.now() - CLOSED_TERMINAL_TAB_TOMBSTONE_TTL_MS - 1))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// Known gap: an older close plus a later non-close empty row still reads as deliberately emptied.
|
||||
it('a stale record from an earlier close suppresses the startup seed; activation reseeds', () => {
|
||||
const earlierClose = closedAt(Date.now() - 60_000)
|
||||
expect(seeds({ [WT]: [] }, earlierClose)).toBe(false)
|
||||
expect(seeds({ [WT]: [] }, earlierClose, { reseedEmptiedWorkspace: true })).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,10 @@
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
|
||||
import '../../../shared/layout-owner-switch'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
|
||||
import {
|
||||
indexLiveTerminalSurfaceOwners,
|
||||
readWorktreeLiveTerminalSurfaceOwners
|
||||
} from './worktree-live-terminal-surface-owners'
|
||||
import { indexLiveTerminalSurfaceOwners } from './worktree-live-terminal-surface-owners'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function summary(overrides: Partial<RuntimeTerminalSummary>): RuntimeTerminalSummary {
|
||||
return {
|
||||
@@ -28,14 +24,6 @@ function summary(overrides: Partial<RuntimeTerminalSummary>): RuntimeTerminalSum
|
||||
}
|
||||
}
|
||||
|
||||
function stubTerminalList(result: unknown) {
|
||||
const call = vi.fn(async () => ({ ok: true, result }))
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtime: { call } }
|
||||
})
|
||||
return call
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
@@ -51,74 +39,6 @@ describe('live terminal surface owners', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('records explicit live orphan evidence for a recovery tab', () => {
|
||||
const ptyId = `${WORKTREE_ID}@@orphan`
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[
|
||||
summary({
|
||||
ptyId,
|
||||
orphaned: true,
|
||||
tabId: `pty:${ptyId}`,
|
||||
leafId: `pty:${ptyId}`
|
||||
})
|
||||
],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.get(ptyId)).toEqual({ unowned: true, recorded: null })
|
||||
})
|
||||
|
||||
it('carries the pane the host last recorded for a live orphan', () => {
|
||||
const ptyId = `${WORKTREE_ID}@@orphan`
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[
|
||||
summary({
|
||||
ptyId,
|
||||
orphaned: true,
|
||||
tabId: `pty:${ptyId}`,
|
||||
leafId: `pty:${ptyId}`,
|
||||
recordedPaneKey: `tab-live:${LEAF_ID}`
|
||||
})
|
||||
],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.get(ptyId)).toEqual({
|
||||
unowned: true,
|
||||
recorded: { paneKey: `tab-live:${LEAF_ID}`, ptyId, tabId: 'tab-live' }
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a recorded pane that is not a terminal pane key', () => {
|
||||
const ptyId = `${WORKTREE_ID}@@orphan`
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[summary({ ptyId, orphaned: true, recordedPaneKey: 'tab-live:not-a-leaf' })],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.get(ptyId)).toEqual({ unowned: true, recorded: null })
|
||||
})
|
||||
|
||||
it('does not authorize adoption of a disconnected orphan', () => {
|
||||
const ptyId = `${WORKTREE_ID}@@orphan`
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[summary({ ptyId, orphaned: true, connected: false })],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.get(ptyId)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not infer orphan ownership from a legacy synthetic surface', () => {
|
||||
const ptyId = `${WORKTREE_ID}@@orphan`
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[summary({ ptyId, tabId: `pty:${ptyId}`, leafId: `pty:${ptyId}` })],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.get(ptyId)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'rejects conflicting owned and orphan rows (orphan first: %s)',
|
||||
(orphanFirst) => {
|
||||
@@ -132,110 +52,4 @@ describe('live terminal surface owners', () => {
|
||||
expect(owners.get(owned.ptyId!)).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('ignores rows belonging to another workspace', () => {
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[summary({ worktreeId: 'repo::/other' })],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('indexes a row the host spelled with an equivalent workspace path', () => {
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[summary({ worktreeId: `${WORKTREE_ID}/` })],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' })
|
||||
})
|
||||
|
||||
it('reports a PTY claimed by two panes as unverifiable rather than unowned', () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-agent`
|
||||
const owners = indexLiveTerminalSurfaceOwners(
|
||||
[summary({}), summary({ handle: 'term-2', leafId: OTHER_LEAF_ID })],
|
||||
WORKTREE_ID
|
||||
)
|
||||
|
||||
expect(owners.has(ptyId)).toBe(true)
|
||||
expect(owners.get(ptyId)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports an unaddressable surface as unverifiable rather than unowned', () => {
|
||||
const ptyId = `${WORKTREE_ID}@@live-agent`
|
||||
const owners = indexLiveTerminalSurfaceOwners([summary({ leafId: 'legacy-0' })], WORKTREE_ID)
|
||||
|
||||
expect(owners.has(ptyId)).toBe(true)
|
||||
expect(owners.get(ptyId)).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a census whose own execution host never answered', async () => {
|
||||
stubTerminalList({
|
||||
terminals: [],
|
||||
truncated: false,
|
||||
hostScope: { hostIds: [], omittedHostIds: ['local', 'ssh:box'] }
|
||||
})
|
||||
|
||||
await expect(readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('indexes a scoped census that omits the hosts of other workspaces', async () => {
|
||||
stubTerminalList({
|
||||
terminals: [summary({})],
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: ['ssh:box-1'] }
|
||||
})
|
||||
|
||||
const owners = await readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID)
|
||||
|
||||
expect(owners?.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' })
|
||||
})
|
||||
|
||||
it('refuses a census from a host that cannot name the scope it answered for', async () => {
|
||||
stubTerminalList({ terminals: [summary({})], truncated: false })
|
||||
|
||||
await expect(readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('refuses a truncated census', async () => {
|
||||
stubTerminalList({
|
||||
terminals: [summary({})],
|
||||
truncated: true,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
})
|
||||
|
||||
await expect(readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('indexes a complete census', async () => {
|
||||
const call = stubTerminalList({
|
||||
terminals: [summary({})],
|
||||
truncated: false,
|
||||
hostScope: { hostIds: ['local'], omittedHostIds: [] }
|
||||
})
|
||||
|
||||
const owners = await readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID)
|
||||
|
||||
expect(owners?.get(`${WORKTREE_ID}@@live-agent`)).toMatchObject({ tabId: 'tab-live' })
|
||||
expect(call).toHaveBeenCalledWith({
|
||||
method: 'terminal.list',
|
||||
params: {
|
||||
worktree: `id:${WORKTREE_ID}`,
|
||||
limit: 200,
|
||||
requireFreshPtyLiveness: true,
|
||||
includeVisualLayouts: false,
|
||||
// Layout-owner build: main places the workspace's unshown live terminals first.
|
||||
...(ORCA_LAYOUT_OWNER_IS_MAIN ? { placeUnshownLiveTerminals: true } : {})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a census the host could not answer', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
api: { runtime: { call: vi.fn(async () => ({ ok: false, error: { message: 'nope' } })) } }
|
||||
})
|
||||
|
||||
await expect(readWorktreeLiveTerminalSurfaceOwners(WORKTREE_ID)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertWorktreeOperationGenerationSnapshotCurrent,
|
||||
captureWorktreeOperationGenerationSnapshot
|
||||
} from './worktree-operation-generation'
|
||||
import {
|
||||
resolveWorktreeOperationRoute,
|
||||
type WorktreeOperationRoute
|
||||
} from './worktree-operation-route'
|
||||
|
||||
const WORKTREE_ID = 'repo::/worktree'
|
||||
const LOCAL_ROUTE: WorktreeOperationRoute = {
|
||||
executionHostId: 'local',
|
||||
runtimeEnvironmentId: null
|
||||
}
|
||||
|
||||
describe('worktree operation generation', () => {
|
||||
it('treats an explicit null current route as stale instead of falling back', () => {
|
||||
const state = {
|
||||
repos: [{ id: 'repo', connectionId: null, executionHostId: 'local' as const }],
|
||||
worktreesByRepo: {
|
||||
repo: [{ id: WORKTREE_ID, repoId: 'repo', hostId: 'local' as const }]
|
||||
}
|
||||
}
|
||||
expect(resolveWorktreeOperationRoute(state, WORKTREE_ID)).toEqual(LOCAL_ROUTE)
|
||||
|
||||
expect(() =>
|
||||
assertWorktreeOperationGenerationSnapshotCurrent(
|
||||
() => state,
|
||||
WORKTREE_ID,
|
||||
captureWorktreeOperationGenerationSnapshot(LOCAL_ROUTE),
|
||||
() => new Error('owner changed'),
|
||||
() => null
|
||||
)
|
||||
).toThrow('owner changed')
|
||||
})
|
||||
})
|
||||
@@ -34,32 +34,6 @@ describe('resolveWorktreeOperationRouteResult', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the HUB owner from its repo for a mixed-version SSH publication', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
connectionId: 'hub-private-target',
|
||||
executionHostId: 'runtime:hub-a'
|
||||
}
|
||||
],
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-1': { worktrees: [worktree('ssh:hub-private-target')] }
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: {
|
||||
executionHostId: 'ssh:hub-private-target',
|
||||
runtimeEnvironmentId: 'hub-a'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the same SSH worktree is projected by two HUBs', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
@@ -77,200 +51,6 @@ describe('resolveWorktreeOperationRouteResult', () => {
|
||||
).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('resolves a host-stamped SSH worktree when its project also exists locally (#10634)', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [
|
||||
{ id: 'repo-1', executionHostId: 'local' },
|
||||
{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [worktree('ssh:ssh-1')]
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('deduplicates identical projections from the same HUB', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
worktree('ssh:same-private-target', 'hub-a'),
|
||||
worktree('ssh:same-private-target', 'hub-a')
|
||||
]
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: {
|
||||
executionHostId: 'ssh:same-private-target',
|
||||
runtimeEnvironmentId: 'hub-a'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('routes an unstamped row local, never to the focused runtime', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'legacy-hub' } as never,
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a legacy publication closed when more than one runtime could own it', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-b' } as never,
|
||||
runtimeEnvironments: [{ id: 'hub-a' } as never, { id: 'hub-b' } as never],
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('fails an unknown stale worktree closed instead of routing it locally', () => {
|
||||
expect(resolveWorktreeOperationRouteResult({}, WORKTREE_ID)).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('routes the reported unstamped local shape with one saved runtime', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'saved-runtime' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
settings: { activeRuntimeEnvironmentId: null } as never,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] },
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-1': { worktrees: [worktree(undefined)] }
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores the number of unrelated saved runtimes for unstamped local identity', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'runtime-a' }, { id: 'runtime-b' }, { id: 'runtime-c' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
settings: { activeRuntimeEnvironmentId: null } as never,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] },
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-1': { worktrees: [worktree(undefined)] }
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat a repo row alone as positive local identity', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'saved-runtime' }],
|
||||
runtimeEnvironmentCatalogHydrated: true
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('fails a paired-client ownerless stale publication closed instead of routing it locally', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] },
|
||||
runtimeEnvironments: [{ id: 'saved-runtime' }],
|
||||
runtimeEnvironmentCatalogHydrated: true
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('keeps an unstamped row ambiguous when another owner names a different host', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'saved-runtime' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: {
|
||||
'repo-1': [worktree('local'), worktree('runtime:hub-a')]
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('never routes an unstamped row to the single saved, focused runtime', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-a' } as never,
|
||||
runtimeEnvironments: [{ id: 'hub-a' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('ignores unrelated removed-runtime tombstones for positive local identity', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'saved-runtime' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
removedRuntimeEnvironmentIds: new Set(['removed-runtime']),
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] },
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-1': { worktrees: [worktree(undefined)] }
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('fails ownerless rows closed mid-hydration while a saved runtime could own them', () => {
|
||||
// No repo/active-runtime evidence can decide first: only the unhydrated saved-runtime catalog fails this closed. Marking it hydrated routes local via the detected row; adding other evidence would leave this branch uncovered.
|
||||
expect(
|
||||
@@ -288,425 +68,6 @@ describe('resolveWorktreeOperationRouteResult', () => {
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('keeps an unstamped row local while the saved-runtime catalog is loading, focus or not', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-b' } as never,
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: false,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves ownerless local compatibility after an empty catalog hydrates', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] },
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-1': { worktrees: [worktree(undefined)] }
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves ownerless local compatibility before detected scan with no saved runtimes', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('fails an unknown stale worktree closed instead of routing it through focus', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-a' } as never,
|
||||
runtimeEnvironments: [{ id: 'hub-a' } as never]
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('does not let legacy runtime focus override explicit local ownership', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-hub' } as never,
|
||||
worktreesByRepo: { 'repo-1': [worktree('local')] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
// Unstamped legacy repo rows prove local host ownership (#16733), matching the folder carve-out (#10251/#10269). They do not prove worktree identity: require a listed/detected worktree too (#16841).
|
||||
describe('local git worktrees without a host stamp (#16733)', () => {
|
||||
const LOCAL_ROUTE = {
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
}
|
||||
|
||||
it('keeps a local worktree local when an unrelated runtime is saved', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual(LOCAL_ROUTE)
|
||||
})
|
||||
|
||||
it('keeps a local worktree local when several unrelated runtimes are saved', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'a' }, { id: 'b' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual(LOCAL_ROUTE)
|
||||
})
|
||||
|
||||
it('keeps a local worktree local while the saved-runtime catalog is still hydrating', () => {
|
||||
// Repo stamps prove host ownership; an unhydrated runtime catalog cannot turn unstamped local rows remote.
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: false,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual(LOCAL_ROUTE)
|
||||
})
|
||||
|
||||
it('keeps a local worktree local after an unrelated runtime has been removed', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
removedRuntimeEnvironmentIds: new Set(['gone-hub']),
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual(LOCAL_ROUTE)
|
||||
})
|
||||
|
||||
it('still routes a local worktree local when no runtime is saved', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual(LOCAL_ROUTE)
|
||||
})
|
||||
|
||||
it('still routes a local worktree local for adapters with no runtime catalog at all', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual(LOCAL_ROUTE)
|
||||
})
|
||||
|
||||
it('never routes a connection-owned repo local when runtimes are saved', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1', connectionId: 'ssh-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('never routes a runtime-stamped repo local when runtimes are saved', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1', executionHostId: 'runtime:hub-a' } as never],
|
||||
runtimeEnvironments: [{ id: 'hub-a' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a host-stamped worktree row authoritative over its unstamped repo', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree('ssh:ssh-1')] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('fails a worktree closed when no repo row and no worktree row know it', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'other' } as never],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('keeps an unstamped row local whichever of several runtimes is focused', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-b' } as never,
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'hub-a' }, { id: 'hub-b' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an unstamped row local when the single saved runtime is focused', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-a' } as never,
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'hub-a' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses local when a second repo row for the id is connection-owned', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never, { id: 'repo-1', connectionId: 'ssh-1' } as never],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes an unstamped row of a project shared with a paired server here (#25467)', () => {
|
||||
// Listings stamp every paired-server row, so the unstamped row was listed on this machine.
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [
|
||||
{ id: 'repo-1', executionHostId: 'local' },
|
||||
{ id: 'repo-1', executionHostId: 'runtime:hub-a' }
|
||||
],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('reports contradictory paired-server repo rows as ambiguous rather than defaulting to local', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [
|
||||
{ id: 'repo-1', executionHostId: 'runtime:hub-b' },
|
||||
{ id: 'repo-1', executionHostId: 'runtime:hub-a' }
|
||||
],
|
||||
runtimeEnvironments: [{ id: 'some-hub' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('active workspace host selection', () => {
|
||||
it('keeps the HUB transport when the paired SSH worktree is the active workspace', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'ssh:hub-private-target',
|
||||
worktreesByRepo: { 'repo-1': [worktree('ssh:hub-private-target', 'hub-a')] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' }
|
||||
})
|
||||
})
|
||||
|
||||
it('recovers the HUB transport from the repo for an active mixed-version SSH publication', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'ssh:hub-private-target',
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
connectionId: 'hub-private-target',
|
||||
executionHostId: 'runtime:hub-a'
|
||||
}
|
||||
],
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-1': { worktrees: [worktree('ssh:hub-private-target')] }
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:hub-private-target', runtimeEnvironmentId: 'hub-a' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the selected host authoritative when the same ID exists on two hosts', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'runtime:hub-a',
|
||||
worktreesByRepo: { 'repo-1': [worktree('local'), worktree('runtime:hub-a')] }
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' }
|
||||
})
|
||||
})
|
||||
|
||||
// A transport-less route would go over this app's own SSH connection (review-5 #2).
|
||||
it('refuses when two HUBs project the active SSH worktree', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'ssh:same-private-target',
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
worktree('ssh:same-private-target', 'hub-a'),
|
||||
worktree('ssh:same-private-target', 'hub-b')
|
||||
]
|
||||
}
|
||||
},
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({ kind: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('keeps an unknown active worktree on its selected host', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{ activeWorktreeId: WORKTREE_ID, activeWorkspaceExecutionHostId: 'ssh:ssh-1' },
|
||||
WORKTREE_ID
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder workspaces', () => {
|
||||
const FOLDER_WORKSPACE_ID = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||
const FOLDER_KEY = `folder:${FOLDER_WORKSPACE_ID}`
|
||||
@@ -720,87 +81,6 @@ describe('resolveWorktreeOperationRouteResult', () => {
|
||||
}
|
||||
}
|
||||
|
||||
it('routes a local folder workspace to the local runtime (#10251)', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
repos: [{ id: 'repo-1' } as never],
|
||||
folderWorkspaces: [folderWorkspace()],
|
||||
projectGroups: [{ id: 'group-1', connectionId: null, executionHostId: null } as never],
|
||||
runtimeEnvironments: [],
|
||||
runtimeEnvironmentCatalogHydrated: true
|
||||
},
|
||||
FOLDER_KEY
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves SSH ownership for a connected folder workspace', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{ folderWorkspaces: [folderWorkspace('ssh-target-1')] },
|
||||
FOLDER_KEY
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'ssh:ssh-target-1', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a runtime-owned project group folder workspace to its runtime', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
folderWorkspaces: [folderWorkspace()],
|
||||
projectGroups: [
|
||||
{ id: 'group-1', connectionId: null, executionHostId: 'runtime:hub-a' } as never
|
||||
]
|
||||
},
|
||||
FOLDER_KEY
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps an ownerless folder workspace local when the single saved runtime is focused', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-a' } as never,
|
||||
runtimeEnvironments: [{ id: 'hub-a' }],
|
||||
folderWorkspaces: [folderWorkspace()]
|
||||
},
|
||||
FOLDER_KEY
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a local folder workspace local when unrelated runtimes exist (#10251)', () => {
|
||||
const state = {
|
||||
folderWorkspaces: [folderWorkspace()],
|
||||
projectGroups: [{ id: 'group-1', connectionId: null, executionHostId: null } as never],
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-a' } as never,
|
||||
runtimeEnvironments: [{ id: 'hub-a' }, { id: 'hub-b' }],
|
||||
runtimeEnvironmentCatalogHydrated: true
|
||||
}
|
||||
expect(resolveWorktreeOperationRouteResult(state, FOLDER_KEY)).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'local', runtimeEnvironmentId: null }
|
||||
})
|
||||
// Folder records prove identity, so route where unknown worktrees in the same multi-runtime state fail closed.
|
||||
expect(resolveWorktreeOperationRouteResult(state, 'repo-x::/tmp/unknown')).toEqual({
|
||||
kind: 'missing'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes a folder workspace and a git worktree alike in one local state (#16733)', () => {
|
||||
// Local folders and git worktrees must route alike; #10269 fixed only folders, leaving the same-state git case missing.
|
||||
const state = {
|
||||
@@ -818,24 +98,5 @@ describe('resolveWorktreeOperationRouteResult', () => {
|
||||
expect(resolveWorktreeOperationRouteResult(state, FOLDER_KEY)).toEqual(localRoute)
|
||||
expect(resolveWorktreeOperationRouteResult(state, WORKTREE_ID)).toEqual(localRoute)
|
||||
})
|
||||
|
||||
it('routes a folder workspace to its restored runtime host during catalog hydration', () => {
|
||||
expect(
|
||||
resolveWorktreeOperationRouteResult(
|
||||
{
|
||||
folderWorkspaces: [folderWorkspace()],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: { [FOLDER_KEY]: 'runtime:hub-a' }
|
||||
},
|
||||
FOLDER_KEY
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'resolved',
|
||||
route: { executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' }
|
||||
})
|
||||
})
|
||||
|
||||
it('fails an unknown folder workspace id closed', () => {
|
||||
expect(resolveWorktreeOperationRouteResult({}, FOLDER_KEY)).toEqual({ kind: 'missing' })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorktreeOperationOwnerRecord } from './worktree-operation-route'
|
||||
import { worktreeOwnerRowsTokenOf } from './worktree-owner-rows-token'
|
||||
|
||||
const a: WorktreeOperationOwnerRecord = { id: 'a', repoId: 'r1', hostId: 'ssh:one' }
|
||||
const b: WorktreeOperationOwnerRecord = { id: 'b', repoId: 'r1' }
|
||||
|
||||
describe('worktreeOwnerRowsTokenOf', () => {
|
||||
it('keeps the token across non-owner patches and changes it for owner changes', () => {
|
||||
const base = worktreeOwnerRowsTokenOf({ r1: [a, b] })
|
||||
expect(worktreeOwnerRowsTokenOf({ r1: [{ ...a }, b] })).toBe(base)
|
||||
expect(worktreeOwnerRowsTokenOf({ r1: [a, { ...b, hostId: undefined }] })).toBe(base)
|
||||
expect(worktreeOwnerRowsTokenOf({ r1: [{ ...a, hostId: 'ssh:two' }, b] })).not.toBe(base)
|
||||
})
|
||||
|
||||
it('treats a row reorder as an owner change', () => {
|
||||
const base = worktreeOwnerRowsTokenOf({ r1: [a, b] })
|
||||
expect(worktreeOwnerRowsTokenOf({ r1: [b, a] })).not.toBe(base)
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import { worktreeMatchesGitHubNumber } from './github-work-item-workspace-attachment'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { folderWorkspaceToWorktree } from '../../../shared/folder-workspace-worktree'
|
||||
import { makeFolderWorkspace } from '@/components/worktree-jump/worktree-jump-palette-test-fixtures'
|
||||
import {
|
||||
referenceWorkspace,
|
||||
referenceRepo
|
||||
} from '@/components/sidebar/workspace-reference-fixtures.test-support'
|
||||
import {
|
||||
matchWorktreePaletteTaskUrl,
|
||||
parseCmdJTaskSourceUrl
|
||||
} from './worktree-palette-task-url-match'
|
||||
|
||||
describe('multiple workspace task attachments', () => {
|
||||
it.each([
|
||||
{ provider: 'github', type: 'pr', number: 42, url: 'https://github.com/acme/orca/pull/42' },
|
||||
{
|
||||
provider: 'gitlab',
|
||||
type: 'mr',
|
||||
number: 42,
|
||||
url: 'https://gitlab.com/team/orca/-/merge_requests/42'
|
||||
},
|
||||
{
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
identifier: 'STA-42',
|
||||
url: 'https://linear.app/acme/issue/STA-42/title'
|
||||
},
|
||||
{
|
||||
provider: 'jira',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
identifier: 'STA-42',
|
||||
url: 'https://acme.atlassian.net/browse/STA-42'
|
||||
}
|
||||
] as const)('matches secondary $provider links by their full URL', (attachment) => {
|
||||
const intent = parseCmdJTaskSourceUrl(attachment.url)
|
||||
if (!intent) {
|
||||
throw new Error('Fixture URL should parse')
|
||||
}
|
||||
const worktree = { ...referenceWorkspace, linkedPR: 1, linkedItems: [attachment] }
|
||||
expect(matchWorktreePaletteTaskUrl({ worktree, intent })).not.toBeNull()
|
||||
})
|
||||
it('does not match a same-number review in another repository', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/other/project/pull/42')
|
||||
if (!intent) {
|
||||
throw new Error('Fixture URL should parse')
|
||||
}
|
||||
const worktree: Worktree = {
|
||||
...referenceWorkspace,
|
||||
linkedItems: [
|
||||
{ provider: 'github', type: 'pr', number: 42, url: 'https://github.com/acme/orca/pull/42' }
|
||||
]
|
||||
}
|
||||
expect(matchWorktreePaletteTaskUrl({ worktree, intent })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('bare GitHub number activation', () => {
|
||||
it.each([
|
||||
{ repoId: 'source-repo' },
|
||||
{ url: 'https://github.com/acme/product/issues/42' },
|
||||
{ repoId: 'source-repo', url: 'https://github.example.com/acme/product/pull/42' }
|
||||
])('matches an explicitly attached folder task with source metadata: %j', (source) => {
|
||||
const worktree = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
linkedItems: [{ provider: 'github', type: 'issue', number: 42, ...source }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(worktreeMatchesGitHubNumber(worktree, 42, undefined)).toBe(true)
|
||||
expect(worktreeMatchesGitHubNumber(worktree, 43, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps other providers out of folder GitHub-number matching', () => {
|
||||
const worktree = folderWorkspaceToWorktree(
|
||||
makeFolderWorkspace({
|
||||
linkedItems: [{ provider: 'gitlab', type: 'issue', number: 42 }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(worktreeMatchesGitHubNumber(worktree, 42, undefined)).toBe(false)
|
||||
})
|
||||
it.each([
|
||||
{ repoId: 'foreign' },
|
||||
{ url: 'https://github.com/foreign/project/pull/42' },
|
||||
{ repoId: 'repo', url: 'https://github.com/foreign/project/issues/42' },
|
||||
{ url: 'https://github.example.com/acme/orca/pull/42' }
|
||||
])('rejects foreign references even without a repo id: %j', (scope) => {
|
||||
const worktree: Worktree = {
|
||||
...referenceWorkspace,
|
||||
linkedItems: [{ provider: 'github', type: 'pr', number: 42, ...scope }]
|
||||
}
|
||||
expect(worktreeMatchesGitHubNumber(worktree, 42, referenceRepo)).toBe(false)
|
||||
})
|
||||
it('matches secondary local references and legacy bare numbers', () => {
|
||||
const worktree: Worktree = {
|
||||
...referenceWorkspace,
|
||||
linkedItems: [
|
||||
{ provider: 'github', type: 'pr', number: 42, url: 'https://github.com/acme/orca/pull/42' }
|
||||
]
|
||||
}
|
||||
expect(worktreeMatchesGitHubNumber(worktree, 42, referenceRepo)).toBe(true)
|
||||
expect(worktreeMatchesGitHubNumber(referenceWorkspace, 7, referenceRepo)).toBe(true)
|
||||
expect(worktreeMatchesGitHubNumber(worktree, 42, undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,17 +10,6 @@ function expectContiguousSlice(text: string, comment: string): void {
|
||||
}
|
||||
|
||||
describe('extractWorktreePaletteCommentSnippet', () => {
|
||||
it('highlights the match inside the returned text', () => {
|
||||
const comment = 'the reconnect work is blocked on infra'
|
||||
const matchStart = comment.indexOf('infra')
|
||||
const { text, matchRange } = extractWorktreePaletteCommentSnippet(
|
||||
comment,
|
||||
matchStart,
|
||||
matchStart + 'infra'.length
|
||||
)
|
||||
expect(text.slice(matchRange.start, matchRange.end)).toBe('infra')
|
||||
})
|
||||
|
||||
// CJK exhausts both 10-step whitespace scans; sweep emoji across cuts to cover either split surrogate half.
|
||||
it('never splits a surrogate pair at the leading edge', () => {
|
||||
let keptEmoji = 0
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CREATE_WORKTREE_ITEM_ID,
|
||||
createWorktreePaletteRequestGuard,
|
||||
getNextWorktreePaletteSelection,
|
||||
getWorktreePaletteSelectionItemIds,
|
||||
getWorktreePaletteCreateActionState,
|
||||
isWorktreePaletteCreateActivationAllowed
|
||||
} from './worktree-palette-create-action'
|
||||
|
||||
describe('worktree-palette-create-action', () => {
|
||||
it('shows create for typed queries with workspace matches but selects the first workspace row', () => {
|
||||
const state = getWorktreePaletteCreateActionState({
|
||||
query: 'feature'
|
||||
})
|
||||
|
||||
expect(state).toEqual({
|
||||
createWorktreeName: 'feature',
|
||||
showCreateAction: true
|
||||
})
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: ['worktree:one', CREATE_WORKTREE_ITEM_ID, 'settings:provider'],
|
||||
showCreateAction: state.showCreateAction
|
||||
})
|
||||
).toBe('worktree:one')
|
||||
})
|
||||
|
||||
it('skips create for free text even when it is listed before every other row', () => {
|
||||
const state = getWorktreePaletteCreateActionState({
|
||||
query: 'opencode-issue'
|
||||
})
|
||||
|
||||
expect(state.showCreateAction).toBe(true)
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: [
|
||||
CREATE_WORKTREE_ITEM_ID,
|
||||
'settings:ai-provider-accounts',
|
||||
'quick-action:new-terminal',
|
||||
'browser-page:one'
|
||||
],
|
||||
showCreateAction: state.showCreateAction
|
||||
})
|
||||
).toBe('settings:ai-provider-accounts')
|
||||
})
|
||||
|
||||
it('selects create ahead of other rows only for a recognized task URL', () => {
|
||||
const selectableItemIds = [CREATE_WORKTREE_ITEM_ID, 'browser-page:one']
|
||||
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds,
|
||||
showCreateAction: true
|
||||
})
|
||||
).toBe('browser-page:one')
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds,
|
||||
showCreateAction: true,
|
||||
autoSelectCreateAction: true
|
||||
})
|
||||
).toBe(CREATE_WORKTREE_ITEM_ID)
|
||||
})
|
||||
|
||||
it('leaves Enter unarmed for typed queries with no real matches', () => {
|
||||
const state = getWorktreePaletteCreateActionState({
|
||||
query: 'new-workspace'
|
||||
})
|
||||
|
||||
expect(state).toEqual({ createWorktreeName: 'new-workspace', showCreateAction: true })
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: [],
|
||||
showCreateAction: state.showCreateAction
|
||||
})
|
||||
).toBe('')
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: [],
|
||||
showCreateAction: state.showCreateAction,
|
||||
autoSelectCreateAction: true
|
||||
})
|
||||
).toBe(CREATE_WORKTREE_ITEM_ID)
|
||||
})
|
||||
|
||||
it('returns empty selection when no create action or rows are available', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: [],
|
||||
showCreateAction: false
|
||||
})
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('falls back to the first row when render-time selection state is empty', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: false,
|
||||
selectableItemIds: ['worktree:first', 'browser-page:second'],
|
||||
showCreateAction: true
|
||||
})
|
||||
).toBe('worktree:first')
|
||||
})
|
||||
|
||||
it('does not keep create selected after the create row disappears', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: CREATE_WORKTREE_ITEM_ID,
|
||||
queryChanged: false,
|
||||
selectableItemIds: ['settings:ai-provider-accounts'],
|
||||
showCreateAction: false
|
||||
})
|
||||
).toBe('settings:ai-provider-accounts')
|
||||
})
|
||||
|
||||
it('moves selection back to the first real row when the query changes after manual create selection', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: CREATE_WORKTREE_ITEM_ID,
|
||||
queryChanged: true,
|
||||
selectableItemIds: ['worktree:match'],
|
||||
showCreateAction: true
|
||||
})
|
||||
).toBe('worktree:match')
|
||||
})
|
||||
|
||||
it('preserves manual create selection during non-query churn while create remains visible', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: CREATE_WORKTREE_ITEM_ID,
|
||||
queryChanged: false,
|
||||
selectableItemIds: ['worktree:match'],
|
||||
showCreateAction: true
|
||||
})
|
||||
).toBe(CREATE_WORKTREE_ITEM_ID)
|
||||
})
|
||||
|
||||
it('hides create for an empty query', () => {
|
||||
expect(
|
||||
getWorktreePaletteCreateActionState({
|
||||
query: ' '
|
||||
}).showCreateAction
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides create for oversized pasted queries without echoing the payload', () => {
|
||||
const oversizedQuery = 'a'.repeat(2049)
|
||||
expect(getWorktreePaletteCreateActionState({ query: 'a'.repeat(2048) })).toEqual({
|
||||
createWorktreeName: 'a'.repeat(2048),
|
||||
showCreateAction: true
|
||||
})
|
||||
|
||||
expect(
|
||||
getWorktreePaletteCreateActionState({
|
||||
query: oversizedQuery
|
||||
})
|
||||
).toEqual({
|
||||
createWorktreeName: '',
|
||||
showCreateAction: false
|
||||
})
|
||||
})
|
||||
|
||||
it('derives selection ids from rendered entries while skipping headers', () => {
|
||||
expect(
|
||||
getWorktreePaletteSelectionItemIds([
|
||||
{ id: '__header_worktrees__', type: 'section-header' },
|
||||
{ id: 'worktree:one', type: 'worktree' },
|
||||
{ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' },
|
||||
{ id: '__hint_worktree_cap__', type: 'hint' },
|
||||
{ id: '__header_actions_settings__', type: 'section-header' },
|
||||
{ id: 'settings:ai-provider-accounts', type: 'settings' },
|
||||
{ id: 'quick-action:new-terminal', type: 'quick-action' },
|
||||
{ id: '__header_browser__', type: 'section-header' },
|
||||
{ id: 'browser-page:one', type: 'browser-page' }
|
||||
])
|
||||
).toEqual([
|
||||
'worktree:one',
|
||||
CREATE_WORKTREE_ITEM_ID,
|
||||
'__hint_worktree_cap__',
|
||||
'settings:ai-provider-accounts',
|
||||
'quick-action:new-terminal',
|
||||
'browser-page:one'
|
||||
])
|
||||
})
|
||||
|
||||
it('names the rendered render key so a duplicate row is reachable by keyboard', () => {
|
||||
// cmdk selects deduplicated render keys; bare IDs omit duplicate rows and snap keyboard highlight back to top.
|
||||
expect(
|
||||
getWorktreePaletteSelectionItemIds(
|
||||
[
|
||||
{ id: '__header_worktrees__', type: 'section-header' },
|
||||
{ id: 'worktree:shared', type: 'worktree' },
|
||||
{ id: 'worktree:shared', type: 'worktree' }
|
||||
],
|
||||
['__header_worktrees__', 'worktree:shared', 'worktree:shared#dup1']
|
||||
)
|
||||
).toEqual(['worktree:shared', 'worktree:shared#dup1'])
|
||||
})
|
||||
|
||||
it('falls back deterministically when the selected row disappears', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: 'worktree:deleted',
|
||||
queryChanged: false,
|
||||
selectableItemIds: ['browser-page:first', 'worktree:second'],
|
||||
showCreateAction: true
|
||||
})
|
||||
).toBe('browser-page:first')
|
||||
})
|
||||
|
||||
it('allows Enter for a typed create name without requiring arrow navigation', () => {
|
||||
expect(
|
||||
isWorktreePaletteCreateActivationAllowed({
|
||||
hasTaskUrlIntent: false,
|
||||
hasCreateName: true,
|
||||
selectionMovedByUser: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorktreePaletteCreateActivationAllowed({
|
||||
hasTaskUrlIntent: false,
|
||||
hasCreateName: false,
|
||||
selectionMovedByUser: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorktreePaletteCreateActivationAllowed({
|
||||
hasTaskUrlIntent: true,
|
||||
hasCreateName: false,
|
||||
selectionMovedByUser: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isWorktreePaletteCreateActivationAllowed({
|
||||
hasTaskUrlIntent: false,
|
||||
hasCreateName: false,
|
||||
selectionMovedByUser: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('invalidates stale async create lookups', () => {
|
||||
const guard = createWorktreePaletteRequestGuard()
|
||||
const first = guard.start()
|
||||
|
||||
expect(guard.isCurrent(first)).toBe(true)
|
||||
guard.invalidate()
|
||||
expect(guard.isCurrent(first)).toBe(false)
|
||||
|
||||
const second = guard.start()
|
||||
expect(guard.isCurrent(first)).toBe(false)
|
||||
expect(guard.isCurrent(second)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CMD_J_FIXTURE_CASES,
|
||||
CMD_J_FIXTURE_PORTS,
|
||||
CMD_J_FIXTURE_REPO_MAP,
|
||||
CMD_J_FIXTURE_WORKTREES
|
||||
} from './cmd-j-evaluation-fixture'
|
||||
import { searchWorktrees, type PaletteSearchResult } from './worktree-palette-search'
|
||||
import { buildWorktreePaletteDocuments } from './worktree-palette-document'
|
||||
import { preparePaletteQuery } from '../../../shared/palette-match/palette-query'
|
||||
import { getWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
function documentKey(worktreeId: string): string {
|
||||
return getWorktreeHostIdentity(
|
||||
CMD_J_FIXTURE_WORKTREES.find((worktree) => worktree.id === worktreeId)!
|
||||
)
|
||||
}
|
||||
|
||||
function search(query: string, extra: Record<string, unknown> = {}): PaletteSearchResult[] {
|
||||
return searchWorktrees(CMD_J_FIXTURE_WORKTREES, query, CMD_J_FIXTURE_REPO_MAP, {
|
||||
workspacePortsByWorktreeId: CMD_J_FIXTURE_PORTS,
|
||||
...extra
|
||||
})
|
||||
}
|
||||
|
||||
function ids(query: string): string[] {
|
||||
return search(query).map((result) => result.worktreeId)
|
||||
}
|
||||
|
||||
describe('cmd-j evaluation gate', () => {
|
||||
it.each(CMD_J_FIXTURE_CASES)('$query', ({ query, expected }) => {
|
||||
const matched = ids(query)
|
||||
expect(matched.slice().sort()).toEqual([...expected].sort())
|
||||
if (expected.length) {
|
||||
expect(matched[0]).toBe(expected[0])
|
||||
}
|
||||
})
|
||||
|
||||
it('never reports a match without renderable evidence', () => {
|
||||
for (const { query, expected } of CMD_J_FIXTURE_CASES) {
|
||||
if (!expected.length) {
|
||||
continue
|
||||
}
|
||||
for (const result of search(query)) {
|
||||
const hasVisible =
|
||||
result.displayNameRanges.length > 0 ||
|
||||
result.branchRanges.length > 0 ||
|
||||
result.repoRanges.length > 0 ||
|
||||
result.hostRanges.length > 0
|
||||
expect(hasVisible || result.supportingText !== null).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('token coverage', () => {
|
||||
it('matches across repo, name, and branch', () => {
|
||||
expect(ids('acme relay-reconnect')).toEqual(['wt-reconnect'])
|
||||
})
|
||||
|
||||
it('keeps composite matching inside a multi-token query', () => {
|
||||
expect(ids('scan orca/main')).toEqual([])
|
||||
expect(ids('main orca/main')).toEqual(['wt-main-orca'])
|
||||
})
|
||||
|
||||
it('removes prior results when an uncovered token is appended', () => {
|
||||
expect(ids('scan daily')).toHaveLength(2)
|
||||
expect(ids('scan daily 08-13')).toEqual(['wt-scan-daily'])
|
||||
expect(ids('scan daily 08-13 missing')).toEqual([])
|
||||
})
|
||||
|
||||
it('cannot bypass short-token guards with a whole-query match', () => {
|
||||
// A single Latin letter may equal or prefix a word, never match mid-word.
|
||||
expect(ids('q')).toEqual([])
|
||||
expect(ids('a').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rejects more than the token limit', () => {
|
||||
const query = 't0 t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12 t13 t14 t15 t16'
|
||||
expect(preparePaletteQuery('t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 t10 t11 t12 t13 t14 t15').state).toBe(
|
||||
'ready'
|
||||
)
|
||||
expect(preparePaletteQuery(query)).toEqual({ state: 'invalid', reason: 'too-many-tokens' })
|
||||
expect(ids(query)).toEqual([])
|
||||
})
|
||||
|
||||
it('never fuzzy-matches identifiers', () => {
|
||||
for (const query of ['93334dd', '1.4.183', 'run-185', 'sta-4053', '3001']) {
|
||||
expect(ids(query)).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('evidence and ranking', () => {
|
||||
it('reads automation and linked-task data without optional caches', () => {
|
||||
expect(ids('nightly review run-184')).toEqual(['wt-scan-daily'])
|
||||
expect(ids('sta-4052')).toEqual(['wt-reconnect'])
|
||||
})
|
||||
|
||||
it('renders one supporting-evidence row for a hidden match', () => {
|
||||
const [result] = search('reconnect infra')
|
||||
expect(result.supportingText?.labelKind).toBe('comment')
|
||||
expect(result.supportingText?.matchRanges).toHaveLength(1)
|
||||
expect(result.supportingText?.accessibilityLabel).toBe('Workspace comment')
|
||||
})
|
||||
|
||||
it('rejects assignments that need two hidden sources', () => {
|
||||
// `vite` is only in the port evidence; `4052` only in another worktree's task.
|
||||
expect(ids('vite reconnect')).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers visible identity over supporting evidence', () => {
|
||||
const [result] = search('docs')
|
||||
expect(result.rank?.coverage).toBeLessThan(3)
|
||||
expect(result.qualityClass).toBe('exact-visible')
|
||||
})
|
||||
|
||||
it('ranks an exact identifier above an incidental numeric substring', () => {
|
||||
const [exact] = search('#4123')
|
||||
expect(exact.supportingText?.labelKind).toBe('pr')
|
||||
expect(exact.qualityClass).toBe('exact-intent')
|
||||
// A port prefix is the only reading of `412`, and it ranks below the exact hit.
|
||||
const [partial] = search('412')
|
||||
expect(partial.supportingText?.labelKind).toBe('port')
|
||||
expect(partial.qualityClass).toBe('partial-evidence')
|
||||
})
|
||||
|
||||
it('classifies a typo hit as fuzzy evidence', () => {
|
||||
const [result] = search('reconect')
|
||||
expect(result.worktreeId).toBe('wt-reconnect')
|
||||
expect(result.qualityClass).toBe('fuzzy-evidence')
|
||||
expect(result.rank?.recovery).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kanban board profile', () => {
|
||||
it('ignores palette-only evidence', () => {
|
||||
expect(
|
||||
searchWorktrees(CMD_J_FIXTURE_WORKTREES, 'main 3000', CMD_J_FIXTURE_REPO_MAP, {
|
||||
workspacePortsByWorktreeId: CMD_J_FIXTURE_PORTS,
|
||||
evidencePolicy: 'board'
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('still matches the comment printed on the card', () => {
|
||||
const matched = searchWorktrees(
|
||||
CMD_J_FIXTURE_WORKTREES,
|
||||
'reconnect infra',
|
||||
CMD_J_FIXTURE_REPO_MAP,
|
||||
{ evidencePolicy: 'board' }
|
||||
)
|
||||
expect(matched.map((result) => result.worktreeId)).toEqual(['wt-reconnect'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('document invalidation inputs', () => {
|
||||
it('indexes the rendered host label only when one is supplied', () => {
|
||||
expect(ids('bastion')).toEqual([])
|
||||
const withHost = searchWorktrees(CMD_J_FIXTURE_WORKTREES, 'bastion', CMD_J_FIXTURE_REPO_MAP, {
|
||||
hostLabelByWorktreeId: new Map([['wt-docs', 'bastion']])
|
||||
})
|
||||
expect(withHost.map((result) => result.worktreeId)).toEqual(['wt-docs'])
|
||||
expect(withHost[0].hostRanges).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rebuilds documents when a port appears without the worktree array changing', () => {
|
||||
const before = buildWorktreePaletteDocuments(CMD_J_FIXTURE_WORKTREES, {
|
||||
repoMap: CMD_J_FIXTURE_REPO_MAP
|
||||
})
|
||||
const after = buildWorktreePaletteDocuments(CMD_J_FIXTURE_WORKTREES, {
|
||||
repoMap: CMD_J_FIXTURE_REPO_MAP,
|
||||
workspacePortsByWorktreeId: CMD_J_FIXTURE_PORTS
|
||||
})
|
||||
// Host-qualified documents keep same-ID twins distinct.
|
||||
const key = documentKey('wt-main-orca')
|
||||
expect(before.get(key)?.evidenceUnits.has('port:3000')).toBe(false)
|
||||
expect(after.get(key)?.evidenceUnits.has('port:3000')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps recency and current-tab state out of normalized field data', () => {
|
||||
const documents = buildWorktreePaletteDocuments(CMD_J_FIXTURE_WORKTREES, {
|
||||
repoMap: CMD_J_FIXTURE_REPO_MAP
|
||||
})
|
||||
const fieldIds = [...(documents.get(documentKey('wt-docs'))?.fields ?? [])].map(
|
||||
(field) => field.id
|
||||
)
|
||||
// Guard the guard: a missing document would make the assertions below vacuous.
|
||||
expect(fieldIds.length).toBeGreaterThan(0)
|
||||
expect(fieldIds).not.toContain('recency')
|
||||
expect(fieldIds).not.toContain('isCurrent')
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { matchWorktreePaletteReview } from './worktree-palette-review-match'
|
||||
import './worktree-palette-review-match'
|
||||
import { searchWorktrees } from './worktree-palette-search'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { HostedReviewInfo } from '../../../shared/hosted-review'
|
||||
|
||||
// Rehydrated caches can lack required title, crashing Cmd+J text queries in matchWorktreePaletteReview (report c5d87873, macOS 1.4.147).
|
||||
|
||||
type MatcherReview = Parameters<typeof matchWorktreePaletteReview>[0]
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'wt-1',
|
||||
@@ -45,32 +41,6 @@ const repoMap = new Map<string, Repo>([
|
||||
]
|
||||
])
|
||||
|
||||
describe('matchWorktreePaletteReview title null-safety', () => {
|
||||
it('returns null instead of throwing when a cached review has no title', () => {
|
||||
const review = { number: 42, provider: 'github' } as unknown as MatcherReview
|
||||
expect(() => matchWorktreePaletteReview(review, 'feature', 'feature')).not.toThrow()
|
||||
expect(matchWorktreePaletteReview(review, 'feature', 'feature')).toBeNull()
|
||||
})
|
||||
|
||||
it('still matches on the title text when a title is present', () => {
|
||||
const review = {
|
||||
number: 42,
|
||||
title: 'Fix the thing',
|
||||
provider: 'github'
|
||||
} as unknown as MatcherReview
|
||||
const match = matchWorktreePaletteReview(review, 'thing', 'thing')
|
||||
expect(match).not.toBeNull()
|
||||
expect(match?.text).toBe('Fix the thing')
|
||||
expect(match?.matchRange).toEqual({ start: 8, end: 13 })
|
||||
})
|
||||
|
||||
it('still matches on the PR number even when the title is missing', () => {
|
||||
const review = { number: 42, provider: 'github' } as unknown as MatcherReview
|
||||
const match = matchWorktreePaletteReview(review, '42', '42')
|
||||
expect(match?.text).toBe('PR #42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchWorktrees with a titleless cached review (Cmd+J palette crash path)', () => {
|
||||
it('does not throw when the checks-review cache entry has no title', () => {
|
||||
const worktree = makeWorktree()
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
} from './palette-repo-resolution'
|
||||
import { buildWorktreePaletteDocuments } from './worktree-palette-document'
|
||||
import { searchWorktreeDocuments } from './worktree-palette-search'
|
||||
import {
|
||||
findAmbiguousWorktreeIds,
|
||||
getPaletteOwnershipWorktreeIds
|
||||
} from './unified-tab-host-ownership'
|
||||
import './unified-tab-host-ownership'
|
||||
|
||||
function makeWorktree(runtimeOwnerEnvironmentId: string, displayName: string): Worktree {
|
||||
return {
|
||||
@@ -72,28 +69,3 @@ it('keeps same-target SSH worktrees from separate paired runtimes distinct', ()
|
||||
)
|
||||
).toEqual(['hub-a', 'hub-b'])
|
||||
})
|
||||
|
||||
it('keeps a physical-host alias only when one runtime owns it', () => {
|
||||
const hubA = makeWorktree('hub-a', 'AlphaOwner workspace')
|
||||
const hubB = makeWorktree('hub-b', 'BetaOwner workspace')
|
||||
const uniqueIndex = buildPaletteWorktreeIndex([hubA])
|
||||
const ambiguousIndex = buildPaletteWorktreeIndex([hubA, hubB])
|
||||
|
||||
expect(
|
||||
resolvePaletteWorktree(uniqueIndex, hubA.id, 'ssh:same-private-target')
|
||||
?.runtimeOwnerEnvironmentId
|
||||
).toBe('hub-a')
|
||||
expect(resolvePaletteWorktree(ambiguousIndex, hubA.id, 'ssh:same-private-target')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps both runtime owners in the tab ownership ambiguity inventory', () => {
|
||||
const hubA = makeWorktree('hub-a', 'AlphaOwner workspace')
|
||||
const hubB = makeWorktree('hub-b', 'BetaOwner workspace')
|
||||
const ownershipWorktrees = getPaletteOwnershipWorktreeIds({
|
||||
worktreesByRepo: { repo: [hubA, hubB] },
|
||||
folderWorkspaces: []
|
||||
})
|
||||
|
||||
expect(ownershipWorktrees).toHaveLength(2)
|
||||
expect(findAmbiguousWorktreeIds(ownershipWorktrees).has(hubA.id)).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { searchWorktreeDocuments } from './worktree-palette-search'
|
||||
import { buildWorktreePaletteDocuments } from './worktree-palette-document'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { composeWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
|
||||
|
||||
vi.mock('../../../shared/sidebar-inputs-switch', () => ({ ORCA_SIDEBAR_INPUTS_IS_MAIN: true }))
|
||||
|
||||
const worktree: Worktree = {
|
||||
id: 'repo-1::/tmp/shell-only',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'local',
|
||||
path: '/tmp/shell-only',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/shell-only',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'Shell only',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 1_000
|
||||
}
|
||||
const repoMap = new Map<string, Repo>([
|
||||
['repo-1', { id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '', addedAt: 0 }]
|
||||
])
|
||||
|
||||
describe('jump palette last active with the switch on', () => {
|
||||
it('counts a visit to a plain-shell workspace as its last activity', () => {
|
||||
const visitedAt = 5_000
|
||||
for (const query of ['', 'shell']) {
|
||||
const [result] = searchWorktreeDocuments({
|
||||
worktrees: [worktree],
|
||||
query,
|
||||
documents: buildWorktreePaletteDocuments([worktree], { repoMap }),
|
||||
repoMap,
|
||||
context: { nowMs: 10_000 },
|
||||
lastVisitedAtByWorktreeId: {
|
||||
[composeWorktreeHostIdentity('local', worktree.id)]: visitedAt
|
||||
}
|
||||
})
|
||||
expect(result?.lastActiveAt).toBe(visitedAt)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorktreePaletteSearchScope, searchWorktrees } from './worktree-palette-search'
|
||||
import { isWorktreePaletteQueryTooLarge } from '../../../shared/palette-match/worktree-palette-query-bounds'
|
||||
import { searchWorktrees } from './worktree-palette-search'
|
||||
import '../../../shared/palette-match/worktree-palette-query-bounds'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import type { HostedReviewInfo } from '../../../shared/hosted-review'
|
||||
@@ -28,20 +28,6 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_QUERY_RESULT_WT1 = {
|
||||
worktreeId: 'wt-1',
|
||||
matchedFields: [],
|
||||
displayNameRanges: [],
|
||||
branchRanges: [],
|
||||
repoRanges: [],
|
||||
hostRanges: [],
|
||||
supportingText: null,
|
||||
qualityClass: null,
|
||||
rank: null,
|
||||
lastActiveAt: null,
|
||||
activity: { ageBucket: null, timestamp: 0 }
|
||||
}
|
||||
|
||||
const repoMap = new Map<string, Repo>([
|
||||
[
|
||||
'repo-1',
|
||||
@@ -70,79 +56,6 @@ function gitLabReview(overrides: Partial<HostedReviewInfo> = {}): HostedReviewIn
|
||||
}
|
||||
|
||||
describe('worktree-palette-search', () => {
|
||||
it('uses the filtered recent list for empty queries', () => {
|
||||
const visible = makeWorktree({ id: 'visible' })
|
||||
const hidden = makeWorktree({ id: 'hidden-by-filter' })
|
||||
|
||||
const scope = getWorktreePaletteSearchScope({
|
||||
hasQuery: false,
|
||||
allWorktrees: [visible, hidden],
|
||||
emptyQueryWorktrees: [visible]
|
||||
})
|
||||
|
||||
expect(scope.map((worktree) => worktree.id)).toEqual(['visible'])
|
||||
})
|
||||
|
||||
it('uses all non-archived worktrees for typed queries', () => {
|
||||
const visible = makeWorktree({ id: 'visible' })
|
||||
const hiddenByFilter = makeWorktree({ id: 'hidden-by-filter' })
|
||||
const archived = makeWorktree({ id: 'archived', isArchived: true })
|
||||
|
||||
const scope = getWorktreePaletteSearchScope({
|
||||
hasQuery: true,
|
||||
allWorktrees: [visible, hiddenByFilter, archived],
|
||||
emptyQueryWorktrees: [visible]
|
||||
})
|
||||
|
||||
expect(scope.map((worktree) => worktree.id)).toEqual(['visible', 'hidden-by-filter'])
|
||||
})
|
||||
|
||||
it('returns every worktree with no match metadata for an empty query', () => {
|
||||
expect(searchWorktrees([makeWorktree()], '', repoMap)).toEqual([EMPTY_QUERY_RESULT_WT1])
|
||||
})
|
||||
|
||||
it('finds an emoji-named workspace by its readable branch shortcode', () => {
|
||||
const results = searchWorktrees(
|
||||
[makeWorktree({ displayName: '🚀', branch: 'refs/heads/rocket' })],
|
||||
'rocket',
|
||||
repoMap
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
matchedFields: ['branch'],
|
||||
branchRanges: [{ start: 0, end: 6 }]
|
||||
})
|
||||
})
|
||||
|
||||
it('drops oversized pasted queries instead of matching their text', () => {
|
||||
// Documents are reused across keystrokes; query preparation owns the byte guard.
|
||||
const oversizedQuery = 'a'.repeat(2049)
|
||||
expect(isWorktreePaletteQueryTooLarge('a'.repeat(2048))).toBe(false)
|
||||
|
||||
expect(isWorktreePaletteQueryTooLarge(oversizedQuery)).toBe(true)
|
||||
expect(searchWorktrees([makeWorktree()], oversizedQuery, repoMap)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects oversized whitespace before trimming worktree palette queries', () => {
|
||||
const query = ' '.repeat(2049)
|
||||
expect(searchWorktrees([makeWorktree()], ' '.repeat(2048), repoMap)).toEqual([
|
||||
EMPTY_QUERY_RESULT_WT1
|
||||
])
|
||||
|
||||
expect(searchWorktrees([makeWorktree()], query, repoMap)).toEqual([])
|
||||
})
|
||||
|
||||
it('enforces the query budget by UTF-8 byte length', () => {
|
||||
const query = 'é'.repeat(1025)
|
||||
|
||||
expect(isWorktreePaletteQueryTooLarge('é'.repeat(1024))).toBe(false)
|
||||
expect(query.length).toBe(1025)
|
||||
expect(isWorktreePaletteQueryTooLarge(query)).toBe(true)
|
||||
expect(searchWorktrees([makeWorktree()], query, repoMap)).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to branch text when a cleared display name left it undefined', () => {
|
||||
const cleared = makeWorktree({
|
||||
displayName: undefined as unknown as string,
|
||||
@@ -160,38 +73,6 @@ describe('worktree-palette-search', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the folder name when both display name and branch are missing', () => {
|
||||
const folderWorkspace = makeWorktree({
|
||||
displayName: undefined as unknown as string,
|
||||
branch: '',
|
||||
path: '/tmp/design-review'
|
||||
})
|
||||
|
||||
expect(searchWorktrees([folderWorkspace], 'design', repoMap)[0]).toMatchObject({
|
||||
matchedFields: ['displayName'],
|
||||
displayNameRanges: [{ start: 0, end: 6 }]
|
||||
})
|
||||
})
|
||||
|
||||
it('survives a cleared display name on composite repo/branch queries', () => {
|
||||
const cleared = makeWorktree({
|
||||
displayName: undefined as unknown as string,
|
||||
branch: undefined as unknown as string
|
||||
})
|
||||
|
||||
expect(() => searchWorktrees([cleared], 'orca/jump', repoMap)).not.toThrow()
|
||||
})
|
||||
|
||||
it('still lists a branch-less row on the empty query, which renders every row', () => {
|
||||
// Empty queries bypass branch reads; render label resolution must guard missing branches too.
|
||||
const cleared = makeWorktree({
|
||||
displayName: undefined as unknown as string,
|
||||
branch: undefined as unknown as string
|
||||
})
|
||||
|
||||
expect(searchWorktrees([cleared], '', repoMap)).toEqual([EMPTY_QUERY_RESULT_WT1])
|
||||
})
|
||||
|
||||
it('returns a truncated comment snippet with the highlighted match range', () => {
|
||||
const results = searchWorktrees(
|
||||
[
|
||||
@@ -212,69 +93,6 @@ describe('worktree-palette-search', () => {
|
||||
expect(results[0].supportingText?.text.slice(range.start, range.end)).toBe('implementation')
|
||||
})
|
||||
|
||||
it('keeps PR matches in the search result model instead of inferring them during render', () => {
|
||||
const results = searchWorktrees(
|
||||
[makeWorktree({ branch: 'refs/heads/feature/palette-refresh', linkedPR: 426 })],
|
||||
'quick jump',
|
||||
repoMap,
|
||||
{
|
||||
prCache: {
|
||||
'/repo/orca::feature/palette-refresh': {
|
||||
data: { number: 426, title: 'Refresh the worktree quick jump palette' }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
// `jump` also hits the visible display name, so only `quick` is left to explain.
|
||||
expect(results[0].displayNameRanges).toEqual([{ start: 0, end: 4 }])
|
||||
expect(results[0].supportingText).toEqual({
|
||||
labelKind: 'pr',
|
||||
text: '#426 · Refresh the worktree quick jump palette',
|
||||
matchRanges: [{ start: 28, end: 33 }],
|
||||
accessibilityLabel: 'Pull request'
|
||||
})
|
||||
})
|
||||
|
||||
it('matches the GitLab review title and number already selected by Checks', () => {
|
||||
const worktree = makeWorktree()
|
||||
const checksReviewByWorktree = new Map([[worktree, gitLabReview()]])
|
||||
|
||||
const titleResults = searchWorktrees([worktree], 'checks tab', repoMap, {
|
||||
checksReviewByWorktree
|
||||
})
|
||||
const numberResults = searchWorktrees([worktree], '!17', repoMap, { checksReviewByWorktree })
|
||||
|
||||
expect(titleResults[0].supportingText).toEqual({
|
||||
labelKind: 'mr',
|
||||
text: '!17 · Reuse checks tab review metadata',
|
||||
matchRanges: [
|
||||
{ start: 12, end: 18 },
|
||||
{ start: 19, end: 22 }
|
||||
],
|
||||
accessibilityLabel: 'Merge request'
|
||||
})
|
||||
expect(numberResults[0].supportingText).toEqual({
|
||||
labelKind: 'mr',
|
||||
text: '!17 · Reuse checks tab review metadata',
|
||||
matchRanges: [{ start: 0, end: 3 }],
|
||||
accessibilityLabel: 'Merge request'
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a review number only exactly, not as an incidental substring', () => {
|
||||
const worktree = makeWorktree()
|
||||
const checksReviewByWorktree = new Map([
|
||||
[worktree, gitLabReview({ number: 4123, title: 'Fix reconnect' })]
|
||||
])
|
||||
|
||||
expect(searchWorktrees([worktree], '!4123', repoMap, { checksReviewByWorktree })).toHaveLength(
|
||||
1
|
||||
)
|
||||
expect(searchWorktrees([worktree], '!123', repoMap, { checksReviewByWorktree })).toEqual([])
|
||||
})
|
||||
|
||||
it('does not search stale GitHub cache metadata when Checks selected another review', () => {
|
||||
const staleWorktree = makeWorktree({
|
||||
branch: 'refs/heads/feature/palette-refresh',
|
||||
@@ -320,76 +138,6 @@ describe('worktree-palette-search', () => {
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps review matches isolated between worktrees on different hosts', () => {
|
||||
const localWorktree = makeWorktree({ id: 'repo-1::/local/wt', hostId: 'local' })
|
||||
const sshWorktree = makeWorktree({ id: 'repo-1-ssh::/remote/wt', hostId: 'ssh:staging' })
|
||||
const review = gitLabReview({ title: 'Remote-only merge request' })
|
||||
|
||||
const results = searchWorktrees([localWorktree, sshWorktree], 'remote-only', repoMap, {
|
||||
checksReviewByWorktree: new Map([[sshWorktree, review]])
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].supportingText?.text).toBe('!17 · Remote-only merge request')
|
||||
})
|
||||
|
||||
it('scopes PR and MR number sigils to their providers', () => {
|
||||
const gitHubWorktree = makeWorktree()
|
||||
const gitHubReview = gitLabReview({
|
||||
provider: 'github',
|
||||
number: 42,
|
||||
title: 'GitHub pull request',
|
||||
url: 'https://github.com/acme/orca/pull/42'
|
||||
})
|
||||
const gitLabWorktree = makeWorktree()
|
||||
|
||||
expect(
|
||||
searchWorktrees([gitHubWorktree], '!42', repoMap, {
|
||||
checksReviewByWorktree: new Map([[gitHubWorktree, gitHubReview]])
|
||||
})
|
||||
).toEqual([])
|
||||
expect(
|
||||
searchWorktrees([gitLabWorktree], '#17', repoMap, {
|
||||
checksReviewByWorktree: new Map([[gitLabWorktree, gitLabReview()]])
|
||||
})
|
||||
).toEqual([])
|
||||
expect(
|
||||
searchWorktrees([makeWorktree({ linkedIssue: 42 })], '!42', repoMap, {
|
||||
workspacePortsByWorktreeId: new Map([['wt-1', [{ port: 42 }]]])
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves input order when query matches a repo name', () => {
|
||||
const worktrees = [
|
||||
makeWorktree({
|
||||
id: 'wt-feature',
|
||||
branch: 'refs/heads/feature/foo',
|
||||
displayName: 'foo feature',
|
||||
isMainWorktree: false
|
||||
}),
|
||||
makeWorktree({
|
||||
id: 'wt-bugfix',
|
||||
branch: 'refs/heads/bugfix/bar',
|
||||
displayName: 'bar bugfix',
|
||||
isMainWorktree: false
|
||||
}),
|
||||
makeWorktree({
|
||||
id: 'wt-main',
|
||||
branch: 'refs/heads/main',
|
||||
displayName: 'main',
|
||||
isMainWorktree: true
|
||||
})
|
||||
]
|
||||
|
||||
// All three match on the repo name, order preserved from input.
|
||||
expect(searchWorktrees(worktrees, 'orca', repoMap).map((result) => result.worktreeId)).toEqual([
|
||||
'wt-feature',
|
||||
'wt-bugfix',
|
||||
'wt-main'
|
||||
])
|
||||
})
|
||||
|
||||
it('supports "repo/worktree" composite queries and highlights both segments', () => {
|
||||
const worktrees = [
|
||||
makeWorktree({ id: 'wt-main', branch: 'refs/heads/main', displayName: 'main' }),
|
||||
@@ -409,18 +157,6 @@ describe('worktree-palette-search', () => {
|
||||
expect(results[0].branchRanges).toEqual([{ start: 0, end: 4 }])
|
||||
})
|
||||
|
||||
it('falls back to single-token matching when a composite query has no composite hits', () => {
|
||||
const results = searchWorktrees(
|
||||
[makeWorktree({ branch: 'refs/heads/feature/palette-refresh' })],
|
||||
'feature/palette',
|
||||
repoMap
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].matchedFields).toEqual(['branch'])
|
||||
expect(results[0].branchRanges).toEqual([{ start: 0, end: 'feature/palette'.length }])
|
||||
})
|
||||
|
||||
it('requires every token of a multi-token query to land somewhere', () => {
|
||||
const worktree = makeWorktree({ comment: 'waiting on infra' })
|
||||
|
||||
@@ -428,29 +164,6 @@ describe('worktree-palette-search', () => {
|
||||
expect(searchWorktrees([worktree], 'jump nowhere', repoMap)).toEqual([])
|
||||
})
|
||||
|
||||
it('matches the remote host chip label rendered on the row', () => {
|
||||
const worktree = makeWorktree({ hostId: 'ssh:staging' })
|
||||
const hostLabelByWorktreeId = new Map([['wt-1', 'staging-box']])
|
||||
|
||||
expect(
|
||||
searchWorktrees([worktree], 'staging', repoMap, { hostLabelByWorktreeId })[0]
|
||||
).toMatchObject({ matchedFields: ['host'], hostRanges: [{ start: 0, end: 7 }] })
|
||||
// Without a rendered chip there is nothing to explain the hit, so it must not match.
|
||||
expect(searchWorktrees([worktree], 'staging', repoMap)).toEqual([])
|
||||
})
|
||||
|
||||
it('matches issue numbers with a leading hash and returns issue render context', () => {
|
||||
const results = searchWorktrees([makeWorktree({ linkedIssue: 304 })], '#304', repoMap)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].supportingText).toEqual({
|
||||
labelKind: 'issue',
|
||||
text: '#304',
|
||||
matchRanges: [{ start: 0, end: 4 }],
|
||||
accessibilityLabel: 'Linked issue'
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a pasted GitHub issue URL to the linked worktree instead of the URL text', () => {
|
||||
const results = searchWorktrees(
|
||||
[
|
||||
@@ -475,93 +188,4 @@ describe('worktree-palette-search', () => {
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('matches a pasted GitHub pull URL to the linked worktree', () => {
|
||||
const results = searchWorktrees(
|
||||
[
|
||||
makeWorktree({
|
||||
id: 'wt-pr',
|
||||
linkedPR: 12789,
|
||||
linkedWorkItem: {
|
||||
provider: 'github',
|
||||
type: 'pr',
|
||||
number: 12789,
|
||||
title: 'Perf',
|
||||
url: 'https://github.com/stablyai/orca/pull/12789'
|
||||
}
|
||||
}),
|
||||
makeWorktree({ id: 'wt-issue', linkedIssue: 12789 })
|
||||
],
|
||||
'https://github.com/stablyai/orca/pull/12789',
|
||||
repoMap
|
||||
)
|
||||
|
||||
expect(results.map((result) => result.worktreeId)).toEqual(['wt-pr'])
|
||||
})
|
||||
|
||||
it('matches a pasted Linear issue URL to the linked worktree', () => {
|
||||
const results = searchWorktrees(
|
||||
[
|
||||
makeWorktree({
|
||||
id: 'wt-linear',
|
||||
linkedLinearIssue: 'STA-4052',
|
||||
linkedLinearIssueOrganizationUrlKey: 'stably'
|
||||
}),
|
||||
makeWorktree({ id: 'wt-name', displayName: 'linear.app' })
|
||||
],
|
||||
'https://linear.app/stably/issue/STA-4052/agent-terminals-disappearing-randomly',
|
||||
repoMap
|
||||
)
|
||||
|
||||
expect(results.map((result) => result.worktreeId)).toEqual(['wt-linear'])
|
||||
})
|
||||
|
||||
it('promotes an exact sigilled issue number above an ordinary port number', () => {
|
||||
const results = searchWorktrees(
|
||||
[makeWorktree({ id: 'wt-port', linkedIssue: 3000 })],
|
||||
'3000',
|
||||
repoMap,
|
||||
{ workspacePortsByWorktreeId: new Map([['wt-port', [{ port: 3000, processName: 'vite' }]]]) }
|
||||
)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].matchedFields).toEqual(['issue'])
|
||||
expect(results[0].supportingText).toEqual({
|
||||
labelKind: 'issue',
|
||||
text: '#3000',
|
||||
matchRanges: [{ start: 1, end: 5 }],
|
||||
accessibilityLabel: 'Linked issue'
|
||||
})
|
||||
})
|
||||
|
||||
it('still prefix-matches port numbers, unlike review numbers', () => {
|
||||
const worktree = makeWorktree({ id: 'wt-port' })
|
||||
const workspacePortsByWorktreeId = new Map([['wt-port', [{ port: 3000 }]]])
|
||||
|
||||
expect(
|
||||
searchWorktrees([worktree], '300', repoMap, { workspacePortsByWorktreeId })
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('secondary workspace attachments in palette search', () => {
|
||||
it('searches the number, identifier, and title of secondary links', () => {
|
||||
const worktree = makeWorktree({
|
||||
linkedPR: 1,
|
||||
linkedItems: [
|
||||
{ provider: 'github', type: 'pr', number: 42, title: 'Secondary payment review' },
|
||||
{
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
identifier: 'STA-84',
|
||||
title: 'Secondary payment task'
|
||||
}
|
||||
]
|
||||
})
|
||||
const repoMap = new Map<string, Repo>()
|
||||
expect(searchWorktrees([worktree], '#42', repoMap)).toHaveLength(1)
|
||||
expect(searchWorktrees([worktree], 'STA-84', repoMap)).toHaveLength(1)
|
||||
expect(searchWorktrees([worktree], 'payment', repoMap)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import {
|
||||
getCmdJTaskUrlCreatePreview,
|
||||
matchWorktreePaletteTaskUrl,
|
||||
parseCmdJTaskSourceUrl,
|
||||
withResolvedCmdJGitHubPreview
|
||||
parseCmdJTaskSourceUrl
|
||||
} from './worktree-palette-task-url-match'
|
||||
|
||||
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
@@ -31,627 +29,7 @@ function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
}
|
||||
}
|
||||
|
||||
const orcaRepo: Repo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo/orca',
|
||||
displayName: 'stablyai/orca',
|
||||
badgeColor: '#22c55e',
|
||||
addedAt: 0
|
||||
}
|
||||
|
||||
function gitLabRepo(canonicalKey: string): Repo {
|
||||
return {
|
||||
...orcaRepo,
|
||||
displayName: 'orca',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey,
|
||||
remoteName: 'origin',
|
||||
remoteUrl: `git@${canonicalKey.replace('/', ':')}.git`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Basename displayName: the common non-fork case, where only the remote identifies the repo. */
|
||||
function gitHubRepo(canonicalKey: string): Repo {
|
||||
return {
|
||||
...orcaRepo,
|
||||
displayName: 'orca',
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey,
|
||||
remoteName: 'origin',
|
||||
remoteUrl: `git@${canonicalKey.replace('/', ':')}.git`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseCmdJTaskSourceUrl', () => {
|
||||
it('parses GitHub issue and pull URLs', () => {
|
||||
expect(parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/14198')).toEqual({
|
||||
provider: 'github',
|
||||
link: {
|
||||
slug: { owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
type: 'issue',
|
||||
number: 14198
|
||||
}
|
||||
})
|
||||
expect(parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')).toEqual({
|
||||
provider: 'github',
|
||||
link: {
|
||||
slug: { owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
||||
type: 'pr',
|
||||
number: 12789
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('parses Linear, GitLab, and Jira URLs', () => {
|
||||
expect(
|
||||
parseCmdJTaskSourceUrl(
|
||||
'https://linear.app/stably/issue/STA-4052/agent-terminals-disappearing-randomly'
|
||||
)
|
||||
).toEqual({
|
||||
provider: 'linear',
|
||||
intent: { identifier: 'STA-4052', organizationUrlKey: 'stably' }
|
||||
})
|
||||
expect(
|
||||
parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
).toMatchObject({
|
||||
provider: 'gitlab',
|
||||
link: { type: 'mr', number: 17 }
|
||||
})
|
||||
expect(parseCmdJTaskSourceUrl('https://company.atlassian.net/browse/ORCA-123')).toEqual({
|
||||
provider: 'jira',
|
||||
parsed: {
|
||||
issueKey: 'ORCA-123',
|
||||
origin: 'https://company.atlassian.net',
|
||||
sitePath: ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('does not treat names or repo homepages as task URLs', () => {
|
||||
expect(parseCmdJTaskSourceUrl('sta-4052-agent-terminals')).toBeNull()
|
||||
expect(parseCmdJTaskSourceUrl('https://github.com/stablyai/orca')).toBeNull()
|
||||
expect(parseCmdJTaskSourceUrl('#14198')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchWorktreePaletteTaskUrl', () => {
|
||||
it('retains the matched workspace host for host-qualified consumers', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/123')
|
||||
expect(intent).not.toBeNull()
|
||||
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
hostId: 'ssh:box',
|
||||
linkedWorkItem: {
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 123,
|
||||
title: 'Host-qualified match',
|
||||
url: 'https://github.com/stablyai/orca/issues/123'
|
||||
}
|
||||
}),
|
||||
intent: intent!
|
||||
})
|
||||
).toMatchObject({ worktreeId: 'wt-1', worktreeHostId: 'ssh:box' })
|
||||
})
|
||||
|
||||
it('matches a GitHub issue URL to the linked worktree in the same repo', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/14198')
|
||||
expect(intent).not.toBeNull()
|
||||
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedIssue: 14198 }),
|
||||
intent: intent!,
|
||||
repo: orcaRepo
|
||||
})
|
||||
).toMatchObject({
|
||||
worktreeId: 'wt-1',
|
||||
matchedFields: ['issue'],
|
||||
supportingText: { labelKind: 'issue', text: 'Issue #14198' }
|
||||
})
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedIssue: 14198 }),
|
||||
intent: intent!,
|
||||
repo: { ...orcaRepo, displayName: 'other/repo' }
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches a GitHub work-item number when the stored URL is missing', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedWorkItem: {
|
||||
provider: 'github',
|
||||
type: 'pr',
|
||||
number: 12789,
|
||||
title: 'Perf',
|
||||
url: ''
|
||||
}
|
||||
}),
|
||||
intent: intent!,
|
||||
repo: orcaRepo
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
})
|
||||
|
||||
it('matches a GitHub pull URL via the stored work-item URL', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedWorkItem: {
|
||||
provider: 'github',
|
||||
type: 'pr',
|
||||
number: 12789,
|
||||
title: 'Perf',
|
||||
url: 'https://github.com/stablyai/orca/pull/12789'
|
||||
}
|
||||
}),
|
||||
intent: intent!,
|
||||
repo: { ...orcaRepo, displayName: 'Repo 1' }
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'], supportingText: { text: 'PR #12789' } })
|
||||
})
|
||||
|
||||
it('gates a stored GitHub number on the repo remote identity', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('github.com/other/project')
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('github.com/stablyai/orca')
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'], supportingText: { text: 'PR #12789' } })
|
||||
})
|
||||
|
||||
it('gates a stored GitHub work item with no URL on the repo remote identity', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/14198')
|
||||
const worktree = makeWorktree({
|
||||
linkedWorkItem: { provider: 'github', type: 'issue', number: 14198, title: 'Bug', url: '' }
|
||||
})
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree,
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('github.com/other/project')
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree,
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('github.com/stablyai/orca')
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['issue'] })
|
||||
})
|
||||
|
||||
it('does not match a GitHub URL on a different host for the same owner/repo', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://ghe.example.com/stablyai/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('github.com/stablyai/orca')
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('ghe.example.com/stablyai/orca')
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
})
|
||||
|
||||
it('matches GitHub remotes whose host is an SSH alias or www form of github.com', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
for (const canonicalKey of [
|
||||
// ssh://git@ssh.github.com:443/... — GitHub's port-443 workaround.
|
||||
'ssh.github.com/stablyai/orca',
|
||||
// git@github-work:... — an OpenSSH `Host` alias `git remote -v` cannot expand.
|
||||
'github-work/stablyai/orca',
|
||||
'www.github.com/stablyai/orca'
|
||||
]) {
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo(canonicalKey)
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
}
|
||||
// A real, resolvable host is evidence of a different forge, not an alias.
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('ghe.example.com/stablyai/orca')
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes host case, port, and owner case before comparing GitHub identities', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://GHE.Example.com:8443/StablyAI/Orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('ghe.example.com/stablyai/orca')
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
})
|
||||
|
||||
it('stays permissive for GitHub numbers when the repo remote identity is unknown', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: { ...orcaRepo, displayName: 'orca' }
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({ worktree: makeWorktree({ linkedPR: 12789 }), intent: intent! })
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
})
|
||||
|
||||
it('stays permissive for a GitHub fork whose identity resolved to the upstream remote', () => {
|
||||
// `deriveGitRemoteIdentity` prefers `upstream`, so the fork's own `origin` is not visible here.
|
||||
const forkRepo: Repo = {
|
||||
...gitHubRepo('github.com/stablyai/orca'),
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'github.com/stablyai/orca',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'git@github.com:stablyai/orca.git'
|
||||
}
|
||||
}
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/me/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: forkRepo
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
// An `origin`-derived identity is authoritative, so a different repo still loses.
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('github.com/stablyai/orca')
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the GitHub number gate type-aware across repos', () => {
|
||||
const prIntent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
const issueIntent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedIssue: 12789 }),
|
||||
intent: prIntent!,
|
||||
repo: gitHubRepo('github.com/stablyai/orca')
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedIssue: 12789 }),
|
||||
intent: issueIntent!,
|
||||
repo: gitHubRepo('github.com/other/project')
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedIssue: 12789 }),
|
||||
intent: issueIntent!,
|
||||
repo: gitHubRepo('github.com/stablyai/orca')
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['issue'], supportingText: { text: 'Issue #12789' } })
|
||||
})
|
||||
|
||||
it('keeps an owner/repo displayName authoritative over a host-alias remote', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedPR: 12789 }),
|
||||
intent: intent!,
|
||||
repo: {
|
||||
...orcaRepo,
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'git-mirror.example.com/stablyai/orca',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@git-mirror.example.com:stablyai/orca.git'
|
||||
}
|
||||
}
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
})
|
||||
|
||||
it('matches a GitHub PR URL via the linked review URL regardless of remote identity', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree(),
|
||||
intent: intent!,
|
||||
repo: gitHubRepo('github.com/other/project'),
|
||||
review: {
|
||||
provider: 'github',
|
||||
number: 12789,
|
||||
title: 'Fork PR',
|
||||
state: 'open',
|
||||
url: 'https://github.com/stablyai/orca/pull/12789',
|
||||
status: 'pending',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
}
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['pr'] })
|
||||
})
|
||||
|
||||
it('rejects a GitLab MR URL from a different project than the stored URL', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedGitLabMR: 17,
|
||||
linkedWorkItem: {
|
||||
provider: 'gitlab',
|
||||
type: 'mr',
|
||||
number: 17,
|
||||
title: 'Other project MR',
|
||||
url: 'https://gitlab.example.com/other/project/-/merge_requests/17'
|
||||
}
|
||||
}),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.example.com/other/project')
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches a GitLab MR URL for the same project', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedGitLabMR: 17,
|
||||
linkedWorkItem: {
|
||||
provider: 'gitlab',
|
||||
type: 'mr',
|
||||
number: 17,
|
||||
title: 'Same project MR',
|
||||
url: 'https://gitlab.com/acme/orca/-/merge_requests/17'
|
||||
}
|
||||
}),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.example.com/other/project')
|
||||
})
|
||||
).toMatchObject({
|
||||
matchedFields: ['mr'],
|
||||
supportingText: { labelKind: 'mr', text: 'MR #17' }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not match a GitLab issue URL against a stored MR of the same number', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedGitLabIssue: 17,
|
||||
linkedWorkItem: {
|
||||
provider: 'gitlab',
|
||||
type: 'issue',
|
||||
number: 17,
|
||||
title: 'Issue',
|
||||
url: 'https://gitlab.com/acme/orca/-/issues/17'
|
||||
}
|
||||
}),
|
||||
intent: intent!
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not match a GitLab URL on a different host for the same project path', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedGitLabMR: 17,
|
||||
linkedWorkItem: {
|
||||
provider: 'gitlab',
|
||||
type: 'mr',
|
||||
number: 17,
|
||||
title: 'Self-hosted MR',
|
||||
url: 'https://gitlab.example.com/acme/orca/-/merge_requests/17'
|
||||
}
|
||||
}),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.example.com/acme/orca')
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('gates a stored GitLab number with no work item on the repo remote identity', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.example.com/other/project')
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.com/acme/orca')
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['mr'] })
|
||||
})
|
||||
|
||||
it('matches GitLab remotes whose host is an SSH alias or www form of gitlab.com', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
for (const canonicalKey of [
|
||||
// altssh.gitlab.com is GitLab's port-443 SSH endpoint; `gitlab-work` is an ssh-config alias.
|
||||
'altssh.gitlab.com/acme/orca',
|
||||
'gitlab-work/acme/orca',
|
||||
'www.gitlab.com/acme/orca'
|
||||
]) {
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo(canonicalKey)
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['mr'] })
|
||||
}
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.example.com/acme/orca')
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('stays permissive for GitLab numbers when the repo remote identity is unknown', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: intent!,
|
||||
repo: orcaRepo
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['mr'] })
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: intent!
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['mr'] })
|
||||
})
|
||||
|
||||
it('declines a different GitLab project even when the identity came from upstream', () => {
|
||||
// GitLab iids are project-scoped; authoritative upstream identity rejects fork-origin URLs hidden by single-remote selection (accepted false negative, STA-4450).
|
||||
const forkRepo: Repo = {
|
||||
...gitLabRepo('gitlab.com/acme/orca'),
|
||||
gitRemoteIdentity: {
|
||||
canonicalKey: 'gitlab.com/acme/orca',
|
||||
remoteName: 'upstream',
|
||||
remoteUrl: 'git@gitlab.com:acme/orca.git'
|
||||
}
|
||||
}
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: parseCmdJTaskSourceUrl('https://gitlab.com/me/orca/-/merge_requests/17')!,
|
||||
repo: forkRepo
|
||||
})
|
||||
).toBeNull()
|
||||
// The project the identity does name still matches.
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')!,
|
||||
repo: forkRepo
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['mr'] })
|
||||
// An `origin`-derived identity is authoritative, so a different project still loses.
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabMR: 17 }),
|
||||
intent: parseCmdJTaskSourceUrl('https://gitlab.com/me/orca/-/merge_requests/17')!,
|
||||
repo: gitLabRepo('gitlab.com/acme/orca')
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches both GitLab issue URL forms and rejects other projects', () => {
|
||||
for (const url of [
|
||||
'https://gitlab.com/acme/orca/-/issues/17',
|
||||
'https://gitlab.com/acme/orca/-/work_items/17'
|
||||
]) {
|
||||
const intent = parseCmdJTaskSourceUrl(url)
|
||||
expect(intent).toMatchObject({ provider: 'gitlab', link: { type: 'issue', number: 17 } })
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabIssue: 17 }),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.com/acme/orca')
|
||||
})
|
||||
).toMatchObject({
|
||||
matchedFields: ['issue'],
|
||||
supportingText: { labelKind: 'issue', text: 'Issue #17' }
|
||||
})
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({ linkedGitLabIssue: 17 }),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.example.com/other/project')
|
||||
})
|
||||
).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('matches a GitLab MR URL via the linked review URL', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree(),
|
||||
intent: intent!,
|
||||
repo: gitLabRepo('gitlab.example.com/other/project'),
|
||||
review: {
|
||||
provider: 'gitlab',
|
||||
number: 17,
|
||||
title: 'Fork MR',
|
||||
state: 'open',
|
||||
url: 'https://gitlab.com/acme/orca/-/merge_requests/17',
|
||||
status: 'pending',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
}
|
||||
})
|
||||
).toMatchObject({ matchedFields: ['mr'] })
|
||||
})
|
||||
|
||||
it('matches a Linear issue URL and rejects a different organization', () => {
|
||||
const intent = parseCmdJTaskSourceUrl(
|
||||
'https://linear.app/stably/issue/STA-4052/agent-terminals-disappearing-randomly'
|
||||
)
|
||||
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedLinearIssue: 'STA-4052',
|
||||
linkedLinearIssueOrganizationUrlKey: 'stably'
|
||||
}),
|
||||
intent: intent!
|
||||
})
|
||||
).toMatchObject({ supportingText: { text: 'STA-4052' } })
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedLinearIssue: 'STA-4052',
|
||||
linkedLinearIssueOrganizationUrlKey: 'other'
|
||||
}),
|
||||
intent: intent!
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches a Jira issue URL on its own tenant', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://acme.atlassian.net/browse/PROJ-123')
|
||||
|
||||
@@ -693,28 +71,6 @@ describe('matchWorktreePaletteTaskUrl', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('carries the worktree host so the board can key the Jira match by host identity', () => {
|
||||
// Jira must carry worktreeHostId like other providers or URL board filters lose every lane.
|
||||
const intent = parseCmdJTaskSourceUrl('https://acme.atlassian.net/browse/PROJ-123')
|
||||
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
hostId: 'ssh:box',
|
||||
linkedWorkItem: {
|
||||
provider: 'jira',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Ticket',
|
||||
jiraIdentifier: 'PROJ-123',
|
||||
url: 'https://acme.atlassian.net/browse/PROJ-123'
|
||||
}
|
||||
}),
|
||||
intent: intent!
|
||||
})
|
||||
).toMatchObject({ worktreeHostId: 'ssh:box' })
|
||||
})
|
||||
|
||||
// Same host, different site path: Jira Server installs are commonly path-scoped.
|
||||
it('rejects a Jira issue URL from a different site path on the same host', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://jira.acme.test/one/browse/PROJ-123')
|
||||
@@ -735,104 +91,4 @@ describe('matchWorktreePaletteTaskUrl', () => {
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
// Blank URLs are normalized away; only a present non-browse URL leaves identifier-only Jira matching reachable.
|
||||
it('falls back to the Jira identifier when the stored url is not a Jira link', () => {
|
||||
const intent = parseCmdJTaskSourceUrl('https://acme.atlassian.net/browse/PROJ-123')
|
||||
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedWorkItem: {
|
||||
provider: 'jira',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Linked elsewhere',
|
||||
jiraIdentifier: 'PROJ-123',
|
||||
url: 'https://github.com/stablyai/orca/issues/14198'
|
||||
}
|
||||
}),
|
||||
intent: intent!
|
||||
})
|
||||
).toMatchObject({ supportingText: { text: 'PROJ-123' } })
|
||||
})
|
||||
|
||||
// Jira appends a tracking query on copy, and the matcher is pathname-only.
|
||||
it('matches a pasted Jira url carrying a tracking query string', () => {
|
||||
const intent = parseCmdJTaskSourceUrl(
|
||||
'https://acme.atlassian.net/browse/PROJ-123?atlOrigin=eyJpIjoiZm9vIn0'
|
||||
)
|
||||
|
||||
expect(
|
||||
matchWorktreePaletteTaskUrl({
|
||||
worktree: makeWorktree({
|
||||
linkedWorkItem: {
|
||||
provider: 'jira',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: 'Tenant scoped',
|
||||
jiraIdentifier: 'PROJ-123',
|
||||
url: 'https://acme.atlassian.net/browse/PROJ-123'
|
||||
}
|
||||
}),
|
||||
intent: intent!
|
||||
})
|
||||
).toMatchObject({ supportingText: { text: 'PROJ-123' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCmdJTaskUrlCreatePreview', () => {
|
||||
it('describes GitHub issue and pull URLs without fetching', () => {
|
||||
expect(
|
||||
getCmdJTaskUrlCreatePreview(
|
||||
parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/14198')!
|
||||
)
|
||||
).toEqual({
|
||||
provider: 'github',
|
||||
identifier: '#14198',
|
||||
subtitle: 'stablyai/orca',
|
||||
kindLabel: 'GitHub issue',
|
||||
createLabel: 'Create worktree from GitHub issue stablyai/orca#14198'
|
||||
})
|
||||
expect(
|
||||
getCmdJTaskUrlCreatePreview(
|
||||
parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/pull/12789')!
|
||||
)?.kindLabel
|
||||
).toBe('GitHub pull request')
|
||||
expect(
|
||||
getCmdJTaskUrlCreatePreview(
|
||||
parseCmdJTaskSourceUrl('https://gitlab.com/acme/orca/-/merge_requests/17')!
|
||||
)
|
||||
).toMatchObject({
|
||||
provider: 'gitlab',
|
||||
identifier: '!17',
|
||||
kindLabel: 'GitLab merge request'
|
||||
})
|
||||
expect(
|
||||
getCmdJTaskUrlCreatePreview(
|
||||
parseCmdJTaskSourceUrl('https://company.atlassian.net/browse/ORCA-123')!
|
||||
)
|
||||
).toMatchObject({
|
||||
provider: 'jira',
|
||||
identifier: 'ORCA-123',
|
||||
kindLabel: 'Jira issue'
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces the GitHub subtitle with the resolved issue title', () => {
|
||||
const preview = getCmdJTaskUrlCreatePreview(
|
||||
parseCmdJTaskSourceUrl('https://github.com/stablyai/orca/issues/14198')!
|
||||
)!
|
||||
expect(
|
||||
withResolvedCmdJGitHubPreview(preview, 'Agent terminals disappearing randomly', false)
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
subtitle: 'Agent terminals disappearing randomly',
|
||||
createLabel:
|
||||
'Create worktree from GitHub issue stablyai/orca#14198: Agent terminals disappearing randomly',
|
||||
loading: false
|
||||
})
|
||||
)
|
||||
expect(withResolvedCmdJGitHubPreview(preview, null, true).loading).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -172,23 +172,6 @@ describe('preserved-pane replacement contract on workspace activation', () => {
|
||||
expect(afterRepeats.tabsByWorktree[worktree.id]).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not append a replacement while the preserved pane still has a live PTY', async () => {
|
||||
const worktree = { ...makeWorktree(), createdWithAgent: undefined }
|
||||
const state = baseState(worktree)
|
||||
seedHuskTab(state, worktree.id, 'pty-live-1')
|
||||
state.ptyIdsByTabId = { [HUSK_TAB_ID]: ['pty-live-1'] }
|
||||
useAppStore.setState(state)
|
||||
seedSleepingRecord(worktree.id, 'codex-session-B')
|
||||
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
const after = useAppStore.getState()
|
||||
expect(after.tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
// The record stays with its live pane instead of forking a duplicate.
|
||||
expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not fork a NON-group-active restorable pane into a replacement tab', async () => {
|
||||
const worktree = { ...makeWorktree(), createdWithAgent: undefined }
|
||||
const state = baseState(worktree)
|
||||
@@ -244,24 +227,6 @@ describe('preserved-pane replacement contract on workspace activation', () => {
|
||||
expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not append a replacement when the preserved pane will cold-restore in place', async () => {
|
||||
const worktree = { ...makeWorktree(), createdWithAgent: undefined }
|
||||
const state = baseState(worktree)
|
||||
// Restorable binding persists, no live PTY: pane-level cold restore owns recovery.
|
||||
seedHuskTab(state, worktree.id, 'pty-old-1')
|
||||
state.activeTabIdByWorktree = { [worktree.id]: HUSK_TAB_ID }
|
||||
state.activeTabTypeByWorktree = { [worktree.id]: 'terminal' }
|
||||
useAppStore.setState(state)
|
||||
seedSleepingRecord(worktree.id, 'codex-session-C')
|
||||
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
const after = useAppStore.getState()
|
||||
expect(after.tabsByWorktree[worktree.id]).toHaveLength(1)
|
||||
expect(after.sleepingAgentSessionsByPaneKey[makePaneKey(HUSK_TAB_ID, LEAF_ID)]).toBeDefined()
|
||||
})
|
||||
|
||||
// Published mirror tabs remain host-owned before hydration. Client replacement once relaunched codex against an active writer, stranding a shell and orphaning the live agent (aug20 windows 2).
|
||||
it('does not replace a still-published web-mirror tab after hydration settles', async () => {
|
||||
const webTabId = 'web-terminal-host-tab'
|
||||
@@ -345,49 +310,4 @@ describe('preserved-pane replacement contract on workspace activation', () => {
|
||||
expect(replayed.automaticAgentResumeClaimsByTabId).toEqual({})
|
||||
expect(replayed.pendingStartupByTabId).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps recovery evidence without replacing a retracted web-mirror tab', async () => {
|
||||
const webTabId = 'web-terminal-host-tab'
|
||||
const worktree = { ...makeWorktree(), createdWithAgent: undefined }
|
||||
const state = baseState(worktree)
|
||||
// Retracted: the layout husk survives for scrollback, the tab row does not.
|
||||
state.terminalLayoutsByTabId = {
|
||||
[webTabId]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-old-1' }
|
||||
} as never
|
||||
}
|
||||
useAppStore.setState(state)
|
||||
const paneKey = makePaneKey(webTabId, LEAF_ID)
|
||||
useAppStore.setState((s) => ({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
...s.sleepingAgentSessionsByPaneKey,
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: webTabId,
|
||||
worktreeId: worktree.id,
|
||||
agent: 'codex' as const,
|
||||
providerSession: { key: 'session_id' as const, id: 'codex-session-F' },
|
||||
prompt: 'resume prior task',
|
||||
state: 'working' as const,
|
||||
origin: 'quit' as const,
|
||||
capturedAt: 1000,
|
||||
updatedAt: 1000,
|
||||
terminalTitle: 'Codex'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
activateAndRevealWorktree(worktree.id)
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
const after = useAppStore.getState()
|
||||
const tabs = after.tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(1)
|
||||
// Activation may seed its ordinary shell; it must not queue an agent resume.
|
||||
expect(after.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined()
|
||||
expect(after.automaticAgentResumeClaimsByTabId).toEqual({})
|
||||
expect(after.consumeTabStartupCommand(tabs[0]!.id)?.resumeProviderSession).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { getDefaultSettings } from '../../../shared/constants'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore, type AppState } from '@/store'
|
||||
import { activateAndRevealWorktree } from './worktree-activation'
|
||||
import { makeCreatedAgentWorktree } from '@/lib/worktree-activation-created-agent.test-fixture'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { waitForWorktreeAgentActivationGateForTests } from './worktree-agent-activation-gate'
|
||||
|
||||
// Main's answer to the window's layout commands, as a switch-on build sends them.
|
||||
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
|
||||
(
|
||||
await import('@/runtime/layout-apply-test-transport.test-fixture')
|
||||
).runtimeRpcClientWithLayoutApply(await importOriginal())
|
||||
)
|
||||
|
||||
// A runtime-owned mirror can lack client bindings while its host PTY still holds the agent session. Defer resume to avoid codex active-writer failure, a stranded shell and tabless live agent (aug20 windows 2). Local fallback remains covered below.
|
||||
|
||||
const initialAppStoreState = useAppStore.getState()
|
||||
|
||||
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const WEB_TAB_ID = 'web-terminal-host-tab-1'
|
||||
|
||||
function baseState(worktree: ReturnType<typeof makeCreatedAgentWorktree>): Partial<AppState> {
|
||||
return {
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
path: path.join(path.sep, 'workspace', 'repo'),
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { 'repo-1': [worktree] },
|
||||
activeRepoId: 'repo-1',
|
||||
activeView: 'terminal',
|
||||
tabsByWorktree: {
|
||||
[worktree.id]: [{ id: WEB_TAB_ID, title: 'Codex', ptyId: null } as never]
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: `unified-${WEB_TAB_ID}`,
|
||||
contentType: 'terminal',
|
||||
entityId: WEB_TAB_ID,
|
||||
groupId: 'group-1'
|
||||
} as never
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: `unified-${WEB_TAB_ID}`,
|
||||
tabOrder: [`unified-${WEB_TAB_ID}`],
|
||||
recentTabIds: []
|
||||
} as never
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [worktree.id]: 'group-1' },
|
||||
layoutByWorktree: {},
|
||||
openFiles: [],
|
||||
browserTabsByWorktree: {},
|
||||
activeFileIdByWorktree: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
activeTabIdByWorktree: {},
|
||||
tabBarOrderByWorktree: {},
|
||||
pendingStartupByTabId: {},
|
||||
automaticAgentResumeClaimsByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
sleepingAgentSessionsByPaneKey: {},
|
||||
ptyIdsByTabId: {},
|
||||
terminalLayoutsByTabId: {
|
||||
[WEB_TAB_ID]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID,
|
||||
// Persisted binding from before the host restart: the pane HAD a PTY.
|
||||
ptyIdsByLeafId: { [LEAF_ID]: 'pty-host-old-1' }
|
||||
} as never
|
||||
},
|
||||
settings: {
|
||||
...getDefaultSettings(''),
|
||||
agentCmdOverrides: {}
|
||||
},
|
||||
markWorktreeVisited: vi.fn(),
|
||||
recordWorktreeVisit: vi.fn(),
|
||||
refreshGitHubForWorktreeIfStale: vi.fn(),
|
||||
revealWorktreeInSidebar: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useAppStore.setState(initialAppStoreState, true)
|
||||
})
|
||||
|
||||
describe('runtime-owned worktree activation with an unhydrated host mirror', () => {
|
||||
it('control: a local worktree with a dead pane still gets the resume fallback', async () => {
|
||||
const worktree = {
|
||||
...makeCreatedAgentWorktree(),
|
||||
createdWithAgent: undefined,
|
||||
hostId: 'local' as const
|
||||
}
|
||||
const state = baseState(worktree)
|
||||
state.activeWorkspaceExecutionHostId = null
|
||||
state.workspaceSessionReady = true
|
||||
state.terminalStartupRestorationReady = true
|
||||
// Local husk tab: same shape, non-mirror tab id.
|
||||
const localTabId = 'husk-tab-1'
|
||||
state.tabsByWorktree = {
|
||||
[worktree.id]: [{ id: localTabId, title: 'Codex', ptyId: null } as never]
|
||||
}
|
||||
state.unifiedTabsByWorktree = {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: `unified-${localTabId}`,
|
||||
contentType: 'terminal',
|
||||
entityId: localTabId,
|
||||
groupId: 'group-1'
|
||||
} as never
|
||||
]
|
||||
}
|
||||
state.groupsByWorktree = {
|
||||
[worktree.id]: [
|
||||
{
|
||||
id: 'group-1',
|
||||
activeTabId: `unified-${localTabId}`,
|
||||
tabOrder: [`unified-${localTabId}`],
|
||||
recentTabIds: []
|
||||
} as never
|
||||
]
|
||||
}
|
||||
state.terminalLayoutsByTabId = {
|
||||
[localTabId]: {
|
||||
root: { type: 'leaf', leafId: LEAF_ID },
|
||||
activeLeafId: LEAF_ID
|
||||
} as never
|
||||
}
|
||||
useAppStore.setState(state)
|
||||
const paneKey = makePaneKey(localTabId, LEAF_ID)
|
||||
useAppStore.setState((s) => ({
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
...s.sleepingAgentSessionsByPaneKey,
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
tabId: localTabId,
|
||||
worktreeId: worktree.id,
|
||||
agent: 'codex' as const,
|
||||
providerSession: { key: 'session_id' as const, id: 'codex-session-dead-1' },
|
||||
prompt: 'resume prior task',
|
||||
state: 'working' as const,
|
||||
origin: 'quit' as const,
|
||||
capturedAt: 1000,
|
||||
updatedAt: 1000,
|
||||
terminalTitle: 'Codex'
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
activateAndRevealWorktree(worktree.id, { notifyHostRuntime: false })
|
||||
await waitForWorktreeAgentActivationGateForTests(worktree.id)
|
||||
|
||||
const after = useAppStore.getState()
|
||||
const tabs = after.tabsByWorktree[worktree.id] ?? []
|
||||
expect(tabs).toHaveLength(2)
|
||||
const replacement = tabs.find((tab) => tab.id !== localTabId)!
|
||||
expect(after.automaticAgentResumeClaimsByTabId[replacement.id]?.providerSession).toEqual({
|
||||
key: 'session_id',
|
||||
id: 'codex-session-dead-1'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,140 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { resolveWorktreeRowOpenInRuntimeOwner } from './worktree-row-open-in-owner'
|
||||
import { getExternalEditorOpenCapability } from './external-editor-open-capability'
|
||||
import { getLocalPathOpenOwnerForRoute, isLocalPathOpenBlocked } from './local-path-open-guard'
|
||||
import { OWNER_RESOLUTION_CASES } from './runtime-owner-alias-matrix.test-fixture'
|
||||
|
||||
describe('an unhosted row resolves through the fold adapter', () => {
|
||||
const caseState = (name: string) => OWNER_RESOLUTION_CASES.find((c) => c.name === name)!.state
|
||||
const row = (id: string, repoId: string) => ({ id, repoId, hostId: undefined })
|
||||
|
||||
it('leaves a git twin unresolved instead of launching it as this desktop checkout', () => {
|
||||
expect(
|
||||
resolveWorktreeRowOpenInRuntimeOwner(
|
||||
caseState('focused | projected-twin::wt'),
|
||||
row('projected-twin::wt', 'projected-twin')
|
||||
).ownerUnresolved
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('routes a row whose only repo row is a server proxying its target through that server', () => {
|
||||
expect(
|
||||
resolveWorktreeRowOpenInRuntimeOwner(
|
||||
caseState('focused | stale-hub-repo::wt'),
|
||||
row('stale-hub-repo::wt', 'stale-hub-repo')
|
||||
)
|
||||
).toEqual({ runtimeEnvironmentId: 'hub-env', connectionId: null, ownerUnresolved: false })
|
||||
})
|
||||
})
|
||||
|
||||
const WORKTREE_ID = 'repo-1::/srv/worktree'
|
||||
|
||||
function worktree(hostId: Worktree['hostId'], runtimeOwnerEnvironmentId?: string): Worktree {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads only id/repoId/hostId/runtimeOwnerEnvironmentId.
|
||||
return {
|
||||
id: WORKTREE_ID,
|
||||
repoId: 'repo-1',
|
||||
path: '/srv/worktree',
|
||||
hostId,
|
||||
runtimeOwnerEnvironmentId
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
// Two paired servers provisioned from one profile publish the same id and SSH host.
|
||||
const rivalHubState = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
worktreesByRepo: {
|
||||
'repo-1': [
|
||||
worktree('ssh:same-private-target', 'hub-a'),
|
||||
worktree('ssh:same-private-target', 'hub-b')
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveWorktreeRowOpenInRuntimeOwner', () => {
|
||||
it('keeps the clicked row stamped owner when rival HUBs share its SSH host', () => {
|
||||
expect(
|
||||
resolveWorktreeRowOpenInRuntimeOwner(
|
||||
rivalHubState,
|
||||
worktree('ssh:same-private-target', 'hub-b')
|
||||
)
|
||||
).toEqual({ runtimeEnvironmentId: 'hub-b', connectionId: null, ownerUnresolved: false })
|
||||
})
|
||||
|
||||
it('reports an unstamped row on a rival-HUB host as unresolved, never as desktop-owned', () => {
|
||||
const owner = resolveWorktreeRowOpenInRuntimeOwner(
|
||||
rivalHubState,
|
||||
worktree('ssh:same-private-target')
|
||||
)
|
||||
expect(owner).toEqual({ runtimeEnvironmentId: null, connectionId: null, ownerUnresolved: true })
|
||||
const context = { command: 'code', ...owner }
|
||||
expect(getExternalEditorOpenCapability(context)).toEqual({
|
||||
allowed: false,
|
||||
reason: 'remote-runtime'
|
||||
})
|
||||
expect(isLocalPathOpenBlocked(getLocalPathOpenOwnerForRoute(context))).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers a single HUB owner for an unstamped row on its SSH host', () => {
|
||||
expect(
|
||||
resolveWorktreeRowOpenInRuntimeOwner(
|
||||
{ worktreesByRepo: { 'repo-1': [worktree('ssh:hub-private-target', 'hub-a')] } },
|
||||
worktree('ssh:hub-private-target')
|
||||
)
|
||||
).toEqual({ runtimeEnvironmentId: 'hub-a', connectionId: null, ownerUnresolved: false })
|
||||
})
|
||||
|
||||
it('keeps a direct SSH row resolved with no runtime', () => {
|
||||
expect(
|
||||
resolveWorktreeRowOpenInRuntimeOwner(
|
||||
{
|
||||
repos: [{ id: 'repo-1', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }],
|
||||
worktreesByRepo: { 'repo-1': [worktree('ssh:ssh-1')] }
|
||||
},
|
||||
worktree('ssh:ssh-1')
|
||||
)
|
||||
).toEqual({ runtimeEnvironmentId: null, connectionId: 'ssh-1', ownerUnresolved: false })
|
||||
})
|
||||
|
||||
it('names the SSH target of a host-only SSH row that has no repo connection', () => {
|
||||
const owner = resolveWorktreeRowOpenInRuntimeOwner(
|
||||
{
|
||||
repos: [{ id: 'repo-1', executionHostId: 'ssh:ssh-1' }],
|
||||
worktreesByRepo: { 'repo-1': [worktree(undefined)] }
|
||||
},
|
||||
worktree(undefined)
|
||||
)
|
||||
expect(owner).toEqual({
|
||||
runtimeEnvironmentId: null,
|
||||
connectionId: 'ssh-1',
|
||||
ownerUnresolved: false
|
||||
})
|
||||
expect(isLocalPathOpenBlocked(getLocalPathOpenOwnerForRoute(owner))).toBe(true)
|
||||
})
|
||||
|
||||
it('names the SSH target of a direct-SSH folder workspace row, which has no repo', () => {
|
||||
const folderRow = { id: 'folder:fw-1', hostId: 'ssh:ssh-1' as const }
|
||||
const owner = resolveWorktreeRowOpenInRuntimeOwner(
|
||||
{
|
||||
folderWorkspaces: [{ id: 'fw-1', projectGroupId: 'group-1', connectionId: 'ssh-1' }],
|
||||
projectGroups: [{ id: 'group-1', connectionId: 'ssh-1' }]
|
||||
},
|
||||
folderRow
|
||||
)
|
||||
expect(owner).toEqual({
|
||||
runtimeEnvironmentId: null,
|
||||
connectionId: 'ssh-1',
|
||||
ownerUnresolved: false
|
||||
})
|
||||
expect(isLocalPathOpenBlocked(getLocalPathOpenOwnerForRoute(owner))).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a local row resolved as desktop-owned', () => {
|
||||
expect(resolveWorktreeRowOpenInRuntimeOwner({}, worktree('local'))).toEqual({
|
||||
runtimeEnvironmentId: null,
|
||||
connectionId: null,
|
||||
ownerUnresolved: false
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
|
||||
import { runtimeEnvironmentIdForWorkspaceOwner } from './resolve-owner'
|
||||
/** The owner's paired server, as production callers read it; null for this app or a twin. */
|
||||
function ownerEnvironmentId(
|
||||
state: Parameters<typeof runtimeEnvironmentIdForWorkspaceOwner>[0],
|
||||
workspaceId: string
|
||||
): string | null {
|
||||
const owner = runtimeEnvironmentIdForWorkspaceOwner(state, { workspaceId })
|
||||
return owner.kind === 'resolved' ? owner.environmentId : null
|
||||
}
|
||||
|
||||
const REPO_COUNT = 20
|
||||
const WORKTREES_PER_REPO = 100
|
||||
const LOOKUPS_PER_SWEEP = 200
|
||||
|
||||
type OwnerRecord = {
|
||||
id: string
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
}
|
||||
type DetectedByRepo = Record<string, { worktrees: readonly OwnerRecord[] }>
|
||||
|
||||
// The pre-index expression, kept as the "before" leg of the benchmark.
|
||||
function walkHasDetected(detectedWorktreesByRepo: DetectedByRepo | undefined, id: string): boolean {
|
||||
return Object.values(detectedWorktreesByRepo ?? {}).some((result) =>
|
||||
result.worktrees.some((worktree) => worktree.id === id)
|
||||
)
|
||||
}
|
||||
|
||||
function buildDetectedCatalog(generation: number, counter?: { reads: number }): DetectedByRepo {
|
||||
const catalog: DetectedByRepo = {}
|
||||
for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) {
|
||||
const repoId = `repo-${repoIndex}`
|
||||
const worktrees: OwnerRecord[] = []
|
||||
for (let index = 0; index < WORKTREES_PER_REPO; index += 1) {
|
||||
const id = `${repoId}::detected-${index}-gen-${generation}`
|
||||
const record: OwnerRecord = { id, repoId, hostId: 'ssh:target-a' }
|
||||
if (counter) {
|
||||
Object.defineProperty(record, 'id', {
|
||||
get: () => {
|
||||
counter.reads += 1
|
||||
return id
|
||||
},
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
worktrees.push(record)
|
||||
}
|
||||
catalog[repoId] = { worktrees }
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
const publishedRepos = Array.from({ length: REPO_COUNT }, (_unused, index) => ({
|
||||
id: `repo-${index}`,
|
||||
connectionId: `target-${index}`
|
||||
}))
|
||||
const publishedWorktreesByRepo: Record<string, OwnerRecord[]> = Object.fromEntries(
|
||||
publishedRepos.map((repo) => [
|
||||
repo.id,
|
||||
Array.from({ length: WORKTREES_PER_REPO }, (_unused, index) => ({
|
||||
id: `${repo.id}::worktree-${index}`,
|
||||
repoId: repo.id,
|
||||
hostId: 'ssh:target-a' as const
|
||||
}))
|
||||
])
|
||||
)
|
||||
|
||||
// Only detected-catalog identity changes, isolating this collection’s invalidation.
|
||||
function buildOwnerState(detectedWorktreesByRepo: DetectedByRepo): WorktreeRuntimeOwnerState {
|
||||
return {
|
||||
repos: publishedRepos,
|
||||
worktreesByRepo: publishedWorktreesByRepo,
|
||||
detectedWorktreesByRepo,
|
||||
activeWorktreeId: null,
|
||||
activeWorkspaceExecutionHostId: null,
|
||||
runtimeEnvironments: []
|
||||
}
|
||||
}
|
||||
|
||||
// Probe ids are published but never detected — the SSH-pane case, and the walk's worst case.
|
||||
const PROBE_IDS = Array.from(
|
||||
{ length: LOOKUPS_PER_SWEEP },
|
||||
(_unused, index) => `repo-${index % REPO_COUNT}::worktree-${index % WORKTREES_PER_REPO}`
|
||||
)
|
||||
|
||||
describe('detected worktree index performance', () => {
|
||||
it('answers repeated owner lookups without rescanning the detected catalog', () => {
|
||||
const counter = { reads: 0 }
|
||||
const catalog = buildDetectedCatalog(0, counter)
|
||||
const state = buildOwnerState(catalog)
|
||||
|
||||
ownerEnvironmentId(state, 'repo-0::warm-the-index')
|
||||
counter.reads = 0
|
||||
for (const probeId of PROBE_IDS) {
|
||||
ownerEnvironmentId(state, probeId)
|
||||
}
|
||||
const indexedReads = counter.reads
|
||||
|
||||
counter.reads = 0
|
||||
for (const probeId of PROBE_IDS) {
|
||||
walkHasDetected(catalog, probeId)
|
||||
}
|
||||
const walkedReads = counter.reads
|
||||
|
||||
expect(indexedReads).toBe(0)
|
||||
expect(walkedReads).toBeGreaterThan(100_000)
|
||||
})
|
||||
})
|
||||
@@ -1,163 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
import {
|
||||
findIndexedDetectedWorktrees,
|
||||
hasIndexedDetectedWorktree,
|
||||
resolveIndexedWorktreeOwner
|
||||
hasIndexedDetectedWorktree
|
||||
} from './worktree-runtime-owner-index'
|
||||
|
||||
type OwnerRecord = {
|
||||
id: string
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
runtimeOwnerEnvironmentId?: string
|
||||
}
|
||||
type DetectedByRepo = Record<string, { worktrees: readonly OwnerRecord[] }>
|
||||
|
||||
// The pre-index expressions this module replaced, kept verbatim as parity oracles.
|
||||
function walkHasDetected(detectedWorktreesByRepo: DetectedByRepo | undefined, id: string): boolean {
|
||||
return Object.values(detectedWorktreesByRepo ?? {}).some((result) =>
|
||||
result.worktrees.some((worktree) => worktree.id === id)
|
||||
)
|
||||
}
|
||||
|
||||
function walkDetectedMatches(
|
||||
detectedWorktreesByRepo: DetectedByRepo | undefined,
|
||||
id: string
|
||||
): OwnerRecord[] {
|
||||
const matches: OwnerRecord[] = []
|
||||
for (const result of Object.values(detectedWorktreesByRepo ?? {})) {
|
||||
for (const worktree of result.worktrees) {
|
||||
if (worktree.id === id) {
|
||||
matches.push(worktree)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
function walkHasKnown(
|
||||
worktreesByRepo: Record<string, readonly OwnerRecord[]> | undefined,
|
||||
id: string
|
||||
): boolean {
|
||||
return Object.values(worktreesByRepo ?? {}).some((worktrees) =>
|
||||
worktrees.some((worktree) => worktree.id === id)
|
||||
)
|
||||
}
|
||||
|
||||
// Deterministic LCG so a parity failure reproduces from the printed case index.
|
||||
function makeRandom(seed: number): () => number {
|
||||
let state = seed >>> 0
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0
|
||||
return state / 0x100000000
|
||||
}
|
||||
}
|
||||
|
||||
const HOST_IDS: (ExecutionHostId | undefined)[] = [
|
||||
undefined,
|
||||
'local',
|
||||
'ssh:target-a',
|
||||
'ssh:target-b',
|
||||
'runtime:hub-a'
|
||||
]
|
||||
|
||||
function buildCase(random: () => number): {
|
||||
detectedWorktreesByRepo: DetectedByRepo | undefined
|
||||
worktreesByRepo: Record<string, readonly OwnerRecord[]>
|
||||
probeIds: string[]
|
||||
} {
|
||||
const roll = random()
|
||||
if (roll < 0.05) {
|
||||
return { detectedWorktreesByRepo: undefined, worktreesByRepo: {}, probeIds: ['repo-0::absent'] }
|
||||
}
|
||||
if (roll < 0.1) {
|
||||
return { detectedWorktreesByRepo: {}, worktreesByRepo: {}, probeIds: ['repo-0::absent'] }
|
||||
}
|
||||
const repoCount = 1 + Math.floor(random() * 6)
|
||||
const detectedWorktreesByRepo: DetectedByRepo = {}
|
||||
const worktreesByRepo: Record<string, readonly OwnerRecord[]> = {}
|
||||
const knownIds: string[] = []
|
||||
for (let repoIndex = 0; repoIndex < repoCount; repoIndex += 1) {
|
||||
const repoId = `repo-${repoIndex}`
|
||||
// Empty arrays are a real store shape: a scan that found nothing still publishes its bucket.
|
||||
const worktreeCount = random() < 0.25 ? 0 : 1 + Math.floor(random() * 5)
|
||||
const detected: OwnerRecord[] = []
|
||||
const published: OwnerRecord[] = []
|
||||
for (let index = 0; index < worktreeCount; index += 1) {
|
||||
// Duplicate ids across repos are how rival publications collide, so allow a shared pool.
|
||||
const id = random() < 0.3 ? `shared::worktree-${index}` : `${repoId}::worktree-${index}`
|
||||
const record: OwnerRecord = {
|
||||
id,
|
||||
repoId,
|
||||
hostId: HOST_IDS[Math.floor(random() * HOST_IDS.length)],
|
||||
...(random() < 0.3 ? { runtimeOwnerEnvironmentId: `hub-${Math.floor(random() * 3)}` } : {})
|
||||
}
|
||||
knownIds.push(id)
|
||||
if (random() < 0.8) {
|
||||
detected.push(record)
|
||||
}
|
||||
if (random() < 0.5) {
|
||||
published.push(record)
|
||||
}
|
||||
}
|
||||
detectedWorktreesByRepo[repoId] = { worktrees: detected }
|
||||
worktreesByRepo[repoId] = published
|
||||
}
|
||||
const probeIds = [
|
||||
knownIds[0] ?? 'repo-0::absent',
|
||||
knownIds.at(-1) ?? 'repo-0::absent',
|
||||
knownIds[Math.floor(random() * Math.max(knownIds.length, 1))] ?? 'repo-0::absent',
|
||||
'shared::worktree-0',
|
||||
'never-published::worktree'
|
||||
]
|
||||
return { detectedWorktreesByRepo, worktreesByRepo, probeIds }
|
||||
}
|
||||
|
||||
describe('detected worktree index', () => {
|
||||
it('matches the pre-index catalog walk across randomized store shapes', () => {
|
||||
const random = makeRandom(0x5eed)
|
||||
for (let caseIndex = 0; caseIndex < 240; caseIndex += 1) {
|
||||
const { detectedWorktreesByRepo, worktreesByRepo, probeIds } = buildCase(random)
|
||||
for (const probeId of probeIds) {
|
||||
expect(
|
||||
{
|
||||
case: caseIndex,
|
||||
probeId,
|
||||
has: hasIndexedDetectedWorktree(detectedWorktreesByRepo, probeId)
|
||||
},
|
||||
`case ${caseIndex} / ${probeId}`
|
||||
).toEqual({
|
||||
case: caseIndex,
|
||||
probeId,
|
||||
has: walkHasDetected(detectedWorktreesByRepo, probeId)
|
||||
})
|
||||
expect(
|
||||
findIndexedDetectedWorktrees(detectedWorktreesByRepo, probeId),
|
||||
`case ${caseIndex} / ${probeId}`
|
||||
).toEqual(walkDetectedMatches(detectedWorktreesByRepo, probeId))
|
||||
expect(
|
||||
resolveIndexedWorktreeOwner(worktreesByRepo, probeId).kind !== 'missing',
|
||||
`case ${caseIndex} / ${probeId}`
|
||||
).toBe(walkHasKnown(worktreesByRepo, probeId))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('returns rival publications in catalog order with identity preserved', () => {
|
||||
const first = { id: 'shared', repoId: 'repo-a', hostId: 'ssh:target-a' as const }
|
||||
const second = { id: 'shared', repoId: 'repo-b', hostId: 'runtime:hub-a' as const }
|
||||
const detectedWorktreesByRepo = {
|
||||
'repo-a': { worktrees: [first] },
|
||||
'repo-b': { worktrees: [second] }
|
||||
}
|
||||
|
||||
const matches = findIndexedDetectedWorktrees(detectedWorktreesByRepo, 'shared')
|
||||
expect(matches).toHaveLength(2)
|
||||
expect(matches[0]).toBe(first)
|
||||
expect(matches[1]).toBe(second)
|
||||
})
|
||||
|
||||
it('re-indexes a new catalog identity instead of serving stale hits', () => {
|
||||
const worktree = { id: 'repo::wt', repoId: 'repo' }
|
||||
const before = { repo: { worktrees: [worktree] } }
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
findIndexedWorktreeOwnerForHost,
|
||||
worktreeOwnerHostIds
|
||||
} from './worktree-runtime-owner-index'
|
||||
|
||||
describe('worktree runtime owner index', () => {
|
||||
it('treats a legacy worktree without a host as local', () => {
|
||||
const legacy = { id: 'legacy', repoId: 'repo-1' }
|
||||
const worktreesByRepo = { 'repo-1': [legacy] }
|
||||
|
||||
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, legacy.id, 'local')).toBe(legacy)
|
||||
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, legacy.id, 'runtime:hub-a')).toBeNull()
|
||||
expect(worktreeOwnerHostIds(legacy)).toEqual(['local'])
|
||||
})
|
||||
|
||||
it('indexes paired worktrees by both runtime owner and physical host', () => {
|
||||
const paired = {
|
||||
id: 'repo-1::same-id',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:private-target' as const,
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
}
|
||||
const directSsh = {
|
||||
id: 'repo-1::direct',
|
||||
repoId: 'repo-1',
|
||||
hostId: 'ssh:direct-target' as const
|
||||
}
|
||||
const worktreesByRepo = { 'repo-1': [paired, directSsh] }
|
||||
|
||||
expect(worktreeOwnerHostIds(paired)).toEqual(['ssh:private-target', 'runtime:hub-a'])
|
||||
|
||||
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, paired.id, 'runtime:hub-a')).toBe(
|
||||
paired
|
||||
)
|
||||
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, paired.id, 'ssh:private-target')).toBe(
|
||||
paired
|
||||
)
|
||||
expect(
|
||||
findIndexedWorktreeOwnerForHost(worktreesByRepo, directSsh.id, 'ssh:direct-target')
|
||||
).toBe(directSsh)
|
||||
expect(
|
||||
findIndexedWorktreeOwnerForHost(worktreesByRepo, directSsh.id, 'runtime:hub-a')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('fails closed when direct and paired worktrees share a physical host alias', () => {
|
||||
const direct = {
|
||||
id: 'same-id',
|
||||
repoId: 'direct-repo',
|
||||
hostId: 'ssh:private-target' as const
|
||||
}
|
||||
const paired = {
|
||||
id: 'same-id',
|
||||
repoId: 'paired-repo',
|
||||
hostId: 'ssh:private-target' as const,
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
}
|
||||
|
||||
for (const worktrees of [
|
||||
[direct, paired],
|
||||
[paired, direct]
|
||||
]) {
|
||||
const worktreesByRepo = { repo: worktrees }
|
||||
expect(
|
||||
findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'ssh:private-target')
|
||||
).toBeNull()
|
||||
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'runtime:hub-a')).toBe(
|
||||
paired
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed when paired worktrees share a runtime host alias', () => {
|
||||
const pairedA = {
|
||||
id: 'same-id',
|
||||
repoId: 'repo-a',
|
||||
hostId: 'ssh:private-a' as const,
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
}
|
||||
const pairedB = {
|
||||
id: 'same-id',
|
||||
repoId: 'repo-b',
|
||||
hostId: 'ssh:private-b' as const,
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
}
|
||||
|
||||
for (const worktrees of [
|
||||
[pairedA, pairedB],
|
||||
[pairedB, pairedA]
|
||||
]) {
|
||||
const worktreesByRepo = { repo: worktrees }
|
||||
expect(
|
||||
findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'runtime:hub-a')
|
||||
).toBeNull()
|
||||
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'ssh:private-a')).toBe(
|
||||
pairedA
|
||||
)
|
||||
expect(findIndexedWorktreeOwnerForHost(worktreesByRepo, 'same-id', 'ssh:private-b')).toBe(
|
||||
pairedB
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import '../../../shared/constants'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getKnownExecutionHostIdForWorktree,
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin
|
||||
} from './worktree-runtime-owner'
|
||||
import { resolveTerminalPtyOwner } from './terminal-worktree-route'
|
||||
import { getRuntimeSessionMirrorEnvironmentIds } from './runtime-session-mirror-owners'
|
||||
import './terminal-worktree-route'
|
||||
import './runtime-session-mirror-owners'
|
||||
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
|
||||
import { runtimeEnvironmentIdForWorkspaceOwner } from './resolve-owner'
|
||||
/** The owner's paired server, as production callers read it; null for this app or a twin. */
|
||||
@@ -18,392 +17,6 @@ function ownerEnvironmentId(
|
||||
return owner.kind === 'resolved' ? owner.environmentId : null
|
||||
}
|
||||
|
||||
const state: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-env' },
|
||||
repos: [
|
||||
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'legacy-repo', connectionId: null, executionHostId: null },
|
||||
{ id: 'runtime-repo', connectionId: null, executionHostId: 'runtime:owner-env' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }],
|
||||
'legacy-repo': [{ id: 'legacy-repo::wt-legacy', repoId: 'legacy-repo' }],
|
||||
'runtime-repo': [{ id: 'runtime-repo::wt-b', repoId: 'runtime-repo' }]
|
||||
},
|
||||
projectGroups: [
|
||||
{ id: 'local-group', connectionId: null, executionHostId: 'local' },
|
||||
{
|
||||
id: 'runtime-group',
|
||||
connectionId: 'ssh-inside-runtime',
|
||||
executionHostId: 'runtime:folder-env'
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{ id: 'local-folder', projectGroupId: 'local-group' },
|
||||
{ id: 'runtime-folder', projectGroupId: 'runtime-group' }
|
||||
]
|
||||
}
|
||||
|
||||
describe('runtimeEnvironmentIdOrLocalUnlessTwin', () => {
|
||||
it('routes to the runtime owner of the worktree', () => {
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(state, 'runtime-repo::wt-b')).toBe('owner-env')
|
||||
})
|
||||
|
||||
it('keeps explicit-local worktrees local even while a runtime is focused', () => {
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(state, 'local-repo::wt-a')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the synthetic floating workspace local while a runtime is focused', () => {
|
||||
const floatingState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'runtime:focused-env'
|
||||
}
|
||||
expect(
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(floatingState, FLOATING_TERMINAL_WORKTREE_ID)
|
||||
).toBeNull()
|
||||
expect(getKnownExecutionHostIdForWorktree(floatingState, FLOATING_TERMINAL_WORKTREE_ID)).toBe(
|
||||
'local'
|
||||
)
|
||||
expect(getExecutionHostIdForWorktree(floatingState, FLOATING_TERMINAL_WORKTREE_ID)).toBe(
|
||||
'local'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes folder workspaces to their project group runtime owner', () => {
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(state, 'folder:runtime-folder')).toBe('folder-env')
|
||||
expect(getExecutionHostIdForWorktree(state, 'folder:runtime-folder')).toBe('runtime:folder-env')
|
||||
})
|
||||
|
||||
it('uses the folder host stamp when same-ID project groups exist on different hosts', () => {
|
||||
const collisionState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'same-folder',
|
||||
projectGroupId: 'same-group',
|
||||
executionHostId: 'runtime:folder-env'
|
||||
}
|
||||
],
|
||||
projectGroups: [
|
||||
{ id: 'same-group', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'same-group', connectionId: null, executionHostId: 'runtime:folder-env' }
|
||||
]
|
||||
}
|
||||
|
||||
expect(getExecutionHostIdForWorktree(collisionState, 'folder:same-folder')).toBe(
|
||||
'runtime:folder-env'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes restored runtime folder workspaces before their catalog loads', () => {
|
||||
const restoredFolderState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-env' },
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
'folder:restored-folder': 'runtime:restored-env'
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(restoredFolderState, 'folder:restored-folder')
|
||||
).toBe('restored-env')
|
||||
expect(ownerEnvironmentId(restoredFolderState, 'folder:restored-folder')).toBe('restored-env')
|
||||
expect(getExecutionHostIdForWorktree(restoredFolderState, 'folder:restored-folder')).toBe(
|
||||
'runtime:restored-env'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps explicit-local folder workspaces local even while a runtime is focused', () => {
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(state, 'folder:local-folder')).toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(state, 'folder:local-folder')).toBe('local')
|
||||
|
||||
const restoredOwnerState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
'folder:local-folder': 'runtime:stale-env'
|
||||
}
|
||||
}
|
||||
expect(
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(restoredOwnerState, 'folder:local-folder')
|
||||
).toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(restoredOwnerState, 'folder:local-folder')).toBe('local')
|
||||
})
|
||||
|
||||
it('keeps folder workspaces with their own SSH target off the focused runtime', () => {
|
||||
const folderConnectionState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
projectGroups: [{ id: 'folder-group', connectionId: null, executionHostId: null }],
|
||||
folderWorkspaces: [
|
||||
{ id: 'folder-ssh', projectGroupId: 'folder-group', connectionId: 'folder-remote' }
|
||||
]
|
||||
}
|
||||
|
||||
expect(
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(folderConnectionState, 'folder:folder-ssh')
|
||||
).toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(folderConnectionState, 'folder:folder-ssh')).toBe(
|
||||
'ssh:folder-remote'
|
||||
)
|
||||
})
|
||||
|
||||
it('prefers project group runtime ownership over stale folder SSH targets', () => {
|
||||
const staleFolderConnectionState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
folderWorkspaces: [
|
||||
{ id: 'runtime-folder', projectGroupId: 'runtime-group', connectionId: 'old-ssh' }
|
||||
]
|
||||
}
|
||||
|
||||
expect(
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(staleFolderConnectionState, 'folder:runtime-folder')
|
||||
).toBe('folder-env')
|
||||
expect(getExecutionHostIdForWorktree(staleFolderConnectionState, 'folder:runtime-folder')).toBe(
|
||||
'runtime:folder-env'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTerminalPtyOwner', () => {
|
||||
it('names the runtime owner, a non-runtime owner, and keeps a twin ambiguous', () => {
|
||||
const twinState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
worktreesByRepo: {
|
||||
...state.worktreesByRepo,
|
||||
'twin-repo': [
|
||||
{ id: 'twin-repo::wt', repoId: 'twin-repo', hostId: 'ssh:ssh-1' },
|
||||
{ id: 'twin-repo::wt', repoId: 'twin-repo', runtimeOwnerEnvironmentId: 'env-1' }
|
||||
]
|
||||
}
|
||||
}
|
||||
expect(resolveTerminalPtyOwner(twinState, 'runtime-repo::wt-b')).toEqual({
|
||||
kind: 'runtime',
|
||||
environmentId: 'owner-env'
|
||||
})
|
||||
expect(resolveTerminalPtyOwner(twinState, 'local-repo::wt-a')).toEqual({
|
||||
kind: 'non-runtime'
|
||||
})
|
||||
expect(resolveTerminalPtyOwner(twinState, 'twin-repo::wt')).toEqual({
|
||||
kind: 'ambiguous'
|
||||
})
|
||||
// Why: read-only callers keep the old collapse to "no server".
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(twinState, 'twin-repo::wt')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the explicit owner environment (resolveOwner)', () => {
|
||||
it('keeps SSH execution on its paired HUB transport owner', () => {
|
||||
const nestedState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'different-hub' },
|
||||
repos: [
|
||||
{
|
||||
id: 'nested-repo',
|
||||
connectionId: 'hub-private-ssh',
|
||||
executionHostId: 'runtime:owner-hub'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'nested-repo': [
|
||||
{
|
||||
id: 'nested-repo::remote-worktree',
|
||||
repoId: 'nested-repo',
|
||||
hostId: 'ssh:hub-private-ssh',
|
||||
runtimeOwnerEnvironmentId: 'owner-hub'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(ownerEnvironmentId(nestedState, 'nested-repo::remote-worktree')).toBe('owner-hub')
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(nestedState, 'nested-repo::remote-worktree')).toBe(
|
||||
'owner-hub'
|
||||
)
|
||||
expect(getExecutionHostIdForWorktree(nestedState, 'nested-repo::remote-worktree')).toBe(
|
||||
'ssh:hub-private-ssh'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the repo HUB owner for a stale SSH publication without transport provenance', () => {
|
||||
const stalePublicationState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'different-hub' },
|
||||
repos: [
|
||||
{
|
||||
id: 'nested-repo',
|
||||
connectionId: 'hub-private-ssh',
|
||||
executionHostId: 'runtime:owner-hub'
|
||||
}
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'nested-repo': [
|
||||
{
|
||||
id: 'nested-repo::remote-worktree',
|
||||
repoId: 'nested-repo',
|
||||
hostId: 'ssh:hub-private-ssh'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(ownerEnvironmentId(stalePublicationState, 'nested-repo::remote-worktree')).toBe(
|
||||
'owner-hub'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses explicit HUB ownership from a detected-only worktree projection', () => {
|
||||
const detectedOnlyState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'different-hub' },
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {
|
||||
'nested-repo': {
|
||||
worktrees: [
|
||||
{
|
||||
id: 'nested-repo::remote-worktree',
|
||||
repoId: 'nested-repo',
|
||||
hostId: 'ssh:hub-private-ssh',
|
||||
runtimeOwnerEnvironmentId: 'owner-hub'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(ownerEnvironmentId(detectedOnlyState, 'nested-repo::remote-worktree')).toBe('owner-hub')
|
||||
expect(getExecutionHostIdForWorktree(detectedOnlyState, 'nested-repo::remote-worktree')).toBe(
|
||||
'ssh:hub-private-ssh'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed for conflicting detected-only HUB ownership', () => {
|
||||
const ambiguousState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-a' },
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
detectedWorktreesByRepo: {
|
||||
'repo-a': {
|
||||
worktrees: [
|
||||
{
|
||||
id: 'shared-worktree',
|
||||
repoId: 'repo-a',
|
||||
hostId: 'ssh:private-a',
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
}
|
||||
]
|
||||
},
|
||||
'repo-b': {
|
||||
worktrees: [
|
||||
{
|
||||
id: 'shared-worktree',
|
||||
repoId: 'repo-b',
|
||||
hostId: 'ssh:private-b',
|
||||
runtimeOwnerEnvironmentId: 'hub-b'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(ownerEnvironmentId(ambiguousState, 'shared-worktree')).toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(ambiguousState, 'shared-worktree')).toBe(
|
||||
'runtime:unresolved-owner'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps simultaneous HUB-owned SSH worktrees scoped to their own runtimes', () => {
|
||||
const multiHubState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: 'hub-b' },
|
||||
repos: [
|
||||
{ id: 'repo-a', connectionId: 'private-a', executionHostId: 'runtime:hub-a' },
|
||||
{ id: 'repo-b', connectionId: 'private-b', executionHostId: 'runtime:hub-b' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'repo-a': [
|
||||
{
|
||||
id: 'repo-a::ssh-worktree',
|
||||
repoId: 'repo-a',
|
||||
hostId: 'ssh:private-a',
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
}
|
||||
],
|
||||
'repo-b': [
|
||||
{
|
||||
id: 'repo-b::ssh-worktree',
|
||||
repoId: 'repo-b',
|
||||
hostId: 'ssh:private-b',
|
||||
runtimeOwnerEnvironmentId: 'hub-b'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(multiHubState, 'repo-a::ssh-worktree')).toBe(
|
||||
'hub-a'
|
||||
)
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(multiHubState, 'repo-b::ssh-worktree')).toBe(
|
||||
'hub-b'
|
||||
)
|
||||
expect(getExecutionHostIdForWorktree(multiHubState, 'repo-a::ssh-worktree')).toBe(
|
||||
'ssh:private-a'
|
||||
)
|
||||
expect(getExecutionHostIdForWorktree(multiHubState, 'repo-b::ssh-worktree')).toBe(
|
||||
'ssh:private-b'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat the focused runtime as ownership for legacy-local worktrees', () => {
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(state, 'legacy-repo::wt-legacy')).toBeNull()
|
||||
expect(ownerEnvironmentId(state, 'legacy-repo::wt-legacy')).toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(state, 'legacy-repo::wt-legacy')).toBe('local')
|
||||
expect(getExecutionHostIdForWorktree(state, 'unknown-repo::wt')).toBe('local')
|
||||
})
|
||||
|
||||
it('keeps a setup terminal on the focused runtime, where it spawns', () => {
|
||||
expect(getExecutionHostIdForWorktree(state, 'ephemeral-setup-terminal:skill-panel')).toBe(
|
||||
'runtime:focused-env'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the runtime owner when the repo or folder explicitly names one', () => {
|
||||
expect(ownerEnvironmentId(state, 'runtime-repo::wt-b')).toBe('owner-env')
|
||||
expect(ownerEnvironmentId(state, 'folder:runtime-folder')).toBe('folder-env')
|
||||
})
|
||||
|
||||
it('uses a worktree host id before the repo owner', () => {
|
||||
const hostOverrideState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
worktreesByRepo: {
|
||||
...state.worktreesByRepo,
|
||||
'runtime-repo': [
|
||||
{ id: 'runtime-repo::wt-local-override', repoId: 'runtime-repo', hostId: 'local' },
|
||||
{
|
||||
id: 'runtime-repo::wt-runtime-override',
|
||||
repoId: 'runtime-repo',
|
||||
hostId: 'runtime:worktree-env'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(ownerEnvironmentId(hostOverrideState, 'runtime-repo::wt-local-override')).toBeNull()
|
||||
expect(
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(hostOverrideState, 'runtime-repo::wt-local-override')
|
||||
).toBeNull()
|
||||
expect(
|
||||
getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-local-override')
|
||||
).toBe('local')
|
||||
expect(ownerEnvironmentId(hostOverrideState, 'runtime-repo::wt-runtime-override')).toBe(
|
||||
'worktree-env'
|
||||
)
|
||||
expect(
|
||||
runtimeEnvironmentIdOrLocalUnlessTwin(hostOverrideState, 'runtime-repo::wt-runtime-override')
|
||||
).toBe('worktree-env')
|
||||
expect(
|
||||
getExecutionHostIdForWorktree(hostOverrideState, 'runtime-repo::wt-runtime-override')
|
||||
).toBe('runtime:worktree-env')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runtime owner identity indexes', () => {
|
||||
it('indexes immutable owner records once across repeated worktree and folder lookups', () => {
|
||||
let worktreeIdReads = 0
|
||||
@@ -530,156 +143,3 @@ describe('runtime owner identity indexes', () => {
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(second, 'folder:folder')).toBe('folder-two')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRuntimeSessionMirrorEnvironmentIds', () => {
|
||||
it('includes focused runtime plus explicit repo, worktree, and folder owners', () => {
|
||||
const multiRuntimeState: WorktreeRuntimeOwnerState = {
|
||||
...state,
|
||||
worktreesByRepo: {
|
||||
...state.worktreesByRepo,
|
||||
'runtime-repo': [
|
||||
...(state.worktreesByRepo?.['runtime-repo'] ?? []),
|
||||
{
|
||||
id: 'runtime-repo::wt-runtime-override',
|
||||
repoId: 'runtime-repo',
|
||||
hostId: 'runtime:worktree-env'
|
||||
},
|
||||
{
|
||||
id: 'runtime-repo::wt-nested-ssh',
|
||||
repoId: 'runtime-repo',
|
||||
hostId: 'ssh:private-target',
|
||||
runtimeOwnerEnvironmentId: 'nested-owner-env'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
expect(getRuntimeSessionMirrorEnvironmentIds(multiRuntimeState)).toEqual([
|
||||
'focused-env',
|
||||
'folder-env',
|
||||
'nested-owner-env',
|
||||
'owner-env',
|
||||
'worktree-env'
|
||||
])
|
||||
})
|
||||
|
||||
it('includes restored runtime folder owners before their catalog loads', () => {
|
||||
expect(
|
||||
getRuntimeSessionMirrorEnvironmentIds({
|
||||
settings: { activeRuntimeEnvironmentId: 'focused-env' },
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: {
|
||||
'folder:restored-folder': 'runtime:restored-env'
|
||||
}
|
||||
})
|
||||
).toEqual(['focused-env', 'restored-env'])
|
||||
})
|
||||
|
||||
it('includes detected-only worktree owners before the primary catalog loads', () => {
|
||||
expect(
|
||||
getRuntimeSessionMirrorEnvironmentIds({
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
detectedWorktreesByRepo: {
|
||||
repo: {
|
||||
worktrees: [
|
||||
{
|
||||
id: 'repo::nested',
|
||||
repoId: 'repo',
|
||||
hostId: 'ssh:private-target',
|
||||
runtimeOwnerEnvironmentId: 'owner-hub'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
).toEqual(['owner-hub'])
|
||||
})
|
||||
|
||||
it('does not include local or SSH owners', () => {
|
||||
const localOnlyState: WorktreeRuntimeOwnerState = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
repos: [
|
||||
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'ssh-repo', connectionId: 'remote', executionHostId: 'ssh:remote' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'local-repo': [{ id: 'local-repo::wt-local', repoId: 'local-repo', hostId: 'local' }],
|
||||
'ssh-repo': [{ id: 'ssh-repo::wt-ssh', repoId: 'ssh-repo', hostId: 'ssh:remote' }]
|
||||
}
|
||||
}
|
||||
|
||||
expect(getRuntimeSessionMirrorEnvironmentIds(localOnlyState)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('active workspace host selection', () => {
|
||||
const PAIRED_HUB_WORKTREE_ID = 'hub-repo::wt-paired'
|
||||
const pairedHubState: WorktreeRuntimeOwnerState = {
|
||||
activeWorktreeId: PAIRED_HUB_WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'ssh:hub-private-target',
|
||||
worktreesByRepo: {
|
||||
'hub-repo': [
|
||||
{
|
||||
id: PAIRED_HUB_WORKTREE_ID,
|
||||
repoId: 'hub-repo',
|
||||
hostId: 'ssh:hub-private-target',
|
||||
runtimeOwnerEnvironmentId: 'hub-a'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps the HUB transport for the active paired SSH worktree', () => {
|
||||
expect(runtimeEnvironmentIdOrLocalUnlessTwin(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe(
|
||||
'hub-a'
|
||||
)
|
||||
expect(ownerEnvironmentId(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe('hub-a')
|
||||
})
|
||||
|
||||
it('keeps the selected host authoritative for the active worktree', () => {
|
||||
expect(getExecutionHostIdForWorktree(pairedHubState, PAIRED_HUB_WORKTREE_ID)).toBe(
|
||||
'ssh:hub-private-target'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getKnownExecutionHostIdForWorktree', () => {
|
||||
const emptyCatalog: WorktreeRuntimeOwnerState = { repos: [], worktreesByRepo: {} }
|
||||
|
||||
it('reports silence, not local, for a git worktree with no repo row', () => {
|
||||
expect(getKnownExecutionHostIdForWorktree(emptyCatalog, 'missing-repo::wt')).toBeNull()
|
||||
// The routing form keeps substituting the default in the same state.
|
||||
expect(getExecutionHostIdForWorktree(emptyCatalog, 'missing-repo::wt')).toBe('local')
|
||||
})
|
||||
|
||||
it('reports silence for a folder workspace with no folder-workspace row', () => {
|
||||
expect(getKnownExecutionHostIdForWorktree(emptyCatalog, 'folder:missing')).toBeNull()
|
||||
expect(getExecutionHostIdForWorktree(emptyCatalog, 'folder:missing')).toBe('local')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an ownerless repo row', { repos: [{ id: 'r' }] }, 'r::wt', 'local'],
|
||||
['an SSH repo row', { repos: [{ id: 'r', connectionId: 'box' }] }, 'r::wt', 'ssh:box'],
|
||||
[
|
||||
'a runtime repo row',
|
||||
{ repos: [{ id: 'r', executionHostId: 'runtime:env-1' }] },
|
||||
'r::wt',
|
||||
'runtime:env-1'
|
||||
],
|
||||
[
|
||||
'a per-worktree host',
|
||||
{ worktreesByRepo: { r: [{ id: 'r::wt', repoId: 'r', hostId: 'ssh:box' }] } },
|
||||
'r::wt',
|
||||
'ssh:box'
|
||||
],
|
||||
[
|
||||
'a folder-workspace row',
|
||||
{ folderWorkspaces: [{ id: 'f', projectGroupId: 'g' }] },
|
||||
'folder:f',
|
||||
'local'
|
||||
],
|
||||
['the floating workspace', {}, FLOATING_TERMINAL_WORKTREE_ID, 'local']
|
||||
] as const)('answers positively for %s', (_label, catalog, worktreeId, expected) => {
|
||||
expect(getKnownExecutionHostIdForWorktree(catalog, worktreeId)).toBe(expected)
|
||||
expect(getExecutionHostIdForWorktree(catalog, worktreeId)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { splitWorktreeSortOrderByHost } from './worktree-sort-order-host-split'
|
||||
|
||||
describe('splitWorktreeSortOrderByHost', () => {
|
||||
it('leaves out a twin and a row only a repo row places on a server', () => {
|
||||
const groups = splitWorktreeSortOrderByHost(
|
||||
{
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
runtimeEnvironments: [{ id: 'hub-env' }],
|
||||
runtimeEnvironmentCatalogHydrated: true,
|
||||
repos: [
|
||||
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'twin', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'twin', connectionId: 'box', executionHostId: 'ssh:box' },
|
||||
{ id: 'stale-hub', connectionId: 'target', executionHostId: 'runtime:hub-env' },
|
||||
{ id: 'hub', connectionId: 'box', executionHostId: 'runtime:hub-env' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'local-repo': [{ id: 'local-repo::wt-a', repoId: 'local-repo' }],
|
||||
twin: [
|
||||
{ id: 'twin::wt', repoId: 'twin', hostId: 'local' },
|
||||
{ id: 'twin::wt', repoId: 'twin', hostId: 'ssh:box' }
|
||||
],
|
||||
'stale-hub': [{ id: 'stale-hub::wt', repoId: 'stale-hub', hostId: 'ssh:target' }],
|
||||
hub: [
|
||||
{
|
||||
id: 'hub::wt',
|
||||
repoId: 'hub',
|
||||
hostId: 'ssh:box',
|
||||
runtimeOwnerEnvironmentId: 'hub-env'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
['twin::wt', 'stale-hub::wt', 'hub::wt', 'local-repo::wt-a']
|
||||
)
|
||||
expect(groups).toEqual([
|
||||
{ hostId: 'runtime:hub-env', orderedIds: ['hub::wt'], rowHostIds: ['ssh:box'] },
|
||||
{ hostId: 'local', orderedIds: ['local-repo::wt-a'], rowHostIds: ['local'] }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getWorktreeStatus } from './worktree-status'
|
||||
|
||||
const LEAF_A = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_C = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
describe('live numeric slots after closing an earlier split pane', () => {
|
||||
it('keeps a working background pane whose runtime id exceeds the remaining leaf count', () => {
|
||||
expect(
|
||||
getWorktreeStatus(
|
||||
[{ id: 'tab', title: 'zsh', launchAgent: 'codex' }],
|
||||
[],
|
||||
{ tab: ['pty-a', 'pty-c'] },
|
||||
{ tab: { 1: 'zsh', 3: '⠋ Codex' } },
|
||||
{
|
||||
terminalLayoutRootsByTabId: {
|
||||
tab: {
|
||||
type: 'split',
|
||||
direction: 'horizontal',
|
||||
first: { type: 'leaf', leafId: LEAF_A },
|
||||
second: { type: 'leaf', leafId: LEAF_C }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
).toBe('working')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pane titles from removed panes', () => {
|
||||
it('ignores a removed durable leaf spinner and falls back to the idle tab title', () => {
|
||||
const removedLeafTitles = { [LEAF_C]: '⠋ Codex' }
|
||||
expect(
|
||||
getWorktreeStatus(
|
||||
[{ id: 'tab', title: 'zsh', launchAgent: 'codex' }],
|
||||
[],
|
||||
{ tab: ['pty-a'] },
|
||||
{ tab: removedLeafTitles },
|
||||
{ terminalLayoutRootsByTabId: { tab: { type: 'leaf', leafId: LEAF_A } } }
|
||||
)
|
||||
).not.toBe('working')
|
||||
})
|
||||
})
|
||||
@@ -28,25 +28,6 @@ function rowCount(tab: Partial<TerminalTab>, paneTitles?: Record<number, string>
|
||||
}).length
|
||||
}
|
||||
|
||||
// Accepted trade-off: hookless remote agents lack exit evidence, so retained launch identity can attribute a later unrelated spinner. Match the row builder’s deliberate #9647 behavior.
|
||||
describe('#9040 spinner attribution trade-off is bounded', () => {
|
||||
it('over-reports a non-agent spinner in a tab an agent was launched in', () => {
|
||||
const tab = {
|
||||
id: 'tab-1',
|
||||
title: '⠋ Progress: resolved 42',
|
||||
launchAgent: 'claude'
|
||||
} satisfies Partial<TerminalTab>
|
||||
|
||||
expect(getWorktreeStatus([tab], [], livePtyMap('tab-1'))).toBe('working')
|
||||
// Bound 1: the same title cannot spin a tab with no launch identity.
|
||||
expect(getWorktreeStatus([{ id: 'tab-1', title: tab.title }], [], livePtyMap('tab-1'))).toBe(
|
||||
'active'
|
||||
)
|
||||
// Bound 2: a dead PTY drops out regardless of launch identity.
|
||||
expect(getWorktreeStatus([tab], [], {})).toBe('inactive')
|
||||
})
|
||||
})
|
||||
|
||||
// Claude and named-provider titles must produce the same dot/sidebar agreement.
|
||||
describe('#9040 spinner attribution matches named-provider dot/row agreement', () => {
|
||||
it('produces a sidebar row alongside the dot, like a named provider does', () => {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalPaneLayoutNode } from '../../../shared/terminal-tab-types'
|
||||
import { resolveWorktreeStatus } from './worktree-status'
|
||||
|
||||
const LEAF_ID_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function splitLayoutRoot(): TerminalPaneLayoutNode {
|
||||
return {
|
||||
type: 'split',
|
||||
direction: 'vertical',
|
||||
first: { type: 'leaf', leafId: LEAF_ID_1 },
|
||||
second: { type: 'leaf', leafId: LEAF_ID_2 }
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveWorktreeStatus terminal layout roots', () => {
|
||||
it('suppresses stale working titles without full layout snapshots', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-0'] },
|
||||
runtimePaneTitlesByTabId: {
|
||||
'tab-1': {
|
||||
1: 'codex [working]',
|
||||
2: 'bash'
|
||||
}
|
||||
},
|
||||
agentStatusPaneIdsByTabId: {
|
||||
'tab-1': new Set([LEAF_ID_1])
|
||||
},
|
||||
terminalLayoutRootsByTabId: {
|
||||
'tab-1': splitLayoutRoot()
|
||||
},
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: true,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('done')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
|
||||
import { getWorktreeStatus, getWorktreeStatusLabel, resolveWorktreeStatus } from './worktree-status'
|
||||
import { resolveWorktreeStatus } from './worktree-status'
|
||||
|
||||
const LEAF_ID_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222'
|
||||
@@ -23,284 +23,7 @@ function splitLayout(): TerminalLayoutSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
describe('getWorktreeStatus', () => {
|
||||
it('prioritizes permission over other live activity states', () => {
|
||||
const status = getWorktreeStatus(
|
||||
[
|
||||
{ id: 'tab-1', title: 'claude [working]' },
|
||||
{ id: 'tab-2', title: 'claude [permission]' }
|
||||
],
|
||||
[{ id: 'browser-1' }],
|
||||
livePtyMap('tab-1', 'tab-2')
|
||||
)
|
||||
|
||||
expect(status).toBe('permission')
|
||||
expect(getWorktreeStatusLabel(status)).toBe('Needs permission')
|
||||
})
|
||||
|
||||
it('treats browser-only worktrees as active', () => {
|
||||
const status = getWorktreeStatus([], [{ id: 'browser-1' }], {})
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('preserves a working agent status without a renderer PTY', () => {
|
||||
const status = getWorktreeStatus([], [], {}, {}, { liveAgentStatus: 'working' })
|
||||
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
|
||||
it('preserves a permission agent status without a renderer PTY', () => {
|
||||
const status = getWorktreeStatus([], [], {}, {}, { liveAgentStatus: 'permission' })
|
||||
|
||||
expect(status).toBe('permission')
|
||||
})
|
||||
|
||||
it('returns inactive when neither tabs nor browser state are live', () => {
|
||||
expect(getWorktreeStatus([], [], {})).toBe('inactive')
|
||||
})
|
||||
|
||||
it('reports working when any pane in a split-pane tab is working even if tab.title is idle', () => {
|
||||
// Focused-pane changes rewrite tab.title; idle focus must not hide another working pane’s spinner.
|
||||
const status = getWorktreeStatus(
|
||||
[{ id: 'tab-1', title: 'claude [done]' }],
|
||||
[],
|
||||
livePtyMap('tab-1'),
|
||||
{ 'tab-1': { 0: 'codex [working]', 1: 'claude [done]' } }
|
||||
)
|
||||
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
|
||||
it('prefers pane-level permission status over tab.title', () => {
|
||||
const status = getWorktreeStatus(
|
||||
[{ id: 'tab-1', title: 'claude [done]' }],
|
||||
[],
|
||||
livePtyMap('tab-1'),
|
||||
{ 'tab-1': { 0: 'claude [permission]', 1: 'claude [done]' } }
|
||||
)
|
||||
|
||||
expect(status).toBe('permission')
|
||||
})
|
||||
|
||||
// Sleep clears live ptyIdsByTabId but retains tab.ptyId as a wake hint; only the former proves liveness.
|
||||
it('returns inactive for a slept tab (ptyIdsByTabId empty even if heuristic title matches working)', () => {
|
||||
const status = getWorktreeStatus([{ id: 'tab-1', title: 'claude [working]' }], [], {
|
||||
'tab-1': []
|
||||
})
|
||||
|
||||
expect(status).toBe('inactive')
|
||||
})
|
||||
|
||||
it('returns active when ptyIdsByTabId has entries (live precondition)', () => {
|
||||
const status = getWorktreeStatus([{ id: 'tab-1', title: 'bash' }], [], livePtyMap('tab-1'))
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
// Exited agents can leave frozen spinner titles. Without attributable Claude identity the row builder shows no agent; the dot must agree.
|
||||
it('does not spin on a bare braille-spinner tab title that yields no agent row', () => {
|
||||
const status = getWorktreeStatus(
|
||||
[{ id: 'tab-1', title: '⠐ Review branch for regressions' }],
|
||||
[],
|
||||
livePtyMap('tab-1')
|
||||
)
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('does not spin on a bare braille-spinner pane title that yields no agent row', () => {
|
||||
const status = getWorktreeStatus([{ id: 'tab-1', title: 'bash' }], [], livePtyMap('tab-1'), {
|
||||
'tab-1': { 0: '⠐ Review branch for regressions', 1: 'bash' }
|
||||
})
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('keeps an unowned task title active despite an incidental agent name', () => {
|
||||
const status = getWorktreeStatus(
|
||||
[{ id: 'tab-1', title: '⠹ codex fix flaky test' }],
|
||||
[],
|
||||
livePtyMap('tab-1')
|
||||
)
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('spins on the same task title with independent launch identity', () => {
|
||||
const status = getWorktreeStatus(
|
||||
[{ id: 'tab-1', title: '⠹ codex fix flaky test', launchAgent: 'codex' }],
|
||||
[],
|
||||
livePtyMap('tab-1')
|
||||
)
|
||||
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveWorktreeStatus', () => {
|
||||
it('returns inactive when no tab has a live pty and no explicit agent row exists', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [done]' }],
|
||||
browserTabs: [],
|
||||
// Slept: live-pty array empty; tab.ptyId would be the wake-hint sessionId.
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('inactive')
|
||||
})
|
||||
|
||||
it('promotes to done when a retained done row is visible, even without a live pty', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'bash' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: true
|
||||
})
|
||||
|
||||
expect(status).toBe('done')
|
||||
})
|
||||
|
||||
it('treats pending paired web host terminal mirrors as inactive without a live pty', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: {},
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('inactive')
|
||||
})
|
||||
|
||||
it('treats ready paired web host terminal mirrors as active once they have a live pty', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: { 'web-terminal-host-tab-1': ['pty-1'] },
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('keeps browser-only paired workspaces active without terminal liveness', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'web-terminal-host-tab-1', title: 'Terminal 1' }],
|
||||
browserTabs: [{ id: 'browser-1' }],
|
||||
ptyIdsByTabId: {},
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('promotes to permission when an explicit agent row needs input, even without a live pty', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [permission]' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: { 'tab-1': [] },
|
||||
hasPermission: true,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('permission')
|
||||
})
|
||||
|
||||
it('promotes to permission when live and hasPermission', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'bash' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: true,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('permission')
|
||||
})
|
||||
|
||||
it('promotes to working from a fresh explicit agent row before pane titles restore', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'bash' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: false,
|
||||
hasLiveWorking: true,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
|
||||
it('reports passive monitoring below active work and permission', () => {
|
||||
const base = {
|
||||
tabs: [{ id: 'tab-1', title: 'bash' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveWorktreeStatus({
|
||||
...base,
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveMonitoring: true
|
||||
})
|
||||
).toBe('monitoring')
|
||||
expect(
|
||||
resolveWorktreeStatus({
|
||||
...base,
|
||||
hasPermission: false,
|
||||
hasLiveWorking: true,
|
||||
hasLiveMonitoring: true
|
||||
})
|
||||
).toBe('working')
|
||||
expect(
|
||||
resolveWorktreeStatus({
|
||||
...base,
|
||||
hasPermission: true,
|
||||
hasLiveWorking: true,
|
||||
hasLiveMonitoring: true
|
||||
})
|
||||
).toBe('permission')
|
||||
})
|
||||
|
||||
it('lets heuristic working beat hasLiveDone (newer in-progress signal wins)', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: true,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
|
||||
it('lets a hook-covered done pane suppress its stale working title', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
|
||||
@@ -327,28 +50,6 @@ describe('resolveWorktreeStatus', () => {
|
||||
expect(status).toBe('done')
|
||||
})
|
||||
|
||||
it('lets a single hook-covered done pane suppress an unmapped single working title', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
runtimePaneTitlesByTabId: {
|
||||
'tab-1': {
|
||||
1: 'codex [working]'
|
||||
}
|
||||
},
|
||||
agentStatusPaneIdsByTabId: {
|
||||
'tab-1': new Set([LEAF_ID_1])
|
||||
},
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: true,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps sibling pane working when hook done covers only another pane', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [working]' }],
|
||||
@@ -374,76 +75,4 @@ describe('resolveWorktreeStatus', () => {
|
||||
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
|
||||
// Permission outranks working/done for both explicit hooks and title heuristics; done overlays must not suppress a scraped permission question.
|
||||
it('honors heuristic permission over hasLiveDone (priority: permission > done)', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'claude [permission]' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: true,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('permission')
|
||||
})
|
||||
|
||||
it('promotes to done when live and hasRetainedDone (no working heuristic)', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'bash' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: true
|
||||
})
|
||||
|
||||
expect(status).toBe('done')
|
||||
})
|
||||
|
||||
it('keeps a hook-backed working agent spinning despite an unattributable stale title', () => {
|
||||
// Hook-managed working rows must survive the title-attribution gate.
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: '⠐ Review branch for regressions' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: false,
|
||||
hasLiveWorking: true,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('working')
|
||||
})
|
||||
|
||||
it('resolves a stale unattributable spinner title with no hook entry to active, not working', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: '⠐ Review branch for regressions' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
|
||||
it('falls through to active heuristic when nothing else applies', () => {
|
||||
const status = resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'bash' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: livePtyMap('tab-1'),
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
|
||||
expect(status).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getWorktreeVisitKey,
|
||||
getWorktreeVisitTimestamp,
|
||||
rekeyWorktreeVisitHost,
|
||||
removeWorktreeVisitEntries
|
||||
} from './worktree-visit-recency'
|
||||
|
||||
describe('worktree visit recency identities', () => {
|
||||
it('falls back to a legacy bare key only when the host key is absent', () => {
|
||||
const id = 'repo::/srv/app'
|
||||
expect(getWorktreeVisitTimestamp({ [id]: 100 }, { id, hostId: 'ssh:builder' })).toBe(100)
|
||||
expect(
|
||||
getWorktreeVisitTimestamp(
|
||||
{ [id]: 100, [getWorktreeVisitKey(id, 'ssh:builder')]: 200 },
|
||||
{ id, hostId: 'ssh:builder' }
|
||||
)
|
||||
).toBe(200)
|
||||
})
|
||||
|
||||
it('removes one host key without deleting the other host twin or legacy fallback', () => {
|
||||
const id = 'repo::/srv/app'
|
||||
const timestamps = {
|
||||
[id]: 50,
|
||||
[getWorktreeVisitKey(id, 'local')]: 100,
|
||||
[getWorktreeVisitKey(id, 'ssh:builder')]: 200
|
||||
}
|
||||
expect(removeWorktreeVisitEntries(timestamps, new Set([id]), 'local')).toEqual({
|
||||
[id]: 50,
|
||||
[getWorktreeVisitKey(id, 'ssh:builder')]: 200
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('rekeyWorktreeVisitHost', () => {
|
||||
const id = 'repo::/srv/app'
|
||||
|
||||
it('moves one host’s visits to the host that now runs its workspaces, newer visit winning', () => {
|
||||
const moved = rekeyWorktreeVisitHost(
|
||||
{
|
||||
[getWorktreeVisitKey(id, 'ssh:box')]: 300,
|
||||
[getWorktreeVisitKey(id, 'runtime:env')]: 100,
|
||||
[getWorktreeVisitKey('repo::/srv/other', 'ssh:box')]: 50,
|
||||
[getWorktreeVisitKey(id, 'ssh:box-2')]: 70,
|
||||
[getWorktreeVisitKey(id, 'local')]: 20,
|
||||
[id]: 10
|
||||
},
|
||||
'ssh:box',
|
||||
'runtime:env'
|
||||
)
|
||||
expect(moved).toEqual({
|
||||
[getWorktreeVisitKey(id, 'runtime:env')]: 300,
|
||||
[getWorktreeVisitKey('repo::/srv/other', 'runtime:env')]: 50,
|
||||
[getWorktreeVisitKey(id, 'ssh:box-2')]: 70,
|
||||
[getWorktreeVisitKey(id, 'local')]: 20,
|
||||
[id]: 10
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveAccountOwner, wslHostDistroForTarget } from './wsl-host-account-owner'
|
||||
|
||||
const ENVIRONMENTS = [
|
||||
{
|
||||
id: 'wsl:Ubuntu',
|
||||
orcadDeployment: {
|
||||
kind: 'wsl' as const,
|
||||
distro: 'Ubuntu',
|
||||
localPort: 41000,
|
||||
remotePort: 41001
|
||||
}
|
||||
},
|
||||
{ id: 'server-1', orcadDeployment: undefined }
|
||||
]
|
||||
|
||||
describe('resolveAccountOwner', () => {
|
||||
it('makes this desktop the owner of a WSL host, for its distro', () => {
|
||||
expect(
|
||||
resolveAccountOwner({ kind: 'environment', environmentId: 'wsl:Ubuntu' }, ENVIRONMENTS)
|
||||
).toEqual({ target: { kind: 'local' }, wslHostDistro: 'Ubuntu' })
|
||||
})
|
||||
|
||||
it('keeps a paired server as the owner of its own accounts', () => {
|
||||
const target = { kind: 'environment' as const, environmentId: 'server-1' }
|
||||
expect(resolveAccountOwner(target, ENVIRONMENTS)).toEqual({
|
||||
target,
|
||||
wslHostDistro: null
|
||||
})
|
||||
})
|
||||
|
||||
it('never guesses a WSL host from an id that is not in the catalog', () => {
|
||||
expect(
|
||||
wslHostDistroForTarget({ kind: 'environment', environmentId: 'wsl:Debian' }, ENVIRONMENTS)
|
||||
).toBeNull()
|
||||
expect(wslHostDistroForTarget({ kind: 'local' }, ENVIRONMENTS)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -254,7 +254,7 @@ test('keeps a paired client workspace create-with-agent off the other client and
|
||||
await expectActiveWorktree(clientB, ids.clientB)
|
||||
await expectActiveWorktree(orcaPage, ids.host)
|
||||
|
||||
// Drive creator selection directly; worktree-creation-flow.test.ts and worktree.spec.ts cover composer auto-navigation.
|
||||
// Drive creator selection directly; composer auto-navigation is outside this journey.
|
||||
await selectWorktree(clientA, createdWorktreeId)
|
||||
await expectActiveWorktree(clientB, ids.clientB)
|
||||
await expectActiveWorktree(orcaPage, ids.host)
|
||||
|
||||
Reference in New Issue
Block a user