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
This commit is contained in:
Neil
2026-11-10 04:51:57 -08:00
committed by GitHub
parent 2c6f84cac0
commit d1466f6017
75 changed files with 164 additions and 2383 deletions
-3
View File
@@ -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
+8 -4
View File
@@ -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."
},
@@ -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,
@@ -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`,
@@ -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',
@@ -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'
-3
View File
@@ -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<TInput extends HostQueryParams, TData> = {
family: string
// Calls the domain's loader. `previous` is the cached data a revalidation may build on; live reads pass none.
@@ -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'
@@ -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'
@@ -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'
@@ -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'))
@@ -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'
@@ -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 {
@@ -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'
@@ -14,7 +14,7 @@ import {
resolveEditorFontFamily,
resolveEditorLigaturesMode,
type EditorLigaturesMode
} from '@/lib/editor/appearance/editor-typography'
} from '../../../../../shared/editor/editor-typography'
import {
supportsCodeFontSmoothing,
type CodeFontSmoothing
@@ -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
@@ -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) }))
}
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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 {
@@ -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
}
@@ -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<string, string> | undefined,
@@ -13,7 +14,7 @@ export function resolveTerminalLayoutActiveLeafId(opts: {
activeLeafId: string | null | undefined
ptyIdsByLeafId?: Record<string, string>
}): string | null {
const leafIds = collectLeafIdsInOrder(opts.root)
const leafIds = collectLayoutLeafIdsInOrder(opts.root)
if (leafIds.length === 0) {
return null
}
@@ -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 &&
@@ -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'])
})
})
@@ -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)
@@ -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'
@@ -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<string>, worktreeId: string): ReadonlySet<string> {
if (current.has(worktreeId)) {
@@ -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<typeof ParkedWatcherRegistryModule>()),
@@ -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<typeof hookArgs> & { 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<typeof hookArgs> & {
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<typeof hookArgs>) => 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<typeof hookArgs>) => 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<typeof hookArgs>) => 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<typeof hookArgs>) => 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.
})
@@ -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'
@@ -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] : []
@@ -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<string>
restoreTitleOnStartTabIds?: Iterable<string>
}): 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)
})
})
})
@@ -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
@@ -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<string, unknown>,
ptyIdsByTabId: {} as Record<string, string[]>,
runtimeStatusByEnvironmentId: new Map<string, unknown>(),
settings: {} as Record<string, unknown>,
terminalLayoutsByTabId: {} as Record<string, unknown>,
runtimePaneTitlesByTabId: {} as Record<string, unknown>,
sleepingAgentSessionsByPaneKey: {} as Record<string, unknown>,
tabsByWorktree: {} as Record<string, TerminalTab[]>
}))
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<typeof ParkedWatcherRegistryModule>()),
disposeParkedTerminalWatchersForWorktree: () => {}
}))
vi.mock('../output-watchers/terminal-parked-watcher-reconciliation', async (importOriginal) => ({
...(await importOriginal<typeof ParkedWatcherReconciliationModule>()),
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<string, { groupId: string; isActiveInGroup: boolean }>()
const EMPTY_PORTALS: never[] = []
const TICK_MS = 10_000
type ParkingStoreState = { tabsByWorktree: Record<string, TerminalTab[]> }
const parkingStore = useAppStore as unknown as {
setState: (partial: (state: ParkingStoreState) => Partial<ParkingStoreState>) => 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<string> = 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(<ParkingHost writes={0} />))
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(<ParkingHost writes={options.publishTitles ? tick : 0} />))
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)
})
})
@@ -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<string>
@@ -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<string, unknown>,
ptyIdsByTabId: {} as Record<string, string[]>,
runtimeStatusByEnvironmentId: new Map<string, unknown>(),
settings: {} as Record<string, unknown>,
terminalLayoutsByTabId: {} as Record<string, unknown>,
runtimePaneTitlesByTabId: {} as Record<string, unknown>,
tabsByWorktree: {} as Record<string, TerminalTab[]>
}))
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<typeof ParkedWatcherRegistryModule>()),
disposeParkedTerminalWatchersForWorktree: () => {}
}))
vi.mock('../output-watchers/terminal-parked-watcher-reconciliation', async (importOriginal) => ({
...(await importOriginal<typeof ParkedWatcherReconciliationModule>()),
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<string, { groupId: string; isActiveInGroup: boolean }>()
const EMPTY_PORTALS: never[] = []
type ParkingStoreState = { tabsByWorktree: Record<string, TerminalTab[]> }
const parkingStore = useAppStore as unknown as {
setState: (partial: (state: ParkingStoreState) => Partial<ParkingStoreState>) => 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 : <TerminalPaneStandIn />}
<WorktreeParkVerdict parked={parked} onVerdict={setColdParkTerminalPanes} />
</>
)
}
function renderOverlayHost(root: Root): unknown {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
let thrown: unknown = null
try {
act(() => {
root.render(<OverlayHost />)
})
} 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)
})
})
@@ -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)
}
@@ -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<string, unknown>[] = []
let storeSubscribers: ((state: StoreState) => void)[] = []
function createDeps(overrides: Record<string, unknown> = {}) {
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<PtyConnectionAgentTitleMocks>(
'@/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<PtyConnectionAgentTitleMocks>(
'@/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<PtyConnectionAgentTitleMocks>(
'@/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')
})
})
}
@@ -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()
@@ -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<ReturnType<typeof window.api.pty.spawn>>
let resolveSpawn: (value: SpawnResult) => void = () => {}
vi.mocked(window.api.pty.spawn).mockReturnValueOnce(
new Promise<SpawnResult>((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)
})
})
@@ -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
}
@@ -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
@@ -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({
@@ -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'
@@ -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,
@@ -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,
@@ -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<Tab, 'id' | 'entityId'>): 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 {
@@ -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<typeof useAppStore.getState>
export type WorktreeTab = NonNullable<StoreSnapshot['tabsByWorktree']>[string][number]
// Index paneKey → tab once per prune instead of flattening all tabs per coordinator.
export type TabIndex = ReadonlyMap<string, WorktreeTab>
export type PaneCoordinatorLivenessSnapshot = Pick<
StoreSnapshot,
'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'suppressedPtyExitIds'
>
@@ -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<typeof useAppStore.getState>
type WorktreeTab = NonNullable<StoreSnapshot['tabsByWorktree']>[string][number]
// Index paneKey → tab once per prune instead of flattening all tabs per coordinator.
type TabIndex = ReadonlyMap<string, WorktreeTab>
type PaneCoordinatorLivenessSnapshot = Pick<
import type {
CoordinatorEntry,
StoreSnapshot,
'tabsByWorktree' | 'ptyIdsByTabId' | 'terminalLayoutsByTabId' | 'suppressedPtyExitIds'
>
WorktreeTab,
TabIndex,
PaneCoordinatorLivenessSnapshot
} from './agent-hook-completion-notification-types'
const coordinatorsByPaneKey = new Map<string, CoordinatorEntry>()
const paneKeysRequiringFreshWorking = new Set<string>()
@@ -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]
@@ -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<typeof UIZoomLevelModule>()),
zoomLevelToPercent: vi.fn(() => 100),
@@ -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<typeof UIZoomLevelModule>()),
zoomLevelToPercent: vi.fn(() => 100),
@@ -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)) {
@@ -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 {
@@ -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
@@ -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<typeof createTestStore> | 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<object>()),
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<string, string>
}
let request!: (data: CreateRequest) => void
const replyTerminalCreate = vi.fn()
let store: ReturnType<typeof createTestStore>
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()
})
})
@@ -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'
+3 -2
View File
@@ -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
}
@@ -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']
@@ -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<typeof ReactModule>('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<typeof UIZoomLevelModule>()),
zoomLevelToPercent: vi.fn(() => 100),
UI_ZOOM_MIN: -3,
UI_ZOOM_MAX: 3
}))
vi.doMock('@/lib/zoom-events', () => ({ dispatchZoomLevelChanged: vi.fn() }))
const makeEvents = (target: Record<string, unknown> = {}): Record<string, unknown> =>
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)
})
})
@@ -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<Agent extends AgentType>(
tabsById.set(tab.id, {
...tab,
worktreeId,
...(root ? { leafIds: new Set(collectLeafIdsInOrder(root)) } : {})
...(root ? { leafIds: new Set(collectLayoutLeafIdsInOrder(root)) } : {})
})
}
}
@@ -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
@@ -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,
@@ -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'
@@ -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'
@@ -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
@@ -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
@@ -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
@@ -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<unknown>>(),
discardOutbox: vi.fn<(sessionId: string) => void>(),
stopSends: vi.fn<(sessionId: string) => void>(),
hasTombstone: vi.fn<(worktreeId: string, sessionId: string) => boolean>(),
markCancelled: vi.fn<typeof markStructuredAgentSessionLaunchCancelled>(),
reacceptLocal:
vi.fn<(worktreeId: string, options?: { reacceptCurrentVersion?: boolean }) => Promise<void>>(),
refreshPaired:
vi.fn<
(
environmentId: string,
worktreeId: string,
options?: { acceptCurrentSnapshot?: boolean; afterCurrentInFlight?: boolean }
) => Promise<void>
>()
}))
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<unknown>
) => 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<string>()
const set = Map.prototype.set
const del = Map.prototype.delete
const added = vi.spyOn(Map.prototype, 'set').mockImplementation(function (
this: Map<unknown, unknown>,
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<unknown, unknown>,
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<string, string> }>()
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()
}
})
@@ -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(
@@ -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<string, number | null>()
const ptyIdByNumericPaneId = new Map<number, string | null>()
for (const leafId of effectiveLeafIds) {
@@ -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
}
@@ -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<WebSessionTabsBatchRecordKey>
openFilesIndex: WebSessionOpenFilesIndex | null
}
export type TabGroupSnapshot = RuntimeMobileSessionTabGroup
export type RemovedTabsResult = RuntimeMobileSessionTabsRemovedResult
@@ -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'
@@ -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<string, (error?: string) => 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<void>((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<string>(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<void> => {
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<boolean> | null =>
isLocalSessionTabCloseOwned(WORKTREE, 'agent-session:chat-b')
? retireStructuredAgentSessionTab({
target: { kind: 'local' },
worktreeId: WORKTREE,
sessionId: 'chat-b'
})
: null
let retirement: Promise<boolean> | 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)
}
}
})
})
})