fix: close terminal tabs when killing all sessions (#8027)

Implement the surface cleanup described in docs/kill-all-sessions-also-kills-empty-terminals.md.

Fixes #8001.
This commit is contained in:
Jinjing 2026-07-09 22:44:12 -07:00 committed by GitHub
parent 620b475acd
commit bab126fb2c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1352 additions and 54 deletions

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-08",
"updatedAt": "2026-07-09",
"policy": {
"maturityLevels": [
"experimental",
@ -105,6 +105,134 @@
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
{
"id": "terminal-session.kill-all-surface-cleanup",
"title": "Kill all sessions removes only the confirmed terminal surfaces and current bindings",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-main-contract",
"surfaces": [
"terminal lifecycle",
"terminal tab cleanup",
"PTY shutdown",
"Manage Sessions",
"Resource Manager"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime",
"mobile-relay"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"daemon",
"ssh"
],
"coverageNotes": "Local macOS deterministic evidence covers the renderer snapshot/coordinator, exact local and SSH-shaped PTY request settlement, active-last and pinned terminal-tab routing, component-unmount continuation, and the existing current/legacy daemon management contract. Windows Electron process absence, live SSH/WSL behavior, and remote-runtime/mobile host completion remain explicit gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8001"
],
"invariant": "Every terminal surface confirmed in the invoking renderer is force-closed exactly once after daemon management settles, later-created surfaces and non-terminal tabs survive, and exact shutdown requests are limited to deduplicated current non-runtime PTY bindings of the confirmed surfaces.",
"oracle": "Snapshot terminal entity IDs before the first await; mutate ownership, active selection, bindings, and tab presence while daemon management is pending; then assert only the immutable targets disappear from both terminal stores, active targets close last with valid editor/browser/deactivated post-state, every captured exact PTY promise settles before callbacks, and no provider inventory sweep or late-tab kill occurs.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts"
],
"testFiles": [
"src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"src/renderer/src/components/shared/useDaemonActions.test.tsx",
"src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts",
"src/main/ipc/pty-management.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts",
"assertions": [
"snapshot deduplicates legacy, unified-only, split, multi-worktree, and floating terminal surfaces while excluding editor tabs",
"cleanup-time moves, active-worktree switches, rebinding, missing targets, and later-created tabs preserve the confirmation boundary and active-last order",
"current exact PTY bindings are deduplicated, remote runtime IDs and stale/late bindings are excluded, and all per-PTY settlements finish before completion",
"the production dependency path calls daemon management exactly once and never invokes listSessions for a post-kill sweep",
"management rejection and per-close/provider failures do not stop remaining cleanup and produce bounded count/latency diagnostics",
"a real 100-tab Zustand fixture records 100 close attempts and exact kills, at least 100 writes, 49 event-loop yields, and no close batch over 50 ms"
]
},
{
"file": "src/renderer/src/components/shared/useDaemonActions.test.tsx",
"assertions": [
"the hook snapshots before onKillAllStart and before coordinator work",
"unmounting the invoking component does not revoke cleanup while React callbacks remain mount-gated",
"error and settled callbacks run only after coordinator settlement",
"closed terminal tabs report success instead of the no-sessions informational state when daemon management reports zero"
]
},
{
"file": "src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts",
"assertions": [
"force closes pinned terminals without a second confirmation",
"closing the last active terminal preserves and activates editor or browser content, otherwise deactivates without auto-spawn"
]
},
{
"file": "src/main/ipc/pty-management.test.ts",
"assertions": [
"killAll fires one shutdown for each initial daemon session and polls those initial IDs until empty",
"freshly respawned session IDs are excluded from remainingCount",
"per-session shutdown rejection does not stop the daemon batch and refused initial sessions remain reported"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-09",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts src/renderer/src/components/shared/useDaemonActions.test.tsx src/renderer/src/components/terminal/terminal-tab-actions-kill-all.test.ts src/main/ipc/pty-management.test.ts",
"result": "passed",
"durationSeconds": 1.31,
"summary": "4 test files and 27 tests passed locally, including the existing daemon handler contract and a real 100-terminal Zustand cleanup fixture."
}
],
"runtimeBudget": {
"p95Seconds": 10,
"scope": "focused renderer/main unit and performance-count tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "The focused 27-test slice passed locally once; it needs CI and soak history before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The initial 100-tab implementation exposed a 145.82 ms close batch and failed the 50 ms budget; two-close event-loop batching reduced the focused run below the budget. Saved CI artifacts and intentional-break evidence for the confirmation-boundary assertions are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "The coordinator performs one existing management call, zero provider inventory sweeps, one close per present unique target, and at most one exact kill per unique current non-runtime PTY binding. A real 100-tab Zustand fixture asserts 100 close attempts, 100 exact kill calls, at least 100 store writes, 49 event-loop yields, and a maximum two-close batch at or below 50 ms."
},
"promotionCriteria": [
"Run the focused gate for at least 100 consecutive passes or 14 days across required CI platforms.",
"Attach Windows Electron evidence for both entry points, empty and established terminals, later-tab survival, xterm removal, and initial PTY absence after settlement.",
"Exercise the SSH fixture and WSL when available, or keep their live process-absence gaps explicit.",
"Propagate and align runtime-host tab-close completion before claiming verified remote-runtime shutdown."
],
"knownGaps": [
"Windows Electron screenshots and live process/xterm absence evidence were not produced by this local macOS run.",
"Live SSH and WSL process absence, Linux local/daemon behavior, and mobile/relay shutdown remain unproved.",
"Runtime-host terminal close is best-effort because closeTerminalTab still discards the existing async host result and its close-intent lifetime is shorter than the possible RPC flow.",
"Daemon adapter listing failures remain suppressed by the existing management API, so reported daemon counts are not authoritative verification of every process."
],
"demotionRule": "Keep experimental or demote to protection none if the gate flakes, permits a late-created tab or unrelated PTY to close, or exceeds the 50 ms maximum close-batch budget without a tracked product or harness bug."
},
{
"id": "terminal-session.startup-cwd-missing-dir-recovery",
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",

View File

@ -0,0 +1,203 @@
# Kill all sessions also closes terminal tabs
GitHub: https://github.com/stablyai/orca/issues/8001
Branch: `bug-kill-all-sessions-doesnt-kill-terminals`
## Problem
Settings → Manage Sessions and Resource Manager expose **Kill all sessions**. The action currently calls only `window.api.pty.management.killAll()`; it does not remove renderer terminal tabs or dispose xterm instances.
The main handler is narrower than the UI wording:
- `pty:management:killAll` snapshots sessions from the current and legacy **daemon** adapters, shuts them down in parallel, then polls only those initial IDs.
- It does not inventory SSH or runtime-hosted terminals. A degraded local provider is also outside the management adapter set.
- The nominal poll sleep budget is 6.5 seconds (65 × 100 ms), but adapter round-trip time is additional. Daemon requests time out after 30 seconds and connection setup has separate 5-second steps, so total latency can be much longer.
- Failed adapter listings are dropped by `collectSessions`, so counts are not authoritative when an adapter is unreachable. This applies during polling too: an adapter that listed an initial session and then becomes unreachable can make that session disappear from `remainingCount` and be counted as killed without a confirming listing.
Renderer exit handling then preserves two important startup-failure states:
- a sole, freshly spawned pane that exits before any user input; and
- a freshly split pane that exits before input or output.
Those guards are correct for direnv/shell setup failures, but they cannot distinguish an explicit bulk kill. A sole fresh-spawned pane remains if the user never typed, even if it produced output or lived for a while; typed/reattached panes normally close through the existing exit path.
## Goal and invariant
After confirmation, every terminal tab that existed in the invoking desktop renderer at that moment is removed, including dead/no-PTY tabs, and its renderer resources are disposed. Terminal tabs created later are not targeted. Initial daemon sessions still receive the existing management shutdown request, while current non-runtime PTY bindings owned by the targeted tabs receive exact per-ID shutdown requests.
The target is the confirmed terminal **surface ID**, not a liveness inference. If the same targeted tab rebinds before cleanup completes, it is still closed; a newly created tab ID survives.
## Non-goals
- Changing **Restart daemon**, which intentionally leaves panes available to reopen.
- Deleting or sleeping worktrees, closing browser/editor tabs, or killing the daemon process.
- Changing the sole-pane/fresh-split startup-failure guards.
- Promising an immediate OS working-set drop; GC and allocator behavior are nondeterministic. The deterministic contract is that tabs/xterms are released and observable daemon/exact-binding shutdown-request failures are surfaced. Runtime-host result propagation remains an accepted gap below.
- Turning this issue into a new cross-provider global kill API. Exact `pty.kill(id)` calls for current bindings of the confirmed tabs are in scope; inventorying and killing unrelated provider sessions is not.
## Design
### 1. Snapshot terminal surfaces once
At confirmation, before `onKillAllStart` and before the first `await`, collect and deduplicate terminal entity IDs from both `tabsByWorktree` and terminal entries in `unifiedTabsByWorktree`. Include the floating-terminal workspace.
Keep this immutable target set through the async daemon call:
- a target already closed elsewhere becomes a no-op;
- a target moved between groups/worktrees is resolved from current state and still closes;
- a terminal created after the snapshot is never closed.
Do not snapshot all terminal IDs again after the daemon call, and do not retain the confirmation-time worktree as cleanup authority. The target ID is immutable; ownership and active-last ordering are resolved again from current state immediately before cleanup.
### 2. Keep daemon shutdown semantics, then close the snapshot
Call the existing `window.api.pty.management.killAll()` once. After it settles, resolve the still-present targets and deduplicate their current `ptyIdsByTabId` entries. These are the renderer's current binding ownership records, not independent liveness proof; do not pull IDs from `tab.ptyId`, deferred SSH restore state, or `terminalLayoutsByTabId.ptyIdsByLeafId`, which can be restore hints rather than current ownership.
Force-close every snapshotted surface through `closeTerminalTab(id, { force: true })`, then await one existing `window.api.pty.kill(id)` call for each captured non-`remote:` PTY ID. Run both phases for management success, partial success, and IPC rejection because the confirmation explicitly covers closing the terminal surfaces. Use per-target/per-PTY settlement so one unexpected store or provider failure does not stop the remaining cleanup. Already-gone PTYs are successful no-ops in the main handler; other exact-kill rejections are reported separately.
Close targets outside the cleanup-time `activeWorktreeId` first and targets currently owned by it last. A tab moved into the active worktree or a workspace selected while the daemon call was pending must therefore move to the last partition. Let `closeTerminalTab` choose the existing post-state for the last local terminal: editor, then browser, then `setActiveWorktree(null)`. Do not pre-clear `activeWorktreeId`; the sleep flow needs that ordering because it leaves tab records mounted while clearing PTYs, whereas this flow removes the tabs. Add active-switch and active-last regression tests and only adopt sleep-intent/deactivate-first if those tests prove a respawn.
This is not one backend call end-to-end. The coordinator issues one daemon-management IPC, one renderer close per unique target, and at most one exact kill IPC per unique current non-runtime PTY binding. The explicit exact kills make local/SSH shutdown requests settle before caller refresh instead of relying on React unmount timing. A mounted transport may race that request and issue an idempotent duplicate during unmount; count that separately in live performance evidence. A still-bound daemon session can receive the exact retry after the management poll, so daemon toast counts remain the management handler's earlier reported snapshot, not final process truth. Runtime-hosted tabs additionally issue one `session.tabs.close` flow per tab.
The coordinator must live outside the hook (for example, `kill-all-terminal-surfaces.ts`) and must not gate cleanup on `mountedRef`. Navigating away or unmounting the invoking popover/settings component while IPC is pending does not revoke an already confirmed destructive action; only React callbacks and hook state updates remain mount-gated.
### 3. Do not add a post-kill orphan sweep
Do not call `pty.listSessions()` and kill everything returned after the management call. Exact kills captured from `ptyIdsByTabId` for the immutable target surfaces are allowed and required; they do not discover or cross into unrelated sessions.
The management handler already targets daemon orphans in its initial snapshot. A later broad sweep would cross the confirmation boundary by killing local/SSH sessions or fresh sessions created while the existing poll was running. It would also add provider inventory fan-out to a recovery action and conflict with the main handler's initial-ID accounting.
The existing explicit **Kill orphan terminals** action remains separate.
### 4. Make completion and failure copy honest
Have the renderer cleanup return a bounded summary: target count, targets absent at completion, failed close attempts, exact PTY kill requests accepted/rejected, and daemon result/error. Verify absence in both terminal and unified state after each close attempt. Toasts must report renderer cleanup separately from daemon counts; do not show **No sessions running** when terminal tabs were closed.
Because the management API suppresses adapter-list failures, copy must describe daemon counts as reported, not claim that every daemon process was verified dead. Returning adapter errors is a follow-up unless this PR chooses to widen the API. Exact `pty.kill` rejection counts may be stated as failed shutdown requests, not as proof that the processes remain alive.
Examples of states, not required wording:
- all daemon targets were reported exited, exact shutdown requests were accepted, and tabs closed: success;
- `remainingCount > 0`: warning that management reported daemon processes not exited before exact binding cleanup;
- management IPC rejected: error that tabs closed, with daemon shutdown unverified and any exact PTY kill failures called out;
- no daemon sessions and no terminal tabs: existing informational state.
Run `onKillAllSettled` only after surface removal and exact PTY kill settlement. `closeTerminalTab` returns before React unmount effects run, so surface removal alone is not a sufficient boundary for Resource Manager's `pty.listSessions()` refresh. Keep one confirmation dialog and update its description to say that terminal tabs across workspaces close and unsaved terminal work is lost.
### 5. Provider limits
`closeTerminalTab` synchronously prunes host-backed local mirrors but currently returns `void` and discards the promise from `closeWebRuntimeSessionTab`. The underlying helper is already awaitable: its close request has a 15-second timeout and, after success, it awaits an eager list refresh with another 15-second timeout. Its boolean can therefore settle after roughly 30 seconds, while close-intent suppression expires after 10 seconds. This change must not claim verified runtime-host completion unless the bulk helper propagates that existing result and aligns the intent lifetime.
For this Windows/local bug, runtime-host cleanup is best-effort and an explicit accepted gap. A follow-up may propagate the existing async close result through terminal-tab actions and align the RPC timeout/intent lifetime. SSH must be exercised if the fixture is available; otherwise record live SSH process absence as an accepted gap. No provider-specific local filesystem or process assumptions are added.
## Data flow
```text
confirm
→ snapshot unique terminal surface IDs
→ pty.management.killAll() daemon current + legacy; existing polling
→ resolve current target ownership + bound PTY IDs
→ close snapshotted tabs, active last renderer/xterm cleanup; runtime-host close starts
→ await exact non-runtime pty.kill IDs local/SSH acknowledgement; no provider sweep
→ toast daemon result + surface/exact-kill summary
→ caller refresh
```
## Concurrency and consistency
| Case | Required behavior |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| No daemon sessions; dead/empty tabs exist | Close target tabs and report tab cleanup, not “No sessions.” |
| Daemon session refuses to exit | Close the confirmed tab; report that management saw it remaining before the exact retry. Refresh may expose it as an orphan. |
| Adapter listing fails | Close target tabs; do not claim all processes were verified dead. Existing management counts may read as zero. |
| Management IPC rejects | Still close confirmed tabs and issue exact current-binding kills; report daemon uncertainty and exact-kill errors. |
| New tab/session during the wait | New tab ID survives. No post-snapshot provider sweep. |
| Target closes or moves during the wait | Missing target no-ops; moved target closes by current ownership. |
| Target tab rebinds to a new PTY | Close it because the confirmed surface, not the old PTY snapshot, is the authority. |
| Pinned or split terminal | `force: true` bypasses a second prompt; closing the tab closes all of its panes. |
| Active last terminal | Existing close routing selects editor/browser or deactivates; no replacement terminal spawns. |
| Two overlapping invocations | Cleanup is idempotent by tab ID. Each main call retains its own initial daemon snapshot/counts. |
| Binding changes after exact-ID capture | Surface close still wins; transport unmount kills the replacement, but that late kill is not in the awaited count. |
| Runtime-host snapshot races close | Existing close intent suppresses stale snapshots temporarily; host completion remains an accepted gap. |
| External/mobile session creation | Sessions created after the main/renderer snapshots are not chased. Existing PTY-exit publication updates mirrors. |
Orca's production UI currently tracks a single desktop `mainWindow`/renderer. The main daemon action is process-global while renderer cleanup is renderer-local. If multiple desktop windows become supported, this action must be broadcast from main with an action ID and per-window acknowledgement; “shared store probably mirrors it” is not sufficient.
## Tests and reliability gate
Add an experimental, `protection: "partial"` `terminal-session.kill-all-surface-cleanup` entry to `config/reliability-gates.jsonc`, owned by `terminal-runtime` at the renderer/main contract layer. The entry must name the focused coordinator and `pty-management` test files/commands and include non-empty assertion refs; otherwise the manifest checker rejects a partial gate.
- **Invariant:** confirmed terminal surfaces close exactly once; later-created surfaces survive; no broad post-snapshot kill occurs.
- **Failure source:** issue #8001 plus the sole-fresh/fresh-split exit guards that intentionally preserve dead startup-failure panes.
- **Deterministic oracle:** the initial tab IDs disappear from both terminal/unified state, non-terminal and later-created tabs remain, active state is valid, exact kills are issued only for deduplicated current target bindings, all exact-kill promises settle before the caller callback, and no `pty.listSessions` sweep occurs. Provider inventory absence is live validation, not the renderer-unit oracle.
- **Diagnostics:** emit one content-free bounded summary with target/absent/failed-close counts, exact-kill accepted/rejected counts, and the daemon result or error. Runtime-host completion remains unknown until terminal-tab actions propagate the existing async close result.
Required deterministic coverage:
- snapshot/dedup across multiple worktrees, unified-only terminals, splits, and floating terminals;
- pinned close with `force`, while browser/editor tabs remain;
- terminal added after the snapshot survives; missing/moved target behavior;
- active-last post-state for editor, browser, and no-other-content cases, including no auto-spawn;
- coordinator ordering for success, partial result, zero sessions, and rejected management IPC;
- exact binding capture excludes restore hints and `remote:` IDs; late-created tab bindings are not captured;
- exactly one management call, zero `pty.listSessions` calls, one close attempt per unique target, and one coordinator-issued exact kill per unique captured non-runtime PTY ID;
- invoking component unmount during the management wait does not cancel store/provider cleanup or invoke mount-gated callbacks;
- existing `src/main/ipc/pty-management.test.ts` cases remain green.
Electron validation on Windows must reproduce an empty shell plus an established terminal, invoke each entry point, and prove:
- target terminal tabs and `.xterm` surfaces are gone;
- initial local/daemon PTY IDs are absent after settle, except daemon IDs reported remaining and exact IDs whose shutdown request failed;
- a terminal created after confirmation survives;
- Resource Manager and Manage Sessions refresh to the same result.
Planned provider/platform accounting:
| Row | Planned status |
| --- | --- |
| Local PTY / daemon / Windows | Covered by deterministic coordinator/main tests plus the required live Electron run. |
| macOS / Linux local and daemon | Shared code is covered deterministically; live process absence remains an accepted gap until run there. |
| SSH | Exact request/settlement is covered by provider-contract mocks; live remote-process absence is covered only when the SSH fixture runs, otherwise accepted-gap. |
| WSL | Uses the local exact-ID path, but real WSL process absence is an accepted gap unless included in the Windows run. |
| Remote runtime | Local mirror removal is covered; host completion is accepted-gap while `closeTerminalTab` discards the async result. |
| Mobile/relay | No newly created session is chased; shared host-tab disappearance and live relay shutdown remain accepted gaps unless exercised with their fixtures. |
Screenshots are UI evidence only; they do not prove PTY or memory cleanup.
## Performance and blast radius
No interval, watcher, or extra provider listing is added. The existing management call can wait for 65 sleeps plus adapter latency. The coordinator adds at most one existing exact-kill IPC per unique current non-runtime PTY binding; record both those calls and any idempotent unmount duplicates as part of the performance evidence.
Repeated `closeTerminalTab` calls are not O(tabs): target resolution and store cascades scan/clone multiple maps per close, so worst-case renderer work is superlinear. Measure the synchronous close phase with a representative high-tab fixture (at least 100 terminal tabs). Record close attempts, Zustand writes, duration, and long tasks. If it creates a >50 ms renderer task, add a focused bulk reducer or yield-safe batching rather than declaring the rare action free.
For resource reclamation, prefer deterministic counts: zero target `TerminalPane`/xterm instances, released listeners/transports, and no initial PTY IDs. A repeated open/kill cycle may supplement this with heap/working-set evidence, but absolute memory deltas are not a stable pass/fail oracle.
## UI quality bar and screenshots
No new layout or visual token is needed. Keep the existing shadcn `Dialog`/destructive `Button`, compact typography, dismissal lock while busy, and accurate consequence-first copy. The post-state must never be a blank/crashed TerminalPane or a wall of dead empty panes.
Required review screenshots:
1. Resource Manager before kill with an empty terminal and an established terminal visible in the workspace.
2. Resource Manager kill-all confirmation with updated close-tabs copy.
3. Matched post-kill workspace showing target terminal tabs gone and refreshed counts.
4. Manage Sessions confirmation or post-state, proving the second entry point uses the same behavior.
## Lightweight Eng Review
- **Scope:** renderer surface cleanup layered on the existing daemon management action; no daemon redesign and no broad orphan/provider sweep.
- **Architecture/data flow:** main owns its initial daemon-session shutdown; renderer owns the confirmed terminal-surface snapshot, exact current-binding shutdown, and tab/xterm disposal; runtime-host close remains behind existing routing.
- **Failure modes:** partial/rejected daemon kill, exact provider-kill failure, no-session cleanup, pinning, splits, cleanup-time active selection, new/moved/disappeared tabs, same-tab rebind, overlapping actions, invoking-component unmount, stale runtime snapshots, adapter-list uncertainty, and future multi-window ownership are explicit above.
- **Tests:** deterministic state/coordinator and main-handler tests plus Windows Electron proof; a new experimental partial reliability gate records provider gaps.
- **Performance/blast radius:** one existing management IPC, bounded exact per-PTY kill IPCs, no new listing/polling, but N close cascades require measurement because current helpers are not linear or atomic.
- **UI quality bar:** existing dialog primitives and truthful copy; screenshots prove both entry points and the terminal-free post-state, not memory reclamation.
- **Residual risks:** daemon adapter listing can stall until client timeouts and failures are treated as empty; terminal-tab actions discard the runtime-host close result and its 10-second intent is shorter than the possible two-request close flow; a binding created after exact-ID capture is killed only by transport unmount; live SSH/WSL/mobile behavior may remain accepted gaps until fixtures prove it.
## Rollout
1. Add the snapshot/coordinator and deterministic tests.
2. Wire it only through `useDaemonActions.runKillAll`; keep the coordinator outside the hook and update toast/dialog copy.
3. Add the experimental reliability gate and performance count/latency evidence.
4. Run focused tests, typecheck, lint, and max-lines ratchet checks.
5. Validate both entry points in Electron on Windows and capture the required screenshots.
6. Force-add this doc when staging because `docs/**` is ignored by default.

View File

@ -0,0 +1,365 @@
import { describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import { useAppStore, type AppState } from '@/store'
import {
runKillAllTerminalSurfaces,
snapshotKillAllTerminalSurfaceIds,
type KillAllTerminalSurfaceState
} from './kill-all-terminal-surfaces'
type TerminalRow = AppState['tabsByWorktree'][string][number]
type UnifiedRow = AppState['unifiedTabsByWorktree'][string][number]
function terminal(id: string, worktreeId: string): TerminalRow {
return { id, worktreeId } as TerminalRow
}
function unified(
id: string,
entityId: string,
worktreeId: string,
contentType: UnifiedRow['contentType'] = 'terminal'
): UnifiedRow {
return { id, entityId, worktreeId, contentType } as UnifiedRow
}
function state(overrides: Partial<KillAllTerminalSurfaceState> = {}): KillAllTerminalSurfaceState {
return {
activeWorktreeId: null,
tabsByWorktree: {},
unifiedTabsByWorktree: {},
ptyIdsByTabId: {},
...overrides
}
}
function removeSurface(current: KillAllTerminalSurfaceState, targetId: string): void {
current.tabsByWorktree = Object.fromEntries(
Object.entries(current.tabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.filter((tab) => tab.id !== targetId)
])
)
current.unifiedTabsByWorktree = Object.fromEntries(
Object.entries(current.unifiedTabsByWorktree).map(([worktreeId, tabs]) => [
worktreeId,
tabs.filter(
(tab) =>
tab.contentType !== 'terminal' || (tab.entityId !== targetId && tab.id !== targetId)
)
])
)
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
describe('snapshotKillAllTerminalSurfaceIds', () => {
it('deduplicates terminal entities across legacy, unified, split, and floating state', () => {
const snapshot = snapshotKillAllTerminalSurfaceIds(
state({
tabsByWorktree: {
'wt-a': [terminal('tab-a', 'wt-a'), terminal('split-tab', 'wt-a')],
'wt-b': [terminal('tab-b', 'wt-b')],
[FLOATING_TERMINAL_WORKTREE_ID]: [terminal('floating-tab', FLOATING_TERMINAL_WORKTREE_ID)]
},
unifiedTabsByWorktree: {
'wt-a': [
unified('visible-a', 'tab-a', 'wt-a'),
unified('unified-only', 'unified-only', 'wt-a'),
unified('editor-a', 'file-a', 'wt-a', 'editor')
],
[FLOATING_TERMINAL_WORKTREE_ID]: [
unified('floating-visible', 'floating-tab', FLOATING_TERMINAL_WORKTREE_ID)
]
},
ptyIdsByTabId: {
'split-tab': ['pty-left', 'pty-right']
}
})
)
expect(snapshot).toEqual(['tab-a', 'split-tab', 'tab-b', 'floating-tab', 'unified-only'])
})
})
describe('runKillAllTerminalSurfaces', () => {
it('resolves current bindings and ownership after management settles, then closes active last', async () => {
const management = deferred<{ killedCount: number; remainingCount: number }>()
const lastExactKill = deferred<void>()
let current = state({
activeWorktreeId: 'wt-old-active',
tabsByWorktree: {
'wt-old-active': [terminal('moved-target', 'wt-old-active')],
'wt-background': [
terminal('background-target', 'wt-background'),
terminal('missing-target', 'wt-background')
]
},
unifiedTabsByWorktree: {
'wt-background': [unified('unified-target', 'unified-only-target', 'wt-background')]
},
ptyIdsByTabId: {
'moved-target': ['stale-pty'],
'background-target': ['stale-background-pty'],
'missing-target': ['stale-missing-pty'],
'unified-only-target': ['stale-unified-pty']
}
})
const targetIds = snapshotKillAllTerminalSurfaceIds(current)
const calls: string[] = []
const killDaemonSessions = vi.fn(() => management.promise)
const closeSurface = vi.fn((targetId: string) => {
calls.push(`close:${targetId}`)
removeSurface(current, targetId)
})
const killPty = vi.fn((ptyId: string) => {
calls.push(`kill:${ptyId}`)
if (ptyId === 'pty-unified') {
return Promise.reject(new Error('provider unavailable'))
}
if (ptyId === 'ssh:host@@pty-last') {
return lastExactKill.promise
}
return Promise.resolve()
})
const reportSummary = vi.fn()
const completion = runKillAllTerminalSurfaces(targetIds, {
getState: () => current,
killDaemonSessions,
closeSurface,
killPty,
reportSummary
})
expect(killDaemonSessions).toHaveBeenCalledTimes(1)
expect(closeSurface).not.toHaveBeenCalled()
current = state({
activeWorktreeId: 'wt-new-active',
tabsByWorktree: {
'wt-background': [terminal('background-target', 'wt-background')],
'wt-new-active': [
{
...terminal('moved-target', 'wt-new-active'),
ptyId: 'tab-restore-hint'
},
terminal('later-tab', 'wt-new-active')
]
},
unifiedTabsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
unified('unified-target-moved', 'unified-only-target', FLOATING_TERMINAL_WORKTREE_ID)
],
'wt-new-active': [unified('later-visible', 'later-tab', 'wt-new-active')]
},
ptyIdsByTabId: {
'background-target': ['pty-shared', 'remote:runtime-only'],
'unified-only-target': ['pty-unified', 'pty-shared'],
'moved-target': ['ssh:host@@pty-last', 'pty-shared'],
'later-tab': ['pty-later']
}
})
;(
current as KillAllTerminalSurfaceState & {
terminalLayoutsByTabId: Record<string, { ptyIdsByLeafId: Record<string, string> }>
}
).terminalLayoutsByTabId = {
'moved-target': { ptyIdsByLeafId: { leaf: 'layout-restore-hint' } }
}
management.resolve({ killedCount: 2, remainingCount: 1 })
await vi.waitFor(() => expect(killPty).toHaveBeenCalledTimes(3))
expect(closeSurface.mock.calls).toEqual([
['background-target', { force: true }],
['unified-only-target', { force: true }],
['moved-target', { force: true }]
])
expect(calls.slice(0, 3)).toEqual([
'close:background-target',
'close:unified-only-target',
'close:moved-target'
])
expect(killPty).toHaveBeenCalledWith('pty-shared')
expect(killPty).toHaveBeenCalledWith('pty-unified')
expect(killPty).toHaveBeenCalledWith('ssh:host@@pty-last')
expect(killPty).not.toHaveBeenCalledWith('remote:runtime-only')
expect(killPty).not.toHaveBeenCalledWith('pty-later')
expect(killPty).not.toHaveBeenCalledWith('stale-pty')
expect(killPty).not.toHaveBeenCalledWith('tab-restore-hint')
expect(killPty).not.toHaveBeenCalledWith('layout-restore-hint')
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later-tab'])
let settled = false
void completion.then(() => {
settled = true
})
await Promise.resolve()
expect(settled).toBe(false)
lastExactKill.resolve()
await expect(completion).resolves.toMatchObject({
targetCount: 4,
closeAttemptCount: 3,
absentTargetCount: 4,
failedCloseAttemptCount: 0,
exactKillAcceptedCount: 2,
exactKillRejectedCount: 1,
daemon: { status: 'fulfilled', killedCount: 2, remainingCount: 1 }
})
expect(reportSummary).toHaveBeenCalledTimes(1)
})
it('still closes and issues exact kills when daemon management rejects', async () => {
let current = state({
tabsByWorktree: { wt: [terminal('target', 'wt'), terminal('later', 'wt')] },
ptyIdsByTabId: { target: ['local-pty'], later: ['later-pty'] }
})
const closeSurface = vi.fn((targetId: string) => removeSurface(current, targetId))
const killPty = vi.fn().mockResolvedValue(undefined)
const summary = await runKillAllTerminalSurfaces(['target'], {
getState: () => current,
killDaemonSessions: vi.fn().mockRejectedValue(new Error('management unavailable')),
closeSurface,
killPty,
reportSummary: vi.fn()
})
expect(summary.daemon).toEqual({ status: 'rejected' })
expect(summary.absentTargetCount).toBe(1)
expect(closeSurface).toHaveBeenCalledWith('target', { force: true })
expect(killPty).toHaveBeenCalledWith('local-pty')
expect(snapshotKillAllTerminalSurfaceIds(current)).toEqual(['later'])
})
it('reports the informational zero state without provider fanout', async () => {
const killDaemonSessions = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 })
const closeSurface = vi.fn()
const killPty = vi.fn()
const summary = await runKillAllTerminalSurfaces([], {
getState: () => state(),
killDaemonSessions,
closeSurface,
killPty,
reportSummary: vi.fn()
})
expect(summary).toMatchObject({
targetCount: 0,
closeAttemptCount: 0,
absentTargetCount: 0,
exactKillAcceptedCount: 0,
exactKillRejectedCount: 0,
daemon: { status: 'fulfilled', killedCount: 0, remainingCount: 0 }
})
expect(killDaemonSessions).toHaveBeenCalledTimes(1)
expect(closeSurface).not.toHaveBeenCalled()
expect(killPty).not.toHaveBeenCalled()
})
it('uses one daemon management call and never inventories sessions afterward', async () => {
const killAll = vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 })
const listSessions = vi.fn()
vi.stubGlobal('window', {
api: {
pty: {
kill: vi.fn(),
management: { killAll, listSessions }
}
}
})
try {
await runKillAllTerminalSurfaces([], {
getState: () => state(),
reportSummary: vi.fn()
})
expect(killAll).toHaveBeenCalledTimes(1)
expect(listSessions).not.toHaveBeenCalled()
} finally {
vi.unstubAllGlobals()
}
})
it('settles each close attempt and verifies final absence in both tab stores', async () => {
const current = state({
tabsByWorktree: {
wt: [terminal('throws-after-close', 'wt'), terminal('remains', 'wt')]
},
unifiedTabsByWorktree: {
wt: [unified('remains-visible', 'remains', 'wt')]
}
})
const closeSurface = vi.fn((targetId: string) => {
if (targetId === 'throws-after-close') {
removeSurface(current, targetId)
throw new Error('post-close store failure')
}
})
const summary = await runKillAllTerminalSurfaces(['throws-after-close', 'remains'], {
getState: () => current,
killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
closeSurface,
killPty: vi.fn(),
reportSummary: vi.fn()
})
expect(closeSurface).toHaveBeenCalledTimes(2)
expect(summary).toMatchObject({
absentTargetCount: 1,
failedCloseAttemptCount: 2
})
})
it('records bounded count and latency evidence for 100 terminal tabs', async () => {
const tabs = Array.from({ length: 100 }, (_, index) => terminal(`tab-${index}`, 'wt'))
const ptyIdsByTabId = Object.fromEntries(tabs.map((tab, index) => [tab.id, [`pty-${index}`]]))
const previousState = useAppStore.getState()
useAppStore.setState({
activeWorktreeId: null,
tabsByWorktree: { wt: tabs },
unifiedTabsByWorktree: {},
ptyIdsByTabId
})
let zustandWrites = 0
const unsubscribe = useAppStore.subscribe(() => {
zustandWrites += 1
})
const killPty = vi.fn().mockResolvedValue(undefined)
try {
const summary = await runKillAllTerminalSurfaces(snapshotKillAllTerminalSurfaceIds(), {
killDaemonSessions: vi.fn().mockResolvedValue({ killedCount: 0, remainingCount: 0 }),
killPty,
reportSummary: vi.fn()
})
expect(summary.closeAttemptCount).toBe(100)
expect(summary.absentTargetCount).toBe(100)
expect(summary.exactKillAcceptedCount).toBe(100)
expect(summary.closeDurationMs).toBeGreaterThanOrEqual(0)
expect(summary.maxCloseBatchDurationMs).toBeLessThanOrEqual(50)
expect(summary.closeYieldCount).toBe(49)
expect(summary.closePhaseExceededLongTaskBudget).toBe(false)
expect(zustandWrites).toBeGreaterThanOrEqual(100)
expect(killPty).toHaveBeenCalledTimes(100)
} finally {
unsubscribe()
useAppStore.setState(previousState, true)
}
})
})

