From d1466f6017c4bb6006b77eb46f2fc0f461ef021e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:51:57 -0800 Subject: [PATCH] refactor: finish terminal runtime and typography owner imports (#40761) * refactor: finish terminal and Mobile defining-owner imports * fix: retain completion coordinator type binding * refactor: consolidate direct terminal tree imports * refactor(renderer): import typography from defining owners --- config/flaky-test-pattern-baseline.txt | 3 - config/reliability-gates.jsonc | 12 +- .../scripts/ci/sharding/ci-shard-timings.json | 5 - ...eck-terminal-parking-ownership-ratchet.mjs | 5 +- .../source-routing/pr-layout-oracle-route.mjs | 2 +- .../src/automations/use-automation-catalog.ts | 3 +- mobile/src/query/host-query.ts | 3 - .../appearance/monospace-font-family.ts | 5 - .../AgentDefaultSurfacePreviews.tsx | 2 +- .../preview-terminal-options.ts | 2 +- .../editor/diff/sections/DiffSectionBody.tsx | 6 +- .../use-markdown-preview-source-foundation.ts | 2 +- .../editor/notebook/document/IpynbViewer.tsx | 2 +- .../native-chat-appearance-style.ts | 4 +- .../editor/EditorTypographySettings.tsx | 2 +- .../appearance/TerminalSettingsPreview.tsx | 2 +- .../tab-bar/use-allow-auto-close.ts | 7 +- .../components/terminal-cold-activation.ts | 3 +- .../appearance/terminal-appearance.ts | 2 +- .../windows-terminal-font-weight-notice.tsx | 2 +- .../TerminalPaneCodexRestartPortals.tsx | 2 +- .../controller/use-terminal-pane-tab-state.ts | 5 +- .../detach/terminal-layout-leaf-detach.ts | 5 +- .../ownership/terminal-layout-active-leaf.ts | 5 +- .../persistence/remote-pane-layout-push.ts | 5 +- .../terminal-layout-leaf-traversal.test.ts | 8 +- .../layout/terminal-layout-leaf-traversal.ts | 5 +- .../mounting/terminal-pane-manager-options.ts | 2 +- .../use-manual-terminal-worktree-parking.ts | 3 +- .../use-terminal-tab-cold-parking.test.ts | 256 +------------ .../use-terminal-tab-cold-parking.ts | 3 +- .../parked-terminal-tab-coverage.ts | 9 +- .../terminal-parked-tab-watchers.test.ts | 292 +------------- .../terminal-parked-tab-watchers.ts | 2 - .../terminal-cold-park-timer-rearm.test.tsx | 175 --------- .../terminal-cold-park-withheld-tabs.ts | 3 +- ...park-verdict-worktree-driver-loop.test.tsx | 180 --------- .../spawn/disposed-spawn-retention.ts | 5 +- .../pty-connection-fresh-spawn-guards.spec.ts | 201 ---------- .../pty-connection-spawn-and-startup.test.ts | 2 - .../ipc/pty-transport-connect-spawn.test.ts | 120 ------ .../remote-layout-scrollback-retention.ts | 5 +- .../titles/use-host-pane-title-sync.ts | 5 +- .../WorkerAutoCloseBanner.tsx | 6 +- .../terminal-parking-pass-candidates.ts | 3 +- .../terminal/use-terminal-worktree-prewarm.ts | 3 +- .../use-terminal-watcher-effects.ts | 2 +- .../wsl-move/wsl-move-scope-rows.ts | 5 +- ...gent-hook-completion-notification-types.ts | 16 + .../agent-hook-completion-notifications.ts | 24 +- ...vents-agent-status-window-test-fixtures.ts | 4 - .../ipc-events-close-routing-test-harness.ts | 4 - .../agent-status-pane-routing-index.ts | 5 +- .../ipc-events/terminal-command-state.ts | 7 +- .../terminal-pane-title-ipc-bridge.ts | 9 +- ...nal-request-ipc-bridge-startup-env.test.ts | 94 ----- .../src/hooks/ipc-events/zoom-ipc-bridge.ts | 4 +- src/renderer/src/hooks/pane-key-pty-id.ts | 5 +- .../src/hooks/use-editor-typography.ts | 2 +- .../useIpcEvents-rate-limit-hydration.test.ts | 199 ---------- .../title-sync/ai-vault-tab-title-requests.ts | 5 +- .../agent-launch-pane-verdict-application.ts | 5 +- .../ownership/agent-status-pane-ownership.ts | 5 +- .../lib/editor/appearance/editor-font-zoom.ts | 8 - .../editor/appearance/editor-typography.ts | 18 +- .../editor/appearance/markdown-code-font.ts | 2 +- .../lib/pane-manager/terminal-symbols-font.ts | 2 +- .../structured-agent-session-status-feed.ts | 4 +- ...tured-agent-session-tab-retirement.test.ts | 354 ----------------- ...structured-agent-session-tab-retirement.ts | 2 - .../mobile-session-capture.ts | 5 +- .../mobile-session-surfaces.ts | 5 +- .../runtime/web-session-tabs-sync/state.ts | 9 +- .../automation-chat-reopen-test-harness.ts | 3 +- ...session-tab-close-regressions.unit.test.ts | 356 +----------------- 75 files changed, 164 insertions(+), 2383 deletions(-) delete mode 100644 src/renderer/src/app-shell/appearance/monospace-font-family.ts delete mode 100644 src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-timer-rearm.test.tsx delete mode 100644 src/renderer/src/components/terminal-pane/parking/verdict/terminal-park-verdict-worktree-driver-loop.test.tsx delete mode 100644 src/renderer/src/components/terminal-pane/pty-connection/spawn/pty-connection-fresh-spawn-guards.spec.ts delete mode 100644 src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts rename src/renderer/src/components/{orchestration-crew => terminal-pane/worker-lifetime}/WorkerAutoCloseBanner.tsx (87%) create mode 100644 src/renderer/src/hooks/agent-hook-completion-notification-types.ts delete mode 100644 src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-startup-env.test.ts delete mode 100644 src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts delete mode 100644 src/renderer/src/lib/editor/appearance/editor-font-zoom.ts delete mode 100644 src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.test.ts diff --git a/config/flaky-test-pattern-baseline.txt b/config/flaky-test-pattern-baseline.txt index b984f4ad50b..1945221c423 100644 --- a/config/flaky-test-pattern-baseline.txt +++ b/config/flaky-test-pattern-baseline.txt @@ -30,7 +30,6 @@ 3 test-no-in-test-import-actual:src/renderer/src/components/terminal-pane/snapshots/remote-snapshot-alt-screen-host-chain.test.ts 1 test-no-in-test-import-actual:src/renderer/src/hooks/useIpcEvents-agent-status-pane-teardown.test.ts 1 test-no-in-test-import-actual:src/renderer/src/hooks/useIpcEvents-agent-status-snapshot-hydration.test.ts -1 test-no-in-test-import-actual:src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts 1 test-no-in-test-import-actual:src/renderer/src/lib/agent-session/launch/new-tab/agent-launch-caller-routing.test.ts 1 test-no-polling-loop:src/main/browser/cookies/browser-cookie-import-concurrency.test.ts 1 test-no-polling-loop:src/main/claude/claude-stop-ack-race.test.ts @@ -652,7 +651,6 @@ 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/ownership/workspace-terminal-host-authority.test.ts 1 test-no-wall-clock:src/renderer/src/lib/workspace/activation/worktree-activation-emptied-workspace-reseed.test.ts -2 test-no-wall-clock:src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.test.ts 2 test-no-wall-clock:src/renderer/src/store/github/pull-requests/github-pr-branch-linked-pr-divergence.test.ts 6 test-no-wall-clock:src/renderer/src/store/slices/jira/jira.test.ts 5 test-no-wall-clock:src/renderer/src/store/slices/worktrees/metadata/worktree-meta-hosted-review.test.ts @@ -676,7 +674,6 @@ 2 test-no-wall-clock:tests/e2e/cross-version-wire/release-source-retention.unit.test.ts 1 test-no-wall-clock:tests/e2e/host-cold-park-remote-subscriber.unit.test.ts 2 test-no-wall-clock:tests/e2e/omp-child-session-recovery.unit.test.ts -1 test-no-wall-clock:tests/e2e/structured-session-tab-close-regressions.unit.test.ts 2 test-timeout-needs-reason:mobile/src/terminal/document/native-document-bundle.test.ts 1 test-timeout-needs-reason:mobile/src/test-support/rpc-recording/family-recordings.test.ts 1 test-timeout-needs-reason:mobile/src/test-support/rpc-recording/mutants/family-mutants.test.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c2a1b58bc7b..211712f364c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -6776,7 +6776,8 @@ "Module layout policy correction: retire the two remaining worktree-ps-agent-row-dismissal.spec.ts registrations. The remint/dismissal/exit case computed expected snapshotVersion increments from a production snapshot and used real-clock vi.waitFor; the legacy OSC case derived its expected paneKey from the production layout-owner switch. The runtime registrar no longer imports this file. These public projection scenarios have no replacement coverage or fresh execution claimed; all historical receipts and earlier loss notes retain their original source scope.", "Audit2153: Intentionally retire the ordinary fake-create event-forwarding registration from src/main/runtime/rpc/server/remote-runtime-request-connection.integration.test.ts. The two retained callbacks prove encrypted fanout/reconnect and physical connection multiplexing under a fake business runtime; neither establishes close-intent authorization, actual terminal sleep, or equivalent replacement for the removed forwarding smoke. Remove this unrelated current close-safety selector. Existing assertionRefs and dated evidenceRuns remain byte-identical and scoped to their own source/commands. No fresh execution or platform/provider coverage is claimed.", "Audit2153 historical original commands item, preserved verbatim; retired selector is not active execution evidence: \"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/construction/orca-runtime.test.ts src/main/runtime/rpc/server/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime/remote-runtime-client.test.ts src/shared/remote-runtime/requests/remote-runtime-request-connection.test.ts src/shared/remote-runtime/control/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts\"", - "Audit2153 historical original testFiles item, preserved verbatim; retired selector is not active execution evidence: \"src/main/runtime/rpc/server/remote-runtime-request-connection.integration.test.ts\"" + "Audit2153 historical original testFiles item, preserved verbatim; retired selector is not active execution evidence: \"src/main/runtime/rpc/server/remote-runtime-request-connection.integration.test.ts\"", + "Terminal/runtime defining-owner cleanup retires complete parking timer, lifecycle, mocked close/disposal and render/flip/mount work-count cases under repository test policy. The retained watcher source has only two literal-oracle coverage decisions; the retained cold-parking hook source has only the layout-PTY exemption invalidation case. Lost alarms include cooldown/measure/pin wakeups, split exit/close attribution and cancellation, watcher disposal/restart, flood timer rearm, worktree verdict damping, and destroyed-transport cold restore. No equivalent replacement coverage or execution pass for the reduced cases is claimed; prior dated receipts retain their original scope." ], "demotionRule": "Keep experimental or quarantine if reconnect emits session-killed for a live canary, an exact root/descendant dies, an old server receives fallback destructive close, cleanup leaks a fixture process/pipe, or the gate flakes without a proven harness defect." }, @@ -7101,7 +7102,8 @@ "Audit1396: src/renderer/src/components/terminal-pane/pty-connection-pending-spawn.spec.ts reduced. Lose standalone debug-state retention/log silence alarm. React StrictMode is not mounted; bindings manually simulate remount over mocked transport. No equivalent replacement coverage is claimed.", "Audit1396 retires this complete dated receipt because a source in its selector was deleted or reduced. It does not validate the reduced selector. Original receipt verbatim:\n{\n \"date\": \"2026-08-12\",\n \"runner\": \"local\",\n \"platform\": \"macos\",\n \"command\": \"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.test.ts --reporter=dot\",\n \"result\": \"passed\",\n \"durationSeconds\": 20,\n \"summary\": \"The full PTY connection suite passed the independent Floating native-host transport oracle.\",\n \"currentCommand\": \"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-spawn-and-startup.test.ts --reporter=dot\"\n }", "Audit1396 historical platform/provider scope before source changes: {\"coveredPlatforms\":[\"macos\",\"windows\"],\"coveredProviders\":[\"local\",\"wsl\"]}. Remaining provider scope applies only to unchanged-source receipts.", - "Audit2371 reduces repos-project-group-create-race.test.ts by removing the two-host generic failed-create-response/no-op example. Its alarm that a failed create reply leaves an intervening refreshed state untouched is intentionally lost. The surviving refreshed-field and original-owner races do not supply equivalent replacement coverage. No fresh execution or red/green evidence validates the reduced source; unrelated physical Windows/WSL receipt retains its original command and scope." + "Audit2371 reduces repos-project-group-create-race.test.ts by removing the two-host generic failed-create-response/no-op example. Its alarm that a failed create reply leaves an intervening refreshed state untouched is intentionally lost. The surviving refreshed-field and original-owner races do not supply equivalent replacement coverage. No fresh execution or red/green evidence validates the reduced source; unrelated physical Windows/WSL receipt retains its original command and scope.", + "Finite R02 ownership closure retires all three cases from pty-connection/spawn/pty-connection-fresh-spawn-guards.spec.ts and its empty aggregate registration. Intentionally lost alarms: deletion-phase fresh-spawn admission; wrapped worktree-removal fence error suppression; classified typing versus synthetic query/mouse/alternate-wheel replay input, normal-arrow preservation, deferred input provenance and post-replay restoration. No equivalent replacement coverage, new execution, real PTY delivery or CI claim. Historical receipts and their original commands/counts/results remain historical and do not validate the reduced selector." ], "demotionRule": "Demote if discovery and launch can resolve different authorities, a scoped Floating probe overwrites the active-project inventory, or local context selection adds recurring scans or retained in-flight entries." }, @@ -9060,7 +9062,8 @@ "Audit3209 composes approved sources3202/3206: deliberately retires seven authored/expanded cases in four whole suites without equivalent replacement. Work-item queries lose real GitHub/GitLab oversized ASCII secret-prefix empty-query and whitespace-before-trim refusal alarms; inputs share the imported size bound and exercise only its fast length branch, with no multibyte or actual provider/exposure guarantee. Skill sharing loses complete/partial managed-bundle and destination-ambiguity association checks plus three negative origin/path/scheme admission examples; these do not establish uploaded contents, arbitrary-URL fetching, authorization or execution. Retain exact originless-project direct-link fallback, cloud-delete confirmation, bundled-target repair/owner isolation and explicit Windows-host preference over stale WSL suites. Rejected3201 runtime-preference removal is excluded. No real cloud/network/host/disk, mobile or mixed-version execution validation is claimed.", "Audit3219 removes the synthetic Cmd+J extra-source suite; this broad command no longer checks its match/tie placement and selection, announced count, empty-query cap, or workspace hiding with retained recent tabs. No replacement coverage is claimed.", "Audit3231 retires supported-host restore-pane key mapping, closed/open creation-sidebar toggle visibility, unrelated scope-error/null classification and quick-submit reveal/no-create checks. These broad commands lose those real narrow alarms; no replacement coverage is claimed.", - "Audit3246 retires NewRowsMarker reveal/no-repeat and pane-scoped beforeinput checks; skill-owner option availability/count/dedup/order; six styled Input/Textarea IME forwarding rows; the static Codex/OpenCode smart-key inventory; tab-map AST ratchet diagnostic fixtures; child-roster work-budget and retained-aggregate alarms; malformed persisted floating-cwd fallback/no-crash/empty exact-root checks; the unanswered-host empty-list discriminator; the direct invalidated-cache commit-false assertion; one redundant digit-keyup IME row; cached non-supported Claude title-generator rows; and the duplicate late naming-result singleton. These root broad commands lose those specific alarms without equivalent replacement. Distinct retained owner/state checks have their own bounded claims; the real scheduler still rejects stale response data, while the direct false-return alarm is retired." + "Audit3246 retires NewRowsMarker reveal/no-repeat and pane-scoped beforeinput checks; skill-owner option availability/count/dedup/order; six styled Input/Textarea IME forwarding rows; the static Codex/OpenCode smart-key inventory; tab-map AST ratchet diagnostic fixtures; child-roster work-budget and retained-aggregate alarms; malformed persisted floating-cwd fallback/no-crash/empty exact-root checks; the unanswered-host empty-list discriminator; the direct invalidated-cache commit-false assertion; one redundant digit-keyup IME row; cached non-supported Claude title-generator rows; and the duplicate late naming-result singleton. These root broad commands lose those specific alarms without equivalent replacement. Distinct retained owner/state checks have their own bounded claims; the real scheduler still rejects stale response data, while the direct false-return alarm is retired.", + "Terminal/runtime defining-owner cleanup retires complete parking timer, lifecycle, mocked close/disposal and render/flip/mount work-count cases under repository test policy. The retained watcher source has only two literal-oracle coverage decisions; the retained cold-parking hook source has only the layout-PTY exemption invalidation case. Lost alarms include cooldown/measure/pin wakeups, split exit/close attribution and cancellation, watcher disposal/restart, flood timer rearm, worktree verdict damping, and destroyed-transport cold restore. No equivalent replacement coverage or execution pass for the reduced cases is claimed; prior dated receipts retain their original scope." ], "demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event or session-tabs publication volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, @@ -14165,7 +14168,8 @@ "Audit1342 retires this complete dated receipt because a source in its selector was deleted or reduced. It does not validate the reduced selector. Original receipt verbatim:\n{\n \"date\": \"2026-11-04\",\n \"runner\": \"local\",\n \"platform\": \"linux\",\n \"command\": \"ORCA_BACKGROUND_LAUNCH=1 pnpm --config.verifyDepsBeforeRun=false test src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --maxWorkers=2\",\n \"currentCommand\": \"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge-split.test.ts src/main/runtime/orca-runtime-terminal-split-authority.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery-adoption-regressions.test.ts src/renderer/src/runtime/web-session-terminal-pending-handle-recovery.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot\",\n \"result\": \"passed\",\n \"durationSeconds\": 14.82,\n \"summary\": \"Test Files 12 passed (12); Tests 86 passed (86). Deterministic fixtures; no physical phone or deployed mixed-host claim.\"\n }", "Audit1342 historical platform/provider scope before source changes: {\"coveredPlatforms\":[\"macos\",\"linux\"],\"coveredProviders\":[\"local-daemon\",\"paired-runtime\"]}. Remaining provider scope applies only to unchanged-source receipts.", "Audit1342 historical assertion inventory for deleted/reduced sources; not current validated assertions:\n[\n {\n \"file\": \"src/renderer/src/runtime/web-session-terminal-orphan-recovery-regressions.test.ts\",\n \"assertions\": [\n \"coalesces degraded frames to one active and one latest queued recovery\",\n \"lets a newer fully ready frame overtake an in-flight degraded recovery\",\n \"limits recovery RPC concurrency globally across worktrees\"\n ]\n }\n]", - "Audit1939 (sources1934/1935): Pruned cold-parking hook and parked-watcher suites retain local timer/selection/split and exit/cancel/disposal progression. Lose supplied capability/eligibility/topology/admission and duplicate state examples recorded below. No assertion objects map to these sources here; preserve independent actual E2E receipt scopes without transferring their results to reduced units. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Exact retired authored alarms: 'parks paired-runtime tabs only when their exact host advertises restore'; 'skips parking scans for capability-equivalent status writes and scans membership changes'; 'keeps eviction-exempt tabs mounted when the worktree is force-parked'; 'resolves eviction exemptions once per force-park input change'; 'does not exempt a tab from a truncated malformed pane key'; 'never starts a watcher for a PTY that exited into the park handoff gap'; 'collapses the exited leaf out of the stored layout when deferring'; 'retires launch/title hints when the launch-owning parked leaf exits'; 'keeps launch ownership when only a parked shell sibling exits'; 'rejects ordinary parking for a preserved daemon with a lossy snapshot'; 'rejects a capture containing a PTY without snapshot backing'; 'accepts layout-derived candidates when the capture is stale'; 'rejects layout-derived candidates missing a leaf PTY binding'." + "Audit1939 (sources1934/1935): Pruned cold-parking hook and parked-watcher suites retain local timer/selection/split and exit/cancel/disposal progression. Lose supplied capability/eligibility/topology/admission and duplicate state examples recorded below. No assertion objects map to these sources here; preserve independent actual E2E receipt scopes without transferring their results to reduced units. Preserve meaningful remaining selectors/assertions and unchanged dated receipts within their original scope. Reduced selectors have no fresh gate execution evidence in this metadata packet. No equivalent replacement, physical watcher/PTY/provider, live SSH/remote/mobile, rendered, cross-platform, soak or measured-budget result is claimed. Exact retired authored alarms: 'parks paired-runtime tabs only when their exact host advertises restore'; 'skips parking scans for capability-equivalent status writes and scans membership changes'; 'keeps eviction-exempt tabs mounted when the worktree is force-parked'; 'resolves eviction exemptions once per force-park input change'; 'does not exempt a tab from a truncated malformed pane key'; 'never starts a watcher for a PTY that exited into the park handoff gap'; 'collapses the exited leaf out of the stored layout when deferring'; 'retires launch/title hints when the launch-owning parked leaf exits'; 'keeps launch ownership when only a parked shell sibling exits'; 'rejects ordinary parking for a preserved daemon with a lossy snapshot'; 'rejects a capture containing a PTY without snapshot backing'; 'accepts layout-derived candidates when the capture is stale'; 'rejects layout-derived candidates missing a leaf PTY binding'.", + "Terminal/runtime defining-owner cleanup retires complete parking timer, lifecycle, mocked close/disposal and render/flip/mount work-count cases under repository test policy. The retained watcher source has only two literal-oracle coverage decisions; the retained cold-parking hook source has only the layout-PTY exemption invalidation case. Lost alarms include cooldown/measure/pin wakeups, split exit/close attribution and cancellation, watcher disposal/restart, flood timer rearm, worktree verdict damping, and destroyed-transport cold restore. No equivalent replacement coverage or execution pass for the reduced cases is claimed; prior dated receipts retain their original scope." ], "demotionRule": "Demote if a live parked/restarting PTY loses its exact graph leaf or verified viewer binding, a retired PTY remains published, multi-pane identity drifts, or either routed client round trip flakes without a diagnosed cause." }, diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index 59c2f2764f6..3b6e0e6bf81 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -5869,7 +5869,6 @@ "src/renderer/src/components/terminal-pane/pty-transport/shutdown/pty-shutdown-exit-deferral.test.ts": 121, "src/renderer/src/components/terminal-pane/viewport/pty-size-reassertion.test.ts": 58, "src/renderer/src/components/terminal-pane/viewport/pty-size-reconcile.test.ts": 68, - "src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts": 3186, "src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-detach-attach-handoff.test.ts": 1199, "src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-eager-buffer-replay.test.ts": 1636, "src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-handler-suspension.test.ts": 1243, @@ -5943,7 +5942,6 @@ "src/renderer/src/components/terminal-pane/input/paste/terminal-bracketed-paste.test.ts": 57, "src/renderer/src/components/terminal-pane/emulation/terminal-capability-replies.test.ts": 131, "src/renderer/src/components/terminal-pane/clipboard/terminal-clipboard-paste.test.ts": 76, - "src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-timer-rearm.test.tsx": 701, "src/renderer/src/components/terminal-pane/input/file-drop/terminal-drop-image-path.test.ts": 61, "src/renderer/src/components/terminal-pane/input/file-drop/terminal-drop-path-writer.test.ts": 245, "src/renderer/src/components/terminal-pane/input/file-drop/terminal-drop-runtime-catalog-owner.test.ts": 872, @@ -6003,7 +6001,6 @@ "src/renderer/src/components/terminal-pane/layout/detach/terminal-pane-tab-detach-main-commit.test.ts": 2090, "src/renderer/src/components/terminal-pane/layout/detach/terminal-pane-tab-detach-main-move.test.ts": 2147, "src/renderer/src/components/terminal-pane/parking/verdict/terminal-park-verdict-flip-telemetry.test.ts": 123, - "src/renderer/src/components/terminal-pane/parking/verdict/terminal-park-verdict-worktree-driver-loop.test.tsx": 735, "src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts": 473, "src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-watcher-reconciliation.test.ts": 243, "src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-watcher-registry.test.ts": 245, @@ -6204,7 +6201,6 @@ "src/renderer/src/hooks/useIpcEvents-cmd-j-digit-chord.test.ts": 2703, "src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts": 2326, "src/renderer/src/hooks/useIpcEvents-new-workspace-shortcut.test.ts": 72, - "src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts": 1597, "src/renderer/src/hooks/useIpcEvents-runtime-environment-selectors.test.ts": 530, "src/renderer/src/hooks/useIpcEvents-session-tab-close-request.test.ts": 2252, "src/renderer/src/hooks/useIssueMetadata.test.tsx": 344, @@ -6613,7 +6609,6 @@ "src/renderer/src/runtime/workspace-layout/server/server-layout-frames.test.ts": 2053, "src/renderer/src/runtime/native-chat/status/structured-agent-session-status-feed-lifecycle.test.ts": 62, "src/renderer/src/runtime/native-chat/status/structured-agent-session-status-feed.test.ts": 82, - "src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.test.ts": 2115, "src/renderer/src/runtime/native-chat/status/structured-agent-session-turn-completion-feed.test.ts": 118, "src/renderer/src/runtime/native-chat/host-capabilities/structured-chat-host-permission.test.ts": 523, "src/renderer/src/runtime/web-session-tabs-sync/placement/structured-conversation-tab-replacement.test.ts": 2029, diff --git a/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs b/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs index 276f658f30c..112b837bbb7 100644 --- a/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs +++ b/config/scripts/static-analysis/ratchets/renderer-state/check-terminal-parking-ownership-ratchet.mjs @@ -26,10 +26,7 @@ const ROOTS = new Map([ `${RENDERER}components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.ts`, ['useTerminalTabColdParking'] ], - [ - `${WATCHERS}terminal-parked-tab-watchers.ts`, - ['syncParkedTerminalTabWatchers', 'canWatcherCoverParkedTerminalTab'] - ], + [`${WATCHERS}terminal-parked-tab-watchers.ts`, ['syncParkedTerminalTabWatchers']], [`${WATCHERS}parked-terminal-tab-coverage.ts`, ['canWatcherCoverParkedTerminalTab']], [ `${RENDERER}lib/agent-session/resume/resume-sleeping-agent-session.ts`, diff --git a/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs b/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs index ac16f8b4df8..62e6e863cec 100644 --- a/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs +++ b/config/scripts/workflow/source-routing/pr-layout-oracle-route.mjs @@ -25,7 +25,7 @@ const LAYOUT_ORACLE_PREFIXES = [ 'src/renderer/src/store/workspace-layout/workspace-layout-cache', 'src/renderer/src/components/terminal-pane/', 'src/renderer/src/components/agent-session-continuation/AgentSessionContinuationMenuItem.tsx', - 'src/renderer/src/components/orchestration-crew/WorkerAutoCloseBanner.tsx', + 'src/renderer/src/components/terminal-pane/worker-lifetime/WorkerAutoCloseBanner.tsx', 'src/renderer/src/attention/orca-window-foreground-focus.ts', 'src/renderer/src/attention/notification-workspace-labels.ts', 'src/renderer/src/lib/agent-session/identity/pane-agent-session-id.ts', diff --git a/mobile/src/automations/use-automation-catalog.ts b/mobile/src/automations/use-automation-catalog.ts index e51cf5190fa..18bc9e31df7 100644 --- a/mobile/src/automations/use-automation-catalog.ts +++ b/mobile/src/automations/use-automation-catalog.ts @@ -12,7 +12,8 @@ import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { RpcClient } from '../transport/rpc/rpc-client-contract' import { hostStatusProbe } from '../transport/connection/host-status-probe-operations' import { hostConnectionEpoch, readHostStatusReply } from '../query/host-status-query' -import { defineHostQuery, useHostEventRouter, useHostQuery } from '../query/host-query' +import { defineHostQuery, useHostQuery } from '../query/host-query' +import { useHostEventRouter } from '../query/host-event-router' import { hostQueryKey, type HostQueryTarget } from '../query/host-query-key' import { isHostNotConnectedError } from '../query/host-query-errors' import { readViewBase } from '../query/host-read-client' diff --git a/mobile/src/query/host-query.ts b/mobile/src/query/host-query.ts index e7b0a36706c..7a03ae8efb4 100644 --- a/mobile/src/query/host-query.ts +++ b/mobile/src/query/host-query.ts @@ -24,9 +24,6 @@ import { import { holdsRestoredHostQueryData, type HostQueryPersist } from './host-query-persistence' import { hostReadClient } from './host-read-client' -// Exported with the query hooks so the router lands in their page chunk, not a chunk of its own. -export { useHostEventRouter } from './host-event-router' - export type HostQuery = { family: string // Calls the domain's loader. `previous` is the cached data a revalidation may build on; live reads pass none. diff --git a/src/renderer/src/app-shell/appearance/monospace-font-family.ts b/src/renderer/src/app-shell/appearance/monospace-font-family.ts deleted file mode 100644 index 7ec27c6ed2b..00000000000 --- a/src/renderer/src/app-shell/appearance/monospace-font-family.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Why shared: main resolves the editor font stack for plugins with the same rule. -export { - BUNDLED_SYMBOLS_FONT_FAMILY, - buildFontFamily -} from '../../../../shared/monospace-font-family' diff --git a/src/renderer/src/components/agent/launch-settings/AgentDefaultSurfacePreviews.tsx b/src/renderer/src/components/agent/launch-settings/AgentDefaultSurfacePreviews.tsx index fea92e20a4f..64e73ff1657 100644 --- a/src/renderer/src/components/agent/launch-settings/AgentDefaultSurfacePreviews.tsx +++ b/src/renderer/src/components/agent/launch-settings/AgentDefaultSurfacePreviews.tsx @@ -17,7 +17,7 @@ import { composeActiveTerminalTheme } from '@/components/terminal-pane/appearanc import { useSystemPrefersDark } from '@/hooks/theme/use-system-prefers-dark' import { useIntlLocale, useTranslate } from '@/i18n/use-translate' import { AgentIcon } from '@/components/agent/icons/AgentIcon' -import { buildFontFamily } from '@/app-shell/appearance/monospace-font-family' +import { buildFontFamily } from '../../../../../shared/monospace-font-family' import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { cn } from '@/lib/utils' diff --git a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts index a0ed19dbc07..0d7f620e41e 100644 --- a/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts +++ b/src/renderer/src/components/dashboard-popout/preview-terminal-options.ts @@ -11,7 +11,7 @@ import { resolveTerminalScrollOnUserInput } from '@/lib/pane-manager/pane-terminal-options' import { buildLocalConptyTerminalOptions } from '@/lib/pane-manager/windows-pty-compatibility' -import { buildFontFamily } from '@/app-shell/appearance/monospace-font-family' +import { buildFontFamily } from '../../../../shared/monospace-font-family' import { pluginFontFamilyWhenLoaded } from '@/store/plugins/appearance/plugin-fonts' import { resolveTerminalMinimumContrastRatio } from '../../../../shared/terminal-themes/terminal-contrast-correction' diff --git a/src/renderer/src/components/editor/diff/sections/DiffSectionBody.tsx b/src/renderer/src/components/editor/diff/sections/DiffSectionBody.tsx index cb755f699c9..fd9801a52e6 100644 --- a/src/renderer/src/components/editor/diff/sections/DiffSectionBody.tsx +++ b/src/renderer/src/components/editor/diff/sections/DiffSectionBody.tsx @@ -28,10 +28,8 @@ import { } from '@/lib/monaco-languages/plugin-syntax-editor-options' import { useMonacoTheme } from '@/hooks/use-monaco-theme' import { wholeFileDiffTintClass } from '../monaco-view/whole-file-diff-tint' -import { - monacoTypographyOptions, - type EditorTypography -} from '@/lib/editor/appearance/editor-typography' +import { monacoTypographyOptions } from '@/lib/editor/appearance/editor-typography' +import type { EditorTypography } from '../../../../../../shared/editor/editor-typography' const ImageDiffViewer = lazy(() => import('../large-files/ImageDiffViewer')) diff --git a/src/renderer/src/components/editor/markdown-preview/surface/use-markdown-preview-source-foundation.ts b/src/renderer/src/components/editor/markdown-preview/surface/use-markdown-preview-source-foundation.ts index a098069e424..8c32ed44107 100644 --- a/src/renderer/src/components/editor/markdown-preview/surface/use-markdown-preview-source-foundation.ts +++ b/src/renderer/src/components/editor/markdown-preview/surface/use-markdown-preview-source-foundation.ts @@ -1,7 +1,7 @@ import { useDiffCommentView } from '../../../diff-comments/use-diff-comment-owner' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createConnectionIdForFileSelector } from '@/lib/workspace/ownership/connection-owner-resolution' -import { computeEditorFontSize } from '@/lib/editor/appearance/editor-font-zoom' +import { computeEditorFontSize } from '../../../../../../shared/editor/editor-font-zoom' import type { HttpLinkSourceOwner } from '@/lib/browser/navigation/http-link-routing' import { isMarkdownComment } from '@/lib/source-control/review/comments/diff-comment-compat' import { useFileOwnerRuntimeTarget } from '@/runtime/rpc/targets/use-worktree-runtime-target' diff --git a/src/renderer/src/components/editor/notebook/document/IpynbViewer.tsx b/src/renderer/src/components/editor/notebook/document/IpynbViewer.tsx index 138d584813b..7b916da380a 100644 --- a/src/renderer/src/components/editor/notebook/document/IpynbViewer.tsx +++ b/src/renderer/src/components/editor/notebook/document/IpynbViewer.tsx @@ -1,7 +1,7 @@ import { isImeOwnedKeyboardEvent } from '@/lib/text-input/ime/ime-composition-keyboard-event' import { useCallback, useMemo, useState } from 'react' import { AlertCircle, Save } from 'lucide-react' -import { computeEditorFontSize } from '@/lib/editor/appearance/editor-font-zoom' +import { computeEditorFontSize } from '../../../../../../shared/editor/editor-font-zoom' import { useAppStore } from '@/store/index' import { Button } from '@/components/ui/button' import { diff --git a/src/renderer/src/components/native-chat/appearance/native-chat-appearance-style.ts b/src/renderer/src/components/native-chat/appearance/native-chat-appearance-style.ts index c891f6e147e..878ca97a5be 100644 --- a/src/renderer/src/components/native-chat/appearance/native-chat-appearance-style.ts +++ b/src/renderer/src/components/native-chat/appearance/native-chat-appearance-style.ts @@ -5,9 +5,9 @@ import type { GlobalSettings } from '../../../../../shared/global-settings-types import { resolveNativeChatAppearanceSettings } from '../../../../../shared/native-chat/native-chat-appearance-settings' import { resolveConfiguredTerminalColors } from '../../../../../shared/terminal-themes/terminal-theme-selection' import { MATCH_TERMINAL_APP_THEME } from '../../../../../shared/plugin-theme-settings' -import { buildFontFamily } from '@/app-shell/appearance/monospace-font-family' +import { buildFontFamily } from '../../../../../shared/monospace-font-family' import { buildAppFontFamily } from '@/app-shell/appearance/app-font-family' -import { resolveEditorFontStack } from '@/lib/editor/appearance/editor-typography' +import { resolveEditorFontStack } from '../../../../../shared/editor/editor-typography' import { getSystemPrefersDark } from '@/lib/terminal-theme' import { isTerminalBackgroundLight } from '../../../../../shared/terminal-themes/terminal-title-contrast' import { useSystemPrefersDark } from '../../../hooks/theme/use-system-prefers-dark' diff --git a/src/renderer/src/components/settings/editor/EditorTypographySettings.tsx b/src/renderer/src/components/settings/editor/EditorTypographySettings.tsx index 34582d570a1..aaff5557108 100644 --- a/src/renderer/src/components/settings/editor/EditorTypographySettings.tsx +++ b/src/renderer/src/components/settings/editor/EditorTypographySettings.tsx @@ -14,7 +14,7 @@ import { resolveEditorFontFamily, resolveEditorLigaturesMode, type EditorLigaturesMode -} from '@/lib/editor/appearance/editor-typography' +} from '../../../../../shared/editor/editor-typography' import { supportsCodeFontSmoothing, type CodeFontSmoothing diff --git a/src/renderer/src/components/settings/terminal/appearance/TerminalSettingsPreview.tsx b/src/renderer/src/components/settings/terminal/appearance/TerminalSettingsPreview.tsx index 7c88efd7f93..22396768c58 100644 --- a/src/renderer/src/components/settings/terminal/appearance/TerminalSettingsPreview.tsx +++ b/src/renderer/src/components/settings/terminal/appearance/TerminalSettingsPreview.tsx @@ -5,7 +5,7 @@ import { Moon, Sun } from 'lucide-react' import '@xterm/xterm/css/xterm.css' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options' -import { buildFontFamily } from '@/app-shell/appearance/monospace-font-family' +import { buildFontFamily } from '../../../../../../shared/monospace-font-family' import { pluginFontFamilyWhenLoaded, usePluginFontReady diff --git a/src/renderer/src/components/tab-bar/use-allow-auto-close.ts b/src/renderer/src/components/tab-bar/use-allow-auto-close.ts index b655fca2d2a..f7e4cf1aca4 100644 --- a/src/renderer/src/components/tab-bar/use-allow-auto-close.ts +++ b/src/renderer/src/components/tab-bar/use-allow-auto-close.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useState } from 'react' import { useAppStore } from '@/store' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner' -import { collectLeafIdsInOrder } from '../terminal-pane/layout/terminal-layout-leaf-traversal' +import '../terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' import { readWorkerAutoClose, setWorkerKeptOpen, @@ -14,7 +15,9 @@ function tabWorkerSubjects(tabId: string, sessionId: string | undefined): Worker if (sessionId) { return [{ sessionId }] } - const leafIds = collectLeafIdsInOrder(useAppStore.getState().terminalLayoutsByTabId[tabId]?.root) + const leafIds = collectLayoutLeafIdsInOrder( + useAppStore.getState().terminalLayoutsByTabId[tabId]?.root + ) return leafIds.filter(isTerminalLeafId).map((leafId) => ({ paneKey: makePaneKey(tabId, leafId) })) } diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index f3c1df24cbb..3339ea2feea 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -13,7 +13,8 @@ import { import { hasRegisteredRuntimeTerminalTab } from '../runtime/hosted-terminals/mounted-surfaces/mounted-terminal-tab-registry' import { anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' import { isParkRestorableTerminalPty } from './terminal-pane/parking/retention/terminal-hidden-view-parking' -import { canWatcherCoverParkedTerminalTab } from './terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' +import './terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from './terminal-pane/parking/output-watchers/parked-terminal-tab-coverage' import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime/remote-runtime-pty-id' import { terminalProviderAllowsParking } from './terminal/terminal-provider-snapshot-capability' import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' diff --git a/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts index bdedb9c8c35..99cac7af0b4 100644 --- a/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/appearance/terminal-appearance.ts @@ -4,7 +4,7 @@ import type { GlobalSettings } from '../../../../../shared/global-settings-types import { resolveTerminalFontWeights } from '../../../../../shared/terminal/appearance/terminal-fonts' import { resolveTerminalLigaturesEnabled } from '../../../../../shared/terminal/appearance/terminal-ligatures' import { resolvePaneStyleOptions, resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' -import { buildFontFamily } from '@/app-shell/appearance/monospace-font-family' +import { buildFontFamily } from '../../../../../shared/monospace-font-family' import { pluginFontFamilyWhenLoaded } from '@/store/plugins/appearance/plugin-fonts' import '@/lib/pane-manager/pane-tree-ops' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-fit' diff --git a/src/renderer/src/components/terminal-pane/appearance/windows-terminal-font-weight-notice.tsx b/src/renderer/src/components/terminal-pane/appearance/windows-terminal-font-weight-notice.tsx index 08fef034979..8021e7d69a3 100644 --- a/src/renderer/src/components/terminal-pane/appearance/windows-terminal-font-weight-notice.tsx +++ b/src/renderer/src/components/terminal-pane/appearance/windows-terminal-font-weight-notice.tsx @@ -3,7 +3,7 @@ import { toast } from 'sonner' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { useTranslate } from '@/i18n/use-translate' -import { buildFontFamily } from '@/app-shell/appearance/monospace-font-family' +import { buildFontFamily } from '../../../../../shared/monospace-font-family' import { isWindowsDesktopClient } from '@/lib/files/os-open/windows-path-access' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' diff --git a/src/renderer/src/components/terminal-pane/controller/TerminalPaneCodexRestartPortals.tsx b/src/renderer/src/components/terminal-pane/controller/TerminalPaneCodexRestartPortals.tsx index f31fcc895fd..60da5c0ef23 100644 --- a/src/renderer/src/components/terminal-pane/controller/TerminalPaneCodexRestartPortals.tsx +++ b/src/renderer/src/components/terminal-pane/controller/TerminalPaneCodexRestartPortals.tsx @@ -5,7 +5,7 @@ import { CodexSharedServerBanner } from '../provider-notices/codex-server-isolat import { ClaudeOldTerminalBanner } from '../provider-notices/claude-account-shell/ClaudeOldTerminalBanner' import { ClaudeDiffPanelNoticeBanner } from '../provider-notices/claude-diff-panel/ClaudeDiffPanelNoticeBanner' import { NewerDaemonHoldBanner } from '../session-restore/newer-daemon/NewerDaemonHoldBanner' -import { WorkerAutoCloseBanner } from '../../orchestration-crew/WorkerAutoCloseBanner' +import { WorkerAutoCloseBanner } from '../worker-lifetime/WorkerAutoCloseBanner' import { WslDistroStoppedHoldCard } from '../session-restore/wsl-distro/WslDistroStoppedHoldCard' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import type { TerminalPaneController } from './use-terminal-pane-controller' diff --git a/src/renderer/src/components/terminal-pane/controller/use-terminal-pane-tab-state.ts b/src/renderer/src/components/terminal-pane/controller/use-terminal-pane-tab-state.ts index 92222d546c5..0760e511135 100644 --- a/src/renderer/src/components/terminal-pane/controller/use-terminal-pane-tab-state.ts +++ b/src/renderer/src/components/terminal-pane/controller/use-terminal-pane-tab-state.ts @@ -3,7 +3,8 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '../../../store/index' import { getCachedTerminalTabForWorktree } from './tab-state/terminal-tab-lookup' import { selectTerminalTabAgentTypesByLeaf } from '../titles/terminal-tab-agent-type-index' -import { collectLeafIdsInOrder } from '../layout/terminal-layout-leaf-traversal' +import '../layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../shared/workspace-layout/terminal-pane-tree' import { EMPTY_LAYOUT } from '../layout/empty-terminal-layout' import { sanitizeTerminalLayoutPaneTitles } from '../../../../../shared/workspace-layout/terminal-pane-title-sanitization' import { selectUnifiedTerminalTabFields } from './tab-state/terminal-unified-tab-lookup' @@ -39,7 +40,7 @@ export function useTerminalPaneTabState({ [savedLayout, terminalTab] ) const expectedLayoutLeafIds = useMemo( - () => collectLeafIdsInOrder(restoredLayout.root), + () => collectLayoutLeafIdsInOrder(restoredLayout.root), [restoredLayout.root] ) return { diff --git a/src/renderer/src/components/terminal-pane/layout/detach/terminal-layout-leaf-detach.ts b/src/renderer/src/components/terminal-pane/layout/detach/terminal-layout-leaf-detach.ts index d6496cffbe9..c602bcf0473 100644 --- a/src/renderer/src/components/terminal-pane/layout/detach/terminal-layout-leaf-detach.ts +++ b/src/renderer/src/components/terminal-pane/layout/detach/terminal-layout-leaf-detach.ts @@ -2,7 +2,8 @@ import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../../../shared/workspace-layout/terminal-tab-types' -import { collectLeafIdsInOrder } from '../terminal-layout-leaf-traversal' +import '../terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../../shared/workspace-layout/terminal-pane-tree' import { normalizeTerminalLayoutSnapshot } from '../terminal-layout-normalization' import { resolveTerminalLayoutActiveLeafId } from '../ownership/terminal-layout-active-leaf' @@ -70,7 +71,7 @@ export function detachTerminalLayoutLeaf( return null } - const originalLeafIds = collectLeafIdsInOrder(layout.root) + const originalLeafIds = collectLayoutLeafIdsInOrder(layout.root) if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) { return null } diff --git a/src/renderer/src/components/terminal-pane/layout/ownership/terminal-layout-active-leaf.ts b/src/renderer/src/components/terminal-pane/layout/ownership/terminal-layout-active-leaf.ts index 3497ceff26f..1889a91330e 100644 --- a/src/renderer/src/components/terminal-pane/layout/ownership/terminal-layout-active-leaf.ts +++ b/src/renderer/src/components/terminal-pane/layout/ownership/terminal-layout-active-leaf.ts @@ -1,5 +1,6 @@ import type { TerminalPaneLayoutNode } from '../../../../../../shared/workspace-layout/terminal-tab-types' -import { collectLeafIdsInOrder } from '../terminal-layout-leaf-traversal' +import '../terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../../shared/workspace-layout/terminal-pane-tree' function hasLeafPtyBinding( ptyIdsByLeafId: Record | undefined, @@ -13,7 +14,7 @@ export function resolveTerminalLayoutActiveLeafId(opts: { activeLeafId: string | null | undefined ptyIdsByLeafId?: Record }): string | null { - const leafIds = collectLeafIdsInOrder(opts.root) + const leafIds = collectLayoutLeafIdsInOrder(opts.root) if (leafIds.length === 0) { return null } diff --git a/src/renderer/src/components/terminal-pane/layout/persistence/remote-pane-layout-push.ts b/src/renderer/src/components/terminal-pane/layout/persistence/remote-pane-layout-push.ts index 05aa8a8c781..2a3df9276cc 100644 --- a/src/renderer/src/components/terminal-pane/layout/persistence/remote-pane-layout-push.ts +++ b/src/renderer/src/components/terminal-pane/layout/persistence/remote-pane-layout-push.ts @@ -5,7 +5,8 @@ import { requeuePendingPaneTitleRenames, takeUnsentPaneTitleRenames } from '@/runtime/web-session-tabs-sync/naming/pending-pane-title-renames' -import { collectLeafIdsInOrder } from '../terminal-layout-leaf-traversal' +import '../terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../../shared/workspace-layout/terminal-pane-tree' export type RemotePaneLayoutPusher = { push: (input: { worktreeId: string; tabId: string; layout: TerminalLayoutSnapshot }) => void @@ -23,7 +24,7 @@ export function createRemotePaneLayoutPusher(): RemotePaneLayoutPusher { return { push: ({ worktreeId, tabId, layout }) => { // Send explicit local renames even back to the last-pushed title; resending the full map is not rename intent. - const renamedLeafIds = takeUnsentPaneTitleRenames(collectLeafIdsInOrder(layout.root)) + const renamedLeafIds = takeUnsentPaneTitleRenames(collectLayoutLeafIdsInOrder(layout.root)) if ( renamedLeafIds.length === 0 && lastAttempt?.worktreeId === worktreeId && diff --git a/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.test.ts b/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.test.ts index 8250f3decce..e418781af30 100644 --- a/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.test.ts +++ b/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from 'vitest' import type { TerminalPaneLayoutNode } from '../../../../../shared/workspace-layout/terminal-tab-types' -import { - collectLeafIdsInOrder, - collectLeafIdsInReplayCreationOrder -} from './terminal-layout-leaf-traversal' +import { collectLeafIdsInReplayCreationOrder } from './terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../shared/workspace-layout/terminal-pane-tree' describe('collectLeafIdsInReplayCreationOrder', () => { it('visits the outer right leaf before the inner left split continuation', () => { @@ -19,7 +17,7 @@ describe('collectLeafIdsInReplayCreationOrder', () => { second: { type: 'leaf', leafId: 'C' } } - expect(collectLeafIdsInOrder(layout)).toEqual(['A', 'B', 'C']) + expect(collectLayoutLeafIdsInOrder(layout)).toEqual(['A', 'B', 'C']) expect(collectLeafIdsInReplayCreationOrder(layout)).toEqual(['A', 'C', 'B']) }) }) diff --git a/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.ts b/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.ts index dd41bd6e2de..ece0a2850c8 100644 --- a/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.ts +++ b/src/renderer/src/components/terminal-pane/layout/terminal-layout-leaf-traversal.ts @@ -1,8 +1,5 @@ import type { TerminalPaneLayoutNode } from '../../../../../shared/workspace-layout/terminal-tab-types' -import { collectLayoutLeafIdsInOrder } from '../../../../../shared/workspace-layout/terminal-pane-tree' - -// Accumulate instead of spreading per split, which copies quadratically on skewed layouts. -export const collectLeafIdsInOrder = collectLayoutLeafIdsInOrder +import '../../../../../shared/workspace-layout/terminal-pane-tree' export function getLeftmostLeafId(node: TerminalPaneLayoutNode): string { return node.type === 'leaf' ? node.leafId : getLeftmostLeafId(node.first) diff --git a/src/renderer/src/components/terminal-pane/mounting/terminal-pane-manager-options.ts b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-manager-options.ts index 65c347612b4..77954e71030 100644 --- a/src/renderer/src/components/terminal-pane/mounting/terminal-pane-manager-options.ts +++ b/src/renderer/src/components/terminal-pane/mounting/terminal-pane-manager-options.ts @@ -13,7 +13,7 @@ import { resolveTerminalCursorInactiveStyle, resolveTerminalScrollOnUserInput } from '@/lib/pane-manager/pane-terminal-options' -import { buildFontFamily } from '@/app-shell/appearance/monospace-font-family' +import { buildFontFamily } from '../../../../../shared/monospace-font-family' import { pluginFontFamilyWhenLoaded } from '@/store/plugins/appearance/plugin-fonts' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' diff --git a/src/renderer/src/components/terminal-pane/parking/lifecycle/use-manual-terminal-worktree-parking.ts b/src/renderer/src/components/terminal-pane/parking/lifecycle/use-manual-terminal-worktree-parking.ts index 02074cf31e9..0cd70a3ee38 100644 --- a/src/renderer/src/components/terminal-pane/parking/lifecycle/use-manual-terminal-worktree-parking.ts +++ b/src/renderer/src/components/terminal-pane/parking/lifecycle/use-manual-terminal-worktree-parking.ts @@ -10,7 +10,8 @@ import { type ManualTerminalWorktreeParkDetail } from '@/components/terminal-pane/parking/lifecycle/manual-terminal-worktree-parking' import { canManuallyParkTerminalWorktreeRenderers } from '../retention/manual-terminal-worktree-park-eligibility' -import { canWatcherCoverParkedTerminalTab } from '../output-watchers/terminal-parked-tab-watchers' +import '../output-watchers/terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from '../output-watchers/parked-terminal-tab-coverage' function addWorktreeId(current: ReadonlySet, worktreeId: string): ReadonlySet { if (current.has(worktreeId)) { diff --git a/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.test.ts index 872c0e79531..43918f5942a 100644 --- a/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import type * as ParkedWatcherRegistryModule from '../output-watchers/terminal-parked-watcher-registry' import { act, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { TerminalTab } from '../../../../../../shared/workspace-layout/terminal-tab-types' const mocks = vi.hoisted(() => ({ @@ -38,9 +38,11 @@ vi.mock('../retention/terminal-eviction-exempt-tabs', () => ({ })) vi.mock('../output-watchers/terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: () => mocks.watcherCoverage, syncParkedTerminalTabWatchers: vi.fn() })) +vi.mock('../output-watchers/parked-terminal-tab-coverage', () => ({ + canWatcherCoverParkedTerminalTab: () => mocks.watcherCoverage +})) vi.mock('../output-watchers/terminal-parked-watcher-registry', async (importOriginal) => ({ ...(await importOriginal()), @@ -51,17 +53,6 @@ vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ recordRendererCrashBreadcrumb: vi.fn() })) -import { - TERMINAL_TAB_COLD_PARK_DELAY_MS, - TERMINAL_TAB_HOT_RETAIN_MS -} from '../retention/terminal-hidden-view-parking' -import { - TERMINAL_TAB_PARK_FLIP_BURST_LIMIT, - TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS, - TERMINAL_TAB_PARK_FLIP_WINDOW_MS -} from '../verdict/terminal-park-verdict-flip-telemetry' -import { BACKGROUND_WORKTREE_MEASURE_WINDOW_MS } from '../../../terminal/background-terminal-worktree-visibility' -import { queueTerminalPaneSplitRequest } from '../../layout/split/terminal-pane-split-request-routing' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' import { invalidateTerminalParkingInputs } from '../terminal-parking-input-events' @@ -85,14 +76,8 @@ function hookArgs(shouldMeasureHiddenWorktree: boolean) { } } -describe('useTerminalTabColdParking measure-clock contract', () => { - beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(1_000_000) - }) - +describe('useTerminalTabColdParking exemptions', () => { afterEach(() => { - vi.useRealTimers() mocks.exemptTabIds = new Set() mocks.watcherCoverage = true mocks.storeState.terminalLayoutsByTabId = {} @@ -100,195 +85,6 @@ describe('useTerminalTabColdParking measure-clock contract', () => { mocks.storeState.runtimeStatusByEnvironmentId = new Map() }) - // Why: leaving a worktree gives every tab the same hidden time. - it('exempts the tab the worktree was left on when every tab hides in one pass', () => { - const assignments = new Map([ - ['tab-1', { groupId: 'group-1', isActiveInGroup: false }], - ['tab-2', { groupId: 'group-1', isActiveInGroup: true }] - ]) - const { result, rerender } = renderHook( - (args: ReturnType & { isWorktreeActive: boolean }) => - useTerminalTabColdParking(args), - { - initialProps: { - ...hookArgs(false), - assignments, - isWorktreeActive: true, - activeTerminalTabId: 'tab-2' - } - } - ) - expect(result.current.size).toBe(0) - - act(() => { - rerender({ - ...hookArgs(false), - assignments, - isWorktreeActive: false, - activeTerminalTabId: 'tab-2' - }) - }) - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - - expect(result.current).toEqual(new Set(['tab-1'])) - }) - - it('records focused split changes when the visible tab set does not change', () => { - const assignments = new Map([ - ['tab-1', { groupId: 'group-1', isActiveInGroup: true }], - ['tab-2', { groupId: 'group-2', isActiveInGroup: true }] - ]) - const { result, rerender } = renderHook( - ( - args: ReturnType & { - activeTerminalTabId: string | null - isWorktreeActive: boolean - } - ) => useTerminalTabColdParking(args), - { - initialProps: { - ...hookArgs(false), - assignments, - isWorktreeActive: true, - activeTerminalTabId: 'tab-1' - } - } - ) - - act(() => { - rerender({ - ...hookArgs(false), - assignments, - isWorktreeActive: true, - activeTerminalTabId: 'tab-2' - }) - }) - act(() => { - rerender({ - ...hookArgs(false), - assignments, - isWorktreeActive: false, - activeTerminalTabId: 'tab-2' - }) - }) - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - - expect(result.current).toEqual(new Set(['tab-1'])) - }) - - // After damping, expired hysteresis leaves pin expiry as the only wakeup; missing it retains the pane indefinitely. - it('re-parks a damped tab once the pin expires with no other store change', () => { - const { result, rerender } = renderHook( - (args: ReturnType) => useTerminalTabColdParking(args), - { initialProps: hookArgs(false) } - ) - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - - // Freeze time so every coverage flip lands within the damping burst window. - for (let flip = 0; flip <= TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1; flip += 1) { - mocks.watcherCoverage = flip % 2 === 1 - act(() => { - rerender(hookArgs(false)) - }) - } - mocks.watcherCoverage = true - act(() => { - rerender(hookArgs(false)) - }) - expect(result.current.size).toBe(0) - - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_PARK_FLIP_WINDOW_MS) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - }) - - // Preserve hiddenSince through measurement, then grant cooldown to prevent immediate re-park. - it('preserves tab hiddenSince through a measure window and re-parks only after the cool-down', () => { - const { result, rerender } = renderHook( - (args: ReturnType) => useTerminalTabColdParking(args), - { initialProps: hookArgs(false) } - ) - expect(result.current.size).toBe(0) - - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - - act(() => { - rerender(hookArgs(true)) - }) - expect(result.current.size).toBe(0) - act(() => { - vi.advanceTimersByTime(3_000) - rerender(hookArgs(false)) - }) - - expect(result.current.size).toBe(0) - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_COLD_PARK_DELAY_MS - 1) - }) - expect(result.current.size).toBe(0) - - // Re-parking after cooldown proves measurement preserved the hidden clock instead of restarting hot retention. - act(() => { - vi.advanceTimersByTime(2) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - }) - - it('temporarily unparks only the exact target of a queued runtime split', () => { - const args = { ...hookArgs(false), coldParkTerminalPanes: true } - const { result } = renderHook(() => useTerminalTabColdParking(args)) - expect(result.current).toEqual(new Set(['tab-1', 'tab-2'])) - - act(() => { - queueTerminalPaneSplitRequest({ - tabId: 'tab-2', - paneRuntimeId: 9, - direction: 'vertical' - }) - }) - expect(result.current).toEqual(new Set(['tab-1'])) - - act(() => { - vi.advanceTimersByTime(BACKGROUND_WORKTREE_MEASURE_WINDOW_MS) - }) - expect(result.current).toEqual(new Set(['tab-1', 'tab-2'])) - }) - - // Visible worktrees end measurement without owing hidden tabs a cooldown. - it('clears the post-measure cool-down when the worktree becomes visible', () => { - const { result, rerender } = renderHook( - (args: ReturnType) => useTerminalTabColdParking(args), - { initialProps: hookArgs(false) } - ) - - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - - act(() => { - rerender(hookArgs(true)) - }) - expect(result.current.size).toBe(0) - - act(() => { - vi.advanceTimersByTime(3_000) - rerender({ ...hookArgs(false), isWorktreeActive: true }) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - }) - // Splits update layout PTYs without changing terminalTabs; that must invalidate exemption memoization. it('re-resolves exemptions when only the layout PTYs change', () => { const stableArgs = { ...hookArgs(false), coldParkTerminalPanes: true, isForceParked: true } @@ -313,46 +109,4 @@ describe('useTerminalTabColdParking measure-clock contract', () => { }) expect(result.current).toEqual(new Set(['tab-2'])) }) - - // Per-tab parking would strand a sleeping agent’s cold restore until reveal. - it('unparks a per-tab-parked pane once it owns a sleeping-session record', () => { - const { result, rerender } = renderHook( - (args: ReturnType) => useTerminalTabColdParking(args), - { initialProps: hookArgs(false) } - ) - - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_HOT_RETAIN_MS + 1) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - - mocks.storeState.sleepingAgentSessionsByPaneKey = { - 'tab-2:22222222-2222-4222-8222-222222222222': { - paneKey: 'tab-2:22222222-2222-4222-8222-222222222222', - tabId: 'tab-2', - worktreeId: WORKTREE_ID - } - } - act(() => { - // Advance fake time so ordinary rerenders do not appear as burst oscillation. - vi.advanceTimersByTime(TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS) - rerender(hookArgs(false)) - }) - expect(result.current.size).toBe(0) - - mocks.storeState.sleepingAgentSessionsByPaneKey = { - 'tab-2:22222222-2222-4222-8222-222222222222': { - paneKey: 'tab-2:22222222-2222-4222-8222-222222222222', - tabId: 'tab-2', - worktreeId: 'other-worktree' - } - } - act(() => { - vi.advanceTimersByTime(TERMINAL_TAB_PARK_FLIP_BURST_WINDOW_MS) - rerender(hookArgs(false)) - }) - expect(result.current).toEqual(new Set(['tab-2'])) - }) - - // Passive-completed sessions never auto-resume and must not retain hidden panes indefinitely. }) diff --git a/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.ts index 6a0886427a3..1a716119a4a 100644 --- a/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/parking/lifecycle/use-terminal-tab-cold-parking.ts @@ -28,7 +28,8 @@ import { getTerminalParkingPolicyOverrides } from '../instrumentation/terminal-p import { selectEvictionExemptTerminalTabIds } from '../retention/terminal-eviction-exempt-tabs' import { selectSleepingRecordParkExemptTabIds } from '../retention/sleeping-record-park-exemption' import { usePendingStartupParkPresence } from '../retention/terminal-pending-startup-park-presence' -import { canWatcherCoverParkedTerminalTab } from '../output-watchers/terminal-parked-tab-watchers' +import '../output-watchers/terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from '../output-watchers/parked-terminal-tab-coverage' import { captureNewlyParkedTerminalTabs } from '../capture/parked-terminal-tab-capture-episodes' import { createTerminalTabActivationOrder } from '../retention/terminal-tab-activation-order' import { buildTerminalTabColdParkCandidates } from '../retention/terminal-tab-park-candidates' diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts index 21ab76c888b..82b8d877268 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-tab-coverage.ts @@ -11,8 +11,11 @@ import { parkedTerminalCaptureRevision } from './terminal-parked-watcher-registry' import type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' -import { collectLeafIdsInOrder } from '../../layout/terminal-layout-leaf-traversal' -import { resolveRootlessTerminalLayoutLeafId } from '../../../../../../shared/workspace-layout/terminal-pane-tree' +import '../../layout/terminal-layout-leaf-traversal' +import { + collectLayoutLeafIdsInOrder, + resolveRootlessTerminalLayoutLeafId +} from '../../../../../../shared/workspace-layout/terminal-pane-tree' import type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' import { launchAgentTabIds } from './parked-tab-agent-owner-index' @@ -114,7 +117,7 @@ function fallbackParkedPaneCandidates( state: ParkedPaneFallbackState ): ParkedTerminalPaneCapture[] { const layout = state.terminalLayoutsByTabId[tab.id] - const rootLeafIds = collectLeafIdsInOrder(layout?.root) + const rootLeafIds = collectLayoutLeafIdsInOrder(layout?.root) const rootlessLeafId = layout ? resolveRootlessTerminalLayoutLeafId(layout) : null const leafIds = rootLeafIds.length > 0 ? rootLeafIds : rootlessLeafId !== null ? [rootlessLeafId] : [] diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts index 0a0cf207692..a53c5e75b0f 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.test.ts @@ -108,19 +108,11 @@ import { clearTerminalProviderSnapshotCapabilities, synchronizeTerminalProviderSnapshotCapabilities } from '../../../terminal/terminal-provider-snapshot-capability' -import { - markTerminalProviderSessionBound, - probeTerminalProviderGoneSessions -} from '../../../terminal/terminal-provider-gone-sessions' -import { - canWatcherCoverParkedTerminalTab, - shouldDeferParkedPtyExitTabClose, - syncParkedTerminalTabWatchers -} from './terminal-parked-tab-watchers' +import { probeTerminalProviderGoneSessions } from '../../../terminal/terminal-provider-gone-sessions' +import './terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from './parked-terminal-tab-coverage' import { captureParkedTerminalPaneCandidates, - disposeParkedTerminalWatchersForPtyIds, - getParkedTerminalWatcherTabIds, pruneParkedTerminalWatchers } from './terminal-parked-watcher-registry' @@ -134,22 +126,6 @@ function capturePanes( captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes) } -function syncParked(args?: { - worktreeId?: string - tabs?: { id: string; ptyId: string | null }[] - parkedTabIds?: Iterable - restoreTitleOnStartTabIds?: Iterable -}): void { - syncParkedTerminalTabWatchers({ - worktreeId: args?.worktreeId ?? WORKTREE_ID, - tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }], - parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID]), - ...(args?.restoreTitleOnStartTabIds - ? { restoreTitleOnStartTabIds: new Set(args.restoreTitleOnStartTabIds) } - : {}) - }) -} - describe('terminal-parked-tab-watchers', () => { beforeEach(async () => { mockStoreState = { @@ -186,51 +162,6 @@ describe('terminal-parked-tab-watchers', () => { ;(globalThis as { window?: unknown }).window = originalWindow }) - it('collapses a dead split leaf even when a concurrent primary handler also observed the exit', () => { - // Coexisting eager/reveal primary handlers do not collapse parked layout; sidecar must still remove dead split leaves. - capturePanes([ - { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, - { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } - ]) - syncParked() - mockStoreState.terminalLayoutsByTabId[TAB_ID] = { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEAF_ID }, - second: { type: 'leaf', leafId: SECOND_LEAF_ID } - }, - activeLeafId: SECOND_LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } - } - - const exited = exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID) - exited?.callback(0, { hadPrimary: true }) - - expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { - root: { type: 'leaf', leafId: LEAF_ID }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } - }) - expect(startedWatchers[1].dispose).toHaveBeenCalledTimes(1) - expect(startedWatchers[0].dispose).not.toHaveBeenCalled() - expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) - }) - - it('closes a parked tab when its PTY exits after the gone mark is cleared', async () => { - await probeTerminalProviderGoneSessions([PTY_ID], async () => [PTY_ID]) - capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) - syncParked() - - markTerminalProviderSessionBound(PTY_ID) - syncParked() - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) - - expect(closeTerminalTab).toHaveBeenCalledWith(TAB_ID, expect.anything()) - }) - it('keeps a split tab mounted when only some pane sessions are gone', async () => { await probeTerminalProviderGoneSessions([PTY_ID], async () => [PTY_ID]) capturePanes([ @@ -250,221 +181,4 @@ describe('terminal-parked-tab-watchers', () => { expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe(false) }) - - it('consumes the exit and drops the parked entry after the last-watcher close resolves', () => { - capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) - syncParked() - - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) - - expect(consumePreHandlerPtyState).not.toHaveBeenCalled() - expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) - const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions - expect(options.captureRecentlyClosed).toBe(false) - // Carry pty-exit intent so paired hosts can reject live-PTY echoes without bypassing the local pinned guard. - expect(options.hostCloseReason).toBe('pty-exit') - expect(options.lifecyclePtyId).toBe(PTY_ID) - options.onClosed?.() - - expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID) - expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) - expect(getParkedTerminalWatcherTabIds()).toEqual([]) - }) - - it('preserves a parked tab when the host reports an unverified PTY loss', () => { - capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) - syncParked() - - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(-1, { hadPrimary: false }) - - expect(closeTerminalTab).not.toHaveBeenCalled() - expect(mockStoreState.markUnverifiedPtyLoss).toHaveBeenCalledWith(TAB_ID) - expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) - expect(getParkedTerminalWatcherTabIds()).toEqual([]) - }) - - it('preserves every leaf and the parked registry across split unverified losses', () => { - capturePanes([ - { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, - { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } - ]) - syncParked() - const layout = { - root: { - type: 'split' as const, - direction: 'vertical' as const, - first: { type: 'leaf' as const, leafId: LEAF_ID }, - second: { type: 'leaf' as const, leafId: SECOND_LEAF_ID } - }, - activeLeafId: SECOND_LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } - } - mockStoreState.terminalLayoutsByTabId[TAB_ID] = layout - - exitSubscriptions - .find((entry) => entry.ptyId === PTY_ID) - ?.callback(-1, { - hadPrimary: false - }) - - expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() - expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) - expect(startedWatchers[1].dispose).not.toHaveBeenCalled() - expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) - - exitSubscriptions - .find((entry) => entry.ptyId === SECOND_PTY_ID) - ?.callback(-1, { - hadPrimary: false - }) - - expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() - expect(startedWatchers[1].dispose).toHaveBeenCalledTimes(1) - expect(getParkedTerminalWatcherTabIds()).toEqual([]) - }) - - it('retains the buffered exit and empty registry entry when pinned close is cancelled', () => { - capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) - syncParked() - - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) - const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions - options.onCancel?.() - syncParked({ parkedTabIds: [] }) - - expect(consumePreHandlerPtyState).not.toHaveBeenCalled() - expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) - expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) - - expect(shouldDeferParkedPtyExitTabClose(TAB_ID, PTY_ID)).toBe(true) - expect(getParkedTerminalWatcherTabIds()).toEqual([]) - expect(shouldDeferParkedPtyExitTabClose(TAB_ID, PTY_ID)).toBe(false) - }) - - it('consumes a queued parked exit when another confirmation closes the tab first', () => { - capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) - syncParked() - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) - - // A queued pinned close may remove the tab before the autonomous exit request resolves. - syncParked({ tabs: [] }) - - expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID) - expect(getParkedTerminalWatcherTabIds()).toEqual([]) - }) - - // Eager/reveal primary handlers can still overlap a parked sidecar after detach drops the session observer. - it('does not queue a second close when a concurrent primary handled the parked exit', () => { - capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) - syncParked() - - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: true }) - - expect(closeTerminalTab).not.toHaveBeenCalled() - expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) - expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) - }) - - it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => { - capturePanes([ - { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, - { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } - ]) - syncParked() - - disposeParkedTerminalWatchersForPtyIds([PTY_ID]) - - expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) - expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) - expect(startedWatchers[1].dispose).not.toHaveBeenCalled() - // Keep the empty entry so sleep cannot restart watchers against stale PTY IDs before wake. - expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) - syncParked() - expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) - }) - - it('restarts watchers from store layout when the tab PTY was re-minted', () => { - capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) - syncParked() - - const remintedPtyId = `${WORKTREE_ID}@@session-after-wake` - mockStoreState.terminalLayoutsByTabId[TAB_ID] = { - root: { type: 'leaf', leafId: LEAF_ID }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId } - } - syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] }) - - expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) - expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) - expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID }) - }) - - describe('shouldDeferParkedPtyExitTabClose', () => { - const closeTab = vi.fn() - - // Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab. - function hostOnPtyExit(tabId: string, ptyId: string): void { - if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { - return - } - closeTab(tabId) - } - - it('collapses a dead leaf then closes when the last parked split leaf exits', () => { - // Parked exits reach only sidecars; they must collapse layout themselves to prevent leaf resurrection. - capturePanes([ - { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, - { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } - ]) - syncParked() - mockStoreState.terminalLayoutsByTabId[TAB_ID] = { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEAF_ID }, - second: { type: 'leaf', leafId: SECOND_LEAF_ID } - }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } - } - - exitSubscriptions - .find((entry) => entry.ptyId === SECOND_PTY_ID) - ?.callback(0, { hadPrimary: false }) - - expect(consumePreHandlerPtyState).toHaveBeenCalledWith(SECOND_PTY_ID) - expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2) - expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { - root: { type: 'leaf', leafId: LEAF_ID }, - activeLeafId: LEAF_ID, - expandedLeafId: null, - ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } - }) - - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) - const options = closeTerminalTab.mock.calls[0]?.[1] as CloseTerminalTabOptions - options.onClosed?.() - expect(consumePreHandlerPtyState).toHaveBeenCalledWith(PTY_ID) - }) - - it('closes the tab when the last surviving leaf of a parked split exits', () => { - capturePanes([ - { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, - { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } - ]) - syncParked() - - // First leaf dies: deferred, then its exit sidecar drops the watcher. - hostOnPtyExit(TAB_ID, PTY_ID) - exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0, { hadPrimary: false }) - expect(closeTab).not.toHaveBeenCalled() - - hostOnPtyExit(TAB_ID, SECOND_PTY_ID) - expect(closeTab).toHaveBeenCalledWith(TAB_ID) - }) - }) }) diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts index 5de92fdf8e9..46497c466e4 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/terminal-parked-tab-watchers.ts @@ -18,8 +18,6 @@ import { type ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' -export { canWatcherCoverParkedTerminalTab } from './parked-terminal-tab-coverage' -export type { ParkedTerminalPtyEligibility } from './parked-terminal-tab-coverage' import { parkedTerminalTabCoverage, parkRestorePolicyFromState diff --git a/src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-timer-rearm.test.tsx b/src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-timer-rearm.test.tsx deleted file mode 100644 index 9f42102eeb2..00000000000 --- a/src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-timer-rearm.test.tsx +++ /dev/null @@ -1,175 +0,0 @@ -/** @vitest-environment happy-dom */ -/** Tab-model writes must recheck watcher coverage without rearming unchanged absolute deadlines; title floods must preserve the park instant and avoid timer churn. */ -import type * as ParkedWatcherRegistryModule from '../output-watchers/terminal-parked-watcher-registry' -import type * as ParkedWatcherReconciliationModule from '../output-watchers/terminal-parked-watcher-reconciliation' -import { act, useEffect, useState } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../../../shared/workspace-layout/terminal-tab-types' -;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - -const park = vi.hoisted(() => ({ - worktreeId: 'repo::/wt-park-timers', - /** Counts cold-park effect runs; the effect reads the overrides exactly once. */ - effectRuns: 0 -})) - -vi.mock('../../../../store/index', async () => { - const { create } = await import('zustand') - const useAppStore = create(() => ({ - pendingStartupByTabId: {} as Record, - ptyIdsByTabId: {} as Record, - runtimeStatusByEnvironmentId: new Map(), - settings: {} as Record, - terminalLayoutsByTabId: {} as Record, - runtimePaneTitlesByTabId: {} as Record, - sleepingAgentSessionsByPaneKey: {} as Record, - tabsByWorktree: {} as Record - })) - return { useAppStore } -}) - -vi.mock('../output-watchers/terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: () => true, - syncParkedTerminalTabWatchers: () => {} -})) - -vi.mock('../output-watchers/terminal-parked-watcher-registry', async (importOriginal) => ({ - ...(await importOriginal()), - disposeParkedTerminalWatchersForWorktree: () => {} -})) - -vi.mock('../output-watchers/terminal-parked-watcher-reconciliation', async (importOriginal) => ({ - ...(await importOriginal()), - resolveParkedTerminalPaneCandidates: () => [] -})) - -/** A 60s hysteresis keeps every hidden tab holding a pending recheck timer. */ -const COLD_PARK_DELAY_MS = 60_000 - -vi.mock('../instrumentation/terminal-parking-e2e-overrides', () => ({ - getTerminalParkingPolicyOverrides: () => { - park.effectRuns += 1 - return { coldParkDelayMs: 60_000, hotRetainMs: 60_000 } - } -})) - -import { useAppStore } from '../../../../store/index' -import { useTerminalTabColdParking } from '../lifecycle/use-terminal-tab-cold-parking' - -const TAB_IDS = ['tab-a', 'tab-b', 'tab-c', 'tab-d', 'tab-e'] as const -const EMPTY_ASSIGNMENTS = new Map() -const EMPTY_PORTALS: never[] = [] - -const TICK_MS = 10_000 - -type ParkingStoreState = { tabsByWorktree: Record } - -const parkingStore = useAppStore as unknown as { - setState: (partial: (state: ParkingStoreState) => Partial) => void -} - -function terminalTab(id: string): TerminalTab { - return { id, ptyId: `${park.worktreeId}@@session-${id}`, title: id } as TerminalTab -} - -/** A runtime-title publication: re-mints tabsByWorktree, changes no park input. */ -function publishTitle(revision: number): void { - parkingStore.setState((state) => ({ - tabsByWorktree: { - ...state.tabsByWorktree, - [park.worktreeId]: (state.tabsByWorktree[park.worktreeId] ?? []).map((tab) => ({ - ...tab, - title: `${tab.id}-${revision}` - })) - } - })) -} - -let latestParkedTabIds: ReadonlySet = new Set() - -function ParkingHost({ writes }: { writes: number }): null { - const terminalTabs = useAppStore( - (state) => (state as ParkingStoreState).tabsByWorktree[park.worktreeId] - ) as TerminalTab[] - latestParkedTabIds = useTerminalTabColdParking({ - worktreeId: park.worktreeId, - terminalTabs, - assignments: EMPTY_ASSIGNMENTS, - isWorktreeActive: false, - activeTerminalTabId: null, - coldParkTerminalPanes: false, - shouldMeasureHiddenWorktree: false, - activityTerminalPortals: EMPTY_PORTALS, - activationDeferredMountTabIds: null - }) - const [written, setWritten] = useState(0) - useEffect(() => { - if (written >= writes) { - return - } - publishTitle(written) - setWritten((current) => current + 1) - }, [writes, written]) - return null -} - -let container: HTMLDivElement -let root: Root | undefined - -beforeEach(() => { - park.effectRuns = 0 - latestParkedTabIds = new Set() - parkingStore.setState(() => ({ - tabsByWorktree: { [park.worktreeId]: TAB_IDS.map(terminalTab) } - })) - container = document.createElement('div') - document.body.appendChild(container) -}) - -afterEach(() => { - act(() => root?.unmount()) - root = undefined - container.remove() - vi.restoreAllMocks() - vi.useRealTimers() -}) - -/** Advances the clock in fixed ticks and reports the first tick that parks. */ -function firstParkedElapsedMs(options: { publishTitles: boolean }): number { - vi.useFakeTimers() - vi.setSystemTime(0) - root = createRoot(container) - act(() => root?.render()) - expect(latestParkedTabIds.size).toBe(0) - - for (let tick = 1; tick <= 12; tick += 1) { - // advanceTimersByTime moves Date.now() and fires due timers together. - act(() => vi.advanceTimersByTime(TICK_MS)) - // Both runs re-render every tick; only the flooded one publishes titles. - act(() => root?.render()) - if (latestParkedTabIds.size > 0) { - return tick * TICK_MS - } - } - throw new Error('never parked') -} - -describe('cold-park recheck timers under a background title flood', () => { - it('parks at the same instant with and without a title flood', () => { - const quiet = firstParkedElapsedMs({ publishTitles: false }) - act(() => root?.unmount()) - root = undefined - vi.useRealTimers() - park.effectRuns = 0 - latestParkedTabIds = new Set() - parkingStore.setState(() => ({ - tabsByWorktree: { [park.worktreeId]: TAB_IDS.map(terminalTab) } - })) - - const flooded = firstParkedElapsedMs({ publishTitles: true }) - - expect(flooded).toBe(quiet) - expect(quiet).toBe(COLD_PARK_DELAY_MS) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-withheld-tabs.ts b/src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-withheld-tabs.ts index 1162a929f8f..15658023d12 100644 --- a/src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-withheld-tabs.ts +++ b/src/renderer/src/components/terminal-pane/parking/retention/terminal-cold-park-withheld-tabs.ts @@ -4,7 +4,8 @@ import { getParkVerdictUnparkPinUntilMs, type ParkVerdictFlipRecord } from '../verdict/terminal-park-verdict-flip-telemetry' -import { canWatcherCoverParkedTerminalTab } from '../output-watchers/terminal-parked-tab-watchers' +import '../output-watchers/terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from '../output-watchers/parked-terminal-tab-coverage' export type WithheldTerminalTabParking = { parkedTabIds: Set diff --git a/src/renderer/src/components/terminal-pane/parking/verdict/terminal-park-verdict-worktree-driver-loop.test.tsx b/src/renderer/src/components/terminal-pane/parking/verdict/terminal-park-verdict-worktree-driver-loop.test.tsx deleted file mode 100644 index 1fc4ea33d3d..00000000000 --- a/src/renderer/src/components/terminal-pane/parking/verdict/terminal-park-verdict-worktree-driver-loop.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/** @vitest-environment happy-dom */ -// Model worktree-level watcher coverage oscillating with mount state (#15136). A lone last-active tab has no cold candidates, proving only rendered-verdict pinning can stop this driver. -import type * as ParkedWatcherRegistryModule from '../output-watchers/terminal-parked-watcher-registry' -import type * as ParkedWatcherReconciliationModule from '../output-watchers/terminal-parked-watcher-reconciliation' -import { act, useEffect, useState } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { TerminalTab } from '../../../../../../shared/workspace-layout/terminal-tab-types' - -;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - -const park = vi.hoisted(() => ({ worktreeId: 'repo::/wt-worktree-driver' })) - -vi.mock('../../../../store/index', async () => { - const { create } = await import('zustand') - const useAppStore = create(() => ({ - pendingStartupByTabId: {} as Record, - ptyIdsByTabId: {} as Record, - runtimeStatusByEnvironmentId: new Map(), - settings: {} as Record, - terminalLayoutsByTabId: {} as Record, - runtimePaneTitlesByTabId: {} as Record, - tabsByWorktree: {} as Record - })) - return { useAppStore } -}) - -vi.mock('../output-watchers/terminal-parked-tab-watchers', () => ({ - canWatcherCoverParkedTerminalTab: () => true, - syncParkedTerminalTabWatchers: () => {} -})) - -vi.mock('../output-watchers/terminal-parked-watcher-registry', async (importOriginal) => ({ - ...(await importOriginal()), - disposeParkedTerminalWatchersForWorktree: () => {} -})) - -vi.mock('../output-watchers/terminal-parked-watcher-reconciliation', async (importOriginal) => ({ - ...(await importOriginal()), - resolveParkedTerminalPaneCandidates: () => [] -})) - -vi.mock('../instrumentation/terminal-parking-e2e-overrides', () => ({ - getTerminalParkingPolicyOverrides: () => ({ coldParkDelayMs: 0, hotRetainMs: 0 }) -})) - -import { useAppStore } from '../../../../store/index' -import { TERMINAL_TAB_PARK_FLIP_BURST_LIMIT } from './terminal-park-verdict-flip-telemetry' -import { useTerminalTabColdParking } from '../lifecycle/use-terminal-tab-cold-parking' - -const TAB_ID = 'tab-worktree-driven' -/** Stops an unfixed loop from hanging the run if React ever raises its bail. */ -const RENDER_HARD_STOP = 400 -/** +1: the pin lands in the passive effect observing the burst flip. */ -const SETTLED_FLIP_BUDGET = TERMINAL_TAB_PARK_FLIP_BURST_LIMIT + 1 -const EMPTY_ASSIGNMENTS = new Map() -const EMPTY_PORTALS: never[] = [] - -type ParkingStoreState = { tabsByWorktree: Record } - -const parkingStore = useAppStore as unknown as { - setState: (partial: (state: ParkingStoreState) => Partial) => void -} - -let hostRenderCount = 0 -let parkVerdictFlipCount = 0 -let lastParkVerdict = false -let paneMountCount = 0 - -/** The pane whose mount/unmount the park verdict authorizes. */ -function TerminalPaneStandIn(): null { - useEffect(() => { - paneMountCount += 1 - }, []) - return null -} - -// Model worktree park coverage gained on mount and lost on unmount, one passive-effect commit per flip. -function WorktreeParkVerdict({ - parked, - onVerdict -}: { - parked: boolean - onVerdict: (coldParkTerminalPanes: boolean) => void -}): null { - useEffect(() => { - onVerdict(!parked) - }, [parked, onVerdict]) - return null -} - -function OverlayHost(): React.JSX.Element | null { - hostRenderCount += 1 - const [coldParkTerminalPanes, setColdParkTerminalPanes] = useState(false) - const terminalTabs = useAppStore( - (state) => (state as ParkingStoreState).tabsByWorktree[park.worktreeId] - ) as TerminalTab[] - const parkedTerminalTabIds = useTerminalTabColdParking({ - worktreeId: park.worktreeId, - terminalTabs, - assignments: EMPTY_ASSIGNMENTS, - isWorktreeActive: false, - activeTerminalTabId: null, - coldParkTerminalPanes, - shouldMeasureHiddenWorktree: false, - activityTerminalPortals: EMPTY_PORTALS, - activationDeferredMountTabIds: null - }) - const parked = parkedTerminalTabIds.has(TAB_ID) - if (parked !== lastParkVerdict) { - parkVerdictFlipCount += 1 - } - lastParkVerdict = parked - if (hostRenderCount > RENDER_HARD_STOP) { - return null - } - return ( - <> - {parked ? null : } - - - ) -} - -function renderOverlayHost(root: Root): unknown { - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) - let thrown: unknown = null - try { - act(() => { - root.render() - }) - } catch (error) { - thrown = error - } - consoleError.mockRestore() - return thrown -} - -describe('park-verdict damping reaches the worktree-level driver', () => { - let container: HTMLDivElement - let root: Root | undefined - - beforeEach(() => { - hostRenderCount = 0 - parkVerdictFlipCount = 0 - lastParkVerdict = false - paneMountCount = 0 - parkingStore.setState(() => ({ - tabsByWorktree: { - [park.worktreeId]: [ - { id: TAB_ID, ptyId: `${park.worktreeId}@@session-1`, title: TAB_ID } as TerminalTab - ] - } - })) - container = document.createElement('div') - document.body.appendChild(container) - }) - - afterEach(() => { - act(() => { - root?.unmount() - }) - root = undefined - container.remove() - }) - - it('settles a verdict whose only driver is the worktree-level park prop', () => { - root = createRoot(container) - const thrown = renderOverlayHost(root) - - expect(thrown).toBeNull() - // Cold-only damping sees an empty set here and merely mutes telemetry while rendered churn continues. - expect(parkVerdictFlipCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET) - // Settle mounted so watcher-dependent bells/titles/completions remain live. - expect(lastParkVerdict).toBe(false) - expect(paneMountCount).toBeGreaterThan(0) - expect(paneMountCount).toBeLessThanOrEqual(SETTLED_FLIP_BUDGET) - expect(hostRenderCount).toBeLessThan(RENDER_HARD_STOP) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn/disposed-spawn-retention.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn/disposed-spawn-retention.ts index ea61723c5c6..e352d93e290 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn/disposed-spawn-retention.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/spawn/disposed-spawn-retention.ts @@ -1,7 +1,8 @@ import type { AppState } from '@/store/types' import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' -import { collectLeafIdsInOrder } from '../../layout/terminal-layout-leaf-traversal' +import '../../layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../../shared/workspace-layout/terminal-pane-tree' import { hasWorktreeTeardownStarted } from '@/store/slices/worktrees/teardown/worktree-delete-state-types' // Main can give a remounted pane the same PTY, so disposal alone does not make it ownerless. @@ -30,5 +31,5 @@ export function shouldRetainDisposedPaneSpawn( } // A new single-pane tab has no layout root until its first pane binds. const root = state.terminalLayoutsByTabId[tabId]?.root - return !root || collectLeafIdsInOrder(root).includes(leafId) + return !root || collectLayoutLeafIdsInOrder(root).includes(leafId) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/spawn/pty-connection-fresh-spawn-guards.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection/spawn/pty-connection-fresh-spawn-guards.spec.ts deleted file mode 100644 index cf346c14def..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-connection/spawn/pty-connection-fresh-spawn-guards.spec.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { bindPtyConnectionTestMocks } from '../test-fixtures/pty-connection-mocks.spec' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { flushAsyncTicks } from '../test-fixtures/pty-connection-test-async' -import { sendTerminalInputThroughPane } from '../test-fixtures/pty-connection-test-dom' -import { - createMockTransport, - createPane, - createManager, - type ConnectCallbacks, - type MockTransport -} from '../test-fixtures/pty-connection-test-pane-fixtures' -import { - buildPaneConnectionDeps, - type PtyConnectionAgentTitleMocks, - bindPaneAgentTitleMocks -} from '../test-fixtures/pty-connection-test-deps' -import { createInitialStoreState } from '../test-fixtures/pty-connection-test-store-fixtures' -import type { StoreState } from '../test-fixtures/pty-connection-test-store-state' -import { - installTerminalTestGlobals, - restoreTerminalTestGlobals -} from '../test-fixtures/pty-connection-test-environment' - -export function registerPtyConnectionFreshSpawnGuardsTests(): void { - let mockStoreState: StoreState - let transportFactoryQueue: MockTransport[] = [] - let createdTransportOptions: Record[] = [] - let storeSubscribers: ((state: StoreState) => void)[] = [] - - function createDeps(overrides: Record = {}) { - return buildPaneConnectionDeps(() => mockStoreState, overrides) - } - - describe('connectPanePty', () => { - beforeEach(async () => { - vi.clearAllMocks() - transportFactoryQueue = [] - createdTransportOptions = [] - storeSubscribers = [] - mockStoreState = createInitialStoreState(() => mockStoreState) - bindPtyConnectionTestMocks({ - getState: () => mockStoreState, - getTransportQueue: () => transportFactoryQueue, - getTransportOptions: () => createdTransportOptions, - getStoreSubscribers: () => storeSubscribers, - setStoreSubscribers: (subscribers) => { - storeSubscribers = subscribers - } - }) - await installTerminalTestGlobals() - }) - - afterEach(async () => { - await restoreTerminalTestGlobals() - }) - - // Do not race PTY respawn into worktree deletion; main’s removal fence must not flash a raw error before unmount. - it('skips a fresh spawn while the pane worktree is being deleted', async () => { - const { connectPanePty } = await import('../connect-pane-pty') - const agentTitleMocks3 = await vi.importMock( - '@/lib/agent-status/aggregation/worktree-agent-status' - ) - const transport = createMockTransport() - transportFactoryQueue.push(transport) - mockStoreState = { - ...mockStoreState, - deleteStateByWorktreeId: { 'wt-1': { isDeleting: true, phase: 'deleting' } } - } - // Use a unique tab ID to avoid pending-spawn entries from other tests and force fresh spawn. - const deps = createDeps({ tabId: 'tab-removal-skip-spawn' }) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - bindPaneAgentTitleMocks(deps as never, agentTitleMocks3) - ) - await flushAsyncTicks() - - expect(transport.connect).not.toHaveBeenCalled() - expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() - }) - - // A racing spawn, including under a removing parent worktree, must swallow main’s removal fence instead of flashing its raw banner. - it('swallows a worktree-removal fence error instead of surfacing it', async () => { - const { connectPanePty } = await import('../connect-pane-pty') - const agentTitleMocks8 = await vi.importMock( - '@/lib/agent-status/aggregation/worktree-agent-status' - ) - const { TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE } = - await import('../../../../../../shared/worktree/removal-fence-error') - const transport = createMockTransport() - const capturedOnError: { current: ((message: string) => void) | null } = { current: null } - transport.connect.mockImplementation( - async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedOnError.current = callbacks.onError ?? null - return 'pty-1' - } - ) - transportFactoryQueue.push(transport) - const deps = createDeps({ tabId: 'tab-fence-swallow' }) - - connectPanePty( - createPane(1) as never, - createManager(1) as never, - bindPaneAgentTitleMocks(deps as never, agentTitleMocks8) - ) - await flushAsyncTicks() - - // Require captured onError wiring before invoking it; optional invocation could falsely pass without exercising suppression. - expect(capturedOnError.current).toBeTypeOf('function') - // Electron wraps the rejected ipcMain error with its own prefix; still swallowed. - capturedOnError.current!( - `Error invoking remote method 'pty:spawn': Error: ${TERMINAL_REMOVAL_IN_PROGRESS_MESSAGE}` - ) - expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled() - }) - - it('preserves classified user input during replay while suppressing synthetic replies', async () => { - const { connectPanePty } = await import('../connect-pane-pty') - const agentTitleMocks16 = await vi.importMock( - '@/lib/agent-status/aggregation/worktree-agent-status' - ) - const pane = createPane(1) - const userInputListeners = new Set<() => void>() - Object.assign(pane.terminal, { - _core: { - coreService: { - onUserInput: (listener: () => void) => { - userInputListeners.add(listener) - return { dispose: () => userInputListeners.delete(listener) } - } - } - } - }) - const transport = createMockTransport('ssh:ssh-1@@pty-1') - transportFactoryQueue.push(transport) - const deps = createDeps() - const deferred: (() => void)[] = [] - Object.assign(deps, { - deferPtyInput: (_paneId: number, data: string, forward: (data: string) => void) => { - deferred.push(() => forward(data)) - } - }) - connectPanePty( - pane as never, - createManager(1, 1) as never, - bindPaneAgentTitleMocks(deps as never, agentTitleMocks16) - ) - await flushAsyncTicks() - transport.sendInput.mockClear() - deps.replayingPanesRef.current.set(pane.id, 1) - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, 'input_under_flood\r') - sendTerminalInputThroughPane(pane, '\x1b[?1;2c') - // A click on replayed scrollback that still has mouse tracking armed is user input to xterm, but must not reach the shell. - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') - - // A wheel over a replayed alt-screen frame becomes cursor keys; the fresh shell must not recall history from them. - pane.terminal.buffer.active.type = 'alternate' - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[B') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenCalledExactlyOnceWith('input_under_flood\r', 'driving') - - // The same bytes on the normal buffer can only be a keyboard arrow, which survives replay. - pane.terminal.buffer.active.type = 'normal' - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[B') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenCalledTimes(2) - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[B', 'driving') - - deps.replayingPanesRef.current.delete(pane.id) - for (const listener of userInputListeners) { - listener() - } - sendTerminalInputThroughPane(pane, '\x1b[<0;12;4M') - for (const forward of deferred.splice(0)) { - forward() - } - expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[<0;12;4M', 'driving') - }) - }) -} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/tests/pty-connection-spawn-and-startup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/tests/pty-connection-spawn-and-startup.test.ts index 409d5e05a76..6cf4f7756b3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/tests/pty-connection-spawn-and-startup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/tests/pty-connection-spawn-and-startup.test.ts @@ -1,6 +1,5 @@ import { describe } from 'vitest' import { registerPtyConnectionPendingSpawnTests } from '../spawn/pty-connection-pending-spawn.spec' -import { registerPtyConnectionFreshSpawnGuardsTests } from '../spawn/pty-connection-fresh-spawn-guards.spec' import { registerPtyConnectionSplitSpawnTests } from '../spawn/pty-connection-split-spawn.spec' import { registerPtyConnectionRestoredRuntimePaneBindingTests } from '../reattach/pty-connection-restored-runtime-pane-binding.spec' import { registerPtyConnectionTerminalPasteStartupCommandDeliveryTests } from '../startup/pty-connection-startup-command-delivery.spec' @@ -11,7 +10,6 @@ import { registerPtyConnectionRemoteRuntimeAttachTests } from '../reattach/pty-c import { registerPtyConnectionRuntimePaneTransportRoutingTests } from '../host-ownership/pty-connection-runtime-pane-transport-routing.spec' describe('pty-connection-pending-spawn', registerPtyConnectionPendingSpawnTests) -describe('pty-connection-fresh-spawn-guards', registerPtyConnectionFreshSpawnGuardsTests) describe('pty-connection-split-spawn', registerPtyConnectionSplitSpawnTests) describe('pty-connection-runtime-owner-spawn-routing', () => { registerPtyConnectionRestoredRuntimePaneBindingTests() diff --git a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts b/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts deleted file mode 100644 index 2ff047ffa57..00000000000 --- a/src/renderer/src/components/terminal-pane/pty-transport/ipc/pty-transport-connect-spawn.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { terminalAttachResult } from '../../../../../../shared/terminal/pty/lifecycle/terminal-attach-result' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installIpcPtyWindow, restorePtySpecWindow } from './pty-transport-test-harness' - -describe('createIpcPtyTransport', () => { - const originalWindow = (globalThis as { window?: typeof window }).window - let onData: ((payload: { id: string; data: string }) => void) | null = null - let onExit: - | ((payload: { - id: string - code: number - preserveRendererBinding?: boolean - incarnationId?: string - }) => void) - | null = null - - beforeEach(() => { - vi.resetModules() - onData = null - onExit = null - installIpcPtyWindow(originalWindow, { - data: (callback) => { - onData = callback - }, - exit: (callback) => { - onExit = callback - } - }) - }) - - afterEach(() => { - restorePtySpecWindow(originalWindow) - }) - - it('drains an exited join with pre-request output and preserves its snapshot projection', async () => { - const { createIpcPtyTransport } = await import('./pty-transport') - const { ensurePtyDispatcher } = await import('../delivery/pty-dispatcher') - const creator = { - kind: 'registration-exited' as const, - result: { - id: 'exited-join', - incarnationId: 'join-inc', - snapshot: 'final snapshot', - snapshotCols: 120, - snapshotRows: 40, - snapshotSeq: 7, - snapshotKittyKeyboardFlags: 3 - } - } - const joined = terminalAttachResult(creator.result) - ensurePtyDispatcher() - onData!({ id: creator.result.id, data: 'final output' }) - onExit!({ id: creator.result.id, code: 23, incarnationId: creator.result.incarnationId }) - vi.mocked(window.api.pty.spawn).mockResolvedValue(joined) - const onOutput = vi.fn() - const onEnd = vi.fn() - const transport = createIpcPtyTransport({}) - const result = await transport.connect({ - url: '', - callbacks: { onData: onOutput, onExit: onEnd } - }) - expect(onOutput).toHaveBeenCalledWith('final output') - expect(onEnd).toHaveBeenCalledWith(23) - expect(result).toEqual({ id: creator.result.id, exitedBeforeAttach: true }) - transport.detach?.() - const snapshotView = createIpcPtyTransport({}) - expect(await snapshotView.connect({ url: '', callbacks: {} })).toMatchObject({ - id: creator.result.id, - isReattach: true, - snapshot: 'final snapshot', - snapshotCols: 120, - snapshotRows: 40, - snapshotSeq: 7, - snapshotKittyKeyboardFlags: 3 - }) - expect(creator.result).not.toHaveProperty('isReattach') - snapshotView.detach?.() - }) - - it('stops gone-parking a session whose cold restore resolves after its transport was destroyed', async () => { - const { createIpcPtyTransport } = await import('./pty-transport') - const gone = await import('../../../terminal/terminal-provider-gone-sessions') - const { canWatcherCoverParkedTerminalTab } = - await import('../../parking/output-watchers/terminal-parked-tab-watchers') - const { captureParkedTerminalPaneCandidates } = - await import('../../parking/output-watchers/terminal-parked-watcher-registry') - const REVIVED_PTY_ID = 'repo::/wt@@revived' - const { useAppStore } = await import('@/store/index') - const { makeTab } = await import('@/store/terminals/terminal-tab.test-fixture') - const tab = makeTab({ id: 'tab-revived', worktreeId: 'repo::/wt', ptyId: REVIVED_PTY_ID }) - useAppStore.setState({ tabsByWorktree: { 'repo::/wt': [tab] } }) - captureParkedTerminalPaneCandidates(tab.id, 'repo::/wt', [ - { - ptyId: REVIVED_PTY_ID, - paneId: 1, - leafId: '11111111-1111-4111-8111-111111111111', - drivesTabTitle: true - } - ]) - await gone.probeTerminalProviderGoneSessions([REVIVED_PTY_ID], async () => [REVIVED_PTY_ID]) - expect(canWatcherCoverParkedTerminalTab('repo::/wt', tab)).toBe(true) - type SpawnResult = Awaited> - let resolveSpawn: (value: SpawnResult) => void = () => {} - vi.mocked(window.api.pty.spawn).mockReturnValueOnce( - new Promise((resolve) => { - resolveSpawn = resolve - }) - ) - - const transport = createIpcPtyTransport({}) - const connecting = transport.connect({ url: '', sessionId: REVIVED_PTY_ID, callbacks: {} }) - await vi.waitFor(() => expect(window.api.pty.spawn).toHaveBeenCalled()) - transport.destroy?.() - resolveSpawn({ id: REVIVED_PTY_ID, coldRestore: { scrollback: '', cwd: '/repo' } }) - await connecting - - // Live again without an authoritative snapshot, so it must stay mounted where its own watchers run. - expect(canWatcherCoverParkedTerminalTab('repo::/wt', tab)).toBe(false) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/snapshots/remote-layout-scrollback-retention.ts b/src/renderer/src/components/terminal-pane/snapshots/remote-layout-scrollback-retention.ts index adefc974c33..28876746d8f 100644 --- a/src/renderer/src/components/terminal-pane/snapshots/remote-layout-scrollback-retention.ts +++ b/src/renderer/src/components/terminal-pane/snapshots/remote-layout-scrollback-retention.ts @@ -1,5 +1,6 @@ import type { TerminalLayoutSnapshot } from '../../../../../shared/workspace-layout/terminal-tab-types' -import { collectLeafIdsInOrder } from '../layout/terminal-layout-leaf-traversal' +import '../layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../shared/workspace-layout/terminal-pane-tree' import { mergeCapturedLeafState } from './merge-captured-leaf-state' /** Host owns layout structure; preserve local captured bytes only for its surviving leaves. Park does not bump generation, so wholesale pulls otherwise erase newer local tails and propagate stale uploads. */ @@ -10,7 +11,7 @@ export function retainLocalScrollbackInRemoteLayout( if (!local?.buffersByLeafId && !local?.scrollbackRefsByLeafId) { return remote } - const currentLeafIds = new Set(collectLeafIdsInOrder(remote.root)) + const currentLeafIds = new Set(collectLayoutLeafIdsInOrder(remote.root)) if (currentLeafIds.size === 0) { return remote } diff --git a/src/renderer/src/components/terminal-pane/titles/use-host-pane-title-sync.ts b/src/renderer/src/components/terminal-pane/titles/use-host-pane-title-sync.ts index 25649d51dec..25a34ae98f3 100644 --- a/src/renderer/src/components/terminal-pane/titles/use-host-pane-title-sync.ts +++ b/src/renderer/src/components/terminal-pane/titles/use-host-pane-title-sync.ts @@ -2,7 +2,8 @@ import { useEffect } from 'react' import { isRemoteRuntimePtyId } from '../../../../../shared/remote-runtime/remote-runtime-pty-id' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' import type { TerminalLayoutSnapshot } from '../../../../../shared/workspace-layout/terminal-tab-types' -import { collectLeafIdsInOrder } from '../layout/terminal-layout-leaf-traversal' +import '../layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../shared/workspace-layout/terminal-pane-tree' import type { TerminalPaneLayoutController } from '../layout/persistence/use-terminal-pane-layout-persistence' /** Sync owner-published pane titles while retaining pending local renames until host echo. */ @@ -17,7 +18,7 @@ export function hostPaneTitleUpdates(args: { if (!ORCA_LAYOUT_OWNER_IS_MAIN && !ptyIds.some((ptyId) => isRemoteRuntimePtyId(ptyId))) { return updates } - const hostLeafIds = new Set(collectLeafIdsInOrder(args.layout.root)) + const hostLeafIds = new Set(collectLayoutLeafIdsInOrder(args.layout.root)) for (const pane of args.panes) { if (!hostLeafIds.has(pane.leafId)) { continue diff --git a/src/renderer/src/components/orchestration-crew/WorkerAutoCloseBanner.tsx b/src/renderer/src/components/terminal-pane/worker-lifetime/WorkerAutoCloseBanner.tsx similarity index 87% rename from src/renderer/src/components/orchestration-crew/WorkerAutoCloseBanner.tsx rename to src/renderer/src/components/terminal-pane/worker-lifetime/WorkerAutoCloseBanner.tsx index dac5a893fcc..e615ad74c67 100644 --- a/src/renderer/src/components/orchestration-crew/WorkerAutoCloseBanner.tsx +++ b/src/renderer/src/components/terminal-pane/worker-lifetime/WorkerAutoCloseBanner.tsx @@ -5,9 +5,9 @@ import { useAppStore } from '@/store/index' import { runtimeEnvironmentIdOrLocalUnlessTwin } from '@/lib/workspace/ownership/worktree-runtime-owner' import { useWorkerAutoClose, workerKeepOpenLabel } from '@/lib/worker-auto-close' import { useTranslate } from '@/i18n/use-translate' -import { formatWorkerAutoCloseCountdown } from '../../../../shared/orchestration/workers/worker-auto-close' -import { makePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' -import { PaneTopWarningBanner } from '../terminal-pane/appearance/pane-top-banner/PaneTopWarningBanner' +import { formatWorkerAutoCloseCountdown } from '../../../../../shared/orchestration/workers/worker-auto-close' +import { makePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' +import { PaneTopWarningBanner } from '../appearance/pane-top-banner/PaneTopWarningBanner' /** Across a temporary orchestration worker's pane: when it closes, and Keep open. */ export function WorkerAutoCloseBanner({ diff --git a/src/renderer/src/components/terminal-parking-pass-candidates.ts b/src/renderer/src/components/terminal-parking-pass-candidates.ts index 96732029047..b62d5c7300d 100644 --- a/src/renderer/src/components/terminal-parking-pass-candidates.ts +++ b/src/renderer/src/components/terminal-parking-pass-candidates.ts @@ -12,7 +12,8 @@ import { type TerminalWorktreeRetentionCandidate } from './terminal-pane/parking/retention/terminal-hidden-worktree-retention' import { getTerminalParkingPolicyOverrides } from './terminal-pane/parking/instrumentation/terminal-parking-e2e-overrides' -import { canWatcherCoverParkedTerminalTab } from './terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' +import './terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from './terminal-pane/parking/output-watchers/parked-terminal-tab-coverage' import { getHeldTerminalPrewarmWorktrees } from './terminal/terminal-worktree-prewarm' import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' diff --git a/src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts b/src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts index 3ab4d05f501..8d4292df366 100644 --- a/src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts +++ b/src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts @@ -3,7 +3,8 @@ import { invalidateTerminalParkingInputs } from '../terminal-pane/parking/termin import { useAppStore } from '@/store' import type { TerminalTab } from '../../../../shared/workspace-layout/terminal-tab-types' import { hasRegisteredRuntimeTerminalTab } from '../../runtime/hosted-terminals/mounted-surfaces/mounted-terminal-tab-registry' -import { canWatcherCoverParkedTerminalTab } from '../terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' +import '../terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from '../terminal-pane/parking/output-watchers/parked-terminal-tab-coverage' import { canAdmitTerminalTabsForStartup } from './background-terminal-worktree-mount' import { type TerminalPrewarmStage, diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index a1ddde18b0e..3f8df15b128 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -5,10 +5,10 @@ import { useTerminalParkingWorkspaceChanges } from './terminal-pane/parking/use- import { getTerminalTabOwnerWorktreeId } from '@/store/terminals/terminal-tab-owner-index' import { findActivityTerminalPortal } from './activity/activity-terminal-portal' import { - canWatcherCoverParkedTerminalTab, syncParkedTerminalTabWatchersForWorkspaces, type ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/parking/output-watchers/terminal-parked-tab-watchers' +import { canWatcherCoverParkedTerminalTab } from './terminal-pane/parking/output-watchers/parked-terminal-tab-coverage' import { disposeAllParkedTerminalWatchers, pruneParkedTerminalWatchers, diff --git a/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts index 8705f60fa83..3c133d31b59 100644 --- a/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts +++ b/src/renderer/src/components/wsl-move/wsl-move-scope-rows.ts @@ -12,7 +12,8 @@ import { import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' import type { Tab } from '../../../../shared/tab-types' import type { TerminalLayoutSnapshot } from '../../../../shared/workspace-layout/terminal-tab-types' -import { collectLeafIdsInOrder } from '../terminal-pane/layout/terminal-layout-leaf-traversal' +import '../terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { Worktree } from '../../../../shared/worktree/types' @@ -136,7 +137,7 @@ export function wslMoveCounts( function terminalPaneCount(catalog: WslMoveCatalog, tab: Pick): number { const layout = catalog.terminalLayoutsByTabId?.[tab.entityId] ?? catalog.terminalLayoutsByTabId?.[tab.id] - return Math.max(1, collectLeafIdsInOrder(layout?.root).length) + return Math.max(1, collectLayoutLeafIdsInOrder(layout?.root).length) } function projectOfWorkspaceKey(key: string): WslMoveRowRef { diff --git a/src/renderer/src/hooks/agent-hook-completion-notification-types.ts b/src/renderer/src/hooks/agent-hook-completion-notification-types.ts new file mode 100644 index 00000000000..31139659ce5 --- /dev/null +++ b/src/renderer/src/hooks/agent-hook-completion-notification-types.ts @@ -0,0 +1,16 @@ +import type { useAppStore } from '@/store' +import type { AgentCompletionCoordinator } from '@/components/terminal-pane/agent-completion/agent-completion-coordinator-types' + +export type CoordinatorEntry = { + worktreeId: string + coordinator: AgentCompletionCoordinator +} + +export type StoreSnapshot = ReturnType +export type WorktreeTab = NonNullable[string][number] +// Index paneKey → tab once per prune instead of flattening all tabs per coordinator. +export type TabIndex = ReadonlyMap +export type PaneCoordinatorLivenessSnapshot = Pick< + StoreSnapshot, + 'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'suppressedPtyExitIds' +> diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index a1ad8aaf375..b91f65180b0 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -7,7 +7,8 @@ import type { } from '@/components/terminal-pane/agent-completion/agent-completion-coordinator-types' import type { TerminalProcessInspection as RuntimeTerminalProcessInspection } from '../../../shared/terminal/pty/lifecycle/terminal-process-inspection' import { dispatchTerminalNotification } from '../components/terminal-pane/notifications/dispatch-terminal-notification' -import { collectLeafIdsInOrder } from '../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../shared/workspace-layout/terminal-pane-tree' import { getPtyIdForPaneKey } from './pane-key-pty-id' import { dispatchAgentHookTerminalLifecycle } from '@/components/terminal-pane/agent-completion/agent-hook-terminal-lifecycle' import { isAgentTaskCompleteTrackingEnabledFromState as isAgentHookCompletionTrackingEnabled } from '../components/terminal-pane/notifications/agent-task-complete-policy' @@ -21,20 +22,13 @@ import { submitHookLaneCompletion } from './agent-hook-completion-revision-hold' import * as finishDrops from '@/attention/agent-finish-drop-breadcrumb' - -type CoordinatorEntry = { - worktreeId: string - coordinator: AgentCompletionCoordinator -} - -type StoreSnapshot = ReturnType -type WorktreeTab = NonNullable[string][number] -// Index paneKey → tab once per prune instead of flattening all tabs per coordinator. -type TabIndex = ReadonlyMap -type PaneCoordinatorLivenessSnapshot = Pick< +import type { + CoordinatorEntry, StoreSnapshot, - 'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'suppressedPtyExitIds' -> + WorktreeTab, + TabIndex, + PaneCoordinatorLivenessSnapshot +} from './agent-hook-completion-notification-types' const coordinatorsByPaneKey = new Map() const paneKeysRequiringFreshWorking = new Set() @@ -177,7 +171,7 @@ function paneKeyHasUnsuppressedPtyHint( return false } const layout = state.terminalLayoutsByTabId?.[parsed.tabId] - if (layout?.root && !collectLeafIdsInOrder(layout.root).includes(parsed.leafId)) { + if (layout?.root && !collectLayoutLeafIdsInOrder(layout.root).includes(parsed.leafId)) { return false } const leafPtyId = layout?.ptyIdsByLeafId?.[parsed.leafId] diff --git a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts index c9b188163a0..ef729efcf24 100644 --- a/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts +++ b/src/renderer/src/hooks/ipc-events-agent-status-window-test-fixtures.ts @@ -168,10 +168,6 @@ export function stubAuxiliaryModules(): void { vi.doMock('@/components/sidebar/worktree-list/listing/visible-worktrees', () => ({ getVisibleWorktreeIds: () => [] })) - vi.doMock('@/lib/editor/appearance/editor-font-zoom', () => ({ - nextEditorFontZoomLevel: vi.fn(() => 0), - computeEditorFontSize: vi.fn(() => 13) - })) vi.doMock('../../../shared/ui-zoom-level', async (importOriginal) => ({ ...(await importOriginal()), zoomLevelToPercent: vi.fn(() => 100), diff --git a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts index 70aca370baa..e78e1132ca2 100644 --- a/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-close-routing-test-harness.ts @@ -130,10 +130,6 @@ export async function useIpcEventsForCloseRouting({ vi.doMock('@/components/sidebar/worktree-list/listing/visible-worktrees', () => ({ getVisibleWorktreeIds: () => [] })) - vi.doMock('@/lib/editor/appearance/editor-font-zoom', () => ({ - nextEditorFontZoomLevel: vi.fn(() => 0), - computeEditorFontSize: vi.fn(() => 13) - })) vi.doMock('../../../shared/ui-zoom-level', async (importOriginal) => ({ ...(await importOriginal()), zoomLevelToPercent: vi.fn(() => 100), diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts index d0808c20532..1eebc15d021 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts @@ -1,5 +1,6 @@ import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' -import { collectLeafIdsInOrder } from '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' import { getIndexedRepoMap } from '@/store/repos/repository-catalog-index' import { getIndexedWorktreeMap } from '@/store/slices/worktrees/listing/worktree-catalog-index' import { parsePaneKey } from '../../../../shared/workspace-layout/stable-pane-id' @@ -218,7 +219,7 @@ export function resolvePaneKeyFromRoutingIndex( let leafIds = index.leafIdsByRoot.get(layout.root) if (!leafIds) { agentStatusPaneRoutingIndexCounters.leafSetBuilds += 1 - leafIds = new Set(collectLeafIdsInOrder(layout.root)) + leafIds = new Set(collectLayoutLeafIdsInOrder(layout.root)) index.leafIdsByRoot.set(layout.root, leafIds) } if (!leafIds.has(leafId)) { diff --git a/src/renderer/src/hooks/ipc-events/terminal-command-state.ts b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts index 6ec2f71050e..1bac9013bde 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-command-state.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-command-state.ts @@ -1,4 +1,5 @@ -import { collectLeafIdsInOrder } from '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' import { focusTerminalTabSurface } from '@/components/terminal-pane/input/focus/focus-terminal-tab-surface' import { focusRuntimeTerminalSurface } from '@/runtime/hosted-terminals/mounted-surfaces/mounted-terminal-tab-registry' import type { RuntimeTerminalPresentation } from '../../../../shared/runtime-terminal-contracts' @@ -77,7 +78,7 @@ export function addSplitLeafToLayout( activateNewLeaf = true ): TerminalLayoutSnapshot { const root = layout?.root ?? { type: 'leaf', leafId: sourceLeafId } - const existingLeafIds = collectLeafIdsInOrder(root) + const existingLeafIds = collectLayoutLeafIdsInOrder(root) const nextActiveLeafId = activateNewLeaf || !layout?.activeLeafId || !existingLeafIds.includes(layout.activeLeafId) ? newLeafId @@ -113,7 +114,7 @@ export function activateExistingLeafInLayout( ptyId: string, title?: string | null ): TerminalLayoutSnapshot | null { - if (!layout?.root || !collectLeafIdsInOrder(layout.root).includes(leafId)) { + if (!layout?.root || !collectLayoutLeafIdsInOrder(layout.root).includes(leafId)) { return null } return { diff --git a/src/renderer/src/hooks/ipc-events/terminal-pane-title-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-pane-title-ipc-bridge.ts index 4289a48fd7a..a9fa5c00bd9 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-pane-title-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-pane-title-ipc-bridge.ts @@ -2,14 +2,17 @@ import { SET_TERMINAL_PANE_TITLE_EVENT, type SetTerminalPaneTitleDetail } from '@/constants/terminal' -import { collectLeafIdsInOrder } from '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { + collectLayoutLeafIdsInOrder, + resolveRootlessTerminalLayoutLeafId +} from '../../../../shared/workspace-layout/terminal-pane-tree' import { chatWorkspaceByTabId as findTerminalTabWorktreeId } from '@/lib/structured-chat-tab-index' import { sendLayoutCommandToOwner } from '@/runtime/workspace-layout/commands/layout-owner-command' import { useAppStore } from '../../store' import type { TerminalLayoutSnapshot } from '../../../../shared/workspace-layout/terminal-tab-types' import { isTerminalLeafId } from '../../../../shared/workspace-layout/stable-pane-id' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' -import { resolveRootlessTerminalLayoutLeafId } from '../../../../shared/workspace-layout/terminal-pane-tree' function showRuntimePaneTitle(tabId: string, leafId: string, title: string | null): void { const store = useAppStore.getState() @@ -39,7 +42,7 @@ export function applyRuntimePaneTitle({ tabId, leafId, title }: SetTerminalPaneT const layout = store.terminalLayoutsByTabId[tabId] // A never-mounted tab's layout can be rootless and name its one pane elsewhere. const leafIds = layout?.root - ? collectLeafIdsInOrder(layout.root) + ? collectLayoutLeafIdsInOrder(layout.root) : [layout && resolveRootlessTerminalLayoutLeafId(layout)] if (!layout || !isTerminalLeafId(leafId) || !leafIds.includes(leafId)) { return diff --git a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-startup-env.test.ts b/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-startup-env.test.ts deleted file mode 100644 index e8f4f9e9ff8..00000000000 --- a/src/renderer/src/hooks/ipc-events/terminal-request-ipc-bridge-startup-env.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTabsSliceMockApi } from '../../store/slices/tabs/tabs-slice-test-harness' -import { createTestStore } from '../../store/testing/app-store-test-fixture' - -vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) -const testStore = vi.hoisted(() => { - const ref: { current: ReturnType | null } = { current: null } - return ref -}) -vi.mock('../../store', () => ({ - useAppStore: { - getState: () => { - if (!testStore.current) { - throw new Error('no test store') - } - return testStore.current.getState() - } - } -})) -vi.mock('@/lib/terminal-worktree-route', () => ({ - resolveTerminalWorktreeRoute: () => ({ runtimeEnvironmentId: null }) -})) -vi.mock('./terminal-command-state', async (importOriginal) => ({ - ...(await importOriginal()), - focusTerminalInitiatedTab: vi.fn() -})) - -createTabsSliceMockApi() - -const { registerTerminalRequestIpcBridge } = await import('./terminal-request-ipc-bridge') - -const WT = 'repo1::/tmp/feature' - -type CreateRequest = { - requestId: string - worktreeId: string - presentation?: 'focused' | 'background' - source?: 'runtime-session' - env?: Record -} - -let request!: (data: CreateRequest) => void -const replyTerminalCreate = vi.fn() -let store: ReturnType - -beforeEach(() => { - store = createTestStore() - testStore.current = store - replyTerminalCreate.mockClear() - vi.stubGlobal('window', { - ...globalThis.window, - api: { - ...globalThis.window?.api, - ui: { - ...globalThis.window?.api?.ui, - onRequestTerminalCreate: (callback: (data: CreateRequest) => void) => { - request = callback - return () => {} - }, - replyTerminalCreate - } - } - }) - registerTerminalRequestIpcBridge([]) - store.getState().setActiveWorktree(WT) -}) - -describe("a paired viewer's command-less terminal create", () => { - // Main sends only spawn env for a plain remote terminal; dropping it left the pane without the - // remote-viewer clipboard marker, so copied text never reached the viewer (#39852). - it('queues the env as startup so the spawned pane carries it', () => { - request({ - requestId: 'request-1', - worktreeId: WT, - source: 'runtime-session', - presentation: 'focused', - env: { ORCA_REMOTE_VIEWER_CLIPBOARD: '1', PI_CLIPBOARD_OSC52: '1' } - }) - - const tabId = replyTerminalCreate.mock.calls[0]?.[0]?.tabId - expect(tabId).toBeDefined() - expect(store.getState().pendingStartupByTabId[tabId!]).toEqual({ - env: { ORCA_REMOTE_VIEWER_CLIPBOARD: '1', PI_CLIPBOARD_OSC52: '1' } - }) - }) - - it('still queues nothing for a bare create without command or env', () => { - request({ requestId: 'request-2', worktreeId: WT, presentation: 'focused' }) - - const tabId = replyTerminalCreate.mock.calls[0]?.[0]?.tabId - expect(tabId).toBeDefined() - expect(store.getState().pendingStartupByTabId[tabId!]).toBeUndefined() - }) -}) diff --git a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts index f8335eede68..550622d4b90 100644 --- a/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/zoom-ipc-bridge.ts @@ -3,8 +3,8 @@ import { applyUIZoom } from '@/lib/ui-zoom' import { computeEditorFontSize, nextEditorFontZoomLevel -} from '@/lib/editor/appearance/editor-font-zoom' -import { resolveEditorBaseFontSize } from '@/lib/editor/appearance/editor-typography' +} from '../../../../shared/editor/editor-font-zoom' +import { resolveEditorBaseFontSize } from '../../../../shared/editor/editor-typography' import { zoomLevelToPercent, stepUIZoomLevel } from '../../../../shared/ui-zoom-level' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' diff --git a/src/renderer/src/hooks/pane-key-pty-id.ts b/src/renderer/src/hooks/pane-key-pty-id.ts index a63dca04357..c58d51ecc0a 100644 --- a/src/renderer/src/hooks/pane-key-pty-id.ts +++ b/src/renderer/src/hooks/pane-key-pty-id.ts @@ -1,6 +1,7 @@ import { useAppStore } from '@/store' import { parsePaneKey } from '../../../shared/workspace-layout/stable-pane-id' -import { collectLeafIdsInOrder } from '../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../shared/workspace-layout/terminal-pane-tree' export function getPtyIdForPaneKey(paneKey: string): string | null { const parsed = parsePaneKey(paneKey) @@ -25,7 +26,7 @@ export function getPtyIdForPaneKey(paneKey: string): string | null { return tabPtyIds[0] ?? null } // After unmount clears leaf binding, use tab liveness only for leaves still present in layout. - return collectLeafIdsInOrder(layout.root).includes(parsed.leafId) + return collectLayoutLeafIdsInOrder(layout.root).includes(parsed.leafId) ? (tabPtyIds[0] ?? null) : null } diff --git a/src/renderer/src/hooks/use-editor-typography.ts b/src/renderer/src/hooks/use-editor-typography.ts index d77eb7dc645..816fa7ebf1e 100644 --- a/src/renderer/src/hooks/use-editor-typography.ts +++ b/src/renderer/src/hooks/use-editor-typography.ts @@ -5,7 +5,7 @@ import { resolveEditorTypography, type EditorTypography, type EditorTypographySurface -} from '@/lib/editor/appearance/editor-typography' +} from '../../../shared/editor/editor-typography' type ResolvedEditorTypography = { settings: AppState['settings'] diff --git a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts b/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts deleted file mode 100644 index 662222ea2d4..00000000000 --- a/src/renderer/src/hooks/useIpcEvents-rate-limit-hydration.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type * as UIZoomLevelModule from '../../../shared/ui-zoom-level' -import type * as ReactModule from 'react' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createEmptyRateLimitState } from '../../../shared/rate-limit-state-factory' - -describe('useIpcEvents rate-limit hydration', () => { - beforeEach(() => { - vi.resetModules() - vi.unstubAllGlobals() - }) - - it('does not miss startup usage updates that land between get and subscription', async () => { - const setRateLimitsFromPush = vi.fn() - const staleState = createEmptyRateLimitState() - const freshState = { - ...staleState, - claude: { - provider: 'claude', - session: { - usedPercent: 12, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: null, - updatedAt: 1, - error: null, - status: 'ok' - }, - codex: { - provider: 'codex', - session: { - usedPercent: 24, - windowMinutes: 300, - resetsAt: null, - resetDescription: null - }, - weekly: null, - updatedAt: 1, - error: null, - status: 'ok' - } - } - - vi.doMock('react', async () => { - const actual = await vi.importActual('react') - return { - ...actual, - useEffect: (effect: () => void | (() => void)) => { - effect() - } - } - }) - vi.doMock('../store', () => ({ - useAppStore: { - subscribe: vi.fn(() => () => {}), - getState: () => ({ - setUpdateStatus: vi.fn(), - fetchRepos: vi.fn(), - fetchWorktrees: vi.fn(), - setActiveView: vi.fn(), - activeModal: null, - closeModal: vi.fn(), - openModal: vi.fn(), - activeWorktreeId: 'wt-1', - activeView: 'terminal', - setActiveRepo: vi.fn(), - setActiveWorktree: vi.fn(), - revealWorktreeInSidebar: vi.fn(), - setIsFullScreen: vi.fn(), - updateBrowserTabPageState: vi.fn(), - activeTabType: 'terminal', - editorFontZoomLevel: 0, - setEditorFontZoomLevel: vi.fn(), - settings: { terminalFontSize: 13 }, - setRateLimitsFromPush, - updateWorktreeBaseStatus: vi.fn(), - updateWorktreeRemoteBranchConflict: vi.fn(), - setSshConnectionState: vi.fn(), - setSshTargetLabels: vi.fn(), - setPortForwards: vi.fn(), - setUnrestoredPortForwards: vi.fn(), - clearPortForwards: vi.fn(), - setDetectedPorts: vi.fn(), - enqueueSshCredentialRequest: vi.fn(), - removeSshCredentialRequest: vi.fn(), - clearTabPtyId: vi.fn(), - updateTabTitle: vi.fn(), - runtimePaneTitlesByTabId: {}, - terminalLayoutsByTabId: {}, - agentStatusByPaneKey: {}, - recentlyClosedAgentStatusTabIds: {}, - repos: [], - worktreesByRepo: {}, - tabsByWorktree: {}, - unifiedTabsByWorktree: {}, - workspaceSessionReady: false - }) - } - })) - vi.doMock('@/lib/ui-zoom', () => ({ applyUIZoom: vi.fn() })) - vi.doMock('../lib/workspace/activation/worktree-activation', () => ({ - activateAndRevealWorktree: vi.fn(), - ensureWorktreeHasInitialTerminal: vi.fn() - })) - vi.doMock('@/components/sidebar/worktree-list/listing/visible-worktrees', () => ({ - getVisibleWorktreeIds: () => [] - })) - vi.doMock('@/lib/editor/appearance/editor-font-zoom', () => ({ - nextEditorFontZoomLevel: vi.fn(() => 0), - computeEditorFontSize: vi.fn(() => 13) - })) - vi.doMock('../../../shared/ui-zoom-level', async (importOriginal) => ({ - ...(await importOriginal()), - zoomLevelToPercent: vi.fn(() => 100), - UI_ZOOM_MIN: -3, - UI_ZOOM_MAX: 3 - })) - vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() })) - - const makeEvents = (target: Record = {}): Record => - new Proxy(target, { - get: (namespace, prop) => { - if (typeof prop === 'string' && prop in namespace) { - return namespace[prop] - } - return () => () => {} - } - }) - - let rateLimitUpdateListener: ((state: unknown) => void) | null = null - const getRateLimits = vi.fn(() => { - if (rateLimitUpdateListener) { - rateLimitUpdateListener(freshState) - } - return Promise.resolve(staleState) - }) - - vi.stubGlobal('window', { - dispatchEvent: vi.fn(), - setTimeout: vi.fn(() => 1), - clearTimeout: vi.fn(), - api: { - repos: makeEvents(), - automations: makeEvents(), - worktrees: makeEvents(), - keybindings: makeEvents(), - settings: makeEvents(), - updater: { - getStatus: () => Promise.resolve({ state: 'idle' }), - onStatus: () => () => {}, - onClearDismissal: () => () => {} - }, - browser: makeEvents(), - rateLimits: { - get: getRateLimits, - onUpdate: (listener: (state: unknown) => void) => { - rateLimitUpdateListener = listener - return () => {} - } - }, - ssh: { - listTargets: () => Promise.resolve([]), - listPortForwards: () => Promise.resolve([]), - listUnrestoredPortForwards: () => Promise.resolve([]), - listDetectedPorts: () => Promise.resolve([]), - getState: () => Promise.resolve(null), - onStateChanged: () => () => {}, - onCredentialRequest: () => () => {}, - onCredentialResolved: () => () => {}, - onPortForwardsChanged: () => () => {}, - onDetectedPortsChanged: () => () => {}, - onTargetRemoved: () => () => {} - }, - runtime: { - getTerminalFitOverrides: () => Promise.resolve([]), - getTerminalDrivers: () => Promise.resolve([]), - getBrowserDrivers: () => Promise.resolve([]), - onTerminalFitOverrideChanged: () => () => {}, - onTerminalDriverChanged: () => () => {}, - onBrowserDriverChanged: () => () => {}, - onClientHostedBrowserRowsChanged: () => () => {}, - getClientHostedBrowserRows: async () => [] - }, - agentStatus: { onSet: () => () => {} }, - ui: makeEvents({ - consumePendingOpenSettings: () => Promise.resolve(false), - getZoomLevel: vi.fn(() => 0) - }) - } - }) - - const { useIpcEvents } = await import('./useIpcEvents') - useIpcEvents() - await Promise.resolve() - - expect(setRateLimitsFromPush).toHaveBeenLastCalledWith(freshState) - }) -}) diff --git a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-requests.ts b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-requests.ts index fa7460b5d1d..b04e52cb8fb 100644 --- a/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-requests.ts +++ b/src/renderer/src/lib/agent-session/history/title-sync/ai-vault-tab-title-requests.ts @@ -13,7 +13,8 @@ import type { TerminalTab } from '../../../../../../shared/workspace-layout/term import { getExecutionHostIdForWorktree } from '@/lib/workspace/ownership/worktree-runtime-owner' import type { AppState } from '@/store/types' import type { AgentPaneConversationIdentity } from '@/store/slices/agent-status/agent-pane-conversation' -import { collectLeafIdsInOrder } from '../../../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../../shared/workspace-layout/terminal-pane-tree' import { getIndexedRepoMap } from '@/store/repos/repository-catalog-index' import { getIndexedWorktreeMap } from '@/store/slices/worktrees/listing/worktree-catalog-index' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' @@ -178,7 +179,7 @@ export function collectAgentPaneConversations( tabsById.set(tab.id, { ...tab, worktreeId, - ...(root ? { leafIds: new Set(collectLeafIdsInOrder(root)) } : {}) + ...(root ? { leafIds: new Set(collectLayoutLeafIdsInOrder(root)) } : {}) }) } } diff --git a/src/renderer/src/lib/agent-session/launch/pane/agent-launch-pane-verdict-application.ts b/src/renderer/src/lib/agent-session/launch/pane/agent-launch-pane-verdict-application.ts index 8806cbe3425..7c801f05b23 100644 --- a/src/renderer/src/lib/agent-session/launch/pane/agent-launch-pane-verdict-application.ts +++ b/src/renderer/src/lib/agent-session/launch/pane/agent-launch-pane-verdict-application.ts @@ -2,7 +2,8 @@ import type { AgentLaunchPaneVerdictEvent } from '../../../../../../shared/agent-launch/contracts/agent-launch-pane-verdict' import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/layout/close/closed-terminal-leaf-notice' -import { collectLeafIdsInOrder } from '../../../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../../shared/workspace-layout/terminal-pane-tree' import { useAppStore } from '@/store/index' import { forgetAgentLaunchPanePrompt } from './agent-launch-pane-prompt' import { settleAgentLaunchPaneStart } from './agent-launch-pane-start' @@ -35,7 +36,7 @@ export function applyAgentLaunchPaneVerdict(event: AgentLaunchPaneVerdictEvent): case 'not-started': case 'unconfirmed': { const root = state.terminalLayoutsByTabId[tabId]?.root - if (root && !collectLeafIdsInOrder(root).includes(leafId)) { + if (root && !collectLayoutLeafIdsInOrder(root).includes(leafId)) { forgetAgentLaunchPanePrompt(tabId) state.setTabAgentLaunchPane(tabId, undefined) return diff --git a/src/renderer/src/lib/agent-status/ownership/agent-status-pane-ownership.ts b/src/renderer/src/lib/agent-status/ownership/agent-status-pane-ownership.ts index 927a85a9631..1ac1578e2a4 100644 --- a/src/renderer/src/lib/agent-status/ownership/agent-status-pane-ownership.ts +++ b/src/renderer/src/lib/agent-status/ownership/agent-status-pane-ownership.ts @@ -1,6 +1,7 @@ import type { AppState } from '../../../store/types' import { parsePaneKey } from '../../../../../shared/workspace-layout/stable-pane-id' -import { collectLeafIdsInOrder } from '../../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../../shared/workspace-layout/terminal-pane-tree' import { getIndexedRepoMap } from '../../../store/repos/repository-catalog-index' import { getIndexedWorktreeMap } from '../../../store/slices/worktrees/listing/worktree-catalog-index' import { selectWorktreesByRepo } from '@/store/workspaces/catalog/workspace-record-fields' @@ -82,7 +83,7 @@ export function resolvePaneKey( } } // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. - const leafExists = layout?.root ? collectLeafIdsInOrder(layout.root).includes(leafId) : true + const leafExists = layout?.root ? collectLayoutLeafIdsInOrder(layout.root).includes(leafId) : true if (!leafExists) { return { exists: false, diff --git a/src/renderer/src/lib/editor/appearance/editor-font-zoom.ts b/src/renderer/src/lib/editor/appearance/editor-font-zoom.ts deleted file mode 100644 index 3b08c9acf36..00000000000 --- a/src/renderer/src/lib/editor/appearance/editor-font-zoom.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Why shared: main resolves the editor font size for plugins with the same rule. -export { - clampEditorFontZoomLevel, - computeDiffEditorFontSize, - computeEditorFontSize, - nextEditorFontZoomLevel, - type EditorZoomDirection -} from '../../../../../shared/editor/editor-font-zoom' diff --git a/src/renderer/src/lib/editor/appearance/editor-typography.ts b/src/renderer/src/lib/editor/appearance/editor-typography.ts index e95772facd2..cd4925d40d7 100644 --- a/src/renderer/src/lib/editor/appearance/editor-typography.ts +++ b/src/renderer/src/lib/editor/appearance/editor-typography.ts @@ -2,23 +2,7 @@ import type { CSSProperties } from 'react' import type { editor } from 'monaco-editor' import type { EditorTypography } from '../../../../../shared/editor/editor-typography' -// Why shared: main resolves the same typography for plugins, so the rules live in one place. -export { - EDITOR_FONT_SIZE_MAX, - EDITOR_FONT_SIZE_MIN, - EDITOR_LINE_HEIGHT_MAX, - EDITOR_LINE_HEIGHT_MIN, - EDITOR_REGULAR_FONT_WEIGHT, - resolveEditorBaseFontSize, - resolveEditorFontFamily, - resolveEditorFontStack, - resolveEditorLigaturesMode, - resolveEditorTypography, - type EditorLigaturesMode, - type EditorTypography, - type EditorTypographyInputs, - type EditorTypographySurface -} from '../../../../../shared/editor/editor-typography' +import '../../../../../shared/editor/editor-typography' /** Marks DOM code surfaces so opt-in font smoothing reaches them like Monaco. */ export const EDITOR_TYPOGRAPHY_SURFACE_CLASS = 'editor-typography-surface' diff --git a/src/renderer/src/lib/editor/appearance/markdown-code-font.ts b/src/renderer/src/lib/editor/appearance/markdown-code-font.ts index fe1e30f3ff8..aaa5db993b2 100644 --- a/src/renderer/src/lib/editor/appearance/markdown-code-font.ts +++ b/src/renderer/src/lib/editor/appearance/markdown-code-font.ts @@ -1,4 +1,4 @@ -import { resolveEditorFontStack } from '@/lib/editor/appearance/editor-typography' +import { resolveEditorFontStack } from '../../../../../shared/editor/editor-typography' /** * Markdown code follows only an explicitly chosen editor font, so an unset one keeps diff --git a/src/renderer/src/lib/pane-manager/terminal-symbols-font.ts b/src/renderer/src/lib/pane-manager/terminal-symbols-font.ts index f75912d37f4..0a83a3ba5ef 100644 --- a/src/renderer/src/lib/pane-manager/terminal-symbols-font.ts +++ b/src/renderer/src/lib/pane-manager/terminal-symbols-font.ts @@ -1,4 +1,4 @@ -import { BUNDLED_SYMBOLS_FONT_FAMILY } from '../../app-shell/appearance/monospace-font-family' +import { BUNDLED_SYMBOLS_FONT_FAMILY } from '../../../../shared/monospace-font-family' import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry' let watching = false diff --git a/src/renderer/src/runtime/native-chat/status/structured-agent-session-status-feed.ts b/src/renderer/src/runtime/native-chat/status/structured-agent-session-status-feed.ts index 4a0186a91be..97f34a2445c 100644 --- a/src/renderer/src/runtime/native-chat/status/structured-agent-session-status-feed.ts +++ b/src/renderer/src/runtime/native-chat/status/structured-agent-session-status-feed.ts @@ -25,12 +25,10 @@ import { type StructuredAgentSessionStatusChange } from './structured-agent-session-status-dispatch' -export type StructuredAgentSessionStatusSnapshot = AgentSessionStatusSnapshot - export type StructuredAgentSessionStatusFeedOwner = { activate: () => () => void /** The live map, updated in place: read entries, never use its identity as a change signal. */ - getSnapshot: () => StructuredAgentSessionStatusSnapshot + getSnapshot: () => AgentSessionStatusSnapshot getNamingSnapshot: () => AgentSessionConversationNames getAssociationRevision: () => number getSessionSleepState: (sessionId: string) => number | null | undefined diff --git a/src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.test.ts b/src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.test.ts deleted file mode 100644 index 38fb968a109..00000000000 --- a/src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import type { markStructuredAgentSessionLaunchCancelled } from '@/lib/agent-session/launch/structured-agent-session-launch-cancel' -import type { StructuredLaunchSlotOwner } from '@/lib/agent-session/launch/structured-agent-session-launch-slot' -import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' - -const mocks = vi.hoisted(() => ({ - remember: vi.fn(), - closeSession: - vi.fn<(target: RuntimeClientTarget, sessionId: string) => Promise<'closed' | 'unsupported'>>(), - callRuntime: - vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise>(), - discardOutbox: vi.fn<(sessionId: string) => void>(), - stopSends: vi.fn<(sessionId: string) => void>(), - hasTombstone: vi.fn<(worktreeId: string, sessionId: string) => boolean>(), - markCancelled: vi.fn(), - reacceptLocal: - vi.fn<(worktreeId: string, options?: { reacceptCurrentVersion?: boolean }) => Promise>(), - refreshPaired: - vi.fn< - ( - environmentId: string, - worktreeId: string, - options?: { acceptCurrentSnapshot?: boolean; afterCurrentInFlight?: boolean } - ) => Promise - >() -})) - -vi.mock('@/lib/agent-session/launch/structured-agent-session-launch-registry', () => ({ - hasStructuredAgentSessionLaunchCancellationTombstone: mocks.hasTombstone -})) -vi.mock('@/lib/agent-session/launch/structured-agent-session-launch-cancel', () => ({ - markStructuredAgentSessionLaunchCancelled: mocks.markCancelled -})) -vi.mock( - '@/components/native-chat/session/options/native-chat-session-option-settings-write', - () => ({ - enqueueSessionOptionSettingsWrite: mocks.remember - }) -) -vi.mock('@/lib/agent-session/launch/structured-agent-session-launch-prompt', () => ({ - discardStructuredAgentSessionChatSends: mocks.discardOutbox -})) -vi.mock( - '@/components/native-chat/session/structured/delivery/structured-agent-session-message-sender', - () => ({ - stopStructuredAgentSessionSends: mocks.stopSends - }) -) -vi.mock('../requests/structured-agent-session-close', () => ({ - closeStructuredAgentSession: mocks.closeSession -})) -vi.mock('../../rpc/calls/runtime-rpc-call', () => ({ - callRuntimeRpc: mocks.callRuntime -})) -vi.mock('../../web-session-tabs-sync/closing/local-session-tab-close-owner', () => ({ - withLocalSessionTabCloseOwner: async ( - _worktreeId: string, - _tabId: string, - close: () => Promise - ) => close() -})) -vi.mock('../../local-structured-session-tabs-sync/inventory-refresh', () => ({ - refreshLocalStructuredSessionWorktreeTabs: mocks.reacceptLocal -})) -vi.mock('../../web-session-tabs-sync/inventory/web-runtime-session-snapshot', () => ({ - refreshWebRuntimeSessionTabsSnapshot: mocks.refreshPaired -})) -vi.mock('../../rpc/targets/runtime-worktree-selector', () => ({ - toRuntimeWorktreeSelector: (worktreeId: string) => `id:${worktreeId}` -})) - -import { - beginStructuredAgentSessionTabClose, - retireStructuredAgentSessionTab -} from './structured-agent-session-tab-retirement' -import { getStructuredAgentSessionReadOwner } from '@/components/native-chat/session/structured/read/structured-agent-session-read-owner' -import { - findStructuredAgentSessionReadOwner, - resetStructuredAgentSessionReadOwnersForTests -} from '@/components/native-chat/session/structured/read/structured-agent-session-read-owner-registry' -import { - isWebSessionCloseIntentPending, - reconcileWebSessionCloseIntents, - resetWebSessionCloseIntentForTests -} from '../../web-session-tabs-sync/closing/web-session-close-intent' -import { LOCAL_STRUCTURED_SESSION_OWNER } from '../local-sessions/local-structured-session-owner' -import { replaceRuntimeEnvironmentRevisions } from '../../rpc/environment-revisions/runtime-environment-revision' - -import { - structuredSessionSelectionController, - forgetStructuredSessionSelection -} from '@/lib/agent-session/options/structured-agent-session-selection-controller' -import { retirePurgedTabLaunches } from '@/store/slices/worktrees/teardown/purged-structured-launch-retirement' -import { useAppStore } from '@/store/index' -import { agentSessionTabFixture } from '@/components/native-chat/surfaces/integration/native-chat-workspace-test-fixtures' - -const layoutOwner: StructuredLaunchSlotOwner = { - unifiedTabsByWorktree: {}, - groupsByWorktree: {}, - reorderUnifiedTabs: vi.fn() -} - -const target: RuntimeClientTarget = { kind: 'local' } - -beforeEach(() => { - vi.clearAllMocks() - resetStructuredAgentSessionReadOwnersForTests() - resetWebSessionCloseIntentForTests() - replaceRuntimeEnvironmentRevisions([]) - mocks.closeSession.mockResolvedValue('closed') - mocks.callRuntime.mockResolvedValue(undefined) - mocks.hasTombstone.mockReturnValue(false) - mocks.reacceptLocal.mockResolvedValue(undefined) - mocks.refreshPaired.mockResolvedValue(undefined) -}) - -const closing = (owner: string): boolean => - isWebSessionCloseIntentPending( - { environmentId: owner }, - 'wt-1', - 'agent-session:session-1', - Date.now() - ) - -describe('structured agent session tab retirement', () => { - it('hides a closing chat from host frames until one stops listing it', async () => { - let answerClose!: () => void - mocks.callRuntime.mockImplementation( - () => - new Promise((resolve) => { - answerClose = () => resolve(undefined) - }) - ) - beginStructuredAgentSessionTabClose({ - layoutOwner, - target, - worktreeId: 'wt-1', - sessionId: 'session-1', - provisional: false - }) - expect(closing(LOCAL_STRUCTURED_SESSION_OWNER)).toBe(true) - // A frame the host sent before handling the close still lists the chat. - reconcileWebSessionCloseIntents( - { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, - 'wt-1', - new Set(['agent-session:session-1']) - ) - expect(closing(LOCAL_STRUCTURED_SESSION_OWNER)).toBe(true) - expect(mocks.reacceptLocal).not.toHaveBeenCalled() - - await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) - answerClose() - await new Promise((resolve) => setTimeout(resolve, 0)) - // This machine's host emitted its removal before answering, so that frame ends it: no read. - expect(closing(LOCAL_STRUCTURED_SESSION_OWNER)).toBe(true) - expect(mocks.reacceptLocal).not.toHaveBeenCalled() - reconcileWebSessionCloseIntents( - { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, - 'wt-1', - new Set() - ) - expect(closing(LOCAL_STRUCTURED_SESSION_OWNER)).toBe(false) - }) - - it('clears a paired close intent under the pairing it began with', async () => { - const paired = { kind: 'environment', environmentId: 'server-1' } as const - replaceRuntimeEnvironmentRevisions([{ id: 'server-1', createdAt: 0, pairingRevision: 3 }]) - let failClose!: () => void - mocks.callRuntime.mockImplementation( - () => - new Promise((_resolve, reject) => { - failClose = () => reject(new Error('runtime_unavailable')) - }) - ) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - beginStructuredAgentSessionTabClose({ - layoutOwner, - target: paired, - worktreeId: 'wt-1', - sessionId: 'session-1', - provisional: false - }) - const pendingUnder = (pairingRevision: number): boolean => - isWebSessionCloseIntentPending( - { environmentId: 'server-1', pairingRevision }, - 'wt-1', - 'agent-session:session-1', - Date.now() - ) - expect(pendingUnder(3)).toBe(true) - - // Re-paired while the close is in flight. - replaceRuntimeEnvironmentRevisions([{ id: 'server-1', createdAt: 0, pairingRevision: 4 }]) - await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) - failClose() - await vi.waitFor(() => - expect(mocks.refreshPaired).toHaveBeenCalledWith('server-1', 'wt-1', { - acceptCurrentSnapshot: true, - afterCurrentInFlight: true, - expectedEnvironmentPairingRevision: 3 - }) - ) - expect(pendingUnder(3)).toBe(false) - warn.mockRestore() - }) - - it('deduplicates concurrent host retirement', async () => { - let release!: () => void - mocks.closeSession.mockImplementation( - () => - new Promise((resolve) => { - release = () => resolve('closed') - }) - ) - retireStructuredAgentSessionTab({ - target, - worktreeId: 'wt-1', - sessionId: 'session-1' - }) - retireStructuredAgentSessionTab({ - target, - worktreeId: 'wt-1', - sessionId: 'session-1' - }) - expect(mocks.closeSession).toHaveBeenCalledTimes(1) - release() - }) - - it('retires an unmounted created reader only on the owning host', async () => { - const local = getStructuredAgentSessionReadOwner('session-1', target) - const remoteTarget = { kind: 'environment', environmentId: 'server-1' } as const - const remote = getStructuredAgentSessionReadOwner('session-1', remoteTarget) - const dispose = vi.spyOn(local, 'dispose') - retireStructuredAgentSessionTab({ target, worktreeId: 'wt-1', sessionId: 'session-1' }) - expect(findStructuredAgentSessionReadOwner('session-1', target)).toBeUndefined() - expect(findStructuredAgentSessionReadOwner('session-1', remoteTarget)).toBe(remote) - expect(dispose).toHaveBeenCalledOnce() - await vi.waitFor(() => expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1')) - }) - - it('still closes the host tab if reader disposal fails', async () => { - const owner = getStructuredAgentSessionReadOwner('session-1', target) - vi.spyOn(owner, 'dispose').mockImplementation(() => { - throw new Error('subscription cleanup failed') - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - expect(() => - retireStructuredAgentSessionTab({ target, worktreeId: 'wt-1', sessionId: 'session-1' }) - ).not.toThrow() - expect(findStructuredAgentSessionReadOwner('session-1', target)).toBeUndefined() - await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) - expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1') - expect(warn).toHaveBeenCalledOnce() - warn.mockRestore() - }) -}) - -it('releases ordinary close/purge owners while retaining the same-session other-host scope', async () => { - const remote = { kind: 'environment', environmentId: 'selection-generations' } as const - const local = { kind: 'local' } as const - const sessionId = 'selection-lifetime' - const retained = new Set() - const set = Map.prototype.set - const del = Map.prototype.delete - const added = vi.spyOn(Map.prototype, 'set').mockImplementation(function ( - this: Map, - key, - value - ) { - if (value?.sessionId === sessionId && value?.controller) { - retained.add(key) - } - return set.call(this, key, value) - }) - const deleted = vi.spyOn(Map.prototype, 'delete').mockImplementation(function ( - this: Map, - key - ) { - retained.delete(key) - return del.call(this, key) - }) - try { - const previous = structuredSessionSelectionController(remote, sessionId, 'codex') - previous.issue('model', 'model-1', null) - expect(retained.size).toBe(1) - const survivor = structuredSessionSelectionController(local, sessionId, 'codex') - survivor.issue('model', 'surviving-model', null) - expect(retained.size).toBe(2) - const reply = Promise.withResolvers<{ kind: 'accepted'; options: Record }>() - previous!.flush(() => previous!.request(() => reply.promise)) - const drain = previous!.drain() - await retireStructuredAgentSessionTab({ target: remote, worktreeId: 'wt-1', sessionId }) - await drain - expect(retained.size).toBe(1) - expect(survivor.overlay()).toEqual({ model: 'surviving-model' }) - // The host can still finish the accepted write; no obsolete client owner remembers it. - reply.resolve({ kind: 'accepted', options: { model: 'model-1' } }) - await reply.promise - await Promise.resolve() - expect(mocks.remember).not.toHaveBeenCalled() - expect(previous!.overlay()).toEqual({}) - const noSend = vi.fn() - await expect(previous!.issue('model', 'dead', null).result).resolves.toEqual({ - kind: 'superseded' - }) - previous!.flush(noSend) - expect(previous!.freeze()).toEqual([]) - await expect(previous!.request(noSend)).rejects.toThrow('retired') - expect(noSend).not.toHaveBeenCalled() - const purged = structuredSessionSelectionController(remote, sessionId, 'codex') - expect(purged).not.toBe(previous) - expect(purged.captureRead()).toEqual([]) - purged.issue('effort', 'high', 'sonnet') - retirePurgedTabLaunches( - useAppStore.getState(), - 'wt-1', - [], - [ - agentSessionTabFixture(sessionId, 'wt-1', { - entityId: sessionId, - executionHostId: 'runtime:selection-generations' - }) - ], - new Set([sessionId]) - ) - expect(purged.overlay()).toEqual({}) - expect(retained.size).toBe(1) - expect(structuredSessionSelectionController(local, sessionId, 'codex')).toBe(survivor) - expect(survivor.overlay()).toEqual({ model: 'surviving-model' }) - survivor.settle(survivor.pending()[0]!, { - kind: 'accepted', - options: { model: 'surviving-model' } - }) - expect(mocks.remember).toHaveBeenLastCalledWith(local, { - type: 'apply-picks', - agent: 'codex', - picks: [{ modelId: 'surviving-model', optionId: 'model', value: 'surviving-model' }] - }) - console.info( - 'selection-registry-lifetime', - JSON.stringify({ - ownerScopes: 2, - retainedPerChat: 1, - retainedAfterScopedClose: 1, - retainedAfterScopedPurge: 1 - }) - ) - } finally { - forgetStructuredSessionSelection(sessionId) - added.mockRestore() - deleted.mockRestore() - } -}) diff --git a/src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.ts index 161dbc03d8a..72f769d9a7a 100644 --- a/src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement.ts @@ -18,8 +18,6 @@ import { captureWebSessionIntentOwner } from '../../web-session-tabs-sync/identi import type { RuntimeClientTarget } from '../../../../../shared/runtime-client-target' import { retireStructuredAgentSessionTab } from './structured-agent-session-host-tab-retirement' -export { retireStructuredAgentSessionTab } - /** Re-reads one worktree from the host, including the version already applied, which the window's * early removal left out of date. */ function reacceptHostSessionTabs( diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts index 1e5ffeb1d1f..68b8304efe6 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-capture.ts @@ -1,4 +1,5 @@ -import { collectLeafIdsInOrder } from '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' import { serializePaneTree } from '@/components/terminal-pane/layout/terminal-layout-dom-serialization' import type { AppState } from '@/store/types' import type { MobileSessionWorktreeInputs, MountedTerminalSurfaceCapture } from './types' @@ -57,7 +58,7 @@ function captureMountedTerminalSurface( const effectiveLeafIds = paneLeafIds.length > 0 ? paneLeafIds - : collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + : collectLayoutLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) const numericPaneIdByLeafId = new Map() const ptyIdByNumericPaneId = new Map() for (const leafId of effectiveLeafIds) { diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts index e05a2d5dd2a..ecf9a34130a 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-surfaces.ts @@ -1,4 +1,5 @@ -import { collectLeafIdsInOrder } from '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import '../../components/terminal-pane/layout/terminal-layout-leaf-traversal' +import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree' import type { AppState } from '@/store/types' import { isTerminalLeafId } from '../../../../shared/workspace-layout/stable-pane-id' import { isWebTerminalSurfaceTabId } from '../../../../shared/workspace-layout/terminal-surface-id' @@ -37,7 +38,7 @@ export function getRuntimeLeafIdsForTerminal( if (liveLeafIds.length > 0) { return liveLeafIds } - const persistedLeafIds = collectLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) + const persistedLeafIds = collectLayoutLeafIdsInOrder(savedLayout?.root).filter(isTerminalLeafId) if (persistedLeafIds.length > 0) { return persistedLeafIds } diff --git a/src/renderer/src/runtime/web-session-tabs-sync/state.ts b/src/renderer/src/runtime/web-session-tabs-sync/state.ts index 86dbdd29547..2e40169902f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/state.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/state.ts @@ -1,10 +1,6 @@ import type { AppState } from '../../store/types' import type { RetiredTerminalTabSweepState } from '../../store/terminals/retired-terminal-tab-state-sweep' -import type { - RuntimeMobileSessionTabGroup, - RuntimeMobileSessionTabsRemovedResult, - RuntimeMobileSessionTabsResult -} from '../../../../shared/runtime-types' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { RuntimeMobileSessionBrowserTab, RuntimeMobileSessionFileTab, @@ -282,6 +278,3 @@ export type WebSessionTabsBatchContext = { changedRecords: Set openFilesIndex: WebSessionOpenFilesIndex | null } - -export type TabGroupSnapshot = RuntimeMobileSessionTabGroup -export type RemovedTabsResult = RuntimeMobileSessionTabsRemovedResult diff --git a/tests/e2e/helpers/automation-chat-reopen-test-harness.ts b/tests/e2e/helpers/automation-chat-reopen-test-harness.ts index effd8d4a48a..977e32dbb6f 100644 --- a/tests/e2e/helpers/automation-chat-reopen-test-harness.ts +++ b/tests/e2e/helpers/automation-chat-reopen-test-harness.ts @@ -20,7 +20,8 @@ import { getAutomationRunViewState } from '../../../src/renderer/src/components/ import { createAutomationRunWriter } from '../../../src/main/automations/automation-run-writer' import { makeRepo } from '../../../src/main/persistence/persistence-test-harness' import { structuredAgentSessionTabId } from '../../../src/shared/agent-session/view/structured-agent-session-projection' -import { retireStructuredAgentSessionTab } from '../../../src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement' +import '../../../src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement' +import { retireStructuredAgentSessionTab } from '../../../src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-host-tab-retirement' export const CHAT = 'claude_saved_reopen', SURVIVOR = 'codex_surviving_chat' diff --git a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts index 1f22a8ebf98..393f551fbd5 100644 --- a/tests/e2e/structured-session-tab-close-regressions.unit.test.ts +++ b/tests/e2e/structured-session-tab-close-regressions.unit.test.ts @@ -5,7 +5,7 @@ import { agentSessionRecordFixture } from '../../src/main/native-chat/agent-sess import { agentSessionConversationView } from '../../src/shared/agent-session/lifecycle/agent-session-conversation-view' // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import type * as RuntimeLayoutClient from '../../src/renderer/src/runtime/workspace-layout/frames/runtime-layout-client' @@ -18,21 +18,15 @@ import { resetWebSessionTabsSnapshotFreshnessForTests } from '../../src/renderer import { registerSessionTabIpcBridge } from '../../src/renderer/src/hooks/ipc-events/session-tab-ipc-bridge' import { registerTerminalUiRoutingIpcBridge } from '../../src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge' import type { RuntimeClientTarget } from '../../src/shared/runtime-client-target' -import { applyWebSessionTabsSnapshot } from '../../src/renderer/src/runtime/web-session-tabs-sync/snapshot-api' -import { - suppressCancelledStructuredSessionTabs, - retireStructuredAgentSessionTab -} from '../../src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement' -import { isLocalSessionTabCloseOwned } from '../../src/renderer/src/runtime/web-session-tabs-sync/closing/local-session-tab-close-owner' -import { clearHostSessionMirrorHydration } from '../../src/renderer/src/runtime/web-session-tabs-sync/hydration/host-session-mirror-hydration' -import { LOCAL_STRUCTURED_SESSION_OWNER } from '../../src/renderer/src/runtime/native-chat/local-sessions/local-structured-session-owner' -import { - isWebSessionCloseIntentPending, - resetWebSessionCloseIntentForTests -} from '../../src/renderer/src/runtime/web-session-tabs-sync/closing/web-session-close-intent' +import '../../src/renderer/src/runtime/web-session-tabs-sync/snapshot-api' +import '../../src/renderer/src/runtime/native-chat/tab-retirement/structured-agent-session-tab-retirement' +import '../../src/renderer/src/runtime/web-session-tabs-sync/closing/local-session-tab-close-owner' +import '../../src/renderer/src/runtime/web-session-tabs-sync/hydration/host-session-mirror-hydration' +import '../../src/renderer/src/runtime/native-chat/local-sessions/local-structured-session-owner' +import { resetWebSessionCloseIntentForTests } from '../../src/renderer/src/runtime/web-session-tabs-sync/closing/web-session-close-intent' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../src/shared/layout-owner-switch' -import { toRuntimeExecutionHostId } from '../../src/shared/execution-host' -import { makeWorktree } from '../../src/renderer/src/store/slices/worktrees/worktree.test-fixture' +import '../../src/shared/execution-host' +import '../../src/renderer/src/store/slices/worktrees/worktree.test-fixture' import { layoutApplyTestTransport } from '../../src/renderer/src/runtime/workspace-layout/testing/layout-apply-test-transport.test-fixture' import { drawTabsFromMain, @@ -305,35 +299,6 @@ describe('reopened structured session close', () => { }) describe('closing a chat reopened from history after /clear', () => { - it('closes only the reopened tab when the user closes it on the desktop', async () => { - const { agentTabs, hostSessionCloses, closeResponses } = await setup() - - useAppStore.getState().closeUnifiedTab(REOPENED_TAB) - - await vi.waitFor(() => expect(hostSessionCloses).toEqual([REOPENED_SESSION])) - // Let any stray echo reach the window before asserting the current chat survived. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(agentTabs()).toEqual([{ id: CURRENT_TAB, entityId: CURRENT_SESSION }]) - expect(bridge.rendererSessionCloses).toEqual([REOPENED_SESSION]) - expect(hostSessionCloses).toEqual([REOPENED_SESSION]) - // The window started this close, so main's relay is acknowledged, not reported missing. - expect(closeResponses).toEqual([undefined]) - }) - - it('closes only the reopened tab when a phone closes it through the runtime', async () => { - const { runtime, agentTabs, hostSessionCloses } = await setup() - - await runtime.closeMobileSessionTab(`id:${WORKTREE}`, `agent-session:${REOPENED_SESSION}`, { - reason: 'user' - }) - - await vi.waitFor(() => - expect(agentTabs()).toEqual([{ id: CURRENT_TAB, entityId: CURRENT_SESSION }]) - ) - expect(bridge.rendererSessionCloses).not.toContain(CURRENT_SESSION) - expect(hostSessionCloses).not.toContain(CURRENT_SESSION) - }) - it('focuses the reopened tab when a phone activates it through the runtime', async () => { const { runtime } = await setup() useAppStore.getState().activateTab(CURRENT_TAB, { worktreeId: WORKTREE }) @@ -348,306 +313,3 @@ describe('reopened structured session close', () => { }) }) }) - -// Hold host close after the window removes its chat, then publish a still-listed frame that must not resurrect it. -// Exercise real main/window/mirror paths with only the process boundary stubbed. -describe('in-flight structured session close', () => { - const WORKTREE = 'repo-1::/tmp/wt-stale-frame' - const SESSIONS = ['chat-a', 'chat-b', 'chat-c'] as const - const tabIdOf = (sessionId: string): string => `structured-agent-session-${sessionId}` - - async function setup(options: { hostFailsClose?: boolean } = {}) { - let closeRequest: ((request: CloseRequest) => void) | undefined - const pendingResponses = new Map void>() - // A failing close answers when the test says, so its frame can be read first. - const failingCloses: (() => void)[] = [] - const runtime = new OrcaRuntimeService(partialRuntimeStore()) - bridge.runtime = runtime - vi.stubGlobal('api', { - ui: { - onCloseSessionTab: () => () => {}, - onSessionTabCloseRequest: (listener: (request: CloseRequest) => void) => { - closeRequest = listener - return () => {} - }, - onMoveSessionTab: () => () => {}, - respondSessionTabClose: ({ requestId, error }: { requestId: string; error?: string }) => - pendingResponses.get(requestId)?.(error) - } - }) - registerSessionTabIpcBridge([]) - - let nextRequest = 0 - runtime.setNotifier( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the chat close path calls only this notifier member. - { - closeSessionTab: (tabId: string, worktreeId: string) => - new Promise((resolve, reject) => { - if (options.hostFailsClose) { - failingCloses.push(() => reject(new Error('window relay failed'))) - return - } - const requestId = `close-${++nextRequest}` - pendingResponses.set(requestId, (error) => - error ? reject(new Error(error)) : resolve() - ) - closeRequest!({ requestId, tabId, worktreeId }) - }) - } as never - ) - // The adapter owns checked records and current visibility; PTY/profile restoration is excluded. - Object.assign(runtime, { - hasPersistedStructuredAgentSessionStore: () => true, - getKnownWorkspaceSessionWorktreeIds: () => new Set(), - hydrateHeadlessMobileSessionTabsFromWorkspaceSession: () => new Set(), - refreshMobileSessionPtyRecords: async () => new Set(), - ensureStructuredAgentSessionHost: async () => undefined - }) - const hostSessionCloses: string[] = [] - const hostTabs = new Set(SESSIONS) - const records = new Map( - [...hostTabs].map((sessionId) => [ - sessionId, - agentSessionConversationView( - agentSessionRecordFixture({ sessionId, location: { workspaceId: WORKTREE } }) - ) - ]) - ) - setStructuredAgentSessionHost( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: adapter supplies current selection/records and the restore/close API used here. - { - deps: { - store: { - getConversation: (id: string) => records.get(id) ?? null, - isSessionUnreadable: () => false, - isSessionTabRetired: (id: string) => !hostTabs.has(id) - } - }, - reconcileRestartLeases: async () => undefined, - seedSessionTabs: async () => undefined, - getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [...hostTabs] }), - isSessionTabVisible: (id: string) => hostTabs.has(id) && records.has(id), - close: async (sessionId: string) => { - hostSessionCloses.push(sessionId) - }, - setSessionTabVisibility: async (sessionId: string, visible: boolean) => { - if (visible) { - hostTabs.add(sessionId) - } else { - hostTabs.delete(sessionId) - } - }, - getSessionTabId: (id: string) => (hostTabs.has(id) ? tabIdOf(id) : null), - listSessionTabs: () => - [...hostTabs].map((sessionId) => ({ sessionId, workspaceId: WORKTREE, agent: 'claude' })) - } satisfies RestoreCloseHost as never - ) - useAppStore.setState({ activeWorktreeId: WORKTREE }) - await mainHoldsWindowLayout(runtime) - await runtime.restoreStructuredAgentSessionTabs() - applyStructuredSessionTabSnapshots([await runtime.listMobileSessionTabs(`id:${WORKTREE}`)]) - await main?.settle() - // The window's live subscription to the host's tab list. - runtime.onMobileSessionTabsChanged((snapshot) => applyStructuredSessionTabSnapshots([snapshot])) - - const strip = (): string[] => - (useAppStore.getState().groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder) - const failHostCloses = async (): Promise => { - await vi.waitFor(() => expect(failingCloses.length).toBeGreaterThan(0)) - failingCloses.splice(0).forEach((fail) => fail()) - } - return { runtime, strip, hostSessionCloses, failHostCloses } - } - - const initialStoreState = useAppStore.getState() - - beforeEach(() => { - bridge.scenario = 'stale-frame' - }) - - afterEach(() => { - closeMain() - setStructuredAgentSessionHost(null) - bridge.runtime = null - bridge.rendererSessionCloses = [] - bridge.closeHeld = null - bridge.pairedClose = null - useAppStore.setState(initialStoreState, true) - resetLocalStructuredSessionVersionForTests() - resetWebSessionTabsSnapshotFreshnessForTests() - resetWebSessionCloseIntentForTests() - vi.unstubAllGlobals() - }) - - describe('a host frame published while a chat tab close is in flight', () => { - it('does not bring the closed chat back at the end of the strip', async () => { - const { runtime, strip, hostSessionCloses } = await setup() - expect(strip()).toEqual(SESSIONS.map(tabIdOf)) - let handleClose!: () => void - bridge.closeHeld = new Promise((resolve) => { - handleClose = resolve - }) - // A close left in flight would be joined by the next test's close of the same chat. - onTestFinished(() => handleClose()) - - const captureRetirement = (): Promise | null => - isLocalSessionTabCloseOwned(WORKTREE, 'agent-session:chat-b') - ? retireStructuredAgentSessionTab({ - target: { kind: 'local' }, - worktreeId: WORKTREE, - sessionId: 'chat-b' - }) - : null - let retirement: Promise | null = null - try { - useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) - retirement = captureRetirement() - // Switch-on, the strip drops the chat once main's frame for the close lands. - await main?.settle() - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) - - // The host has not handled the close yet, so its next frame still lists chat-b. - runtime.touchMobileSessionTabsForWorktree(WORKTREE, { immediate: true }) - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) - - handleClose() - await vi.waitFor(() => expect(hostSessionCloses).toEqual(['chat-b'])) - // The host published its removal before it stopped the chat, and that frame lifted the hide. - expect( - isWebSessionCloseIntentPending( - { environmentId: LOCAL_STRUCTURED_SESSION_OWNER }, - WORKTREE, - 'agent-session:chat-b', - Date.now() - ) - ).toBe(false) - runtime.touchMobileSessionTabsForWorktree(WORKTREE, { immediate: true }) - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) - } finally { - retirement ??= captureRetirement() - handleClose() - await retirement - } - }) - - it('shows the chat again when the host could not close it', async () => { - const { strip, failHostCloses } = await setup({ hostFailsClose: true }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - useAppStore.getState().closeUnifiedTab(tabIdOf('chat-b')) - await main?.settle() - expect(strip()).toEqual([tabIdOf('chat-a'), tabIdOf('chat-c')]) - await failHostCloses() - - // The host still has the chat, so once the close fails the window re-reads and shows it. - await vi.waitFor(() => expect(strip()).toContain(tabIdOf('chat-b'))) - expect(strip()).toHaveLength(3) - warn.mockRestore() - }) - - it("does not bring a paired server's closed chat back either", async () => { - const SERVER = 'server-1' - const frame = (snapshotVersion: number): RuntimeMobileSessionTabsResult => ({ - worktree: WORKTREE, - publicationEpoch: 'server-epoch', - snapshotVersion, - activeGroupId: 'group-1', - activeTabId: null, - activeTabType: null, - tabs: SESSIONS.map((sessionId) => ({ - type: 'agent-session' as const, - id: `agent-session:${sessionId}`, - title: sessionId, - sessionId, - agent: 'claude' as const, - isActive: false - })), - tabGroups: [ - { - id: 'group-1', - tabOrder: SESSIONS.map((sessionId) => `agent-session:${sessionId}`), - activeTabId: null - } - ] - }) - const applyServerFrame = (snapshot: RuntimeMobileSessionTabsResult): void => - useAppStore.setState((state) => - applyWebSessionTabsSnapshot( - state, - suppressCancelledStructuredSessionTabs(snapshot, { - kind: 'environment', - environmentId: SERVER - }), - SERVER - ) - ) - const strip = (): string[] => - (useAppStore.getState().groupsByWorktree[WORKTREE] ?? []).flatMap((group) => group.tabOrder) - // The server owns the workspace, so its layout is mirrored, never drawn by main's frames. - useAppStore.setState({ - activeWorktreeId: WORKTREE, - worktreesByRepo: { - 'repo-1': [ - makeWorktree({ - id: WORKTREE, - repoId: 'repo-1', - hostId: toRuntimeExecutionHostId(SERVER) - }) - ] - } - }) - applyServerFrame(frame(1)) - const chatB = useAppStore - .getState() - .unifiedTabsByWorktree[WORKTREE]?.find((tab) => tab.entityId === 'chat-b') - expect(chatB?.executionHostId).toBe(`runtime:${SERVER}`) - const before = strip() - expect(before).toHaveLength(3) - let answerClose!: () => void - bridge.pairedClose = new Promise((resolve) => { - answerClose = resolve - }) - onTestFinished(() => answerClose()) - const snapshots = - await import('../../src/renderer/src/runtime/web-session-tabs-sync/inventory/web-runtime-session-snapshot') - const reread = vi.spyOn(snapshots, 'refreshWebRuntimeSessionTabsSnapshot') - const settledFrame = frame(3) - settledFrame.tabs = settledFrame.tabs.filter((tab) => tab.id !== 'agent-session:chat-b') - settledFrame.tabGroups?.forEach((group) => { - group.tabOrder = group.tabOrder.filter((id) => id !== 'agent-session:chat-b') - }) - vi.stubGlobal('api', { - runtimeEnvironments: { - call: async () => ({ id: 'paired-close-list', ok: true, result: settledFrame }) - } - }) - - try { - useAppStore.getState().closeUnifiedTab(chatB!.id) - await main?.settle() - const after = before.filter((id) => id !== chatB!.id) - expect(strip()).toEqual(after) - - // The server has not handled the close yet, so its next frame still lists chat-b. - applyServerFrame(frame(2)) - expect(strip()).toEqual(after) - } finally { - answerClose() - try { - await vi.waitFor( - async () => { - const result = reread.mock.results[0] - if (result?.type !== 'return') { - throw new Error('The paired close did not start its follow-up refresh') - } - await result.value - }, - { timeout: 10_000 } - ) - } finally { - reread.mockRestore() - clearHostSessionMirrorHydration(SERVER) - } - } - }) - }) -})