mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-4
@@ -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'
|
||||
|
||||
@@ -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
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 &&
|
||||
|
||||
+3
-5
@@ -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'
|
||||
|
||||
+2
-1
@@ -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)) {
|
||||
|
||||
+5
-251
@@ -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.
|
||||
})
|
||||
|
||||
+2
-1
@@ -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'
|
||||
|
||||
+6
-3
@@ -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] : []
|
||||
|
||||
+3
-289
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
-2
@@ -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
|
||||
|
||||
-175
@@ -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)
|
||||
})
|
||||
})
|
||||
+2
-1
@@ -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>
|
||||
|
||||
-180
@@ -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)
|
||||
})
|
||||
})
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
|
||||
-201
@@ -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')
|
||||
})
|
||||
})
|
||||
}
|
||||
-2
@@ -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()
|
||||
|
||||
-120
@@ -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)
|
||||
})
|
||||
})
|
||||
+3
-2
@@ -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
|
||||
|
||||
+3
-3
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+3
-2
@@ -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)) } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
-354
@@ -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()
|
||||
}
|
||||
})
|
||||
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user