View File

@ -0,0 +1,215 @@
import { useAppStore, type AppState } from '@/store'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
const CLOSE_BATCH_SIZE = 2
type DaemonKillAllResult = {
killedCount: number
remainingCount: number
}
export type KillAllTerminalSurfaceState = Pick<
AppState,
'activeWorktreeId' | 'ptyIdsByTabId' | 'tabsByWorktree' | 'unifiedTabsByWorktree'
>
export type KillAllTerminalSurfacesSummary = {
targetCount: number
closeAttemptCount: number
absentTargetCount: number
failedCloseAttemptCount: number
exactKillAcceptedCount: number
exactKillRejectedCount: number
closeDurationMs: number
maxCloseBatchDurationMs: number
closeYieldCount: number
closePhaseExceededLongTaskBudget: boolean
daemon:
| ({ status: 'fulfilled' } & DaemonKillAllResult)
| {
status: 'rejected'
}
}
type KillAllTerminalSurfaceDependencies = {
getState: () => KillAllTerminalSurfaceState
killDaemonSessions: () => Promise<DaemonKillAllResult>
closeSurface: (tabId: string, options: { force: true }) => void
killPty: (ptyId: string) => Promise<void>
now: () => number
yieldToRenderer: () => Promise<void>
reportSummary: (summary: KillAllTerminalSurfacesSummary) => void
}
export function snapshotKillAllTerminalSurfaceIds(
state: KillAllTerminalSurfaceState = useAppStore.getState()
): string[] {
const targetIds = new Set<string>()
for (const tabs of Object.values(state.tabsByWorktree)) {
for (const tab of tabs) {
targetIds.add(tab.id)
}
}
for (const tabs of Object.values(state.unifiedTabsByWorktree)) {
for (const tab of tabs) {
if (tab.contentType === 'terminal') {
targetIds.add(tab.entityId)
}
}
}
return [...targetIds]
}
function getTargetOwners(
state: KillAllTerminalSurfaceState,
targetIds: ReadonlySet<string>
): Map<string, string> {
const ownerByTargetId = new Map<string, string>()
for (const [worktreeId, tabs] of Object.entries(state.tabsByWorktree)) {
for (const tab of tabs) {
if (targetIds.has(tab.id) && !ownerByTargetId.has(tab.id)) {
ownerByTargetId.set(tab.id, worktreeId)
}
}
}
for (const [worktreeId, tabs] of Object.entries(state.unifiedTabsByWorktree)) {
for (const tab of tabs) {
if (
tab.contentType === 'terminal' &&
targetIds.has(tab.entityId) &&
!ownerByTargetId.has(tab.entityId)
) {
ownerByTargetId.set(tab.entityId, worktreeId)
}
}
}
return ownerByTargetId
}
function isTargetPresent(state: KillAllTerminalSurfaceState, targetId: string): boolean {
return snapshotKillAllTerminalSurfaceIds(state).includes(targetId)
}
function createDefaultDependencies(): KillAllTerminalSurfaceDependencies {
return {
getState: useAppStore.getState,
killDaemonSessions: () => window.api.pty.management.killAll(),
closeSurface: closeTerminalTab,
killPty: (ptyId) => window.api.pty.kill(ptyId),
now: () => globalThis.performance?.now() ?? Date.now(),
yieldToRenderer: () =>
new Promise((resolve) => {
const channel = new MessageChannel()
channel.port1.onmessage = () => {
channel.port1.close()
channel.port2.close()
resolve()
}
channel.port2.postMessage(undefined)
}),
reportSummary: (summary) => console.info('[kill-all-terminal-surfaces]', summary)
}
}
export async function runKillAllTerminalSurfaces(
snapshotTargetIds: readonly string[],
dependencies: Partial<KillAllTerminalSurfaceDependencies> = {}
): Promise<KillAllTerminalSurfacesSummary> {
const deps = { ...createDefaultDependencies(), ...dependencies }
const targetIds = [...new Set(snapshotTargetIds)]
const targetIdSet = new Set(targetIds)
let daemon: KillAllTerminalSurfacesSummary['daemon']
try {
daemon = { status: 'fulfilled', ...(await deps.killDaemonSessions()) }
} catch {
daemon = { status: 'rejected' }
}
const cleanupState = deps.getState()
const ownerByTargetId = getTargetOwners(cleanupState, targetIdSet)
const presentTargetIds = targetIds.filter((targetId) => ownerByTargetId.has(targetId))
const activeWorktreeId = cleanupState.activeWorktreeId
// Why: keeping the active worktree until its targets close lets the existing
// tab action choose editor/browser/deactivation without spawning a replacement.
const closeOrder = [
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) !== activeWorktreeId),
...presentTargetIds.filter((targetId) => ownerByTargetId.get(targetId) === activeWorktreeId)
]
const exactPtyIds = new Set<string>()
for (const targetId of presentTargetIds) {
for (const ptyId of cleanupState.ptyIdsByTabId[targetId] ?? []) {
if (ptyId.length > 0 && !ptyId.startsWith('remote:')) {
exactPtyIds.add(ptyId)
}
}
}
let failedCloseAttemptCount = 0
const closeStartedAt = deps.now()
let closeBatchStartedAt = closeStartedAt
let maxCloseBatchDurationMs = 0
let closeYieldCount = 0
for (let index = 0; index < closeOrder.length; index += 1) {
const targetId = closeOrder[index]
let failed = false
try {
deps.closeSurface(targetId, { force: true })
} catch {
failed = true
}
try {
failed ||= isTargetPresent(deps.getState(), targetId)
} catch {
failed = true
}
if (failed) {
failedCloseAttemptCount += 1
}
const isBatchEnd = (index + 1) % CLOSE_BATCH_SIZE === 0 || index + 1 === closeOrder.length
if (isBatchEnd) {
maxCloseBatchDurationMs = Math.max(maxCloseBatchDurationMs, deps.now() - closeBatchStartedAt)
}
if (isBatchEnd && index + 1 < closeOrder.length) {
// Why: closeTab cascades clone several store maps, so large confirmed
// snapshots yield between bounded batches instead of monopolizing a frame.
try {
await deps.yieldToRenderer()
} catch {
// Yield failure is not cleanup failure; keep closing the confirmed set.
}
closeYieldCount += 1
closeBatchStartedAt = deps.now()
}
}
const closeDurationMs = Math.max(0, deps.now() - closeStartedAt)
const exactKillResults = await Promise.allSettled(
[...exactPtyIds].map((ptyId) => Promise.resolve().then(() => deps.killPty(ptyId)))
)
const exactKillAcceptedCount = exactKillResults.filter(
(result) => result.status === 'fulfilled'
).length
const finalTargetIds = new Set(snapshotKillAllTerminalSurfaceIds(deps.getState()))
const absentTargetCount = targetIds.filter((targetId) => !finalTargetIds.has(targetId)).length
const summary: KillAllTerminalSurfacesSummary = {
targetCount: targetIds.length,
closeAttemptCount: closeOrder.length,
absentTargetCount,
failedCloseAttemptCount,
exactKillAcceptedCount,
exactKillRejectedCount: exactKillResults.length - exactKillAcceptedCount,
closeDurationMs: Math.round(closeDurationMs * 100) / 100,
maxCloseBatchDurationMs: Math.round(maxCloseBatchDurationMs * 100) / 100,
closeYieldCount,
closePhaseExceededLongTaskBudget: maxCloseBatchDurationMs > 50,
daemon
}
try {
deps.reportSummary(summary)
} catch {
// Diagnostics must not change the already-settled destructive action.
}
return summary
}

View File

@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { runCleanupMock, snapshotMock, toastErrorMock, toastInfoMock, toastSuccessMock } =
vi.hoisted(() => ({
runCleanupMock: vi.fn(),
snapshotMock: vi.fn(),
toastErrorMock: vi.fn(),
toastInfoMock: vi.fn(),
toastSuccessMock: vi.fn()
}))
vi.mock('./kill-all-terminal-surfaces', () => ({
runKillAllTerminalSurfaces: runCleanupMock,
snapshotKillAllTerminalSurfaceIds: snapshotMock
}))
vi.mock('sonner', () => ({
toast: {
error: toastErrorMock,
info: toastInfoMock,
success: toastSuccessMock,
warning: vi.fn()
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
import type { KillAllTerminalSurfacesSummary } from './kill-all-terminal-surfaces'
import { useDaemonActions } from './useDaemonActions'
function rejectedSummary(): KillAllTerminalSurfacesSummary {
return {
targetCount: 1,
closeAttemptCount: 1,
absentTargetCount: 1,
failedCloseAttemptCount: 0,
exactKillAcceptedCount: 1,
exactKillRejectedCount: 0,
closeDurationMs: 1,
maxCloseBatchDurationMs: 1,
closeYieldCount: 0,
closePhaseExceededLongTaskBudget: false,
daemon: { status: 'rejected' }
}
}
function successfulSurfaceSummary(): KillAllTerminalSurfacesSummary {
return {
targetCount: 1,
closeAttemptCount: 1,
absentTargetCount: 1,
failedCloseAttemptCount: 0,
exactKillAcceptedCount: 0,
exactKillRejectedCount: 0,
closeDurationMs: 1,
maxCloseBatchDurationMs: 1,
closeYieldCount: 0,
closePhaseExceededLongTaskBudget: false,
daemon: { status: 'fulfilled', killedCount: 0, remainingCount: 0 }
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
describe('useDaemonActions kill-all cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
snapshotMock.mockReturnValue(['confirmed-tab'])
})
it('snapshots before start and does not revoke cleanup when the caller unmounts', async () => {
const cleanup = deferred<KillAllTerminalSurfacesSummary>()
const sequence: string[] = []
const onKillAllStart = vi.fn(() => sequence.push('start'))
const onKillAllError = vi.fn()
const onKillAllSettled = vi.fn()
snapshotMock.mockImplementation(() => {
sequence.push('snapshot')
return ['confirmed-tab']
})
runCleanupMock.mockImplementation(() => {
sequence.push('cleanup')
return cleanup.promise
})
const { result, unmount } = renderHook(() =>
useDaemonActions({ onKillAllStart, onKillAllError, onKillAllSettled })
)
let completion!: Promise<void>
act(() => {
completion = result.current.runKillAll()
})
expect(sequence).toEqual(['snapshot', 'start', 'cleanup'])
expect(runCleanupMock).toHaveBeenCalledWith(['confirmed-tab'])
unmount()
cleanup.resolve(rejectedSummary())
await completion
expect(toastErrorMock).toHaveBeenCalledTimes(1)
expect(onKillAllError).not.toHaveBeenCalled()
expect(onKillAllSettled).not.toHaveBeenCalled()
})
it('runs mounted error and settled callbacks only after cleanup settlement', async () => {
const cleanup = deferred<KillAllTerminalSurfacesSummary>()
const onKillAllError = vi.fn()
const onKillAllSettled = vi.fn()
runCleanupMock.mockReturnValue(cleanup.promise)
const { result } = renderHook(() => useDaemonActions({ onKillAllError, onKillAllSettled }))
let completion!: Promise<void>
act(() => {
completion = result.current.runKillAll()
})
expect(onKillAllError).not.toHaveBeenCalled()
expect(onKillAllSettled).not.toHaveBeenCalled()
await act(async () => {
cleanup.resolve(rejectedSummary())
await completion
})
expect(onKillAllError).toHaveBeenCalledTimes(1)
expect(onKillAllSettled).toHaveBeenCalledTimes(1)
})
it('reports closed terminal tabs as success when daemon management found no sessions', async () => {
runCleanupMock.mockResolvedValue(successfulSurfaceSummary())
const { result } = renderHook(() => useDaemonActions())
await act(async () => {
await result.current.runKillAll()
})
expect(toastSuccessMock).toHaveBeenCalledWith(
'Terminal tabs closed and shutdown requested.',
expect.any(Object)
)
expect(toastInfoMock).not.toHaveBeenCalled()
})
})

View File

@ -12,6 +12,11 @@ import {
DialogTitle
} from '../ui/dialog'
import { translate } from '@/i18n/i18n'
import {
runKillAllTerminalSurfaces,
snapshotKillAllTerminalSurfaceIds,
type KillAllTerminalSurfacesSummary
} from './kill-all-terminal-surfaces'
export type DaemonActionKind = 'restart' | 'killAll'
@ -38,6 +43,90 @@ export type DaemonActionsApi = {
runConfirmed: () => void
}
function showKillAllTerminalSurfacesResult(summary: KillAllTerminalSurfacesSummary): void {
const surfaceDescription =
summary.targetCount > 0
? translate(
'auto.components.shared.useDaemonActions.71a8d342b0',
'Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.',
{
value0: summary.absentTargetCount,
value1: summary.targetCount,
value2: summary.failedCloseAttemptCount,
value3: summary.exactKillAcceptedCount,
value4: summary.exactKillRejectedCount
}
)
: null
const daemonDescription =
summary.daemon.status === 'rejected'
? translate(
'auto.components.shared.useDaemonActions.2e57c1a940',
'The daemon shutdown result is unverified because its management request failed.'
)
: translate(
'auto.components.shared.useDaemonActions.993af6052c',
'Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.',
{
value0: summary.daemon.killedCount,
value1: summary.daemon.killedCount + summary.daemon.remainingCount,
value2: summary.daemon.remainingCount
}
)
const description = [surfaceDescription, daemonDescription].filter(Boolean).join(' ')
if (summary.daemon.status === 'rejected') {
toast.error(
translate(
'auto.components.shared.useDaemonActions.1f0d8ac762',
'Terminal cleanup finished with errors.'
),
{ description }
)
return
}
if (summary.failedCloseAttemptCount > 0 || summary.exactKillRejectedCount > 0) {
toast.error(
translate(
'auto.components.shared.useDaemonActions.1f0d8ac762',
'Terminal cleanup finished with errors.'
),
{ description }
)
return
}
if (summary.daemon.remainingCount > 0) {
toast.warning(
translate(
'auto.components.shared.useDaemonActions.80b6ea14cf',
'Terminal cleanup finished with warnings.'
),
{ description }
)
return
}
if (summary.targetCount === 0 && summary.daemon.killedCount === 0) {
toast.info(
translate(
'auto.components.shared.useDaemonActions.47cd2a50e9',
'No sessions or terminal tabs were reported.'
)
)
return
}
toast.success(
summary.targetCount > 0
? translate(
'auto.components.shared.useDaemonActions.c34fb1098d',
'Terminal tabs closed and shutdown requested.'
)
: translate(
'auto.components.shared.useDaemonActions.d9657ac204',
'Terminal session shutdown requested.'
),
{ description }
)
}
export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActionsApi {
const [pending, setPending] = useState<PendingConfirm>(null)
const [busyKind, setBusyKind] = useState<DaemonActionKind | null>(null)
@ -83,61 +172,26 @@ export function useDaemonActions(callbacks?: DaemonActionCallbacks): DaemonActio
}, [callbacks, clearPendingAction, mountedRef])
const runKillAll = useCallback(async () => {
// Why: confirmation covers the surface identities visible now; taking this
// before callbacks or awaits keeps later-created terminal tabs out of scope.
const targetSurfaceIds = snapshotKillAllTerminalSurfaceIds()
setBusyKind('killAll')
callbacks?.onKillAllStart?.()
try {
const { killedCount, remainingCount } = await window.api.pty.management.killAll()
if (remainingCount > 0 && killedCount > 0) {
toast.warning(
translate(
'auto.components.shared.useDaemonActions.fe2ab66d45',
'Killed {{value0}} of {{value1}} sessions. {{value2}} refused to exit.',
{ value0: killedCount, value1: killedCount + remainingCount, value2: remainingCount }
)
)
} else if (killedCount === 1) {
toast.success(
translate(
'auto.components.shared.useDaemonActions.87412c2a68',
'Killed {{value0}} session.',
{ value0: killedCount }
)
)
} else if (killedCount > 0) {
toast.success(
translate(
'auto.components.shared.useDaemonActions.a2f040ac1c',
'Killed {{value0}} sessions.',
{ value0: killedCount }
)
)
} else if (remainingCount === 0) {
toast.info(
translate('auto.components.shared.useDaemonActions.baad8cd651', 'No sessions running.')
)
} else if (remainingCount === 1) {
toast.error(
translate(
'auto.components.shared.useDaemonActions.63520148e2',
'{{value0}} session refused to exit.',
{ value0: remainingCount }
)
)
} else {
toast.error(
translate(
'auto.components.shared.useDaemonActions.cc0a26cb14',
'{{value0}} sessions refused to exit.',
{ value0: remainingCount }
)
)
const summary = await runKillAllTerminalSurfaces(targetSurfaceIds)
if (summary.daemon.status === 'rejected' && mountedRef.current) {
callbacks?.onKillAllError?.()
}
showKillAllTerminalSurfacesResult(summary)
} catch (err) {
if (mountedRef.current) {
callbacks?.onKillAllError?.()
}
toast.error(
translate('auto.components.shared.useDaemonActions.2b4efdc162', 'Couldnt kill sessions.'),
translate(
'auto.components.shared.useDaemonActions.e8f25bd903',
'Couldnt finish terminal cleanup.'
),
{
description: err instanceof Error ? err.message : undefined
}
@ -203,8 +257,8 @@ function getCopy(kind: DaemonActionKind): CopyShape {
description: (
<>
{translate(
'auto.components.shared.useDaemonActions.28c8e53176',
"This force-quits every running terminal pane across all workspaces. Any unsaved work in those sessions is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
'auto.components.shared.useDaemonActions.a702d4196e',
"This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
)}
</>
),

View File

@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getStateMock, closeWebRuntimeSessionTabMock, isWebRuntimeSessionActiveMock } = vi.hoisted(
() => ({
getStateMock: vi.fn(),
closeWebRuntimeSessionTabMock: vi.fn(),
isWebRuntimeSessionActiveMock: vi.fn(() => false)
})
)
vi.mock('@/store', () => ({
useAppStore: { getState: getStateMock }
}))
vi.mock('@/runtime/web-runtime-session', () => ({
activateWebRuntimeSessionTab: vi.fn(),
closeWebRuntimeSessionTab: closeWebRuntimeSessionTabMock,
createWebRuntimeSessionTerminal: vi.fn(),
isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock,
toHostSessionTabId: vi.fn((tabId: string) => tabId)
}))
vi.mock('@/runtime/web-session-tabs-sync', () => ({
resolveHostSessionTabIdForWebSessionTab: vi.fn(() => null)
}))
import { closeTerminalTab } from './terminal-tab-actions'
function baseState(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true },
repos: [],
worktreesByRepo: {},
tabsByWorktree: { wt: [{ id: 'terminal-1' }] },
unifiedTabsByWorktree: {},
activeWorktreeId: 'wt',
activeTabId: 'terminal-1',
openFiles: [],
browserTabsByWorktree: {},
closeTab: vi.fn(),
closeUnifiedTab: vi.fn(),
setActiveFile: vi.fn(),
setActiveBrowserTab: vi.fn(),
setActiveTabType: vi.fn(),
setActiveTab: vi.fn(),
setActiveWorktree: vi.fn(),
requestPinnedTabCloseConfirm: vi.fn(),
createTab: vi.fn(),
closeFile: vi.fn(),
closeBrowserTab: vi.fn(),
...overrides
}
}
describe('closeTerminalTab kill-all routing', () => {
beforeEach(() => {
vi.clearAllMocks()
isWebRuntimeSessionActiveMock.mockReturnValue(false)
})
it('force-closes a pinned terminal without opening a second confirmation', () => {
const closeUnifiedTab = vi.fn()
const requestPinnedTabCloseConfirm = vi.fn()
getStateMock.mockReturnValue(
baseState({
tabsByWorktree: {},
unifiedTabsByWorktree: {
wt: [
{
id: 'visible-pinned',
entityId: 'terminal-1',
contentType: 'terminal',
isPinned: true
}
]
},
closeUnifiedTab,
requestPinnedTabCloseConfirm
})
)
closeTerminalTab('terminal-1', { force: true })
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
expect(closeUnifiedTab).toHaveBeenCalledWith('visible-pinned')
})
it('routes the last active terminal to an existing editor without closing it', () => {
const state = baseState({
openFiles: [{ id: 'editor-1', worktreeId: 'wt' }]
})
getStateMock.mockReturnValue(state)
closeTerminalTab('terminal-1', { force: true })
expect(state.closeTab).toHaveBeenCalledWith('terminal-1')
expect(state.setActiveFile).toHaveBeenCalledWith('editor-1')
expect(state.setActiveTabType).toHaveBeenCalledWith('editor')
expect(state.closeFile).not.toHaveBeenCalled()
expect(state.closeBrowserTab).not.toHaveBeenCalled()
expect(state.setActiveWorktree).not.toHaveBeenCalled()
expect(state.createTab).not.toHaveBeenCalled()
})
it('routes the last active terminal to an existing browser when no editor exists', () => {
const state = baseState({
browserTabsByWorktree: { wt: [{ id: 'browser-1' }] }
})
getStateMock.mockReturnValue(state)
closeTerminalTab('terminal-1', { force: true })
expect(state.setActiveBrowserTab).toHaveBeenCalledWith('browser-1')
expect(state.setActiveTabType).toHaveBeenCalledWith('browser')
expect(state.closeBrowserTab).not.toHaveBeenCalled()
expect(state.setActiveWorktree).not.toHaveBeenCalled()
expect(state.createTab).not.toHaveBeenCalled()
})
it('deactivates after the last active terminal when no other content exists', () => {
const state = baseState()
getStateMock.mockReturnValue(state)
closeTerminalTab('terminal-1', { force: true })
expect(state.setActiveWorktree).toHaveBeenCalledWith(null)
expect(state.setActiveFile).not.toHaveBeenCalled()
expect(state.setActiveBrowserTab).not.toHaveBeenCalled()
expect(state.createTab).not.toHaveBeenCalled()
})
})

View File

@ -4645,7 +4645,17 @@
"87412c2a68": "Killed {{value0}} session.",
"a2f040ac1c": "Killed {{value0}} sessions.",
"63520148e2": "{{value0}} session refused to exit.",
"cc0a26cb14": "{{value0}} sessions refused to exit."
"cc0a26cb14": "{{value0}} sessions refused to exit.",
"71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.",
"2e57c1a940": "The daemon shutdown result is unverified because its management request failed.",
"993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.",
"1f0d8ac762": "Terminal cleanup finished with errors.",
"80b6ea14cf": "Terminal cleanup finished with warnings.",
"47cd2a50e9": "No sessions or terminal tabs were reported.",
"c34fb1098d": "Terminal tabs closed and shutdown requested.",
"d9657ac204": "Terminal session shutdown requested.",
"e8f25bd903": "Couldnt finish terminal cleanup.",
"a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
}
},
"setup": {

View File

@ -4645,7 +4645,17 @@
"87412c2a68": "Se terminó {{value0}} sesión.",
"a2f040ac1c": "Se terminaron {{value0}} sesiones.",
"63520148e2": "La sesión {{value0}} se negó a salir.",
"cc0a26cb14": "{{value0}} sesiones se negaron a salir."
"cc0a26cb14": "{{value0}} sesiones se negaron a salir.",
"71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.",
"2e57c1a940": "The daemon shutdown result is unverified because its management request failed.",
"993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.",
"1f0d8ac762": "Terminal cleanup finished with errors.",
"80b6ea14cf": "Terminal cleanup finished with warnings.",
"47cd2a50e9": "No sessions or terminal tabs were reported.",
"c34fb1098d": "Terminal tabs closed and shutdown requested.",
"d9657ac204": "Terminal session shutdown requested.",
"e8f25bd903": "Couldnt finish terminal cleanup.",
"a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
}
},
"setup": {

View File

@ -4645,7 +4645,17 @@
"87412c2a68": "{{value0}} 個のセッションを終了しました。",
"a2f040ac1c": "{{value0}} 個のセッションを終了しました。",
"63520148e2": "{{value0}} 個のセッションが終了を拒否しました。",
"cc0a26cb14": "{{value0}} 個のセッションが終了を拒否しました。"
"cc0a26cb14": "{{value0}} 個のセッションが終了を拒否しました。",
"71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.",
"2e57c1a940": "The daemon shutdown result is unverified because its management request failed.",
"993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.",
"1f0d8ac762": "Terminal cleanup finished with errors.",
"80b6ea14cf": "Terminal cleanup finished with warnings.",
"47cd2a50e9": "No sessions or terminal tabs were reported.",
"c34fb1098d": "Terminal tabs closed and shutdown requested.",
"d9657ac204": "Terminal session shutdown requested.",
"e8f25bd903": "Couldnt finish terminal cleanup.",
"a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
}
},
"setup": {

View File

@ -4645,7 +4645,17 @@
"87412c2a68": "{{value0}}개 세션을 종료했습니다.",
"a2f040ac1c": "{{value0}}개 세션을 종료했습니다.",
"63520148e2": "{{value0}}개 세션이 종료를 거부했습니다.",
"cc0a26cb14": "{{value0}}개 세션이 종료를 거부했습니다."
"cc0a26cb14": "{{value0}}개 세션이 종료를 거부했습니다.",
"71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.",
"2e57c1a940": "The daemon shutdown result is unverified because its management request failed.",
"993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.",
"1f0d8ac762": "Terminal cleanup finished with errors.",
"80b6ea14cf": "Terminal cleanup finished with warnings.",
"47cd2a50e9": "No sessions or terminal tabs were reported.",
"c34fb1098d": "Terminal tabs closed and shutdown requested.",
"d9657ac204": "Terminal session shutdown requested.",
"e8f25bd903": "Couldnt finish terminal cleanup.",
"a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
}
},
"setup": {

View File

@ -4645,7 +4645,17 @@
"87412c2a68": "已终止 {{value0}} 个会话。",
"a2f040ac1c": "已终止 {{value0}} 个会话。",
"63520148e2": "{{value0}} 个会话拒绝退出。",
"cc0a26cb14": "{{value0}} 个会话拒绝退出。"
"cc0a26cb14": "{{value0}} 个会话拒绝退出。",
"71a8d342b0": "Terminal tabs absent: {{value0}}/{{value1}}. Failed close attempts: {{value2}}. Exact PTY shutdown requests accepted: {{value3}}; failed: {{value4}}.",
"2e57c1a940": "The daemon shutdown result is unverified because its management request failed.",
"993af6052c": "Daemon management reported exited: {{value0}}/{{value1}}; still present before exact cleanup: {{value2}}.",
"1f0d8ac762": "Terminal cleanup finished with errors.",
"80b6ea14cf": "Terminal cleanup finished with warnings.",
"47cd2a50e9": "No sessions or terminal tabs were reported.",
"c34fb1098d": "Terminal tabs closed and shutdown requested.",
"d9657ac204": "Terminal session shutdown requested.",
"e8f25bd903": "Couldnt finish terminal cleanup.",
"a702d4196e": "This closes every terminal tab across all workspaces and requests shutdown for its current terminal sessions. Any unsaved terminal work is lost. The daemon itself keeps running, and new terminals can be opened immediately. This can't be undone."
}
},
"setup": {