mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Merge origin/main into aug23-sta5269-pty-owner-identity
This commit is contained in:
@@ -134,7 +134,8 @@ jobs:
|
||||
# scripts still need the Linux toolchain even though this shard reuses
|
||||
# the prebuilt Electron output.
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 zsh
|
||||
# Why: paired Quick Open coverage exercises the resource-bounded host search.
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
|
||||
|
||||
# Why: Electron on Linux needs an X display even when the app
|
||||
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
|
||||
|
||||
@@ -74,6 +74,7 @@ jobs:
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on macOS
|
||||
@@ -87,6 +88,7 @@ jobs:
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
|
||||
|
||||
- name: Run golden E2E tests on Windows
|
||||
@@ -97,6 +99,7 @@ jobs:
|
||||
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
|
||||
pnpm run --if-present test:e2e:workspace-session-golden
|
||||
pnpm run --if-present test:e2e:windows-fresh-startup-golden
|
||||
pnpm run --if-present test:e2e:tab-bar-agent-launch-golden
|
||||
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
|
||||
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
|
||||
}
|
||||
|
||||
@@ -673,6 +673,9 @@ jobs:
|
||||
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
|
||||
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
|
||||
|
||||
- name: Smoke packaged Windows PTY native capability
|
||||
run: pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe
|
||||
|
||||
- name: Smoke packaged CLI
|
||||
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/win-unpacked
|
||||
|
||||
@@ -707,38 +710,9 @@ jobs:
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED="$(git diff --name-only --diff-filter=AMCR --merge-base "$BASE" "$HEAD")"
|
||||
TEST_FILES="$(printf '%s\n' "$CHANGED" | grep -E '^tests/e2e/.*\.spec\.ts$' || true)"
|
||||
if printf '%s\n' "$CHANGED" | grep -Eq '^src/(main/ephemeral-vm-(runtime-(service|provisioning-persistence)|failed-start-cleanup)|shared/(ephemeral-vm-runtime-(store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$'; then
|
||||
TEST_FILES="$(printf '%s\n%s\n' "$TEST_FILES" 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: the Docker-SSH specs only ever ran when someone edited a spec, so four
|
||||
# pane-restore regressions shipped from SSH source edits that touched no test.
|
||||
# Unit tests are excluded because they prove the same source without Docker.
|
||||
# ssh-startup-exec-readiness.spec.ts is listed to route e2e.yml's dedicated
|
||||
# ssh-docker-watcher-isolation lane, which filters it out of changed-e2e.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(src/main/ssh/|src/main/providers/ssh-|src/main/ipc/(ssh-|pty)|src/relay/|src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts' \
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts' \
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts' \
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts' \
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts' | sort -u)"
|
||||
fi
|
||||
# Why: same lesson as the SSH specs above. The IME e2e suite asserts real pty bytes and
|
||||
# real overlay geometry, but only ran when someone edited a spec, so every IME fix in the
|
||||
# 1.4.18x window shipped without it - including one whose diagnosis hardware later refuted.
|
||||
# config/patches is listed because the terminal's input handling lives in the xterm patch.
|
||||
if printf '%s\n' "$CHANGED" | grep -Ev '\.test\.tsx?$' | grep -Eq '^(config/patches/|src/renderer/src/components/terminal-pane/(terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))'; then
|
||||
TEST_FILES="$(printf '%s\n' "$TEST_FILES" \
|
||||
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts' \
|
||||
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts' \
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts' \
|
||||
'tests/e2e/terminal-korean-composing-chord-order.spec.ts' \
|
||||
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts' \
|
||||
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts' \
|
||||
'tests/e2e/terminal-korean-preedit-visibility.spec.ts' | sort -u)"
|
||||
fi
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$TEST_FILES" | jq --raw-input --slurp --compact-output 'split("\n") | map(select(length > 0))')"
|
||||
# Source routes are executable contracts so a test can prove exact
|
||||
# authorities, exclusions, and sentinels without evaluating workflow shell.
|
||||
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
|
||||
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
|
||||
if [ "$TEST_FILES_JSON" != '[]' ]; then
|
||||
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+588
-32
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-08-20",
|
||||
"updatedAt": "2026-08-23",
|
||||
"policy": {
|
||||
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
|
||||
"blockingPromotion": {
|
||||
@@ -2674,7 +2674,7 @@
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-browser-link.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"result": "failed",
|
||||
"durationSeconds": 60,
|
||||
"durationSeconds": 53,
|
||||
"summary": "The byte-identical final Electron oracle failed at its exact host/client identity barrier when the sole source delta replaced src/renderer/src/store/index.ts registration lines 110-113 with registerRuntimeHttpLinkBrowserOpener(null); no host-owned browser appeared within the deterministic 60-second bound."
|
||||
}
|
||||
],
|
||||
@@ -3680,14 +3680,16 @@
|
||||
"providers": ["local", "daemon", "ssh", "runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "daemon", "ssh", "runtime"],
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. Deterministic tests cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
|
||||
"coverageNotes": "Live macOS Electron tests prove exact local PTY disappearance after parked-tab close and detached-pgid descendant death after agent close. A deterministic final-pane interleaving now proves durable tab retirement commits before externally owned PTY teardown can publish graph loss, and preserves an unrelated live canary through duplicate exit retirement. Deterministic tests also cover final-pane CLI close durability, persisted-empty restart suppression, same-incarnation handle continuity, replacement and hard graph-loss stale fencing, preallocated-handle supersession, daemon and SSH routing, local/daemon pending-snapshot ownership across natural exit, stale-root descendant-signal suppression, graceful-to-immediate kill upgrades, duplicate-kill completion sharing, locale-stable bounded/fresh/coalesced process-table reads, deadline-safe successor scans, cycle-safe linear descendant traversal, target-only escalation indexing, 32-wide bulk teardown, source-scan timestamp identity, same-second PID ambiguity, ordinary runtime close ownership, unified-only hydration, split ownership, pane detach transfer, restart alias hydration, and late-hook suppression; live Linux, Windows, WSL, SSH, and remote-runtime process evidence remains pending. A paired macOS Electron journey (real attached-window desktop host plus a separate real paired desktop client, isolated profiles) covers the inverse authority direction for host-created terminals: a renderer graph sync must not retire a tab that `orca terminal create` made. That claim now covers BOTH shapes, including the incident shape the real CLI produces - a repo's FIRST host-created terminal, since src/cli/handlers/terminal.ts sends no clientMutationId - each asserted by its own test. An earlier reading of this gate called a preceding host-created terminal and a `clientMutationId` protective; main-process instrumentation refuted that. The renderer's stale session write deleted the host tab from persistence in every variant, and only the delete's timing relative to the measured sync differed, so those rows were false negatives of a single-sync oracle. The defect was in persistence: persistPtyBinding did not raise the repo's terminal topology fence for the first host-admitted tab, leaving the renderer's pre-create tab list free to replay over it. Elapsed time and paired-client mirroring never decided anything. A windowless `orca serve` parity arm runs the identical oracle and passes on both the merge-base and the branch, confirming that topology does not exhibit the defect (the gate it turns on is window-conditioned) and that making the host binding unconditional did not regress it. All of this is macOS-local only. The reported incident occurred on a physical Windows host; we did not run against one. The claim that the mechanism is platform-independent rests on the authoritative-window gate and publication-epoch inheritance being OS-agnostic, not on Windows evidence, so no ConPTY, Windows window-lifecycle, update, or sleep behavior is covered here.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4327",
|
||||
"https://github.com/stablyai/orca/pull/14590",
|
||||
"https://github.com/stablyai/orca/pull/8628",
|
||||
"https://github.com/stablyai/orca/pull/8706",
|
||||
"https://github.com/stablyai/orca/issues/14524"
|
||||
],
|
||||
"invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close does not acknowledge before durable tab retirement, and a persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
|
||||
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must kill the exact PTY once, invoke one durability-acknowledged tab retirement, stay pending until the persisted row is removed, and never fall back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
|
||||
"invariant": "Close permanently removes the owned provider session, agent descendants, persisted tab/layout authority, and resume authority even when no TerminalPane is mounted; final-pane CLI close commits durable tab retirement before PTY stop can publish graph loss, does not acknowledge before that retirement, and treats the later exit retirement as idempotent. A persisted explicit-empty state prevents initial-tab fallback from repopulating the workspace after reload or restart. A handle remains valid while the same provider-attested PTY incarnation survives renderer reload or re-key, and becomes stale without adopting a replacement incarnation. A terminating id remains reserved through natural exit, duplicate callers await the same completion, and immediate teardown upgrades any graceful request without signalling a recycled PID or a descendant tree after root ownership is lost; process-table work is locale-stable, bounded, fresh for each post-start request, same-turn coalesced, and begins within the requesting caller's deadline, including bulk worktree cleanup; detach and park preserve ownership; aliases prevent a detached agent's immutable physical pane key from being retired with its former tab.",
|
||||
"oracle": "Capture exact tab, pane, PTY, handle, incarnation, and persisted layout identities. Final-pane terminal.close must invoke one durability-acknowledged tab retirement before exact PTY teardown; controlled PTY exit and graph removal afterward must remain idempotent, omit the target from persistence and provider inventory, and leave an unrelated canary handle live. The ordinary path stays pending until the persisted row is removed and never falls back to the fire-and-forget pane event; reload/restart must keep the explicitly emptied worktree at zero terminal tabs and provider sessions. Reloading the renderer with the same tab/pane/PTY/incarnation must keep the handle readable; changing only the incarnation behind the same PTY id, losing the authoritative graph, or superseding the renderer handle with a preallocated handle must reject the old handle with terminal_handle_stale. Parked-close tests prove the exact PTY disappears; descendant/process tests keep natural exits reserved, upgrade teardown safely, bound/coalesce process-table work, and protect recycled identities.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal/initial-terminal.test.ts src/renderer/src/lib/worktree-activation-default-tabs.test.ts src/renderer/src/store/slices/terminals-explicit-empty-hydration.test.ts",
|
||||
@@ -3733,6 +3735,7 @@
|
||||
"file": "src/main/runtime/orca-runtime-terminal-close-continuity.test.ts",
|
||||
"assertions": [
|
||||
"final-pane terminal.close remains pending until one renderer durability acknowledgement retires the persisted tab and layout",
|
||||
"final-pane externally owned teardown commits session retirement before PTY stop, tolerates duplicate exit retirement, and preserves an unrelated live canary",
|
||||
"post-ack workspace-session loss falls back to renderer cleanup and verified PTY teardown while every pre-ack retirement error remains non-destructive",
|
||||
"renderer reload with the same provider-attested PTY incarnation preserves the exact handle",
|
||||
"same-id replacement incarnation publishes a new handle and makes the old handle deterministically stale",
|
||||
@@ -3913,7 +3916,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2."
|
||||
"evidence": "STA-4327 used two byte-identical oracles. Runtime oracle cd248b67cc10ef64f2def315b86c3b0236b102639d03c65a0c5d9519e538d3af failed the same three durability/continuity checks on reported v1.4.180 (0b62333cf1), latest main (9bb8836bb6), and disabled fix; runtime candidate 3cf8a37447 passed 5/5, and final code candidate 121e112d77 separately passed its expanded 6/6 oracle. Renderer oracle 3b0949eef8b792cf5b09cfa55341f4216c08714f158e4b132e988e8114636cea failed both explicit-empty hydration/fallback checks on v1.4.180, latest main, and disabled fix, while renderer candidate 121e112d77 passed 2/2. The 1.4.187 recurrence oracle deterministically failed with historical tab_not_found when PTY stop was moved ahead of durable retirement, then passed with retirement committed before externally owned teardown while the canary remained live."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -8047,6 +8050,7 @@
|
||||
"remote-runtime host surface materialization",
|
||||
"remote-runtime mirror polling",
|
||||
"remote-runtime network recovery",
|
||||
"paired client sleep/wake reconnect",
|
||||
"terminal create idempotency",
|
||||
"provider listing",
|
||||
"reattach",
|
||||
@@ -8059,6 +8063,8 @@
|
||||
"coveredProviders": ["ssh", "remote-runtime"],
|
||||
"coverageNotes": "Deterministic renderer coverage proves startup publishes the state returned by ssh.connect, retained native and runtime SSH payloads are admitted through production routes only with valid complete authority, stale cleanup cannot unregister a replacement runtime terminal, direct SSH Git and folder panes clear and retry by exact authority, one authority chain stops after two automatic attempts even when each timeout exceeds the rolling window, rejected acknowledgements mutate no store maps, and one shared exact attempt admits every concurrent split-pane spawn and reattach while preserving the first PTY as the tab fallback. A later sibling failure rotates the tab once, stale callbacks from the prior attempt mutate no state, split remount activity suppression is counted per leaf, primary PTY exit promotes a bound survivor or preserves an empty continuation gap for a late sibling, and primary, non-primary, or null-PTY detach preserves exact authority on both resulting tabs. Intentional pane disposal cancels its settlement timer without breaking StrictMode remount timeout ownership. Target snapshot hydration/reconnect preserves sibling SSH/local/WSL/runtime state, and a mounted remote-runtime terminal survives repeated transport partitions without changing PTY identity. A real encrypted-WebSocket oracle proves a successful reachability probe can replace a pre-ready shared-control socket without rejecting or duplicating the waiting RPC. Direct SSH coordinator tests cover immediate terminal finalization, hydration correction, damping, bounded retry, and telemetry non-interference. Client/server heartbeat tests cover timer suspension, socket generations fence stale callbacks, cold restored-terminal attachment retries, cached pixels remain unhealthy until authoritative replay, automatic retries stop after one minute, manual reconnect preserves the PTY, and pane closure releases recovery UI state. Current macOS Electron journeys against an ephemeral Linux Docker SSH target cover exact-authority repo/worktree hydration, live terminal recovery after disconnect/reconnect, and eager six-terminal remount after renderer reload. A Windows remote-runtime smoke covers reachability and PTY round-trip. Multi-target live fanout, paired-close, WSL, and patched live partition journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-3107",
|
||||
"https://github.com/stablyai/orca/pull/12664",
|
||||
"https://github.com/stablyai/orca/pull/6951",
|
||||
"https://github.com/stablyai/orca/pull/6955",
|
||||
"https://github.com/stablyai/orca/pull/6979",
|
||||
@@ -8070,6 +8076,7 @@
|
||||
"oracle": "Deterministic tests cover bounded stale-handle replacement, suspended heartbeat clocks, cold and established subscription failure, ten partition/recovery cycles, automatic-recovery cutoff, manual reconnect, and exact direct SSH binding recovery. They assert one atomic store publication clears only exact-target PTY indexes, null-PTY activation remains unchanged, relay identity survives, Git and folder panes retry symmetrically, another target/local/WSL/runtime panes remain byte-identical through target snapshot hydration and reconnect, only an accepted exact failure or timeout starts the second attempt, two 31-second timeouts cannot start a third settlement-triggered attempt, rejected stale/mismatched acknowledgements preserve every store map, and concurrent split-pane spawn and reattach callbacks both commit under the same attempt ID after the first success replaces pending state with live shared authority. A sibling failure revokes that shared authority and starts exactly one second attempt; duplicate failures and late first-attempt PTY callbacks preserve the second attempt and every state map. Attempt-two failure retains continuation authority for later siblings, primary exit promotes a bound survivor or preserves the lease until a late sibling binds, and primary plus non-primary detach retain exact authority and history on both resulting tabs. Both remount callbacks consume split-count activity suppression, intentional dispose emits no failure/timeout, and a same-attempt StrictMode remount still owns one timeout. Hydration clears an untrusted PTY hint without clearing its current pending owner, healthy current-authority bindings suppress correction, hydration finalizes once, and reconnect emits no paired close lifecycle. A provider-level session-ID reattach returns an incarnation, then a legacy exit without an incarnation must resolve to that returned identity rather than minting a fallback identity. The shared-control oracle withholds the first encrypted ready frame, starts one RPC, triggers the successful-probe refresh, then requires exactly two client connections, one host request, a successful response, zero pending calls, and zero retained request bytes. Tests also assert one unsubscribe per remote-runtime epoch, observable recovery phases, stable PTY identity, resumed snapshot/output/input, no healthy state before replay, no retry or input after cutoff, a new manual epoch against the same PTY, quiet recovery UI with an explicit Reconnect action, pane-close state cleanup, one stable create mutation id, old-runtime no-retry behavior, cross-process PTY adoption, and bounded in-flight coordination.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/startup/ssh-startup-reconnect.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts src/renderer/src/runtime/sync-runtime-graph-scheduling.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-spawn-retry.test.ts src/renderer/src/components/terminal-pane/pty-connection-direct-ssh-reattach-retry.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-session-terminal-handle-events.test.ts src/renderer/src/store/slices/terminal-pty-identity-replacement.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport-reattach-admission.test.ts src/renderer/src/components/terminal-pane/pty-transport-detach-attach-handoff.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-attach-subscription.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-surface-replacement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-stream-reconnect.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-expired-pane-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-outcome-recovery.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-create-handoff.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts src/renderer/src/components/terminal-pane/TerminalRemoteRuntimeReconnectBanner.test.tsx src/renderer/src/components/terminal-pane/terminal-remote-runtime-recovery-ui-state.test.ts src/shared/remote-runtime-socket-liveness.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/shared/remote-runtime-shared-control-socket-generation.test.ts src/shared/remote-runtime-client-error-classification.test.ts src/main/runtime/rpc/remote-runtime-server-heartbeat.test.ts src/main/runtime/rpc/methods/terminal-create-idempotency.test.ts src/main/runtime/orca-runtime-terminal-create-idempotency.test.ts",
|
||||
@@ -8084,6 +8091,8 @@
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/providers/ssh-pty-provider-reattach-incarnation.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts",
|
||||
"src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts",
|
||||
"src/renderer/src/startup/ssh-startup-reconnect.test.ts",
|
||||
"src/renderer/src/lib/resolved-worktree-execution-host.test.ts",
|
||||
"src/renderer/src/components/terminal/background-terminal-worktree-mount.test.ts",
|
||||
@@ -8142,6 +8151,20 @@
|
||||
"tests/e2e/ssh-docker-relay-perf.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts",
|
||||
"assertions": [
|
||||
"six panes sharing one dropped multiplex connection each resubscribe to a distinct live host handle",
|
||||
"a pane parked while the client slept is authoritatively activated and rematerialized without leaving any pane handle null"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts",
|
||||
"assertions": [
|
||||
"after a long sleep every live host PTY and tab retains its client-owned sidebar agent row",
|
||||
"unwritten or explicitly released pane authority still cedes deletion to the host"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/startup/ssh-startup-reconnect.test.ts",
|
||||
"assertions": [
|
||||
@@ -8471,6 +8494,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/paired-reconnect-multi-pane-materialization.test.ts src/renderer/src/runtime/paired-reconnect-sidebar-agent-count.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.54,
|
||||
"summary": "Seven deterministic STA-3107 contracts passed across six-pane multiplex rematerialization and client-owned sidebar-row retention after sleep/wake."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-14",
|
||||
"runner": "local",
|
||||
@@ -8554,7 +8586,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion."
|
||||
"evidence": "The remote-runtime fault tests failed before the recovery changes by leaving a cold restored subscription detached, reporting connected before authoritative replay, delivering a fatal setup error twice, and allowing an unknown create outcome to spawn again after process-local state was lost. For STA-3107, reverting #11542 in the six-pane multiplex harness leaves exactly one parked pane without a resubscribed handle while its siblings recover; before #12664, the first post-sleep host snapshot erases every stale client-owned sidebar row despite live tabs and host PTYs. The deterministic contracts pass after host activation rematerializes the missing pane and client-written ownership, rather than freshness, controls row retention. On exact pre-fix HEAD 939719443, the split-pane store, fresh-spawn, and reattach oracles failed because the first success removed pending authority and the sibling could not bind; the same three oracles pass after live bindings retain the exact attempt ID. On exact committed HEAD d501f2e96, primary-exit-before-sibling and primary/non-primary split-detach oracles failed because live authority was deleted during the empty gap or transferred to only one resulting tab; all three pass after continuation-gap preservation and two-sided detach projection. On exact committed HEAD d44ea382b with test-only oracles, a pending-only all-null detach deleted the attempt ledger, a sibling mounted after first success committed without its lease, and that sibling could bind after authority rotation; all three pass after pending-only projection and retained-live-lease capture with provider retirement. On exact committed HEAD 9fa84dacf, the production-manager in-progress oracle rejected the fresh authority and the forward-removal barrier delayed stale transport cancellation; both pass after replacement starts cancellation immediately, shares concurrent fresh callers, and waits for teardown before connecting. On exact committed HEAD e5ba9a9e5, overlapping disconnect allowed a replacement connect before forward teardown completed and delayed transport cancellation behind that barrier; disconnect, removal, and terminate now share a target lifecycle barrier, start transport cancellation immediately, retain captured-session identity, and admit the replacement only after cleanup. On exact committed HEAD 9a29e7a81, a rejected forward teardown short-circuited the lifecycle while transport disconnect was pending, removal left its captured relay session alive, same-turn connect escaped admission, and reset remained outside the target barrier; the exact failure oracles pass after both cleanup branches settle, captured sessions always retire, admission is authority-fenced, and reset shares the barrier. On exact committed HEAD 47c7198f2, reset's remaining bespoke forward teardown could still reject after authority rotation but before captured-session retirement; reset now uses the same hardened session teardown and a failed reset remains cleanly retryable. Other direct SSH tests encode red conditions for non-atomic binding clear, cross-target retry, folder omission, duplicate same-authority attempts, hydration overwrite, terminal finalization delayed behind provider work, obsolete-authority pending-spawn adoption, Git lineage namespace mismatch, and snapshot PTY hint promotion without exact-attempt acknowledgement. Existing SSH and stale-handle reattach coverage remains green. Needs WSL, paired-close, and a patched live remote-runtime partition journey before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -9878,22 +9910,27 @@
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract, live Codex orchestration, a dev-runtime CLI repro, and Claude and Codex composer traces. A deterministic worker-start RPC/SQLite contract proves delayed Codex composer acceptance, exactly one submitted turn, durable Dispatch success, and explicit stalled failure when Enter is swallowed without a rescue send. The render gate stays on the PTY-owning runtime, so paired and SSH callers use the same provider write/output path without a wire change. SSH, daemon, remote-runtime, Linux, and Windows live journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-3800",
|
||||
"https://linear.app/stably/issue/STA-3904",
|
||||
"https://linear.app/stably/issue/STA-4328",
|
||||
"https://github.com/stablyai/orca/issues/7226",
|
||||
"https://github.com/stablyai/orca/issues/13821",
|
||||
"https://github.com/stablyai/orca/issues/14347"
|
||||
],
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. Orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-prompt-injection.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
|
||||
"src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"tests/tools/repro-orchestration-long-prompt.mjs"
|
||||
],
|
||||
@@ -9928,6 +9965,13 @@
|
||||
"failed prompt injection rolls back the active dispatch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
|
||||
"assertions": [
|
||||
"delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts",
|
||||
"a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"assertions": [
|
||||
@@ -9945,6 +9989,15 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 21.84,
|
||||
"summary": "Two deterministic worker-start RPC contracts passed with fake clocks and reopened SQLite receipts for one accepted turn and one swallowed-Enter stalled outcome."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
@@ -10001,7 +10054,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
"evidence": "The original Claude-shaped oracle fails on unmodified main and passes with the render gate. The hardened multi-frame oracle then fails on the first-marker candidate, which submits at 751 ms during an intermediate frame, and passes when the gate requires post-marker quiescence. Codex used the legacy platform delay before this candidate; the late-marker oracle proves the original deadline would pre-empt slow-host settlement without re-arming. The worker-start negative controls caught both premature submission before composer readiness and a false-ready swallowed Enter; the accepted candidate records one started turn, while the swallowed arm records agent_prompt_stalled without a rescue Enter. The earlier live harness also reproduced the unsafe raw multiline contract before its framing fix. Intentional-break evidence remains local rather than a separate CI job."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -12284,7 +12337,7 @@
|
||||
"id": "terminal-render.pixel-refresh-repair",
|
||||
"title": "Forced repaint does not change revealed pane pixels",
|
||||
"maturity": "experimental",
|
||||
"protection": "none",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "electron-visual-golden",
|
||||
"surfaces": [
|
||||
@@ -12296,47 +12349,207 @@
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon"],
|
||||
"coveredPlatforms": [],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Registered gap only; the live repro harness with a refresh-repair oracle exists but is not productized into the release-blocking terminal-rendering-golden suite.",
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A headed macOS Electron test drives explicit destructive atlas recovery while DEC 2026 output is held, then compares compositor screenshots before and after a direct full-buffer present. The same renderer choreography is provider-local, but daemon, SSH, remote-runtime, Linux, and Windows GPU runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7133",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "After any reveal, restore, or wake, the pixels a pane displays must equal what a forced full model-invalidating repaint would draw from the buffer; a material refresh-repair diff means stale rendering escaped, whatever the mechanism.",
|
||||
"oracle": "Screenshot the revealed pane, force a model-invalidating full repaint, screenshot again, and assert the bounded pixel diff is immaterial; pair with a JS-level render-model-vs-buffer comparison. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"invariant": "After proven renderer damage triggers destructive recovery, visible pixels equal a full model-invalidating present of the current buffer.",
|
||||
"oracle": "Hold DEC 2026, invoke explicit atlas recovery, require an atlas clear, screenshot the pane, force a direct renderer present, and assert the bounded pixel diff is immaterial. Compositor screenshots only, because WebGL canvas readback is blank with preserveDrawingBuffer disabled.",
|
||||
"commands": [
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts",
|
||||
"assertions": [
|
||||
"deduplicates a deferred dpr retry and consumes it after repair succeeds",
|
||||
"caps a deferred dpr transition at sixteen animation-frame retries"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"assertions": ["defers a heavy-reveal dpr present until the shared atlas recovery"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts",
|
||||
"assertions": [
|
||||
"repairs when the canvas and renderer dimensions share a stale dpr cache",
|
||||
"defers an unmeasurable WebGL canvas but accepts a renderer-less pane"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
|
||||
"assertions": ["@headful atlas recovery presents a synchronized-output WebGL frame"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-canvas-dpr-repair.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2,
|
||||
"summary": "All 98 renderer contracts passed, including same-DPR zero-scan behavior, visible-only fanout, tri-state DPR repair, deduplicated bounded retry, heavy-reveal repair, and listener cleanup."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 60,
|
||||
"summary": "Both headed oracles passed together. Explicit destructive atlas recovery presented the held synchronized-output buffer with zero pixels different from a direct renderer present; the test attached before/after screenshots plus pixel-diff metadata."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "ORCA_E2E_RECORD_VIDEO=1 pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --repeat-each=3",
|
||||
"result": "passed",
|
||||
"durationSeconds": 48,
|
||||
"summary": "Three consecutive headed real-WebGL runs passed in 15.0-15.7 seconds each and emitted a 1920x1080 video per BrowserWindow."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 300,
|
||||
"scope": "release-blocking golden Electron visual test"
|
||||
"p95Seconds": 120,
|
||||
"scope": "focused prebuilt headed Electron visual test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Runs nightly only while the per-platform diff threshold stabilizes; the destination is the release-blocking terminal-rendering-golden suite in release-cut.yml."
|
||||
"status": "unknown",
|
||||
"evidence": "Five local macOS headed runs passed, including one on the final branch state and three consecutive video-recorded repeats; CI soak history and cross-platform GPU runs are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "missing",
|
||||
"evidence": "Needs an intentionally fossilized render model (skip the model invalidation on reveal) proving the refresh-repair diff fires."
|
||||
"status": "complete",
|
||||
"evidence": "Without the forced synchronized-output present, the damaged compositor differed from a direct renderer present across 87.68% of pixels even though serialization already contained the new frame. With explicit destructive recovery, the same oracle passes the 1.5% material-diff threshold."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Soak-only; must not add product-path repaints beyond the existing reveal hardening."
|
||||
"evidence": "Destructive recovery remains event-driven and limited to confirmed renderer-lifecycle boundaries. Gate-local contracts prove ordinary same-DPR resize performs zero pane scans; a true transition scans only the visible manager; unavailable canvas retry is deduplicated and capped at 16 frames; and heavy reveal adds one bounded visible-pane scan. The shared terminal-render.atlas-recovery-fanout contracts separately prove zero hidden-manager atlas clears or presents and a per-pane display:none retry capped at 16 frames."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Productize the existing stale-render repro harness with the refresh-repair oracle and failure artifacts.",
|
||||
"Establish a stable material-diff threshold across platforms via nightly runs.",
|
||||
"Promotion means membership in the release-blocking terminal-rendering-golden suite that release-cut.yml runs against every release tag; never PR-blocking.",
|
||||
"Register the gate command as a direct spec-file invocation; the checker rejects --grep tag selectors, so do not reuse the existing --grep-based golden script as the command."
|
||||
"Add Linux and Windows GPU evidence or retain those platform gaps explicitly."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No manifest command yet.",
|
||||
"Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest."
|
||||
"The deterministic recovery test disables xterm's one-second safety timeout so the held-frame damage remains stable until recovery.",
|
||||
"The renderer-level frame injection isolates the paint failure; provider delivery and hidden-output snapshot restore have separate gates.",
|
||||
"Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded."
|
||||
],
|
||||
"demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners."
|
||||
},
|
||||
{
|
||||
"id": "terminal-render.synchronized-reveal-coherence",
|
||||
"title": "Ordinary reveal preserves synchronized-output frame coherence",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-rendering",
|
||||
"layer": "electron-visual-golden",
|
||||
"surfaces": ["WebGL rendering", "tab reveal", "snapshot restore", "DEC 2026"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A headed macOS Electron test uses a real xterm WebGL renderer and production tab reveal. It proves the coherent pre-hide canvas remains visible while DEC 2026 holds a newer buffer, then proves normal release and xterm's one-second watchdog publish complete frames. Renderer behavior is provider-neutral; live Linux, Windows, daemon, SSH, and remote-runtime GPU runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7133",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "terminal-render.synchronized-reveal-coherence: an ordinary reveal never clears a healthy shared atlas or publishes a partially built DEC 2026 frame; it retains the last coherent canvas until release or xterm's bounded watchdog.",
|
||||
"oracle": "Paint and capture a coherent frame, hide the real terminal tab, build a distinct full-screen frame while DEC 2026 is held, reveal through production, and require zero atlas clears plus pixel-equivalence with the coherent frame. Then release normally and through the one-second watchdog, requiring both compositor frames to match a direct renderer present.",
|
||||
"commands": [
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"tests/e2e/terminal-reveal-paused-render-repro.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts",
|
||||
"assertions": [
|
||||
"schedules an atlas-preserving present on a light tab reveal",
|
||||
"keeps heavy atlas recovery off a plain window refocus"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/pane-webgl-renderer.test.ts",
|
||||
"assertions": ["preserves a synchronized frame on an ordinary reveal present"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/pane-manager/terminal-render-pause-release.test.ts",
|
||||
"assertions": [
|
||||
"routes synchronized output through RenderService instead of the renderer",
|
||||
"releases observer pause before requesting the synchronized frame"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-reveal-paused-render-repro.spec.ts",
|
||||
"assertions": [
|
||||
"@headful reveal preserves the coherent frame until synchronized output releases"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-reveal-paused-render-repro.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1",
|
||||
"result": "passed",
|
||||
"durationSeconds": 53,
|
||||
"summary": "Both final-head headed oracles passed together. The coherent held frame differed by 73 of 1,404,288 pixels (0.0052%), ordinary reveal performed zero atlas clears, normal release differed from the direct present by 95 pixels (0.0068%), and watchdog release matched exactly. All three ratios passed the 1.5% compositor threshold."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 120,
|
||||
"scope": "focused prebuilt headed Electron prevention and watchdog test"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "One final watchdog-inclusive macOS headed run passed; CI soak history and cross-platform GPU runs are not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Before the prevention change, the ordinary reveal oracle failed deterministically because it observed three atlas clears. After the change, reveal performs zero clears and the first visible frame remains pixel-equivalent to the coherent pre-hide frame."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Ordinary reveal removes three shared-atlas clears and their global visible-manager presents. It adds one settled, event-driven full-viewport refresh request per revealed pane; DEC 2026 coalesces it and uses xterm's existing one-second watchdog. No polling, subprocess, provider call, PTY inventory, background-output work, listener, or unbounded retry was added; the display-none retry remains deduplicated and capped at 16 frames."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Accumulate stable macOS runs for both normal release and watchdog release.",
|
||||
"Add Linux and Windows GPU evidence or retain those platform gaps explicitly.",
|
||||
"Keep the destructive atlas-recovery oracle separate and passing."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The deterministic test injects full-screen marker frames through xterm rather than invoking a live model; genuine OpenCode full-window evidence separately proves the original corruption.",
|
||||
"Linux, Windows, daemon, SSH, and remote-runtime live GPU evidence is not yet recorded.",
|
||||
"The 1.5% compositor threshold tolerates cursor and shell-prompt timing noise; exact buffer content and atlas-clear counts are asserted separately."
|
||||
],
|
||||
"demotionRule": "Demote if ordinary reveal clears the shared atlas, the held frame stops matching its coherent baseline, release/watchdog output diverges from a direct present, or the headed oracle cannot remain deterministic."
|
||||
},
|
||||
{
|
||||
"id": "pty-delivery.renderer-lifecycle-accounting-reset",
|
||||
"title": "Renderer lifecycle reset clears leaked PTY delivery accounting so surviving PTYs cannot freeze",
|
||||
@@ -14305,6 +14518,349 @@
|
||||
"No design can recover a PTY master after its actual owning process dies."
|
||||
],
|
||||
"demotionRule": "Demote if any missing owner, route, adapter, session incarnation, or transport is interpreted as exit; if a reused logical id is retired by an older incarnation; or if guarded recovery clears topology or spawns a shell solely because ownership is unverifiable."
|
||||
},
|
||||
{
|
||||
"id": "terminal-startup.quick-command-pre-bind-recovery",
|
||||
"title": "Quick Command survives a pre-bind terminal recovery",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-renderer-lifecycle",
|
||||
"layer": "electron-ui-and-real-local-pty",
|
||||
"surfaces": ["Quick Commands", "terminal pane recovery", "fresh PTY startup"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local"],
|
||||
"coverageNotes": "A forced pre-spawn barrier makes one visible Quick Command cross a recovery remount before its successor PTY binds. Store and lifecycle contracts retain lower-layer ownership; the Electron sentinel adds the rendered affordance, real PTY, exact-once terminal marker, staged identity file, and unrelated sibling canary.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4876",
|
||||
"https://github.com/stablyai/orca/pull/15630"
|
||||
],
|
||||
"invariant": "A Quick Command selected for a new terminal remains owned by that tab through a recovery remount, executes exactly once only after the successor PTY binds, and never reaches an unrelated terminal.",
|
||||
"oracle": "Arm the E2E-only pre-spawn barrier, click the visible Quick Command, force one recovery generation while the original spawn is blocked, and release it. Require one exact semantic xterm marker, an independently written pane/tab identity file matching the bound live PTY, queue consumption only after bind, and a live interactive sibling canary.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts src/renderer/src/store/slices/terminal-startup-command-retention.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/pty-connection-queued-startup-consume.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-startup-command-retention.test.ts",
|
||||
"tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/terminal-startup-command-retention.test.ts",
|
||||
"assertions": ["recovery remount retains the exact queued startup command"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts",
|
||||
"assertions": [
|
||||
"the visible Quick Command executes exactly once in the intended fresh PTY",
|
||||
"queue consumption follows PTY binding and the sibling terminal remains live"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"durationSeconds": 23,
|
||||
"summary": "The exact rebased PR head passed the headless Electron flow in 23.0 seconds after an E2E-mode build."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "ORCA_E2E_FORCE_HEADFUL=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"durationSeconds": 24,
|
||||
"summary": "The exact rebased PR head passed the visible-window Electron flow in 23.9 seconds using the same E2E build."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 90,
|
||||
"scope": "one isolated Electron app, one forced recovery remount, and two local PTYs"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The new sentinel has local headless and forced-headful evidence but no routed CI soak history."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical spec failed with pending:null before PTY bind when the pre-6e25a900 eager-consume behavior was restored, then passed after restoration."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "The barrier exists only in E2E builds, adds no production timer or polling, and the live sentinel completed in under 20 seconds locally."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes over at least 14 days without an unexplained flake.",
|
||||
"Keep retries at zero and retain both semantic and identity oracles.",
|
||||
"Collect Linux Electron evidence before promoting the focused lane to blocking."
|
||||
],
|
||||
"knownGaps": [
|
||||
"Windows, Linux, SSH, WSL, and paired-runtime topologies are not covered because they were not causal to STA-4876.",
|
||||
"The sentinel remains advisory while the repository-wide E2E signal is red."
|
||||
],
|
||||
"demotionRule": "Demote if the barrier can be bypassed nondeterministically, either independent oracle is removed, the sibling canary is no longer isolated, or one unexplained flake occurs."
|
||||
},
|
||||
{
|
||||
"id": "quick-open.paired-host-path-search",
|
||||
"title": "Paired Quick Open searches bounded host paths",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "runtime-filesystem",
|
||||
"layer": "host-file-search-and-paired-runtime",
|
||||
"surfaces": ["Quick Open", "paired desktop runtime", "headless runtime host"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "ssh", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "paired-runtime"],
|
||||
"coverageNotes": "Focused contracts cover bounded host search, ignore traversal, RPC response bounds, renderer cancellation, deterministic ordering, and transport payload size. Headed and headless paired-runtime journeys use an 88,763-file fixture and are source-routed into advisory PR E2E; physical SSH and Windows remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-4354",
|
||||
"https://github.com/stablyai/orca/pull/15158"
|
||||
],
|
||||
"invariant": "Quick Open queries execute on the worktree host, find exact ignored-path matches beyond the legacy inventory prefix, return deterministic bounded results, and keep the paired runtime usable without overflowing its outbound reply buffer.",
|
||||
"oracle": "Create 88,763 paths with 77,792 ignored entries and a nominal 22 GiB sparse file, query exact late gitignored and .orcaignored targets through headed and headless paired runtimes, and require one deterministic match, a payload below 4 KiB, and a healthy follow-up worktree call.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/paired-quick-open-large-tree.spec.ts --config tests/playwright.config.ts --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/filesystem-search-file-paths.test.ts",
|
||||
"src/main/runtime/orca-runtime-files-search.test.ts",
|
||||
"src/main/runtime/rpc/methods/files-path-search.test.ts",
|
||||
"src/renderer/src/components/quick-open-file-list.react.test.tsx",
|
||||
"src/renderer/src/runtime/runtime-file-client-search-listing.test.ts",
|
||||
"src/shared/quick-open-transport-budget.test.ts",
|
||||
"tests/e2e/paired-quick-open-large-tree.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/ipc/filesystem-search-file-paths.test.ts",
|
||||
"assertions": [
|
||||
"host search stays bounded and respects gitignore plus .orcaignore traversal"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/runtime/runtime-file-client-search-listing.test.ts",
|
||||
"assertions": ["paired path queries stay deterministic and bounded across transport"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-quick-open-large-tree.spec.ts",
|
||||
"assertions": ["headed and headless hosts find late ignored targets and remain callable"]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/filesystem-search-file-paths.test.ts src/main/runtime/orca-runtime-files-search.test.ts src/main/runtime/rpc/methods/files-path-search.test.ts src/renderer/src/components/quick-open-file-list.react.test.tsx src/renderer/src/runtime/runtime-file-client-search-listing.test.ts src/shared/quick-open-transport-budget.test.ts --reporter=dot",
|
||||
"durationSeconds": 5,
|
||||
"summary": "Six focused files passed 52 tests across host search, runtime RPC, renderer query ownership, and transport bounds; existing React act warnings remained non-fatal."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 300,
|
||||
"scope": "focused deterministic contracts plus two serial paired Electron large-tree journeys"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The sentinel is newly source-routed for PR collection; no route-specific soak history exists yet."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The merged STA-4354 oracle places both targets beyond the removed inventory prefix; the old client-side prefix inventory misses them, while host search returns the exact deterministic paths."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Search is query-driven, result-limited, cancellation-aware, and payload-bounded; PR routing selects this fixture only for its exact production authorities or direct spec edits."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
|
||||
"Collect physical SSH and Windows paired-runtime evidence.",
|
||||
"Keep deterministic result hashes, payload bounds, and post-query runtime health green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live large-tree evidence uses local headed and headless hosts rather than physical SSH.",
|
||||
"Windows path-search tooling and filesystem behavior are not represented by the local evidence."
|
||||
],
|
||||
"demotionRule": "Demote if the routed fixture flakes without a diagnosed product or harness cause, returns unbounded payloads, misses either late ignored target, or leaves the paired runtime unhealthy."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.host-cold-park-stream-continuity",
|
||||
"title": "Host cold parking preserves paired terminal streams",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime-graph",
|
||||
"layer": "renderer-runtime-graph-and-terminal-stream",
|
||||
"surfaces": ["host terminal cold park", "paired remote viewer", "multi-pane runtime graph"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local-daemon", "ssh-daemon", "paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local-daemon", "paired-runtime"],
|
||||
"coverageNotes": "Deterministic policy and multiplex tests separate renderer parking from authoritative stream liveness, while runtime-graph tests cover exact parked leaf, pane runtime ID, title, multi-pane active-leaf, and disposal behavior. One headed paired journey proves input and echoed output while the host pane remains cold-parked.",
|
||||
"motivatingLinks": [
|
||||
"https://linear.app/stably/issue/STA-2854",
|
||||
"https://github.com/stablyai/orca/pull/15514"
|
||||
],
|
||||
"invariant": "Cold parking a host renderer pane never retires its live PTY's runtime-graph leaf or interrupts a paired subscriber's stream, input, reconnect, pane identity, or multi-pane routing; the leaf retires only when exact PTY ownership ends.",
|
||||
"oracle": "Cold-park a host-owned pane while a paired client actively views it, require the host manager to unmount, then type through the client and require the same PTY to receive and echo the token without any disconnected sample. Separately publish multi-pane parked leaves with exact pane runtime IDs and require per-PTY disposal to remove only the retired leaf.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/host-parked-pane-remote-viewer.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
|
||||
"src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
|
||||
"tests/e2e/host-cold-park-remote-subscriber.unit.test.ts",
|
||||
"tests/e2e/host-parked-pane-remote-viewer.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts",
|
||||
"assertions": [
|
||||
"a live parked watcher publishes the exact leaf, pane runtime ID, and title",
|
||||
"multi-pane publication never names an unsent active leaf and exact disposal retires ownership"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/host-cold-park-remote-subscriber.unit.test.ts",
|
||||
"assertions": [
|
||||
"a fully unmounted host renderer still streams, accepts input, and resubscribes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/host-parked-pane-remote-viewer.spec.ts",
|
||||
"assertions": [
|
||||
"a cold-parked host pane carries a complete paired-client input and echo round trip"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/runtime/sync-runtime-graph-parked-leaf.test.ts tests/e2e/host-cold-park-remote-subscriber.unit.test.ts --reporter=dot",
|
||||
"durationSeconds": 8,
|
||||
"summary": "Three focused files passed 64 watcher, multi-pane runtime-graph, cold-park policy, and authoritative-stream tests."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 180,
|
||||
"scope": "focused deterministic contracts plus one serial headed paired cold-park journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The existing sentinel is newly source-routed for PR collection; route-specific CI history has not started."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "On the affected STA-2854 path, cold parking removed the runtime-graph leaf and the paired stream disconnected; retaining the exact live parked watcher leaf keeps every sampled client phase connected and completes input plus echo."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Publication reads the existing bounded parked-watcher registry and adds no timer, polling, provider scan, subprocess, or wire field; the 90-second live timeline is selected only by exact parking and graph authorities."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
|
||||
"Collect Linux, Windows, WSL, and physical SSH cold-park evidence.",
|
||||
"Keep exact per-leaf disposal, multi-pane identity, and full client round-trip assertions green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The live paired cold-park journey is macOS-only and uses a local daemon PTY.",
|
||||
"Physical SSH, WSL, Linux, Windows, and host-restart cold-park journeys are not recorded."
|
||||
],
|
||||
"demotionRule": "Demote if a live parked PTY loses its exact graph leaf, a retired PTY remains published, multi-pane identity drifts, or the routed client round trip flakes without a diagnosed cause."
|
||||
},
|
||||
{
|
||||
"id": "terminal-session.remote-pane-layout-retry",
|
||||
"title": "Remote pane layouts retry identical state after reconnect",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-layout-sync",
|
||||
"layer": "renderer-paired-runtime-layout",
|
||||
"surfaces": ["paired terminal split layout", "pane title", "runtime reconnect"],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["paired-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["paired-runtime"],
|
||||
"coverageNotes": "Pure contracts cover identity-equal local write bailouts and remote push deduplication, while a headless-host paired Electron journey proves that an identical local layout is retried after a failed disconnected push and becomes visible to an independent observer.",
|
||||
"motivatingLinks": ["https://github.com/stablyai/orca/pull/12420"],
|
||||
"invariant": "Identity-equal pane layout writes avoid local churn but never suppress a remote retry whose prior push failed; reconnecting and repeating the same layout persists it on the host and publishes it to other paired clients.",
|
||||
"oracle": "Disconnect a paired client, change a pane title, and observe the failed host push while local state retains the layout. Reconnect without remounting, submit the identical title again, and require the headless host plus a second paired observer to receive it.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot",
|
||||
"pnpm exec playwright test tests/e2e/paired-remote-pane-layout-retry.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts",
|
||||
"tests/e2e/paired-remote-pane-layout-retry.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts",
|
||||
"assertions": ["only successfully pushed layouts deduplicate later remote writes"]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts",
|
||||
"assertions": ["identity-equal layout and timer writes avoid redundant local updates"]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/paired-remote-pane-layout-retry.spec.ts",
|
||||
"assertions": [
|
||||
"an identical layout retries after reconnect and reaches a second paired observer"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"result": "passed",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-pane-layout-push.test.ts src/renderer/src/store/slices/terminal-write-identity-bailouts.test.ts --reporter=dot",
|
||||
"durationSeconds": 5,
|
||||
"summary": "Two focused files passed 20 remote-push and identity-bailout contracts."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 90,
|
||||
"scope": "two focused renderer contracts plus one serial headless-host paired journey"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "The existing paired sentinel is newly source-routed and has no route-specific CI soak history."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The affected implementation cached the disconnected layout before host acknowledgment and suppressed the identical retry; the merged oracle keeps only successful remote pushes deduplicated and the observer receives the retried title."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Successful identity-equal writes remain O(1) bailouts; one failed remote push permits one later identical retry, with no polling, timer, provider scan, subprocess, or new wire field."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive routed CI passes or 14 days without an unexplained flake.",
|
||||
"Collect Linux and Windows paired-runtime reconnect evidence.",
|
||||
"Keep local identity bailouts and failed-push retry ownership green."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The paired reconnect journey has only local macOS evidence.",
|
||||
"The sentinel changes a pane title within an existing layout; concurrent split and close conflicts remain separate coverage."
|
||||
],
|
||||
"demotionRule": "Demote if a failed push suppresses an identical retry, a successful equal write resumes redundant churn, or the routed observer journey flakes without a diagnosed cause."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parse as parseJsonc } from 'jsonc-parser'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
|
||||
const e2eWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
|
||||
const reliabilityManifest = parseJsonc(
|
||||
readFileSync(join(projectDir, 'config/reliability-gates.jsonc'), 'utf8')
|
||||
)
|
||||
const playwrightConfig = readFileSync(join(projectDir, 'tests/playwright.config.ts'), 'utf8')
|
||||
const sshDockerRunner = readFileSync(
|
||||
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
|
||||
'utf8'
|
||||
@@ -69,8 +75,15 @@ describe('PR E2E gate contract', () => {
|
||||
|
||||
it('selects modified Playwright specs without running deleted tests', () => {
|
||||
expect(filterStep.run).toContain('--diff-filter=AMCR')
|
||||
expect(filterStep.run).toContain("'^tests/e2e/.*\\.spec\\.ts$'")
|
||||
expect(filterStep.run).toContain('config/scripts/pr-e2e-source-routing.mjs')
|
||||
expect(filterStep.run).not.toContain('tests/playwright\\.')
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'tests/e2e/active-view-restart-restore.spec.ts',
|
||||
'tests/e2e/deleted.spec.ts.bak',
|
||||
'tests/e2e/global-teardown.unit.test.ts'
|
||||
])
|
||||
).toEqual(['tests/e2e/active-view-restart-restore.spec.ts'])
|
||||
})
|
||||
|
||||
it('uses one runner for changed specs and keeps full runs sharded', () => {
|
||||
@@ -89,6 +102,7 @@ describe('PR E2E gate contract', () => {
|
||||
expect(changedRun.run).toContain(
|
||||
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
|
||||
)
|
||||
expect(playwrightConfig).toContain('retries: 0')
|
||||
})
|
||||
|
||||
it('keeps startup-exec live parity in the isolated SSH lane', () => {
|
||||
@@ -121,7 +135,7 @@ describe('PR E2E gate contract', () => {
|
||||
]
|
||||
|
||||
for (const file of dedicatedWorkflows) {
|
||||
const workflow = parse(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
const workflow = parseYaml(readFileSync(join(projectDir, '.github/workflows', file), 'utf8'))
|
||||
expect(workflow.on.pull_request, file).toBeUndefined()
|
||||
}
|
||||
})
|
||||
@@ -138,12 +152,14 @@ describe('PR E2E gate contract', () => {
|
||||
const sshSourceAuthorities = [
|
||||
'src/main/ssh/',
|
||||
'src/main/providers/ssh-',
|
||||
'src/main/ipc/(ssh-|pty)',
|
||||
'src/main/ipc/pty',
|
||||
'src/relay/',
|
||||
'src/renderer/src/components/terminal-pane/(pty-|ssh-|remote-runtime-|terminal-parked-pty)'
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-'
|
||||
]
|
||||
for (const authority of sshSourceAuthorities) {
|
||||
expect(filterStep.run).toContain(authority)
|
||||
expect(selectPrE2eSpecs([`${authority}routing.ts`])).toContain(
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
|
||||
)
|
||||
}
|
||||
|
||||
const mappedSpecs = [
|
||||
@@ -154,7 +170,7 @@ describe('PR E2E gate contract', () => {
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
]
|
||||
for (const spec of mappedSpecs) {
|
||||
expect(filterStep.run).toContain(`'${spec}'`)
|
||||
expect(selectPrE2eSpecs(['src/main/ssh/connection.ts'])).toContain(spec)
|
||||
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
|
||||
// Why: a spec that stops reading the flag would silently run without Docker.
|
||||
if (spec !== 'tests/e2e/ssh-startup-exec-readiness.spec.ts') {
|
||||
@@ -162,8 +178,7 @@ describe('PR E2E gate contract', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: unit tests next to the mapped sources prove the same code without Docker.
|
||||
expect(filterStep.run).toContain("grep -Ev '\\.test\\.tsx?$'")
|
||||
expect(selectPrE2eSpecs(['src/main/ssh/connection.test.ts'])).toEqual([])
|
||||
|
||||
// Why: startup readiness is filtered out of changed-e2e, so listing it is only
|
||||
// meaningful while it still routes the dedicated Docker lane.
|
||||
@@ -183,6 +198,99 @@ describe('PR E2E gate contract', () => {
|
||||
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
|
||||
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
|
||||
expect(rollbackStep.run).toContain('src/shared/orca-yaml-hook-types.ts')
|
||||
expect(filterStep.run).toContain('ephemeral-vm-recipes|orca-yaml-hook-types')
|
||||
expect(selectPrE2eSpecs(['src/shared/ephemeral-vm-recipes.ts'])).toEqual([
|
||||
'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'
|
||||
])
|
||||
})
|
||||
|
||||
it('routes P0 sentinels from their causal sources', () => {
|
||||
const cases = [
|
||||
[
|
||||
'src/renderer/src/components/tab-bar/TabBarQuickCommandsMenu.tsx',
|
||||
'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
|
||||
],
|
||||
['src/main/runtime/orca-runtime-files.ts', 'tests/e2e/paired-quick-open-large-tree.spec.ts'],
|
||||
[
|
||||
'src/renderer/src/runtime/sync-runtime-graph.ts',
|
||||
'tests/e2e/host-parked-pane-remote-viewer.spec.ts'
|
||||
],
|
||||
[
|
||||
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts',
|
||||
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
|
||||
],
|
||||
[
|
||||
'src/renderer/src/components/terminal-pane/remote-pane-layout-push.ts',
|
||||
'tests/e2e/paired-remote-pane-layout-retry.spec.ts'
|
||||
]
|
||||
]
|
||||
for (const [source, spec] of cases) {
|
||||
expect(selectPrE2eSpecs([source]), source).toEqual([spec])
|
||||
expect(selectPrE2eSpecs([source.replace(/\.tsx?$/, '.test.ts')]), source).toEqual([])
|
||||
expect(existsSync(join(projectDir, spec)), spec).toBe(true)
|
||||
}
|
||||
const quickCommandSpec = 'tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'
|
||||
for (const source of [
|
||||
'src/renderer/src/components/terminal-pane/pty-connection.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/connect-pane-pty.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts',
|
||||
'src/renderer/src/components/terminal-pane/pty-connection/pty-input-recovery.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([source]), source).toContain(quickCommandSpec)
|
||||
expect(selectPrE2eSpecs([source.replace(/\.ts$/, '.test.ts')]), source).not.toContain(
|
||||
quickCommandSpec
|
||||
)
|
||||
}
|
||||
for (const source of [
|
||||
'src/main/ipc/rg-availability.ts',
|
||||
'src/shared/ripgrep-process-availability.ts'
|
||||
]) {
|
||||
expect(selectPrE2eSpecs([source]), source).toEqual([
|
||||
'tests/e2e/paired-quick-open-large-tree.spec.ts'
|
||||
])
|
||||
}
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/main/runtime/orca-runtime-files.ts',
|
||||
'tests/e2e/paired-quick-open-large-tree.spec.ts'
|
||||
])
|
||||
).toEqual(['tests/e2e/paired-quick-open-large-tree.spec.ts'])
|
||||
expect(selectPrE2eSpecs(['src/renderer/src/components/FileExplorer.tsx'])).toEqual([])
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts'
|
||||
])
|
||||
).toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
|
||||
expect(
|
||||
selectPrE2eSpecs([
|
||||
'src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-test-harness.ts'
|
||||
])
|
||||
).not.toContain('tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts')
|
||||
expect(selectPrE2eSpecs(['src/main/ipc/pty.ts'])).not.toContain(
|
||||
'tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps source-routed sentinels registered to their reliability gates', () => {
|
||||
const routedGateIds = [
|
||||
'terminal-startup.quick-command-pre-bind-recovery',
|
||||
'quick-open.paired-host-path-search',
|
||||
'terminal-session.host-cold-park-stream-continuity',
|
||||
'terminal-provider.ssh-remote-reattach-contract',
|
||||
'terminal-session.remote-pane-layout-retry'
|
||||
]
|
||||
for (const gateId of routedGateIds) {
|
||||
const route = PR_E2E_SOURCE_ROUTES.find((candidate) => candidate.id === gateId)
|
||||
const gate = reliabilityManifest.gates.find((candidate) => candidate.id === gateId)
|
||||
expect(route, gateId).toBeDefined()
|
||||
expect(gate, gateId).toMatchObject({ maturity: 'experimental', protection: 'partial' })
|
||||
for (const spec of route.specs) {
|
||||
expect(gate.testFiles, gateId).toContain(spec)
|
||||
expect(
|
||||
gate.commands.some((command) => command.includes(spec)),
|
||||
gateId
|
||||
).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import process from 'node:process'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
const isProductSource = (file) => !/\.test\.tsx?$/.test(file)
|
||||
|
||||
export const PR_E2E_SOURCE_ROUTES = [
|
||||
{
|
||||
id: 'ephemeral-vm-runtime.rollback-readable-sidecar',
|
||||
specs: ['tests/e2e/ephemeral-vm-provisioned-root.spec.ts'],
|
||||
matches: (file) =>
|
||||
/^(?:src\/main\/ephemeral-vm-(?:runtime-(?:service|provisioning-persistence)|failed-start-cleanup)|src\/shared\/(?:ephemeral-vm-runtime-(?:store|feature-store|rollback-projection|runtimes)|ephemeral-vm-recipes|orca-yaml-hook-types))\.ts$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ssh-terminal-source',
|
||||
specs: [
|
||||
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
|
||||
'tests/e2e/ssh-cold-activation-restore.spec.ts',
|
||||
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
|
||||
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
|
||||
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-input.ime-and-synthetic-forwarding',
|
||||
specs: [
|
||||
'tests/e2e/terminal-cjk-ime-committed-text.spec.ts',
|
||||
'tests/e2e/terminal-hangul-wrap-boundary-bytes.spec.ts',
|
||||
'tests/e2e/terminal-ime-exact-byte.spec.ts',
|
||||
'tests/e2e/terminal-korean-composing-chord-order.spec.ts',
|
||||
'tests/e2e/terminal-korean-endofrow-preedit-cell-span.spec.ts',
|
||||
'tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts',
|
||||
'tests/e2e/terminal-korean-preedit-visibility.spec.ts'
|
||||
],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:config\/patches\/|src\/renderer\/src\/components\/terminal-pane\/(?:terminal-ime-|use-terminal-pane-lifecycle|xterm-bypass-policy|terminal-option-shortcut-policy))/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-startup.quick-command-pre-bind-recovery',
|
||||
specs: ['tests/e2e/terminal-quick-command-pre-bind-recovery.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/tab-bar\/TabBarQuickCommandsMenu\.tsx|src\/renderer\/src\/hooks\/use-terminal-quick-command-hosts\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:pty-connection|pty-transport|terminal-pty-pre-spawn-e2e-barrier)\.ts|src\/renderer\/src\/components\/terminal-pane\/pty-connection\/(?:connect-pane-pty|fresh-spawn-start|pane-pty-visibility-bind|pty-input-recovery)\.ts|src\/renderer\/src\/components\/terminal-pane\/(?:TerminalPane|use-terminal-pane-lifecycle)\.tsx?|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'quick-open.paired-host-path-search',
|
||||
specs: ['tests/e2e/paired-quick-open-large-tree.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/main\/ipc\/(?:filesystem-(?:list-files|search-file-paths)|rg-availability)\.ts|src\/main\/providers\/(?:filesystem-provider-contract|ssh-filesystem-provider(?:-capabilities)?)\.ts|src\/main\/runtime\/(?:orca-runtime-files|rpc\/methods\/files)\.ts|src\/relay\/(?:fs-handler(?:-install-rg|-list-files|-ripgrep-fallback)?|fs-list-files-fallback-chain)\.ts|src\/renderer\/src\/(?:components\/(?:QuickOpen|quick-open-file-list|quick-open-search)\.tsx?|runtime\/(?:runtime-file-client|runtime-legacy-quick-open-inventory)\.ts)|src\/shared\/(?:quick-open-(?:install-rg|path-search|transport-budget)|ripgrep-process-availability)\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-session.host-cold-park-stream-continuity',
|
||||
specs: ['tests/e2e/host-parked-pane-remote-viewer.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:terminal-hidden-view-parking|terminal-tab-park-candidates|terminal-tab-activation-order|terminal-parked-pty-watcher|terminal-parked-tab-watchers|terminal-parked-watcher-registry)\.ts|src\/renderer\/src\/runtime\/sync-runtime-graph\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-provider.ssh-remote-reattach-contract',
|
||||
specs: ['tests/e2e/paired-remote-terminal-materialization-reconnect.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
!file.endsWith('-test-harness.ts') &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/remote-runtime-pty-transport(?:-[a-z0-9-]+)?\.ts|src\/renderer\/src\/runtime\/remote-runtime-terminal-multiplexer\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'terminal-session.remote-pane-layout-retry',
|
||||
specs: ['tests/e2e/paired-remote-pane-layout-retry.spec.ts'],
|
||||
matches: (file) =>
|
||||
isProductSource(file) &&
|
||||
/^(?:src\/renderer\/src\/components\/terminal-pane\/(?:remote-pane-layout-push|TerminalPane)\.tsx?|src\/renderer\/src\/lib\/terminal-layout-equality\.ts|src\/renderer\/src\/runtime\/web-session-tabs-sync\.ts|src\/renderer\/src\/store\/slices\/terminals\.ts)$/.test(
|
||||
file
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
|
||||
const specs = new Set(changedPaths.filter((file) => /^tests\/e2e\/.*\.spec\.ts$/.test(file)))
|
||||
for (const route of PR_E2E_SOURCE_ROUTES) {
|
||||
const matchedFiles = changedPaths.filter(route.matches)
|
||||
if (matchedFiles.length === 0) {
|
||||
continue
|
||||
}
|
||||
route.specs.forEach((spec) => specs.add(spec))
|
||||
reportRoute(`[pr-e2e] ${route.id}: ${route.specs.join(', ')}`)
|
||||
}
|
||||
return [...specs].sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
let input = ''
|
||||
process.stdin.setEncoding('utf8')
|
||||
for await (const chunk of process.stdin) {
|
||||
input += chunk
|
||||
}
|
||||
const changedPaths = input.split(/\r?\n/).filter(Boolean)
|
||||
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
|
||||
process.stdout.write(`${JSON.stringify(specs)}\n`)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
|
||||
|
||||
describe('packaged Windows PTY native capability routing', () => {
|
||||
it('runs the packaged executable immediately after the unpacked app is built', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const packageIndex = job.steps.findIndex((step) => step.name === 'Package unpacked app')
|
||||
const smokeIndex = job.steps.findIndex(
|
||||
(step) => step.name === 'Smoke packaged Windows PTY native capability'
|
||||
)
|
||||
const smoke = job.steps[smokeIndex]
|
||||
|
||||
expect(job['runs-on']).toBe('windows-2022')
|
||||
expect(smokeIndex).toBe(packageIndex + 1)
|
||||
expect(smoke.run).toBe(
|
||||
'pnpm run smoke:windows-pty-native-capability -- --exe=dist/win-unpacked/Orca.exe'
|
||||
)
|
||||
expect(smoke.if).toBeUndefined()
|
||||
expect(smoke['continue-on-error']).toBeUndefined()
|
||||
expect(packageJson.scripts['smoke:windows-pty-native-capability']).toBe(
|
||||
'node tests/tools/windows-pty-native-capability-smoke/run.mjs'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
|
||||
const job = workflow.jobs.package_windows
|
||||
const sourceRebuild = job.steps.find(
|
||||
(step) => step.name === 'Rebuild node-pty from patched source'
|
||||
)
|
||||
const build = job.steps.find((step) => step.name === 'Build package inputs')
|
||||
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
|
||||
const packageStep = job.steps.find((step) => step.name === 'Package unpacked app')
|
||||
const verify = workflow.jobs.verify.steps.find(
|
||||
(step) => step.name === 'Require successful checks'
|
||||
)
|
||||
|
||||
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
|
||||
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
|
||||
expect(build.run).toBe('pnpm run build:release:parallel')
|
||||
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
|
||||
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
|
||||
expect(workflow.jobs.verify.needs).toContain('package_windows')
|
||||
expect(verify.env.PACKAGE_WINDOWS).toBe('${{ needs.package_windows.result }}')
|
||||
expect(verify.run).toContain('"$PACKAGE_WINDOWS"')
|
||||
})
|
||||
|
||||
it('keeps the native probe event-based, scoped, and runnable in packaged Node mode', () => {
|
||||
const driver = readFileSync('tests/tools/windows-pty-native-capability-smoke/run.mjs', 'utf8')
|
||||
const probe = readFileSync(
|
||||
'tests/tools/windows-pty-native-capability-smoke/packaged-node-pty-capability-probe.cjs',
|
||||
'utf8'
|
||||
)
|
||||
const source = `${driver}\n${probe}`
|
||||
|
||||
expect(driver).toContain("ELECTRON_RUN_AS_NODE: '1'")
|
||||
expect(source).not.toMatch(/\b(?:sleep|tasklist|taskkill)\b/i)
|
||||
expect(source).not.toContain('maxRetries')
|
||||
expect(source).not.toContain('retryDelay')
|
||||
expect(source).not.toContain("from 'node:child_process'")
|
||||
expect(source).not.toContain("require('node:child_process')")
|
||||
expect(probe).toContain("'System32', 'wscript.exe'")
|
||||
expect(probe).toContain('real-orca-detached-launcher.vbs')
|
||||
expect(probe).not.toMatch(/cmd\.exe|start "" \/b/i)
|
||||
expect(probe).toContain('native.terminateJob(target._pty, target.pid)')
|
||||
})
|
||||
})
|
||||
@@ -100,6 +100,7 @@
|
||||
"test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:agent-tui-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-agent-tui-launch.spec.ts tests/e2e/golden-shell-after-agent-exit.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:tab-bar-agent-launch-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-tab-bar-agent-launch.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
"test:e2e:terminal-perf:scale": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-e2e.mjs",
|
||||
@@ -114,6 +115,7 @@
|
||||
"test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"win-update-e2e": "node tests/tools/win-update-e2e/run.mjs",
|
||||
"win-crash-survival-e2e": "node tests/tools/win-crash-survival-e2e/run.mjs",
|
||||
"smoke:windows-pty-native-capability": "node tests/tools/windows-pty-native-capability-smoke/run.mjs",
|
||||
"test:e2e:ssh-codex-artifacts-repro": "node config/scripts/run-ssh-codex-artifacts-repro-e2e.mjs",
|
||||
"test:e2e:headful": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project electron-headful",
|
||||
"test:e2e:terminal-ime-native": "node config/scripts/run-terminal-ibus-hangul-e2e.mjs",
|
||||
|
||||
+2
-1
@@ -547,7 +547,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
'element-index': '--element-index <n> Element index from get-app-state',
|
||||
title: '--title <text> Custom title for the terminal tab (omit to reset)',
|
||||
enter: '--enter Append Enter after sending text',
|
||||
force: '--force Force worktree removal when supported',
|
||||
force:
|
||||
'--force Force worktree removal when supported; does not force branch deletion',
|
||||
focus: '--focus Reveal the created terminal session in Orca',
|
||||
for: '--for exit|tui-idle Wait condition to satisfy',
|
||||
'from-element-index': '--from-element-index <n> Source element index from get-app-state',
|
||||
|
||||
@@ -170,7 +170,10 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
summary: 'Remove a worktree from Orca and git',
|
||||
usage: 'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'],
|
||||
notes: ['Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.']
|
||||
notes: [
|
||||
'Repo-defined orca.yaml archive hooks are skipped unless --run-hooks is passed.',
|
||||
'For Git worktrees, removal also attempts to delete the checked-out local branch, with or without --force. Orca retains branches it knows predated the worktree and any branch whose changes it cannot prove are already merged.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'ps'],
|
||||
|
||||
@@ -46,7 +46,10 @@ export function forwardGuestShortcutInput(
|
||||
return true
|
||||
}
|
||||
if (input.isAutoRepeat) {
|
||||
if (action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) {
|
||||
if (
|
||||
(action?.type === 'dictationKeyDown' && shouldForwardDictationShortcut?.()) ||
|
||||
action?.type === 'deleteCurrentWorkspace'
|
||||
) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
@@ -176,6 +179,8 @@ export function forwardGuestShortcutInput(
|
||||
renderer.send('ui:toggleQuickCommandsMenu')
|
||||
} else if (action?.type === 'openNewWorkspace') {
|
||||
renderer.send('ui:openNewWorkspace')
|
||||
} else if (action?.type === 'deleteCurrentWorkspace') {
|
||||
renderer.send('ui:deleteCurrentWorkspace')
|
||||
} else if (action?.type === 'openWorkspaceBoard') {
|
||||
renderer.send('ui:openWorkspaceBoard')
|
||||
} else if (action?.type === 'openTasks') {
|
||||
|
||||
@@ -623,6 +623,47 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:toggleQuickCommandsMenu')
|
||||
})
|
||||
|
||||
it('forwards workspace delete shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer()
|
||||
})
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const preventDefault = triggerBeforeInput({
|
||||
code: 'Backspace',
|
||||
key: 'Backspace',
|
||||
meta: isMac,
|
||||
control: !isMac,
|
||||
shift: true
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(rendererSendMock).toHaveBeenCalledWith('ui:deleteCurrentWorkspace')
|
||||
})
|
||||
|
||||
it('consumes repeated workspace delete shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer()
|
||||
})
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const preventDefault = triggerBeforeInput({
|
||||
code: 'Backspace',
|
||||
key: 'Backspace',
|
||||
meta: isMac,
|
||||
control: !isMac,
|
||||
shift: true,
|
||||
isAutoRepeat: true
|
||||
})
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
expect(rendererSendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consumes guest zoom shortcuts even when the renderer is unavailable', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
|
||||
|
||||
export const AGENT_PROMPT_TEST_WORKTREE_PATH = '/tmp/worktree-a'
|
||||
export const AGENT_PROMPT_TEST_WORKTREE_ID = 'repo-1::/tmp/worktree-a'
|
||||
|
||||
export async function createAgentPromptSubmissionRuntime(
|
||||
onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void,
|
||||
launchAgent: TuiAgent = 'aider'
|
||||
): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const writes: string[] = []
|
||||
runtime.setPtyController({
|
||||
spawn: async () => ({ id: 'pty-prompt' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
onWrite(runtime, data, writes.length)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, {
|
||||
launchAgent
|
||||
})
|
||||
return { runtime, handle: terminal.handle, writes }
|
||||
}
|
||||
@@ -3,10 +3,14 @@ import {
|
||||
AGENT_PROMPT_BRACKETED_PASTE_END,
|
||||
AGENT_PROMPT_SUBMIT_DELAY_MS
|
||||
} from '../../shared/agent-prompt-injection'
|
||||
import {
|
||||
AGENT_PROMPT_TEST_WORKTREE_PATH,
|
||||
createAgentPromptSubmissionRuntime
|
||||
} from './agent-prompt-submission-runtime-test-fixture'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
|
||||
|
||||
const WORKTREE_PATH = '/tmp/worktree-a'
|
||||
const createPromptRuntime = createAgentPromptSubmissionRuntime
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
@@ -29,37 +33,18 @@ vi.mock('../git/worktree', () => ({
|
||||
])
|
||||
}))
|
||||
|
||||
async function createPromptRuntime(
|
||||
onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void
|
||||
): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const writes: string[] = []
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
onWrite(runtime, data, writes.length)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, {
|
||||
launchAgent: 'aider'
|
||||
})
|
||||
return { runtime, handle: terminal.handle, writes }
|
||||
}
|
||||
|
||||
describe('agent prompt submission runtime', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('submits exactly once after an observed lifecycle transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime(
|
||||
(runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
await vi.runAllTimersAsync()
|
||||
@@ -374,8 +359,11 @@ describe('agent prompt submission runtime', () => {
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
handle = (await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' }))
|
||||
.handle
|
||||
handle = (
|
||||
await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, {
|
||||
launchAgent: 'aider'
|
||||
})
|
||||
).handle
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n' +
|
||||
|
||||
@@ -10,13 +10,62 @@ const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}`
|
||||
const TAB_ID = 'tab-close-continuity'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const SIBLING_LEAF_ID = '33333333-3333-4333-8333-333333333333'
|
||||
const CANARY_TAB_ID = 'tab-close-continuity-canary'
|
||||
const CANARY_LEAF_ID = '55555555-5555-4555-8555-555555555555'
|
||||
const PTY_ID = 'pty-close-continuity'
|
||||
const RUNTIME_OWNED_PTY_ID = 'serve-close-continuity'
|
||||
const SIBLING_PTY_ID = 'pty-close-continuity-sibling'
|
||||
const CANARY_PTY_ID = 'pty-close-continuity-canary'
|
||||
const INCARNATION_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const SIBLING_INCARNATION_ID = '44444444-4444-4444-8444-444444444444'
|
||||
const CANARY_INCARNATION_ID = '66666666-6666-4666-8666-666666666666'
|
||||
const canarySessionTab = {
|
||||
id: CANARY_TAB_ID,
|
||||
ptyId: CANARY_PTY_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Canary shell',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 1,
|
||||
createdAt: 2
|
||||
}
|
||||
const canarySessionLayout = {
|
||||
root: { type: 'leaf' as const, leafId: CANARY_LEAF_ID },
|
||||
activeLeafId: CANARY_LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID }
|
||||
}
|
||||
const canarySyncedTab = {
|
||||
tabId: CANARY_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
title: 'Canary shell',
|
||||
activeLeafId: CANARY_LEAF_ID,
|
||||
layout: { type: 'leaf' as const, leafId: CANARY_LEAF_ID }
|
||||
}
|
||||
const canarySyncedLeaf = {
|
||||
tabId: CANARY_TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: CANARY_LEAF_ID,
|
||||
paneRuntimeId: 9,
|
||||
ptyId: CANARY_PTY_ID
|
||||
}
|
||||
const canaryMobileTab = {
|
||||
type: 'terminal' as const,
|
||||
id: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`,
|
||||
parentTabId: CANARY_TAB_ID,
|
||||
leafId: CANARY_LEAF_ID,
|
||||
ptyId: CANARY_PTY_ID,
|
||||
title: 'Canary shell',
|
||||
isActive: false
|
||||
}
|
||||
const canaryProcess = {
|
||||
id: CANARY_PTY_ID,
|
||||
incarnationId: CANARY_INCARNATION_ID,
|
||||
cwd: WORKTREE_PATH,
|
||||
title: 'Canary shell'
|
||||
}
|
||||
|
||||
function makeSession(ptyId = PTY_ID): WorkspaceSessionState {
|
||||
function makeSession(ptyId = PTY_ID, includeCanary = false): WorkspaceSessionState {
|
||||
return {
|
||||
...getDefaultWorkspaceSession(),
|
||||
tabsByWorktree: {
|
||||
@@ -30,7 +79,8 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState {
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
},
|
||||
...(includeCanary ? [canarySessionTab] : [])
|
||||
]
|
||||
},
|
||||
terminalLayoutsByTabId: {
|
||||
@@ -39,10 +89,14 @@ function makeSession(ptyId = PTY_ID): WorkspaceSessionState {
|
||||
activeLeafId: LEAF_ID,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [LEAF_ID]: ptyId }
|
||||
}
|
||||
},
|
||||
...(includeCanary ? { [CANARY_TAB_ID]: canarySessionLayout } : {})
|
||||
},
|
||||
terminalPtyIncarnationsByPaneKey: {
|
||||
[makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID
|
||||
[makePaneKey(TAB_ID, LEAF_ID)]: INCARNATION_ID,
|
||||
...(includeCanary
|
||||
? { [makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,13 +110,19 @@ function makeDeferred() {
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
options: { ptyId?: string; publishMobileSurface?: boolean; registerPtyBacked?: boolean } = {}
|
||||
options: {
|
||||
ptyId?: string
|
||||
publishMobileSurface?: boolean
|
||||
registerPtyBacked?: boolean
|
||||
includeCanary?: boolean
|
||||
} = {}
|
||||
) {
|
||||
const ptyId = options.ptyId ?? PTY_ID
|
||||
let session = makeSession(ptyId)
|
||||
let session = makeSession(ptyId, options.includeCanary)
|
||||
let sessionAvailable = true
|
||||
let incarnationId = INCARNATION_ID
|
||||
let includeSiblingPty = false
|
||||
let victimPtyListed = true
|
||||
const repo = {
|
||||
id: REPO_ID,
|
||||
path: WORKTREE_PATH,
|
||||
@@ -80,29 +140,40 @@ function createHarness(
|
||||
getWorkspaceSession: () => (sessionAvailable ? session : undefined),
|
||||
setWorkspaceSession: (next: WorkspaceSessionState) => {
|
||||
session = next
|
||||
}
|
||||
},
|
||||
flushOrThrow: () => {}
|
||||
}
|
||||
const acknowledged = makeDeferred()
|
||||
let closeTerminalTabError: Error | null = null
|
||||
let closeTerminalTabAction: (() => void | Promise<void>) | null = null
|
||||
const closeTerminal = vi.fn()
|
||||
const closeTerminalTab = vi.fn(() =>
|
||||
closeTerminalTabError ? Promise.reject(closeTerminalTabError) : acknowledged.promise
|
||||
)
|
||||
const closeTerminalTab = vi.fn(() => {
|
||||
if (closeTerminalTabError) {
|
||||
return Promise.reject(closeTerminalTabError)
|
||||
}
|
||||
return closeTerminalTabAction ? Promise.resolve(closeTerminalTabAction()) : acknowledged.promise
|
||||
})
|
||||
const kill = vi.fn(() => true)
|
||||
let verifiedStopResult: boolean | Error = false
|
||||
const stopAndWait = vi.fn(async () => {
|
||||
let stopAndWaitAction: ((stoppingPtyId: string) => void | Promise<void>) | null = null
|
||||
const stopAndWait = vi.fn(async (stoppingPtyId: string) => {
|
||||
await stopAndWaitAction?.(stoppingPtyId)
|
||||
if (verifiedStopResult instanceof Error) {
|
||||
throw verifiedStopResult
|
||||
}
|
||||
return verifiedStopResult
|
||||
})
|
||||
const listProcesses = vi.fn(async () => [
|
||||
{
|
||||
id: ptyId,
|
||||
incarnationId,
|
||||
cwd: WORKTREE_PATH,
|
||||
title: 'Fixture shell'
|
||||
},
|
||||
...(victimPtyListed
|
||||
? [
|
||||
{
|
||||
id: ptyId,
|
||||
incarnationId,
|
||||
cwd: WORKTREE_PATH,
|
||||
title: 'Fixture shell'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(includeSiblingPty
|
||||
? [
|
||||
{
|
||||
@@ -112,7 +183,8 @@ function createHarness(
|
||||
title: 'Fixture sibling shell'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
: []),
|
||||
...(options.includeCanary ? [canaryProcess] : [])
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(store as never)
|
||||
runtime.setNotifier({ closeTerminal, closeTerminalTab } as never)
|
||||
@@ -134,7 +206,8 @@ function createHarness(
|
||||
title: 'Fixture shell',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: { type: 'leaf', leafId: LEAF_ID }
|
||||
}
|
||||
},
|
||||
...(options.includeCanary ? [canarySyncedTab] : [])
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
@@ -143,7 +216,8 @@ function createHarness(
|
||||
leafId: LEAF_ID,
|
||||
paneRuntimeId: 7,
|
||||
ptyId
|
||||
}
|
||||
},
|
||||
...(options.includeCanary ? [canarySyncedLeaf] : [])
|
||||
],
|
||||
...(options.publishMobileSurface
|
||||
? {
|
||||
@@ -164,13 +238,34 @@ function createHarness(
|
||||
ptyId,
|
||||
title: 'Fixture shell',
|
||||
isActive: true
|
||||
}
|
||||
},
|
||||
...(options.includeCanary ? [canaryMobileTab] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
: {})
|
||||
})
|
||||
const syncCanaryGraph = () =>
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [canarySyncedTab],
|
||||
leaves: [canarySyncedLeaf],
|
||||
...(options.publishMobileSurface
|
||||
? {
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: WORKTREE_ID,
|
||||
publicationEpoch: 'renderer:close-continuity',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: `${CANARY_TAB_ID}::${CANARY_LEAF_ID}`,
|
||||
activeTabType: 'terminal' as const,
|
||||
tabs: [{ ...canaryMobileTab, isActive: true }]
|
||||
}
|
||||
]
|
||||
}
|
||||
: {})
|
||||
})
|
||||
const syncEmptyGraph = () => runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
const syncFixtureTabWithoutLeaf = () =>
|
||||
runtime.syncWindowGraph(1, {
|
||||
@@ -181,9 +276,10 @@ function createHarness(
|
||||
title: 'Fixture shell',
|
||||
activeLeafId: LEAF_ID,
|
||||
layout: { type: 'leaf', leafId: LEAF_ID }
|
||||
}
|
||||
},
|
||||
...(options.includeCanary ? [canarySyncedTab] : [])
|
||||
],
|
||||
leaves: []
|
||||
leaves: options.includeCanary ? [canarySyncedLeaf] : []
|
||||
})
|
||||
const syncSplitFixtureGraph = () => {
|
||||
includeSiblingPty = true
|
||||
@@ -245,6 +341,13 @@ function createHarness(
|
||||
leafId: LEAF_ID,
|
||||
incarnationId: INCARNATION_ID
|
||||
})
|
||||
if (options.includeCanary) {
|
||||
runtime.registerPty(CANARY_PTY_ID, WORKTREE_ID, null, {
|
||||
tabId: CANARY_TAB_ID,
|
||||
leafId: CANARY_LEAF_ID,
|
||||
incarnationId: CANARY_INCARNATION_ID
|
||||
})
|
||||
}
|
||||
}
|
||||
syncFixtureGraph()
|
||||
return {
|
||||
@@ -254,6 +357,7 @@ function createHarness(
|
||||
closeTerminalTab,
|
||||
kill,
|
||||
stopAndWait,
|
||||
syncCanaryGraph,
|
||||
syncEmptyGraph,
|
||||
syncFixtureGraph,
|
||||
syncFixtureTabWithoutLeaf,
|
||||
@@ -262,20 +366,41 @@ function createHarness(
|
||||
makeSessionUnavailable: () => {
|
||||
sessionAvailable = false
|
||||
},
|
||||
removeVictimFromInventory: () => {
|
||||
victimPtyListed = false
|
||||
},
|
||||
retirePersistedTab: () => {
|
||||
const victimPaneKey = makePaneKey(TAB_ID, LEAF_ID)
|
||||
session = {
|
||||
...session,
|
||||
tabsByWorktree: { ...session.tabsByWorktree, [WORKTREE_ID]: [] },
|
||||
terminalLayoutsByTabId: {},
|
||||
terminalPtyIncarnationsByPaneKey: {}
|
||||
tabsByWorktree: {
|
||||
...session.tabsByWorktree,
|
||||
[WORKTREE_ID]: (session.tabsByWorktree[WORKTREE_ID] ?? []).filter(
|
||||
(tab) => tab.id !== TAB_ID
|
||||
)
|
||||
},
|
||||
terminalLayoutsByTabId: Object.fromEntries(
|
||||
Object.entries(session.terminalLayoutsByTabId).filter(([tabId]) => tabId !== TAB_ID)
|
||||
),
|
||||
terminalPtyIncarnationsByPaneKey: Object.fromEntries(
|
||||
Object.entries(session.terminalPtyIncarnationsByPaneKey ?? {}).filter(
|
||||
([paneKey]) => paneKey !== victimPaneKey
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
setCloseTerminalTabAction: (action: () => void | Promise<void>) => {
|
||||
closeTerminalTabAction = action
|
||||
},
|
||||
rejectTerminalTabClose: (error: Error) => {
|
||||
closeTerminalTabError = error
|
||||
},
|
||||
setVerifiedStopResult: (result: boolean | Error) => {
|
||||
verifiedStopResult = result
|
||||
},
|
||||
setStopAndWaitAction: (action: (stoppingPtyId: string) => void | Promise<void>) => {
|
||||
stopAndWaitAction = action
|
||||
},
|
||||
replaceIncarnation: (next: string) => {
|
||||
incarnationId = next
|
||||
}
|
||||
@@ -317,6 +442,79 @@ describe('terminal close and handle incarnation continuity', () => {
|
||||
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
})
|
||||
|
||||
it('fences final-pane exit ordering until exact retirement commits', async () => {
|
||||
const harness = createHarness({
|
||||
includeCanary: true,
|
||||
publishMobileSurface: true,
|
||||
registerPtyBacked: true
|
||||
})
|
||||
harness.syncFixtureTabWithoutLeaf()
|
||||
const closeMobileSessionTab = vi.spyOn(harness.runtime, 'closeMobileSessionTab')
|
||||
const order: string[] = []
|
||||
harness.setCloseTerminalTabAction(() => {
|
||||
order.push('session-retirement')
|
||||
harness.retirePersistedTab()
|
||||
harness.syncCanaryGraph()
|
||||
order.push('committed-graph-removal')
|
||||
})
|
||||
harness.setVerifiedStopResult(true)
|
||||
harness.setStopAndWaitAction((stoppingPtyId) => {
|
||||
order.push('pty-stop')
|
||||
harness.removeVictimFromInventory()
|
||||
harness.syncCanaryGraph()
|
||||
order.push('idempotent-graph-removal')
|
||||
harness.runtime.onPtyExit(stoppingPtyId, 0, INCARNATION_ID)
|
||||
order.push('idempotent-session-retirement')
|
||||
})
|
||||
const terminals = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
const victim = terminals.find((terminal) => terminal.ptyId === PTY_ID)!
|
||||
const canary = terminals.find((terminal) => terminal.ptyId === CANARY_PTY_ID)!
|
||||
|
||||
await expect(harness.runtime.closeTerminal(victim.handle)).resolves.toMatchObject({
|
||||
handle: victim.handle,
|
||||
tabId: TAB_ID,
|
||||
ptyKilled: true
|
||||
})
|
||||
|
||||
expect(order).toEqual([
|
||||
'session-retirement',
|
||||
'committed-graph-removal',
|
||||
'pty-stop',
|
||||
'idempotent-graph-removal',
|
||||
'idempotent-session-retirement'
|
||||
])
|
||||
expect(harness.stopAndWait).toHaveBeenCalledTimes(1)
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, {
|
||||
deadlineMs: expect.any(Number)
|
||||
})
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
expect(closeMobileSessionTab).toHaveBeenCalledTimes(1)
|
||||
expect(closeMobileSessionTab).toHaveBeenCalledWith(`id:${WORKTREE_ID}`, TAB_ID, {
|
||||
localPtyTeardownOwnedExternally: true
|
||||
})
|
||||
expect(harness.closeTerminalTab).toHaveBeenCalledTimes(1)
|
||||
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([
|
||||
expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID })
|
||||
])
|
||||
expect(harness.getSession().terminalLayoutsByTabId).toEqual({
|
||||
[CANARY_TAB_ID]: expect.objectContaining({
|
||||
ptyIdsByLeafId: { [CANARY_LEAF_ID]: CANARY_PTY_ID }
|
||||
})
|
||||
})
|
||||
expect(harness.getSession().terminalPtyIncarnationsByPaneKey).toEqual({
|
||||
[makePaneKey(CANARY_TAB_ID, CANARY_LEAF_ID)]: CANARY_INCARNATION_ID
|
||||
})
|
||||
const survivors = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
expect(survivors).toHaveLength(1)
|
||||
const [survivor] = survivors
|
||||
expect(survivor).toMatchObject({ ptyId: CANARY_PTY_ID })
|
||||
await expect(harness.runtime.readTerminal(survivor.handle)).resolves.toMatchObject({
|
||||
handle: survivor.handle,
|
||||
status: 'running'
|
||||
})
|
||||
expect(canary.ptyId).toBe(survivor.ptyId)
|
||||
})
|
||||
|
||||
it('does not kill the final PTY when durable tab retirement is rejected', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
|
||||
@@ -35611,23 +35611,36 @@ export class OrcaRuntimeService {
|
||||
* later transition. A provider screen that is still working when this fires
|
||||
* resolves through the poll, not here. */
|
||||
private startTuiIdleVisibleReadProbe(waiter: TerminalWaiter, waiterTimeoutMs: number): void {
|
||||
const snapshotTimeoutMs = Math.min(
|
||||
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
|
||||
Math.max(0, waiterTimeoutMs - 1)
|
||||
const settleMarginMs = Math.min(
|
||||
TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS,
|
||||
Math.max(1, Math.floor(waiterTimeoutMs / 3))
|
||||
)
|
||||
const probeTimeoutMs = Math.min(
|
||||
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS + settleMarginMs,
|
||||
Math.max(0, waiterTimeoutMs - settleMarginMs)
|
||||
)
|
||||
const providerTimeoutMs = Math.min(
|
||||
VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS,
|
||||
Math.max(0, probeTimeoutMs - settleMarginMs)
|
||||
)
|
||||
// Node clamps sub-millisecond timers to 1ms, so no distinct retirement deadline exists.
|
||||
if (providerTimeoutMs < 1) {
|
||||
return
|
||||
}
|
||||
// Retire the provider before the detached probe and waiter can settle.
|
||||
void withTimeout(
|
||||
this.readTerminal(
|
||||
waiter.handle,
|
||||
{},
|
||||
{
|
||||
timeoutMs: snapshotTimeoutMs,
|
||||
timeoutMs: providerTimeoutMs,
|
||||
retireOnTimeout: true,
|
||||
// Why: the ready banner stays in scrollback for the whole session, so
|
||||
// classifying history would call a working agent idle (#15569 review).
|
||||
visibleScreenOnly: true
|
||||
}
|
||||
),
|
||||
snapshotTimeoutMs,
|
||||
probeTimeoutMs,
|
||||
null
|
||||
)
|
||||
.then((read) => {
|
||||
@@ -38652,6 +38665,7 @@ const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
|
||||
export const AUTHORITATIVE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 8_000
|
||||
const VISIBLE_TERMINAL_SNAPSHOT_TIMEOUT_MS = 750
|
||||
const VISIBLE_TERMINAL_SNAPSHOT_RETRY_MS = 1_000
|
||||
const TUI_IDLE_VISIBLE_PROBE_SETTLE_MARGIN_MS = 10
|
||||
const MAX_PREVIEW_LINES = 6
|
||||
const MAX_PREVIEW_CHARS = 300
|
||||
const WORKTREE_STATUS_PRIORITY: Record<RuntimeWorktreeStatus, number> = {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../../../shared/agent-prompt-injection'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
|
||||
import {
|
||||
AGENT_PROMPT_TEST_WORKTREE_ID,
|
||||
createAgentPromptSubmissionRuntime
|
||||
} from '../../agent-prompt-submission-runtime-test-fixture'
|
||||
import { OrchestrationDb } from '../../orchestration/db'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import { ORCHESTRATION_METHODS } from './orchestration'
|
||||
|
||||
vi.mock('../../../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/prompt-contract',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]),
|
||||
listWorktreesStrict: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/prompt-contract',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}))
|
||||
|
||||
const COORDINATOR_PANE_KEY = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const WORKER_PANE_KEY = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const REQUEST_ID = 'worker_start_prompt_contract'
|
||||
const openDatabases: OrchestrationDb[] = []
|
||||
const temporaryRoots: string[] = []
|
||||
|
||||
type PromptContractHarness = {
|
||||
db: OrchestrationDb
|
||||
dbPath: string
|
||||
dispatcher: RpcDispatcher
|
||||
request: RpcRequest
|
||||
requestId: string
|
||||
taskId: string
|
||||
submittedTurns: () => number
|
||||
startedTurns: () => number
|
||||
prematureSubmits: () => number
|
||||
writes: string[]
|
||||
}
|
||||
|
||||
async function createPromptContractHarness(
|
||||
outcome: 'accepted' | 'swallowed'
|
||||
): Promise<PromptContractHarness> {
|
||||
let composerReady = false
|
||||
let submittedTurns = 0
|
||||
let startedTurns = 0
|
||||
let prematureSubmits = 0
|
||||
const fixture = await createAgentPromptSubmissionRuntime((runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
setTimeout(() => runtime.onPtyData('pty-prompt', 'partial composer frame', Date.now()), 650)
|
||||
setTimeout(() => runtime.onPtyData('pty-prompt', '\x1b[?25h', Date.now()), 750)
|
||||
setTimeout(() => {
|
||||
composerReady = true
|
||||
runtime.onPtyData('pty-prompt', 'final composer frame', Date.now())
|
||||
}, 1_000)
|
||||
return
|
||||
}
|
||||
if (data !== '\r') {
|
||||
return
|
||||
}
|
||||
submittedTurns += 1
|
||||
if (!composerReady) {
|
||||
prematureSubmits += 1
|
||||
}
|
||||
if (outcome === 'accepted') {
|
||||
startedTurns += 1
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
}, 'codex')
|
||||
const { runtime, handle } = fixture
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), 'orca-worker-prompt-contract-'))
|
||||
temporaryRoots.push(temporaryRoot)
|
||||
const dbPath = join(temporaryRoot, 'orchestration.db')
|
||||
const db = new OrchestrationDb(dbPath)
|
||||
openDatabases.push(db)
|
||||
runtime.setOrchestrationDb(db)
|
||||
const run = db.createRun({
|
||||
objective: 'Worker prompt contract',
|
||||
coordinatorHandle: 'term_coord',
|
||||
coordinatorPaneKey: COORDINATOR_PANE_KEY
|
||||
})
|
||||
const task = db.createTask({ spec: 'start exactly one worker turn', runId: run.id })
|
||||
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((candidate) =>
|
||||
candidate === 'term_coord'
|
||||
? COORDINATOR_PANE_KEY
|
||||
: candidate === handle
|
||||
? WORKER_PANE_KEY
|
||||
: null
|
||||
)
|
||||
vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((candidate) =>
|
||||
candidate === handle ? `runtime_test:${handle}:1` : null
|
||||
)
|
||||
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
|
||||
vi.spyOn(runtime, 'showTerminal').mockResolvedValue({
|
||||
handle: 'term_coord',
|
||||
worktreeId: 'repo::parent',
|
||||
status: 'running'
|
||||
} as never)
|
||||
vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({
|
||||
id: 'repo::parent',
|
||||
repoId: 'repo-1'
|
||||
} as never)
|
||||
vi.spyOn(runtime, 'showRepo').mockResolvedValue({ id: 'repo-1', kind: 'git' } as never)
|
||||
vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({
|
||||
worktree: { id: AGENT_PROMPT_TEST_WORKTREE_ID, repoId: 'repo-1' },
|
||||
startupTerminal: { spawned: true, handle },
|
||||
setupReceipt: {
|
||||
requested: 'run',
|
||||
hookFound: false,
|
||||
startupPolicy: 'start-immediately',
|
||||
state: 'not_configured'
|
||||
}
|
||||
} as never)
|
||||
vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca')
|
||||
|
||||
return {
|
||||
db,
|
||||
dbPath,
|
||||
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }),
|
||||
request: {
|
||||
id: `rpc_${outcome}`,
|
||||
authToken: 'caller-token',
|
||||
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
|
||||
orchestrationRequestId: `${REQUEST_ID}_${outcome}`,
|
||||
method: 'orchestration.workerStart',
|
||||
params: {
|
||||
task: task.id,
|
||||
from: 'term_coord',
|
||||
worktree: 'new-child',
|
||||
name: `prompt-contract-${outcome}`,
|
||||
agent: 'codex'
|
||||
}
|
||||
},
|
||||
requestId: `${REQUEST_ID}_${outcome}`,
|
||||
taskId: task.id,
|
||||
submittedTurns: () => submittedTurns,
|
||||
startedTurns: () => startedTurns,
|
||||
prematureSubmits: () => prematureSubmits,
|
||||
writes: fixture.writes
|
||||
}
|
||||
}
|
||||
|
||||
function reopenPromptContractDb(harness: PromptContractHarness): OrchestrationDb {
|
||||
const index = openDatabases.indexOf(harness.db)
|
||||
if (index !== -1) {
|
||||
openDatabases.splice(index, 1)
|
||||
}
|
||||
harness.db.close()
|
||||
harness.db = new OrchestrationDb(harness.dbPath)
|
||||
openDatabases.push(harness.db)
|
||||
return harness.db
|
||||
}
|
||||
|
||||
describe('orchestration worker-start prompt contract', () => {
|
||||
afterEach(() => {
|
||||
for (const db of openDatabases.splice(0)) {
|
||||
db.close()
|
||||
}
|
||||
for (const root of temporaryRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('durably accepts exactly one submitted and started turn', async () => {
|
||||
vi.useFakeTimers()
|
||||
const harness = await createPromptContractHarness('accepted')
|
||||
const pending = harness.dispatcher.dispatch(harness.request)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
const response = await pending
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
state: 'ready',
|
||||
stage: 'input_accepted',
|
||||
mutation: { requestId: harness.requestId, replayed: false },
|
||||
effects: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' })
|
||||
])
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const dispatchId = (response.result as { dispatchId: string }).dispatchId
|
||||
expect(harness.submittedTurns()).toBe(1)
|
||||
expect(harness.startedTurns()).toBe(1)
|
||||
expect(harness.prematureSubmits()).toBe(0)
|
||||
expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
const persisted = reopenPromptContractDb(harness)
|
||||
expect(persisted.getTask(harness.taskId)?.status).toBe('dispatched')
|
||||
expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({
|
||||
status: 'dispatched',
|
||||
capability_hash: expect.any(String),
|
||||
capability_revoked_at: null
|
||||
})
|
||||
expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({
|
||||
state: 'ready',
|
||||
stage: 'input_accepted',
|
||||
last_error: null
|
||||
})
|
||||
const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint()
|
||||
const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId)
|
||||
expect(receipt).toMatchObject({ state: 'completed' })
|
||||
expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({
|
||||
dispatchId,
|
||||
state: 'ready',
|
||||
stage: 'input_accepted'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a swallowed Enter as stalled without sending a rescue Enter', async () => {
|
||||
vi.useFakeTimers()
|
||||
const harness = await createPromptContractHarness('swallowed')
|
||||
const pending = harness.dispatcher.dispatch(harness.request)
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
const response = await pending
|
||||
expect(response).toMatchObject({
|
||||
ok: true,
|
||||
result: {
|
||||
state: 'failed',
|
||||
failedStage: 'dispatch_input',
|
||||
lastError: 'agent_prompt_stalled',
|
||||
mutation: { requestId: harness.requestId, replayed: false }
|
||||
}
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const dispatchId = (response.result as { dispatchId: string }).dispatchId
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
expect(harness.submittedTurns()).toBe(1)
|
||||
expect(harness.startedTurns()).toBe(0)
|
||||
expect(harness.prematureSubmits()).toBe(0)
|
||||
expect(harness.writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
const persisted = reopenPromptContractDb(harness)
|
||||
expect(persisted.getTask(harness.taskId)?.status).toBe('failed')
|
||||
expect(persisted.getDispatchContextById(dispatchId)).toMatchObject({
|
||||
status: 'failed',
|
||||
last_failure: 'agent_prompt_stalled',
|
||||
capability_revoked_at: expect.any(String)
|
||||
})
|
||||
expect(persisted.getWorkerDispatch(dispatchId)).toMatchObject({
|
||||
state: 'failed',
|
||||
stage: 'dispatch_input',
|
||||
last_error: 'agent_prompt_stalled'
|
||||
})
|
||||
const callerFingerprint = persisted.getOrCreateLocalMutationCallerFingerprint()
|
||||
const receipt = persisted.getMutationReceipt(callerFingerprint, harness.requestId)
|
||||
expect(receipt).toMatchObject({ state: 'completed' })
|
||||
expect(JSON.parse(receipt?.receipt ?? 'null')).toMatchObject({
|
||||
dispatchId,
|
||||
state: 'failed',
|
||||
failedStage: 'dispatch_input',
|
||||
lastError: 'agent_prompt_stalled'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -341,6 +341,64 @@ describe('createMainWindow', () => {
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses auto-repeat workspace deletes from before-input-event', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event, handler) => {
|
||||
windowHandlers[event] = handler
|
||||
}),
|
||||
setZoomLevel: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
invalidate: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
send: vi.fn(),
|
||||
isDevToolsOpened: vi.fn(),
|
||||
openDevTools: vi.fn(),
|
||||
closeDevTools: vi.fn()
|
||||
}
|
||||
const browserWindowInstance = {
|
||||
webContents,
|
||||
on: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
isMaximized: vi.fn(() => true),
|
||||
isFullScreen: vi.fn(() => false),
|
||||
getSize: vi.fn(() => [1200, 800]),
|
||||
setSize: vi.fn(),
|
||||
maximize: vi.fn(),
|
||||
show: vi.fn(),
|
||||
loadFile: vi.fn(),
|
||||
loadURL: vi.fn()
|
||||
}
|
||||
browserWindowMock.mockImplementation(function () {
|
||||
return browserWindowInstance
|
||||
})
|
||||
|
||||
createMainWindow(null)
|
||||
|
||||
const isDarwin = process.platform === 'darwin'
|
||||
const input = {
|
||||
type: 'keyDown',
|
||||
code: 'Backspace',
|
||||
key: 'Backspace',
|
||||
meta: isDarwin,
|
||||
control: !isDarwin,
|
||||
alt: false,
|
||||
shift: true
|
||||
}
|
||||
windowHandlers['before-input-event']({ preventDefault: vi.fn() } as never, input)
|
||||
expect(webContents.send).toHaveBeenCalledWith('ui:deleteCurrentWorkspace')
|
||||
|
||||
webContents.send.mockClear()
|
||||
const repeatPreventDefault = vi.fn()
|
||||
windowHandlers['before-input-event']({ preventDefault: repeatPreventDefault } as never, {
|
||||
...input,
|
||||
isAutoRepeat: true
|
||||
})
|
||||
|
||||
expect(repeatPreventDefault).toHaveBeenCalledOnce()
|
||||
expect(webContents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets Terminal-first pass risky app shortcuts through when terminal input is focused', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
@@ -624,7 +682,7 @@ describe('createMainWindow', () => {
|
||||
expect(webContents.send).toHaveBeenNthCalledWith(2, 'ui:openQuickOpen')
|
||||
})
|
||||
|
||||
it('forwards the configured workspace delete shortcut while terminal input is focused', () => {
|
||||
it('forwards the default workspace delete shortcut while terminal input is focused', () => {
|
||||
const windowHandlers: Record<string, (...args: any[]) => void> = {}
|
||||
const webContents = {
|
||||
on: vi.fn((event, handler) => {
|
||||
@@ -662,7 +720,7 @@ describe('createMainWindow', () => {
|
||||
getSettings: () => ({ terminalShortcutPolicy: 'terminal-first' })
|
||||
} as never,
|
||||
{
|
||||
getKeybindings: () => ({ 'workspace.delete': ['Mod+Shift+Backspace'] })
|
||||
getKeybindings: () => ({})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -811,7 +811,10 @@ export function createMainWindow(
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.type === 'toggleQuickCommandsMenu' && isAutoRepeat) {
|
||||
if (
|
||||
(action.type === 'toggleQuickCommandsMenu' || action.type === 'deleteCurrentWorkspace') &&
|
||||
isAutoRepeat
|
||||
) {
|
||||
event.preventDefault()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { HoveredWorkspaceDeleteTarget } from '../components/sidebar/hovered-workspace-delete'
|
||||
import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteHoveredWorkspaceImmediately: vi.fn(),
|
||||
hoveredTarget: { kind: 'worktree', worktree: {} } as HoveredWorkspaceDeleteTarget | null,
|
||||
store: {} as AppState
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store })
|
||||
}))
|
||||
|
||||
vi.mock('../components/sidebar/hovered-workspace-delete', () => ({
|
||||
deleteHoveredWorkspaceImmediately: mocks.deleteHoveredWorkspaceImmediately,
|
||||
resolveHoveredWorkspaceDeleteTarget: () => mocks.hoveredTarget
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/floating-workspace-terminal-actions', () => ({
|
||||
isFloatingWorkspacePanelFocused: () => false
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({
|
||||
showTerminalShortcutCaptureNotification: vi.fn()
|
||||
}))
|
||||
|
||||
import { createAppCommandHandlers } from './app-command-handlers'
|
||||
|
||||
function shortcutState(overrides: Partial<AppShortcutState> = {}): AppShortcutState {
|
||||
return {
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: 'repo::/feature',
|
||||
actions: {} as AppShortcutState['actions'],
|
||||
creationLayoutActive: false,
|
||||
floatingTerminalEnabled: false,
|
||||
floatingTerminalOpen: false,
|
||||
floatingVisibleTabCount: 0,
|
||||
keybindings: {},
|
||||
openFloatingWorkspaceMaximized: vi.fn(),
|
||||
pluginCommands: [],
|
||||
setFloatingTerminalOpen: vi.fn(),
|
||||
terminalShortcutPolicy: 'orca-first',
|
||||
workspaceChromeActive: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function shortcutInput(): ShortcutDispatchInput {
|
||||
return {
|
||||
target: null,
|
||||
defaultPrevented: false,
|
||||
preventDefault: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace delete app command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.store = { activeWorktreeId: 'repo::/feature' } as AppState
|
||||
mocks.hoveredTarget = { kind: 'worktree', worktree: {} as never }
|
||||
})
|
||||
|
||||
it('claims the chord and immediately deletes the active workspace', () => {
|
||||
const input = shortcutInput()
|
||||
const handler = createAppCommandHandlers(shortcutState(), input, 'terminal').get(
|
||||
'workspace.delete'
|
||||
)
|
||||
|
||||
expect(handler?.()).toBe(true)
|
||||
expect(input.preventDefault).toHaveBeenCalledOnce()
|
||||
expect(mocks.deleteHoveredWorkspaceImmediately).toHaveBeenCalledWith(
|
||||
mocks.store,
|
||||
mocks.hoveredTarget
|
||||
)
|
||||
})
|
||||
|
||||
it('does not claim the chord without a hovered workspace', () => {
|
||||
const input = shortcutInput()
|
||||
const handler = createAppCommandHandlers(shortcutState(), input).get('workspace.delete')
|
||||
|
||||
mocks.hoveredTarget = null
|
||||
expect(handler?.()).toBe(false)
|
||||
expect(input.preventDefault).not.toHaveBeenCalled()
|
||||
expect(mocks.deleteHoveredWorkspaceImmediately).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,10 @@ import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to-
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls'
|
||||
import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel'
|
||||
import {
|
||||
deleteHoveredWorkspaceImmediately,
|
||||
resolveHoveredWorkspaceDeleteTarget
|
||||
} from '../components/sidebar/hovered-workspace-delete'
|
||||
import { useAppStore } from '../store'
|
||||
import type { usePluginCommands } from '@/store/plugin-panels'
|
||||
import { isGitRepoKind } from '../../../shared/repo-kind'
|
||||
@@ -191,6 +195,22 @@ export function createAppCommandHandlers(
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'workspace.delete',
|
||||
() => {
|
||||
if (floatingWorkspaceFocused) {
|
||||
return false
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const target = resolveHoveredWorkspaceDeleteTarget(store)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
return claim('workspace.delete', () => {
|
||||
deleteHoveredWorkspaceImmediately(store, target)
|
||||
})
|
||||
}
|
||||
],
|
||||
[
|
||||
'workspace.openBoard',
|
||||
() => {
|
||||
|
||||
@@ -236,6 +236,9 @@ export function useGlobalKeybindings(args: {
|
||||
}
|
||||
|
||||
const handlers = createAppCommandHandlers(state, input, context)
|
||||
if (matchShortcut('workspace.delete') && handlers.get('workspace.delete')?.()) {
|
||||
return
|
||||
}
|
||||
for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) {
|
||||
if (matchShortcut(actionId) && handlers.get(actionId)?.()) {
|
||||
return
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { AutomationTargetAvailability } from './automation-target-availability'
|
||||
import { getAutomationSourceDisplay } from './automation-source-display'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { AutomationPromptDisclosure } from './AutomationPromptDisclosure'
|
||||
|
||||
type AutomationDetailProps = {
|
||||
automation: Automation | null
|
||||
@@ -301,21 +302,10 @@ export function AutomationDetail({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
|
||||
<div className="border-b border-border/50 px-3 py-2 text-sm font-medium">
|
||||
{translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')}
|
||||
</div>
|
||||
<div className="px-3 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{translate('auto.components.automations.AutomationDetail.007c8ad874', 'Prompt')}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-4 whitespace-pre-wrap text-sm text-foreground">
|
||||
{automation.prompt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AutomationPromptDisclosure
|
||||
key={`${automation.id}:${automation.prompt}`}
|
||||
prompt={automation.prompt}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import type React from 'react'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Automation } from '../../../../shared/automations-types'
|
||||
import { i18n } from '@/i18n/i18n'
|
||||
import { AutomationDetail } from './AutomationDetail'
|
||||
import { AutomationPromptDisclosure } from './AutomationPromptDisclosure'
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
let promptNaturalHeight = 0
|
||||
let resizeCallback: ResizeObserverCallback | null = null
|
||||
|
||||
class PromptResizeObserver implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallback = callback
|
||||
}
|
||||
|
||||
disconnect(): void {}
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
}
|
||||
|
||||
function makeAutomation(
|
||||
updatedAt: number,
|
||||
prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}`
|
||||
): Automation {
|
||||
return {
|
||||
id: 'synthetic-automation',
|
||||
name: 'Synthetic automation',
|
||||
prompt,
|
||||
precheck: null,
|
||||
agentId: 'codex',
|
||||
projectId: 'synthetic-project',
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
schedulerOwner: 'local_host_service',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'synthetic-workspace',
|
||||
baseBranch: null,
|
||||
reuseSession: false,
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY',
|
||||
dtstart: 1,
|
||||
enabled: false,
|
||||
nextRunAt: 2,
|
||||
missedRunPolicy: 'run_once_within_grace',
|
||||
missedRunGraceMinutes: 720,
|
||||
createdAt: 1,
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
const detailCallbacks = {
|
||||
onRunNow: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onToggle: vi.fn(),
|
||||
onDelete: vi.fn()
|
||||
}
|
||||
|
||||
describe('AutomationPromptDisclosure', () => {
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage('en')
|
||||
vi.stubGlobal('ResizeObserver', PromptResizeObserver)
|
||||
vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
if (this.tagName !== 'P') {
|
||||
return 0
|
||||
}
|
||||
return this.classList.contains('line-clamp-4')
|
||||
? Math.min(promptNaturalHeight, 80)
|
||||
: promptNaturalHeight
|
||||
}
|
||||
)
|
||||
vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(
|
||||
function (this: HTMLElement) {
|
||||
return this.tagName === 'P' ? promptNaturalHeight : 0
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
cleanup()
|
||||
resizeCallback = null
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
await i18n.changeLanguage('en')
|
||||
})
|
||||
|
||||
it('leaves a short prompt fully readable without a disclosure control', () => {
|
||||
promptNaturalHeight = 40
|
||||
render(<AutomationPromptDisclosure prompt="Synthetic short prompt." />)
|
||||
|
||||
expect(screen.getByText('Synthetic short prompt.')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels the prompt correctly in Spanish', async () => {
|
||||
promptNaturalHeight = 40
|
||||
await i18n.changeLanguage('es')
|
||||
render(<AutomationPromptDisclosure prompt="Synthetic short prompt." />)
|
||||
|
||||
expect(screen.getByText('Prompt')).toBeVisible()
|
||||
expect(screen.queryByText('Inmediato')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reveals the complete long prompt from the keyboard and keeps it selectable', async () => {
|
||||
promptNaturalHeight = 240
|
||||
const prompt = `Synthetic opening.\n${'Synthetic detail. '.repeat(80)}\nSYNTHETIC-END-MARKER`
|
||||
const user = userEvent.setup()
|
||||
render(<AutomationPromptDisclosure prompt={prompt} />)
|
||||
|
||||
const content = screen.getByText(/SYNTHETIC-END-MARKER/)
|
||||
const toggle = screen.getByRole('button', { name: 'Show more' })
|
||||
expect(screen.getAllByText('Prompt')).toHaveLength(1)
|
||||
expect(content).toHaveClass('line-clamp-4', 'select-text', '[overflow-wrap:anywhere]')
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(toggle).toHaveAttribute('aria-controls', content.id)
|
||||
expect(toggle).toHaveAttribute('data-variant', 'ghost')
|
||||
expect(toggle).toHaveAttribute('data-size', 'xs')
|
||||
expect(toggle).not.toHaveClass('h-auto', 'p-0')
|
||||
|
||||
await user.tab()
|
||||
expect(toggle).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show less' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'true'
|
||||
)
|
||||
expect(content).not.toHaveClass('line-clamp-4')
|
||||
expect(content).toHaveTextContent('SYNTHETIC-END-MARKER')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show less' }))
|
||||
expect(screen.getByRole('button', { name: 'Show more' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
expect(content).toHaveClass('line-clamp-4')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show more' }))
|
||||
act(() => resizeCallback?.([], {} as ResizeObserver))
|
||||
expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show less' }))
|
||||
expect(screen.getByRole('button', { name: 'Show more' })).toHaveFocus()
|
||||
expect(content).toHaveClass('line-clamp-4')
|
||||
})
|
||||
|
||||
it('offers disclosure after a narrow resize makes the prompt overflow', () => {
|
||||
promptNaturalHeight = 60
|
||||
render(<AutomationPromptDisclosure prompt="Synthetic prompt that reflows at narrow widths." />)
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
|
||||
|
||||
promptNaturalHeight = 180
|
||||
act(() => resizeCallback?.([], {} as ResizeObserver))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show more' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('moves focus to the fully visible prompt when resizing removes the disclosure', () => {
|
||||
promptNaturalHeight = 180
|
||||
render(<AutomationPromptDisclosure prompt="Synthetic prompt that fits after widening." />)
|
||||
const content = screen.getByText('Synthetic prompt that fits after widening.')
|
||||
screen.getByRole('button', { name: 'Show more' }).focus()
|
||||
|
||||
promptNaturalHeight = 60
|
||||
act(() => resizeCallback?.([], {} as ResizeObserver))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument()
|
||||
expect(content).toHaveFocus()
|
||||
})
|
||||
|
||||
it('preserves expansion and focus across unrelated automation updates', async () => {
|
||||
promptNaturalHeight = 240
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(
|
||||
<AutomationDetail
|
||||
automation={makeAutomation(1)}
|
||||
runs={[]}
|
||||
projectName="Synthetic project"
|
||||
workspaceName="Synthetic workspace"
|
||||
projectDefaultBaseRef={null}
|
||||
runNowAvailability={null}
|
||||
now={1}
|
||||
{...detailCallbacks}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show more' }))
|
||||
const showLess = screen.getByRole('button', { name: 'Show less' })
|
||||
showLess.focus()
|
||||
|
||||
rerender(
|
||||
<AutomationDetail
|
||||
automation={makeAutomation(2)}
|
||||
runs={[]}
|
||||
projectName="Synthetic project"
|
||||
workspaceName="Synthetic workspace"
|
||||
projectDefaultBaseRef={null}
|
||||
runNowAvailability={null}
|
||||
now={2}
|
||||
{...detailCallbacks}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Show less' })).toHaveFocus()
|
||||
|
||||
promptNaturalHeight = 40
|
||||
rerender(
|
||||
<AutomationDetail
|
||||
automation={makeAutomation(3, 'Synthetic replacement prompt.')}
|
||||
runs={[]}
|
||||
projectName="Synthetic project"
|
||||
workspaceName="Synthetic workspace"
|
||||
projectDefaultBaseRef={null}
|
||||
runNowAvailability={null}
|
||||
now={3}
|
||||
{...detailCallbacks}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Synthetic replacement prompt.')).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Show less' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function AutomationPromptDisclosure({ prompt }: { prompt: string }): React.JSX.Element {
|
||||
const contentId = useId()
|
||||
const expandedRef = useRef(false)
|
||||
const toggleRef = useRef<HTMLButtonElement>(null)
|
||||
const [promptElement, setPromptElement] = useState<HTMLParagraphElement | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [overflows, setOverflows] = useState(false)
|
||||
|
||||
const measureOverflow = useCallback((element: HTMLParagraphElement) => {
|
||||
const nextOverflows = element.scrollHeight > element.clientHeight + 1
|
||||
if (!nextOverflows && document.activeElement === toggleRef.current) {
|
||||
element.focus({ preventScroll: true })
|
||||
}
|
||||
setOverflows((current) => (current === nextOverflows ? current : nextOverflows))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!promptElement || expanded) {
|
||||
return
|
||||
}
|
||||
|
||||
const updateOverflow = () => {
|
||||
if (!expandedRef.current) {
|
||||
measureOverflow(promptElement)
|
||||
}
|
||||
}
|
||||
updateOverflow()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateOverflow)
|
||||
return () => window.removeEventListener('resize', updateOverflow)
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(updateOverflow)
|
||||
observer.observe(promptElement)
|
||||
return () => observer.disconnect()
|
||||
}, [expanded, measureOverflow, promptElement])
|
||||
|
||||
const toggleExpanded = (): void => {
|
||||
const nextExpanded = !expanded
|
||||
expandedRef.current = nextExpanded
|
||||
setExpanded(nextExpanded)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border/50 bg-muted/20 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/50 px-3 py-2">
|
||||
<div className="text-sm font-medium">
|
||||
{translate('auto.components.automations.AutomationEditorDialog.058c23cb3f', 'Prompt')}
|
||||
</div>
|
||||
{overflows || expanded ? (
|
||||
<Button
|
||||
ref={toggleRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="-mr-2 text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
{expanded
|
||||
? translate(
|
||||
'auto.components.automations.AutomationPromptDisclosure.showLess',
|
||||
'Show less'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.automations.AutomationPromptDisclosure.showMore',
|
||||
'Show more'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 px-3 py-3">
|
||||
<p
|
||||
ref={setPromptElement}
|
||||
id={contentId}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'select-text whitespace-pre-wrap text-sm text-foreground [overflow-wrap:anywhere]',
|
||||
!expanded && 'line-clamp-4'
|
||||
)}
|
||||
>
|
||||
{prompt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
getVisibleBranchResults,
|
||||
getVisibleHeldProviderResults,
|
||||
isBlockingJiraUrlIntent,
|
||||
isBlockingTaskUrlResolution,
|
||||
type SmartNameMode,
|
||||
type SmartWorkspaceSourceRow
|
||||
} from './smart-workspace-source-results'
|
||||
@@ -639,6 +640,20 @@ export default function SmartWorkspaceNameField({
|
||||
() => (sourceQueryWithinLimit ? parseGitHubIssueOrPRLink(debouncedQuery) : null),
|
||||
[debouncedQuery, sourceQueryWithinLimit]
|
||||
)
|
||||
const githubUrlIntent = useMemo(
|
||||
() =>
|
||||
isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'github')
|
||||
? parseGitHubIssueOrPRLink(value)
|
||||
: null,
|
||||
[mode, value]
|
||||
)
|
||||
const gitlabUrlIntent = useMemo(
|
||||
() =>
|
||||
isSmartWorkspaceSourceQueryWithinLimit(value) && (mode === 'smart' || mode === 'gitlab')
|
||||
? parseGitLabIssueOrMRLink(value)
|
||||
: null,
|
||||
[mode, value]
|
||||
)
|
||||
const linearUrlIntent = useMemo(
|
||||
() => parseBoundedSmartWorkspaceLinearIssueUrlIntent(value),
|
||||
[value]
|
||||
@@ -1270,12 +1285,14 @@ export default function SmartWorkspaceNameField({
|
||||
value,
|
||||
debouncedQuery
|
||||
}),
|
||||
githubUrlIntent,
|
||||
gitlabAvailable: gitlabSourceAvailable,
|
||||
gitlabItems: getVisibleHeldProviderResults({
|
||||
items: gitlabItems,
|
||||
value,
|
||||
debouncedQuery
|
||||
}),
|
||||
gitlabUrlIntent,
|
||||
jiraIntent: jiraSource.intent,
|
||||
jiraIssue: jiraSource.issue,
|
||||
jiraIssues: getVisibleHeldProviderResults({
|
||||
@@ -1302,8 +1319,10 @@ export default function SmartWorkspaceNameField({
|
||||
branchResultsSource,
|
||||
debouncedQuery,
|
||||
githubItems,
|
||||
githubUrlIntent,
|
||||
gitlabSourceAvailable,
|
||||
gitlabItems,
|
||||
gitlabUrlIntent,
|
||||
jiraSource.accountChoices,
|
||||
jiraSource.intent,
|
||||
jiraSource.issue,
|
||||
@@ -1369,6 +1388,12 @@ export default function SmartWorkspaceNameField({
|
||||
linearAvailable &&
|
||||
sourceIntent !== 'linear' &&
|
||||
(linearLoading || settledLinearUrlQuery !== linearQuery.trim())
|
||||
const blockingTaskUrlResolution = isBlockingTaskUrlResolution({
|
||||
sourceIntent: sourceIntent === 'github' || sourceIntent === 'gitlab' ? sourceIntent : null,
|
||||
isQueryStale,
|
||||
githubLoading,
|
||||
gitlabLoading
|
||||
})
|
||||
|
||||
const resolvedCommandValue = resolveSmartWorkspaceCommandValue({
|
||||
currentValue: commandValue,
|
||||
@@ -1975,7 +2000,7 @@ export default function SmartWorkspaceNameField({
|
||||
handleEmojiSelect(selectedEmojiSuggestion)
|
||||
return
|
||||
}
|
||||
if (unresolvedLinearUrlIntent) {
|
||||
if (unresolvedLinearUrlIntent || blockingTaskUrlResolution) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getVisibleBranchResults,
|
||||
getVisibleHeldProviderResults,
|
||||
isBlockingJiraUrlIntent,
|
||||
isBlockingTaskUrlResolution,
|
||||
shouldHoldSourceResultsForQuery
|
||||
} from './smart-workspace-source-results'
|
||||
import {
|
||||
@@ -684,6 +685,38 @@ describe('Linear issue source input', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Task URL Enter protection', () => {
|
||||
it('blocks Enter while a pasted GitHub or GitLab URL is resolving', () => {
|
||||
expect(
|
||||
isBlockingTaskUrlResolution({
|
||||
sourceIntent: 'github',
|
||||
isQueryStale: true,
|
||||
githubLoading: false,
|
||||
gitlabLoading: false
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isBlockingTaskUrlResolution({
|
||||
sourceIntent: 'gitlab',
|
||||
isQueryStale: false,
|
||||
githubLoading: false,
|
||||
gitlabLoading: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the workspace-name fallback after lookup settles without a match', () => {
|
||||
expect(
|
||||
isBlockingTaskUrlResolution({
|
||||
sourceIntent: 'github',
|
||||
isQueryStale: false,
|
||||
githubLoading: false,
|
||||
gitlabLoading: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Jira issue search', () => {
|
||||
it('keeps valid Jira URL intent blocking in both Smart and Jira modes', () => {
|
||||
const firstUrl = 'https://company.atlassian.net/browse/ORCA-1'
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const shortcutLabelMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useOptionalShortcutLabel: shortcutLabelMock,
|
||||
formatShortcutLabel: () => '',
|
||||
formatOptionalShortcutLabel: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: function DropdownMenu(props: { children?: React.ReactNode; open?: boolean }) {
|
||||
return <div data-testid="dropdown-menu">{props.children}</div>
|
||||
},
|
||||
DropdownMenuContent: function DropdownMenuContent(props: { children?: React.ReactNode }) {
|
||||
return <div data-testid="dropdown-menu-content">{props.children}</div>
|
||||
},
|
||||
DropdownMenuItem: function DropdownMenuItem(props: {
|
||||
children?: React.ReactNode
|
||||
disabled?: boolean
|
||||
variant?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid="dropdown-menu-item"
|
||||
data-variant={props.variant}
|
||||
data-disabled={props.disabled}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
DropdownMenuSeparator: function DropdownMenuSeparator() {
|
||||
return <hr />
|
||||
},
|
||||
DropdownMenuShortcut: function DropdownMenuShortcut(props: { children?: React.ReactNode }) {
|
||||
return <span data-testid="dropdown-menu-shortcut">{props.children}</span>
|
||||
},
|
||||
DropdownMenuLabel: function DropdownMenuLabel(props: { children?: React.ReactNode }) {
|
||||
return <div>{props.children}</div>
|
||||
},
|
||||
DropdownMenuSub: function DropdownMenuSub(props: { children?: React.ReactNode }) {
|
||||
return <div>{props.children}</div>
|
||||
},
|
||||
DropdownMenuSubContent: function DropdownMenuSubContent(props: { children?: React.ReactNode }) {
|
||||
return <div>{props.children}</div>
|
||||
},
|
||||
DropdownMenuSubTrigger: function DropdownMenuSubTrigger(props: { children?: React.ReactNode }) {
|
||||
return <div>{props.children}</div>
|
||||
},
|
||||
DropdownMenuTrigger: function DropdownMenuTrigger(props: { children?: React.ReactNode }) {
|
||||
return <div>{props.children}</div>
|
||||
},
|
||||
DropdownMenuRadioGroup: function DropdownMenuRadioGroup(props: { children?: React.ReactNode }) {
|
||||
return <div>{props.children}</div>
|
||||
},
|
||||
DropdownMenuRadioItem: function DropdownMenuRadioItem(props: { children?: React.ReactNode }) {
|
||||
return <div>{props.children}</div>
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', async () =>
|
||||
(await import('../tab-bar/lucide-icon-stub-fixture')).stubEveryIcon()
|
||||
)
|
||||
|
||||
vi.mock('@/i18n/i18n', () => ({
|
||||
translate: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
const defaultStoreState = {
|
||||
updateWorktreeMeta: vi.fn(),
|
||||
setWorktreesPinnedAndReveal: vi.fn(),
|
||||
workspaceStatuses: [],
|
||||
openModal: vi.fn(),
|
||||
projectGroups: [],
|
||||
createProjectGroup: vi.fn(),
|
||||
moveProjectToGroup: vi.fn(),
|
||||
deleteStateByWorktreeId: {},
|
||||
worktreeLineageById: {},
|
||||
workspaceLineageByChildKey: {},
|
||||
updateWorktreeLineage: vi.fn(),
|
||||
tabsByWorktree: {},
|
||||
ptyIdsByTabId: {},
|
||||
browserTabsByWorktree: {},
|
||||
worktreesByRepo: {},
|
||||
repos: []
|
||||
}
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: typeof defaultStoreState) => unknown) => selector(defaultStoreState),
|
||||
{
|
||||
getState: () => defaultStoreState
|
||||
}
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useAllWorktrees: () => [],
|
||||
useRepoById: (repoId?: string) =>
|
||||
repoId ? { id: repoId, name: repoId, displayName: repoId } : undefined,
|
||||
useRepoMap: () => new Map(),
|
||||
useWorktreeMap: () => new Map()
|
||||
}))
|
||||
|
||||
vi.mock('./ProjectGroupNameDialog', () => ({
|
||||
ProjectGroupNameDialog: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeParentPickerPopover', () => ({
|
||||
WorktreeParentPickerPopover: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeDeveloperMenu', () => ({
|
||||
WorktreeDeveloperMenu: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorktreeOpenInMenu', () => ({
|
||||
WorktreeOpenInSubMenu: () => null
|
||||
}))
|
||||
|
||||
vi.mock('./WorkspaceSleepMenuItems', () => ({
|
||||
WorkspaceSleepMenuItems: () => null
|
||||
}))
|
||||
|
||||
const mounted: { container: HTMLDivElement; root: Root }[] = []
|
||||
|
||||
function renderContextMenu(worktree: Worktree) {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorktreeContextMenu worktree={worktree}>
|
||||
<div data-testid="card-child">Card Content</div>
|
||||
</WorktreeContextMenu>
|
||||
)
|
||||
})
|
||||
mounted.push({ container, root })
|
||||
return container
|
||||
}
|
||||
|
||||
describe('WorktreeContextMenu delete shortcut display', () => {
|
||||
beforeEach(() => {
|
||||
shortcutLabelMock.mockImplementation((action: string) => {
|
||||
if (action === 'workspace.delete') {
|
||||
return '⌘⇧⌫'
|
||||
}
|
||||
return null
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { root, container } of mounted) {
|
||||
act(() => {
|
||||
root.unmount()
|
||||
})
|
||||
container.remove()
|
||||
}
|
||||
mounted.length = 0
|
||||
})
|
||||
|
||||
it('renders the delete shortcut badge for standard worktree delete', () => {
|
||||
const worktree = {
|
||||
id: 'repo::wt-1',
|
||||
repoId: 'repo',
|
||||
name: 'wt-1',
|
||||
path: '/path/to/wt-1',
|
||||
isMainWorktree: false
|
||||
} as unknown as Worktree
|
||||
|
||||
const container = renderContextMenu(worktree)
|
||||
const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement
|
||||
expect(target).toBeTruthy()
|
||||
act(() => {
|
||||
target.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })
|
||||
)
|
||||
})
|
||||
|
||||
const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]')
|
||||
const deleteShortcuts = Array.from(shortcuts).filter((el) => el.textContent === '⌘⇧⌫')
|
||||
expect(deleteShortcuts.length).toBe(1)
|
||||
})
|
||||
|
||||
it('omits the delete shortcut on disabled Delete Worktree for primary checkout', () => {
|
||||
const worktree = {
|
||||
id: 'repo-1::main',
|
||||
repoId: 'repo-1',
|
||||
name: 'main',
|
||||
path: '/path/to/main',
|
||||
isMainWorktree: true
|
||||
} as unknown as Worktree
|
||||
|
||||
const container = renderContextMenu(worktree)
|
||||
const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement
|
||||
act(() => {
|
||||
target.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })
|
||||
)
|
||||
})
|
||||
|
||||
const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]')
|
||||
const deleteShortcuts = Array.from(shortcuts).filter((el) => el.textContent === '⌘⇧⌫')
|
||||
expect(deleteShortcuts.length).toBe(0)
|
||||
})
|
||||
|
||||
it('omits the shortcut badge when the action is unassigned', () => {
|
||||
shortcutLabelMock.mockReturnValue(null)
|
||||
const worktree = {
|
||||
id: 'repo::wt-1',
|
||||
repoId: 'repo',
|
||||
name: 'wt-1',
|
||||
path: '/path/to/wt-1',
|
||||
isMainWorktree: false
|
||||
} as unknown as Worktree
|
||||
|
||||
const container = renderContextMenu(worktree)
|
||||
const target = container.querySelector('[data-worktree-context-menu-scope]') as HTMLElement
|
||||
act(() => {
|
||||
target.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 })
|
||||
)
|
||||
})
|
||||
|
||||
const shortcuts = container.querySelectorAll('[data-testid="dropdown-menu-shortcut"]')
|
||||
expect(shortcuts.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
@@ -68,6 +69,7 @@ import { translate } from '@/i18n/i18n'
|
||||
import { unnestWorktrees } from './worktree-unnest'
|
||||
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
|
||||
import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
|
||||
type Props = {
|
||||
worktree: Worktree
|
||||
@@ -337,6 +339,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
const deleteState = useAppStore((s) =>
|
||||
getDeleteStateForWorktreeHost(worktree, s.deleteStateByWorktreeId)
|
||||
)
|
||||
const deleteShortcut = useOptionalShortcutLabel('workspace.delete')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Why: the Developer submenu is a power-user affordance, so it is revealed by
|
||||
// holding Option/Alt at right-click — captured at open time (like the Help
|
||||
@@ -1086,6 +1089,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
'auto.components.sidebar.WorktreeContextMenu.f4475537d8',
|
||||
'Delete'
|
||||
)}
|
||||
{!isMultiContext && !removesProject && deleteShortcut ? (
|
||||
<DropdownMenuShortcut>{deleteShortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -66,7 +66,8 @@ vi.mock('@/components/ui/dropdown-menu', () => {
|
||||
DropdownMenuSub: passthrough,
|
||||
DropdownMenuSubContent: passthrough,
|
||||
DropdownMenuSubTrigger: passthrough,
|
||||
DropdownMenuTrigger: passthrough
|
||||
DropdownMenuTrigger: passthrough,
|
||||
DropdownMenuShortcut: passthrough
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
deleteHoveredWorkspaceImmediately,
|
||||
getHoveredWorkspaceIdentity,
|
||||
resolveHoveredWorkspaceDeleteTarget
|
||||
} from './hovered-workspace-delete'
|
||||
|
||||
function worktree(overrides: Partial<Worktree> = {}): Worktree {
|
||||
return {
|
||||
id: 'repo::/feature',
|
||||
repoId: 'repo',
|
||||
path: '/feature',
|
||||
branch: 'feature',
|
||||
isMainWorktree: false,
|
||||
...overrides
|
||||
} as Worktree
|
||||
}
|
||||
|
||||
function hoveredDocument(...rows: { workspaceId: string; hostIdentity: string }[]) {
|
||||
return {
|
||||
activeElement: null,
|
||||
querySelectorAll: () => ({
|
||||
length: rows.length,
|
||||
item: (index: number) => {
|
||||
const row = rows[index]
|
||||
return row
|
||||
? ({
|
||||
dataset: {
|
||||
worktreeId: row.workspaceId,
|
||||
worktreeHostIdentity: row.hostIdentity
|
||||
}
|
||||
} as unknown as HTMLElement)
|
||||
: null
|
||||
}
|
||||
})
|
||||
} as unknown as Pick<Document, 'activeElement' | 'querySelectorAll'>
|
||||
}
|
||||
|
||||
function state(worktrees: Worktree[] = []): AppState {
|
||||
return {
|
||||
activeModal: 'none',
|
||||
activeWorkspaceExecutionHostId: worktrees[0]?.hostId ?? null,
|
||||
activeWorktreeId: worktrees[0]?.id ?? null,
|
||||
deleteFolderWorkspace: vi.fn(),
|
||||
deleteStateByWorktreeId: {},
|
||||
setActiveWorktree: vi.fn(),
|
||||
worktreesByRepo: { repo: worktrees }
|
||||
} as unknown as AppState
|
||||
}
|
||||
|
||||
describe('hovered workspace delete', () => {
|
||||
it('uses the deepest hovered worktree row', () => {
|
||||
expect(
|
||||
getHoveredWorkspaceIdentity(
|
||||
hoveredDocument(
|
||||
{ workspaceId: 'parent', hostIdentity: 'local|parent' },
|
||||
{ workspaceId: 'child', hostIdentity: 'local|child' }
|
||||
)
|
||||
)
|
||||
).toEqual({ workspaceId: 'child', hostIdentity: 'local|child' })
|
||||
})
|
||||
|
||||
it('resolves the exact hovered host instead of the active workspace', () => {
|
||||
const active = worktree({ id: 'repo::/active', path: '/active', hostId: 'local' })
|
||||
const hovered = worktree({ hostId: 'ssh:build', instanceId: 'instance-2' })
|
||||
|
||||
expect(
|
||||
resolveHoveredWorkspaceDeleteTarget(
|
||||
state([active, hovered]),
|
||||
hoveredDocument({ workspaceId: hovered.id, hostIdentity: 'ssh:build|repo::/feature' })
|
||||
)
|
||||
).toEqual({ kind: 'worktree', worktree: hovered })
|
||||
})
|
||||
|
||||
it('retains the hovered host when resolving a folder workspace', () => {
|
||||
expect(
|
||||
resolveHoveredWorkspaceDeleteTarget(
|
||||
state(),
|
||||
hoveredDocument({
|
||||
workspaceId: 'folder:folder-1',
|
||||
hostIdentity: 'runtime:remote-1|folder:folder-1'
|
||||
})
|
||||
)
|
||||
).toEqual({
|
||||
kind: 'folder',
|
||||
executionHostId: 'runtime:remote-1',
|
||||
folderWorkspaceId: 'folder-1',
|
||||
workspaceKey: 'folder:folder-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects primary worktrees, stale rows, and missing hover', () => {
|
||||
const primary = worktree({ hostId: 'local', isMainWorktree: true })
|
||||
const current = state([primary])
|
||||
|
||||
expect(
|
||||
resolveHoveredWorkspaceDeleteTarget(
|
||||
current,
|
||||
hoveredDocument({ workspaceId: primary.id, hostIdentity: 'local|repo::/feature' })
|
||||
)
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveHoveredWorkspaceDeleteTarget(
|
||||
current,
|
||||
hoveredDocument({ workspaceId: 'stale', hostIdentity: 'local|stale' })
|
||||
)
|
||||
).toBeNull()
|
||||
expect(resolveHoveredWorkspaceDeleteTarget(current, hoveredDocument())).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects worktrees that are already deleting', () => {
|
||||
const target = worktree({ hostId: 'ssh:build' })
|
||||
const current = state([target])
|
||||
current.deleteStateByWorktreeId = {
|
||||
'ssh:build|repo::/feature': {
|
||||
isDeleting: true,
|
||||
error: null,
|
||||
canForceDelete: false,
|
||||
forceDeleteReason: null
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveHoveredWorkspaceDeleteTarget(
|
||||
current,
|
||||
hoveredDocument({ workspaceId: target.id, hostIdentity: 'ssh:build|repo::/feature' })
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects hovered rows while an editable control has focus', () => {
|
||||
class EditableElement {
|
||||
classList = { contains: () => false }
|
||||
isContentEditable = false
|
||||
closest = () => this
|
||||
}
|
||||
vi.stubGlobal('HTMLElement', EditableElement)
|
||||
const target = worktree({ hostId: 'local' })
|
||||
const doc = hoveredDocument({
|
||||
workspaceId: target.id,
|
||||
hostIdentity: 'local|repo::/feature'
|
||||
}) as Pick<Document, 'activeElement' | 'querySelectorAll'>
|
||||
Object.defineProperty(doc, 'activeElement', { value: new EditableElement() })
|
||||
|
||||
try {
|
||||
expect(resolveHoveredWorkspaceDeleteTarget(state([target]), doc)).toBeNull()
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('routes the hovered worktree through the host-qualified safety flow', () => {
|
||||
const target = worktree({ hostId: 'ssh:build', instanceId: 'instance-2' })
|
||||
const deleteWorktree = vi.fn()
|
||||
const current = state([target])
|
||||
|
||||
expect(
|
||||
deleteHoveredWorkspaceImmediately(
|
||||
current,
|
||||
{ kind: 'worktree', worktree: target },
|
||||
{
|
||||
deleteWorktree,
|
||||
getCurrentState: () => current
|
||||
}
|
||||
)
|
||||
).toBe(true)
|
||||
expect(deleteWorktree).toHaveBeenCalledWith(target.id, {
|
||||
expectedHostId: 'ssh:build',
|
||||
expectedInstanceId: 'instance-2'
|
||||
})
|
||||
})
|
||||
|
||||
it('removes a hovered folder workspace from Orca without deleting its directory', async () => {
|
||||
const current = state()
|
||||
current.activeWorktreeId = 'folder:folder-1'
|
||||
current.activeWorkspaceExecutionHostId = 'runtime:remote-1'
|
||||
current.deleteFolderWorkspace = vi.fn().mockResolvedValue(true)
|
||||
|
||||
expect(
|
||||
deleteHoveredWorkspaceImmediately(
|
||||
current,
|
||||
{
|
||||
kind: 'folder',
|
||||
executionHostId: 'runtime:remote-1',
|
||||
folderWorkspaceId: 'folder-1',
|
||||
workspaceKey: 'folder:folder-1'
|
||||
},
|
||||
{ deleteWorktree: vi.fn(), getCurrentState: () => current }
|
||||
)
|
||||
).toBe(true)
|
||||
await vi.waitFor(() => expect(current.setActiveWorktree).toHaveBeenCalledWith(null))
|
||||
expect(current.deleteFolderWorkspace).toHaveBeenCalledWith('folder-1', {
|
||||
executionHostId: 'runtime:remote-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a duplicate folder delete while the first request is pending', async () => {
|
||||
let finishDelete!: (deleted: boolean) => void
|
||||
const current = state()
|
||||
current.deleteFolderWorkspace = vi.fn(
|
||||
() => new Promise<boolean>((resolve) => (finishDelete = resolve))
|
||||
)
|
||||
const target = {
|
||||
kind: 'folder' as const,
|
||||
executionHostId: 'runtime:remote-2' as const,
|
||||
folderWorkspaceId: 'folder-2',
|
||||
workspaceKey: 'folder:folder-2'
|
||||
}
|
||||
const dependencies = { deleteWorktree: vi.fn(), getCurrentState: () => current }
|
||||
|
||||
expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(true)
|
||||
expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(false)
|
||||
expect(current.deleteFolderWorkspace).toHaveBeenCalledOnce()
|
||||
|
||||
finishDelete(false)
|
||||
await vi.waitFor(() =>
|
||||
expect(deleteHoveredWorkspaceImmediately(current, target, dependencies)).toBe(true)
|
||||
)
|
||||
finishDelete(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { getAllWorktreesFromState } from '@/store/selectors'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { isEditableTarget } from '@/lib/editable-target'
|
||||
import {
|
||||
composeWorktreeHostIdentity,
|
||||
getExecutionHostIdFromWorktreeHostIdentity,
|
||||
getWorktreeHostIdentity
|
||||
} from '../../../../shared/worktree/host-qualified-identity'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { runWorktreeDelete } from './delete-worktree-flow'
|
||||
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
|
||||
|
||||
const pendingFolderDeletes = new Set<string>()
|
||||
|
||||
type DeleteWorktree = typeof runWorktreeDelete
|
||||
type HoveredWorkspaceDeleteState = Pick<
|
||||
AppState,
|
||||
| 'activeModal'
|
||||
| 'activeWorktreeId'
|
||||
| 'deleteFolderWorkspace'
|
||||
| 'deleteStateByWorktreeId'
|
||||
| 'worktreesByRepo'
|
||||
>
|
||||
type CurrentWorkspaceState = Pick<
|
||||
AppState,
|
||||
'activeWorkspaceExecutionHostId' | 'activeWorktreeId' | 'setActiveWorktree'
|
||||
>
|
||||
type HoveredWorkspaceDeleteDependencies = {
|
||||
deleteWorktree: DeleteWorktree
|
||||
getCurrentState: () => CurrentWorkspaceState
|
||||
}
|
||||
type HoveredWorkspaceDocument = Pick<Document, 'activeElement' | 'querySelectorAll'>
|
||||
|
||||
export type HoveredWorkspaceDeleteTarget =
|
||||
| {
|
||||
kind: 'folder'
|
||||
executionHostId: ExecutionHostId
|
||||
folderWorkspaceId: string
|
||||
workspaceKey: string
|
||||
}
|
||||
| { kind: 'worktree'; worktree: Worktree }
|
||||
|
||||
export function getHoveredWorkspaceIdentity(
|
||||
doc: HoveredWorkspaceDocument = document
|
||||
): { hostIdentity: string; workspaceId: string } | null {
|
||||
const hoveredRows = doc.querySelectorAll<HTMLElement>(
|
||||
'[data-worktree-sidebar] [role="option"][data-worktree-id]:hover'
|
||||
)
|
||||
const row = hoveredRows.item(hoveredRows.length - 1)
|
||||
const workspaceId = row?.dataset.worktreeId
|
||||
const hostIdentity = row?.dataset.worktreeHostIdentity
|
||||
return workspaceId && hostIdentity ? { workspaceId, hostIdentity } : null
|
||||
}
|
||||
|
||||
export function resolveHoveredWorkspaceDeleteTarget(
|
||||
state: HoveredWorkspaceDeleteState,
|
||||
doc: HoveredWorkspaceDocument = document
|
||||
): HoveredWorkspaceDeleteTarget | null {
|
||||
if (state.activeModal !== 'none' || (doc.activeElement && isEditableTarget(doc.activeElement))) {
|
||||
return null
|
||||
}
|
||||
const hovered = getHoveredWorkspaceIdentity(doc)
|
||||
if (!hovered) {
|
||||
return null
|
||||
}
|
||||
const workspaceScope = parseWorkspaceKey(hovered.workspaceId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
const executionHostId = getExecutionHostIdFromWorktreeHostIdentity(hovered.hostIdentity)
|
||||
if (!executionHostId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
kind: 'folder',
|
||||
executionHostId,
|
||||
folderWorkspaceId: workspaceScope.folderWorkspaceId,
|
||||
workspaceKey: hovered.workspaceId
|
||||
}
|
||||
}
|
||||
const worktree = getAllWorktreesFromState(state).find(
|
||||
(candidate) =>
|
||||
candidate.id === hovered.workspaceId &&
|
||||
getWorktreeHostIdentity(candidate) === hovered.hostIdentity
|
||||
)
|
||||
return worktree &&
|
||||
!worktree.isMainWorktree &&
|
||||
!getDeleteStateForWorktreeHost(worktree, state.deleteStateByWorktreeId)?.isDeleting
|
||||
? { kind: 'worktree', worktree }
|
||||
: null
|
||||
}
|
||||
|
||||
export function deleteHoveredWorkspaceImmediately(
|
||||
state: HoveredWorkspaceDeleteState,
|
||||
target: HoveredWorkspaceDeleteTarget | null = resolveHoveredWorkspaceDeleteTarget(state),
|
||||
dependencies: HoveredWorkspaceDeleteDependencies = {
|
||||
deleteWorktree: runWorktreeDelete,
|
||||
getCurrentState: useAppStore.getState
|
||||
}
|
||||
): boolean {
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
if (target.kind === 'folder') {
|
||||
const pendingIdentity = composeWorktreeHostIdentity(target.executionHostId, target.workspaceKey)
|
||||
if (pendingFolderDeletes.has(pendingIdentity)) {
|
||||
return false
|
||||
}
|
||||
pendingFolderDeletes.add(pendingIdentity)
|
||||
void state
|
||||
.deleteFolderWorkspace(target.folderWorkspaceId, {
|
||||
executionHostId: target.executionHostId
|
||||
})
|
||||
.then((deleted) => {
|
||||
const current = dependencies.getCurrentState()
|
||||
if (
|
||||
deleted &&
|
||||
current.activeWorktreeId === target.workspaceKey &&
|
||||
current.activeWorkspaceExecutionHostId === target.executionHostId
|
||||
) {
|
||||
current.setActiveWorktree(null)
|
||||
}
|
||||
})
|
||||
.finally(() => pendingFolderDeletes.delete(pendingIdentity))
|
||||
return true
|
||||
}
|
||||
dependencies.deleteWorktree(target.worktree.id, {
|
||||
expectedInstanceId: target.worktree.instanceId,
|
||||
...(target.worktree.hostId ? { expectedHostId: target.worktree.hostId } : {})
|
||||
})
|
||||
return true
|
||||
}
|
||||
+42
@@ -6,6 +6,10 @@ import { createMockTransport, createPane, createManager } from './pty-connection
|
||||
import { buildPaneConnectionDeps, buildDirectSshSplitRetryCommit } from './pty-connection-test-deps'
|
||||
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
|
||||
import type { StoreState } from './pty-connection-test-store-state'
|
||||
import {
|
||||
pendingSpawnByPaneKey,
|
||||
pendingSpawnGenerationByPaneKey
|
||||
} from './pty-connection/pty-connect-limits'
|
||||
import type { MockTransport } from './pty-connection-test-pane-fixtures'
|
||||
import {
|
||||
installTerminalTestGlobals,
|
||||
@@ -139,6 +143,8 @@ describe('connectPanePty', () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
pendingSpawnByPaneKey.clear()
|
||||
pendingSpawnGenerationByPaneKey.clear()
|
||||
await restoreTerminalTestGlobals()
|
||||
})
|
||||
|
||||
@@ -161,6 +167,42 @@ describe('connectPanePty', () => {
|
||||
logSpy.mockRestore()
|
||||
}, 30_000)
|
||||
|
||||
it('does not hold a successor behind a canceled spawn serializer declaration', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const declaration = createDeferred<number>()
|
||||
const spawn = createDeferred<null>()
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockReturnValueOnce(spawn.promise)
|
||||
transportFactoryQueue.push(transport)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
|
||||
ptyIdsByTabId: { 'tab-1': [] }
|
||||
}
|
||||
vi.mocked(window.api.pty.declarePendingPaneSerializer).mockReturnValueOnce(declaration.promise)
|
||||
|
||||
const binding = connectPanePty(
|
||||
createPane(1) as never,
|
||||
createManager(1) as never,
|
||||
createDeps() as never
|
||||
)
|
||||
await flushAsyncTicks(12)
|
||||
binding.dispose()
|
||||
spawn.resolve(null)
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(pendingSpawnByPaneKey.size).toBe(0)
|
||||
expect(window.api.pty.clearPendingPaneSerializer).not.toHaveBeenCalled()
|
||||
|
||||
declaration.resolve(7)
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledExactlyOnceWith(
|
||||
expect.any(String),
|
||||
7
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['rejects', 'resolves empty'] as const)(
|
||||
'settles the exact direct SSH retry when its fresh spawn %s',
|
||||
async (outcome) => {
|
||||
|
||||
+10
-2
@@ -33,14 +33,16 @@ const {
|
||||
scheduleRuntimeGraphSync,
|
||||
shouldSeedCacheTimerOnInitialTitle,
|
||||
toastInfo,
|
||||
notifyCodexPaneBoundForStaleSweep
|
||||
notifyCodexPaneBoundForStaleSweep,
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
} = vi.hoisted(() => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
|
||||
scheduleTerminalWebglAtlasRecovery: vi.fn(),
|
||||
scheduleRuntimeGraphSync: vi.fn(),
|
||||
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
|
||||
toastInfo: vi.fn(),
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn()
|
||||
notifyCodexPaneBoundForStaleSweep: vi.fn(),
|
||||
presentPaneViewportPreservingSynchronizedOutput: vi.fn()
|
||||
}))
|
||||
|
||||
let mockStoreState: StoreState
|
||||
@@ -57,6 +59,11 @@ vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
presentPaneViewportPreservingSynchronizedOutput
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTerminalWebglAtlasRecovery
|
||||
}))
|
||||
@@ -201,6 +208,7 @@ describe('connectPanePty', () => {
|
||||
expect.stringContaining('remote snapshot with hidden remote output'),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(presentPaneViewportPreservingSynchronizedOutput).toHaveBeenCalledWith(pane)
|
||||
disposable.dispose()
|
||||
})
|
||||
|
||||
|
||||
+8
-3
@@ -151,9 +151,14 @@ export function installAgentIdleWorkingHandlers(session: ConnectPanePtySession):
|
||||
: undefined
|
||||
return attempt
|
||||
})()
|
||||
session.pendingSpawnKey = session.directSshRetryAttempt
|
||||
? JSON.stringify([session.cacheKey, session.directSshRetryAttempt.attemptId])
|
||||
: session.cacheKey
|
||||
// Generation is part of ownership: a recovery remount must not join a spawn
|
||||
// started by the pane instance it replaced, while StrictMode remounts keep
|
||||
// the same generation and may still share their in-flight spawn.
|
||||
session.pendingSpawnKey = JSON.stringify([
|
||||
session.cacheKey,
|
||||
session.tabGeneration,
|
||||
session.directSshRetryAttempt?.attemptId ?? null
|
||||
])
|
||||
session.capturedDirectSshRetryPtyAccepted = false
|
||||
session.directSshPaneRetrySettlementCancelled = false
|
||||
session.directSshPaneRetrySettlementTimers = new Set<ReturnType<typeof setTimeout>>()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import { useAppStore } from '@/store'
|
||||
import { TerminalKittyKeyboardModeTracker } from '../../../../../shared/terminal-kitty-keyboard-mode-tracker'
|
||||
import type { PtyConnectionDeps } from '../pty-connection-types'
|
||||
import {
|
||||
@@ -35,6 +36,7 @@ import { installPtyInputRecovery } from './pty-input-recovery'
|
||||
import { installPtyInputForward } from './pty-input-forward'
|
||||
import { installPtyResizeGeometry } from './pty-resize-geometry'
|
||||
import { installSessionReconcileDispose } from './session-reconcile-dispose'
|
||||
import { resolveTerminalTabId } from './terminal-tab-id'
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
@@ -48,6 +50,43 @@ export function connectPanePty(
|
||||
const session = { pane, manager, deps } as ConnectPanePtySession
|
||||
session.shouldRefreshForegroundSynchronously = (): boolean =>
|
||||
!session.manager.hasWebglRenderer(session.pane.id)
|
||||
const state = useAppStore.getState()
|
||||
const unifiedTab = state.getTab?.(deps.tabId)
|
||||
const initialOwnerWorktreeId =
|
||||
state.getTerminalTabOwnerWorktreeId?.(deps.tabId) ??
|
||||
(unifiedTab?.contentType === 'terminal'
|
||||
? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId)
|
||||
: null)
|
||||
const terminalTabId = resolveTerminalTabId(
|
||||
{
|
||||
getTab: state.getTab,
|
||||
hasTerminalTab: (candidateId) =>
|
||||
Boolean(
|
||||
state.tabsByWorktree[deps.worktreeId]?.some(
|
||||
(candidate) => candidate.id === candidateId
|
||||
) ||
|
||||
(initialOwnerWorktreeId
|
||||
? state.tabsByWorktree[initialOwnerWorktreeId]?.some(
|
||||
(candidate) => candidate.id === candidateId
|
||||
)
|
||||
: false)
|
||||
)
|
||||
},
|
||||
deps.tabId
|
||||
)
|
||||
const ownerWorktreeId =
|
||||
state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId
|
||||
const terminalTab =
|
||||
state.tabsByWorktree[deps.worktreeId]?.find((candidate) => candidate.id === terminalTabId) ??
|
||||
(ownerWorktreeId
|
||||
? state.tabsByWorktree[ownerWorktreeId]?.find((candidate) => candidate.id === terminalTabId)
|
||||
: undefined) ??
|
||||
// Why: folder/worktree migrations can leave the pane's render key stale for one commit.
|
||||
Object.values(state.tabsByWorktree)
|
||||
.find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId))
|
||||
?.find((candidate) => candidate.id === terminalTabId)
|
||||
const tab = terminalTab ?? (unifiedTab && 'generation' in unifiedTab ? unifiedTab : null)
|
||||
session.tabGeneration = tab?.generation ?? 0
|
||||
// Why: recovery ownership belongs to this xterm instance. A request that
|
||||
// settles after remount must not remount its already-replaced successor.
|
||||
session.terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(session.deps.tabId)
|
||||
|
||||
+12
-1
@@ -3,7 +3,11 @@ import { useAppStore } from '@/store'
|
||||
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
|
||||
import { getEagerPtyBufferHandle } from '../pty-dispatcher'
|
||||
|
||||
import { pendingSpawnByPaneKey, recordPtyConnectDiagnostic } from './pty-connect-limits'
|
||||
import {
|
||||
pendingSpawnByPaneKey,
|
||||
pendingSpawnGenerationByPaneKey,
|
||||
recordPtyConnectDiagnostic
|
||||
} from './pty-connect-limits'
|
||||
import {
|
||||
isRemoteRuntimePtyId,
|
||||
canRestorePairedParkedTerminal,
|
||||
@@ -158,6 +162,13 @@ export function runDeferredSessionReattachChoice(session: ConnectPanePtySession)
|
||||
session.allowInitialIdleCacheSeed = false
|
||||
const pendingSpawn = pendingSpawnByPaneKey.get(session.pendingSpawnKey)
|
||||
if (pendingSpawn) {
|
||||
const pendingGeneration = pendingSpawnGenerationByPaneKey.get(session.pendingSpawnKey)
|
||||
if (pendingGeneration !== undefined && pendingGeneration !== session.tabGeneration) {
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> STALE PENDING SPAWN`)
|
||||
session.startFreshSpawn()
|
||||
scheduleRuntimeGraphSync()
|
||||
return
|
||||
}
|
||||
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> PENDING SPAWN`)
|
||||
session.armDirectSshPaneRetryTimeout(pendingSpawn, session.directSshRetryAttempt)
|
||||
void pendingSpawn
|
||||
|
||||
@@ -3,7 +3,7 @@ import { hasPtySerializer } from '../pty-buffer-serializer'
|
||||
import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
|
||||
import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice'
|
||||
import { pendingSpawnByPaneKey } from './pty-connect-limits'
|
||||
import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits'
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { toProcessExitStartup } from './process-exit-startup'
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
} from './fresh-spawn-types'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
import { resolveTerminalTabId } from './terminal-tab-id'
|
||||
|
||||
export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
session.startFreshSpawn = (
|
||||
@@ -56,6 +57,15 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
const preSignalPromise = session.runtimeEnvironmentId
|
||||
? Promise.resolve(null)
|
||||
: window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null)
|
||||
const clearPreSignaledSerializer = (): void => {
|
||||
// A disposed pre-bind connect must not keep a successor behind a slow
|
||||
// serializer declaration. Cleanup can finish independently of spawn ownership.
|
||||
void preSignalPromise.then((gen) => {
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
session.transportConnectInFlightSince = Date.now()
|
||||
const effectiveStartup = startupOverride === undefined ? session.paneStartup : startupOverride
|
||||
@@ -86,6 +96,51 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}),
|
||||
...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}),
|
||||
...(session.shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
|
||||
shouldContinue: () => {
|
||||
const state = useAppStore.getState()
|
||||
const unifiedTab = state.getTab?.(session.deps.tabId)
|
||||
const initialOwnerWorktreeId =
|
||||
state.getTerminalTabOwnerWorktreeId?.(session.deps.tabId) ??
|
||||
(unifiedTab?.contentType === 'terminal'
|
||||
? state.getTerminalTabOwnerWorktreeId?.(unifiedTab.entityId)
|
||||
: null)
|
||||
const terminalTabId = resolveTerminalTabId(
|
||||
{
|
||||
getTab: state.getTab,
|
||||
hasTerminalTab: (candidateId) =>
|
||||
Boolean(
|
||||
state.tabsByWorktree[session.deps.worktreeId]?.some(
|
||||
(candidate) => candidate.id === candidateId
|
||||
) ||
|
||||
(initialOwnerWorktreeId
|
||||
? state.tabsByWorktree[initialOwnerWorktreeId]?.some(
|
||||
(candidate) => candidate.id === candidateId
|
||||
)
|
||||
: false)
|
||||
)
|
||||
},
|
||||
session.deps.tabId
|
||||
)
|
||||
const ownerWorktreeId =
|
||||
state.getTerminalTabOwnerWorktreeId?.(terminalTabId) ?? initialOwnerWorktreeId
|
||||
const terminalTab =
|
||||
state.tabsByWorktree[session.deps.worktreeId]?.find(
|
||||
(candidate) => candidate.id === terminalTabId
|
||||
) ??
|
||||
(ownerWorktreeId
|
||||
? state.tabsByWorktree[ownerWorktreeId]?.find(
|
||||
(candidate) => candidate.id === terminalTabId
|
||||
)
|
||||
: undefined)
|
||||
const fallbackTab = Object.values(state.tabsByWorktree)
|
||||
.find((tabs) => tabs.some((candidate) => candidate.id === terminalTabId))
|
||||
?.find((candidate) => candidate.id === terminalTabId)
|
||||
const currentTab =
|
||||
terminalTab ??
|
||||
fallbackTab ??
|
||||
(unifiedTab && 'generation' in unifiedTab ? unifiedTab : null)
|
||||
return !session.disposed && (currentTab?.generation ?? 0) === session.tabGeneration
|
||||
},
|
||||
callbacks: outputCallbacks.callbacks
|
||||
})
|
||||
|
||||
@@ -98,10 +153,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
.then(async (spawnedPtyId) => {
|
||||
if (outputCallbacks.generation !== session.transportStreamGeneration) {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
clearPreSignaledSerializer()
|
||||
return null
|
||||
}
|
||||
const resolvedPtyId =
|
||||
@@ -114,10 +166,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
// Why: an outstanding declare keeps main's cooperation gate suppressing
|
||||
// this paneKey's daemon-snapshot seed until something releases it.
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
clearPreSignaledSerializer()
|
||||
return null
|
||||
}
|
||||
const connectResult =
|
||||
@@ -196,6 +245,11 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
if (resolvedPtyId) {
|
||||
session.reconcilePtySizeAfterSpawn(resolvedPtyId, session.cols, session.rows)
|
||||
}
|
||||
if (!resolvedPtyId) {
|
||||
clearPreSignaledSerializer()
|
||||
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
|
||||
return null
|
||||
}
|
||||
const gen = await preSignalPromise
|
||||
// Why: a bound PTY owns the renderer serializer even when the declare was
|
||||
// rejected; the gen token only settles or clears the pending declaration.
|
||||
@@ -206,8 +260,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.settlePaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
} else if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
if (resolvedPtyId && session.connectionId) {
|
||||
if (
|
||||
@@ -229,15 +281,13 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
) {
|
||||
session.clearRegisteredStartupLaunchConfig()
|
||||
}
|
||||
const gen = await preSignalPromise
|
||||
if (typeof gen === 'number') {
|
||||
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
|
||||
}
|
||||
clearPreSignaledSerializer()
|
||||
return null
|
||||
})
|
||||
.finally(() => {
|
||||
if (pendingSpawnByPaneKey.get(session.pendingSpawnKey) === trackedPromise) {
|
||||
pendingSpawnByPaneKey.delete(session.pendingSpawnKey)
|
||||
pendingSpawnGenerationByPaneKey.delete(session.pendingSpawnKey)
|
||||
}
|
||||
})
|
||||
session.armDirectSshPaneRetryTimeout(trackedPromise, session.directSshRetryAttempt)
|
||||
@@ -259,6 +309,7 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void {
|
||||
// Why: split panes in the same tab can spawn concurrently. Key by pane
|
||||
// as well as tab so a remount cannot attach to a sibling setup pane's PTY.
|
||||
pendingSpawnByPaneKey.set(session.pendingSpawnKey, trackedPromise)
|
||||
pendingSpawnGenerationByPaneKey.set(session.pendingSpawnKey, session.tabGeneration)
|
||||
return trackedPromise
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -11,6 +11,7 @@ import {
|
||||
writeTerminalOutput
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
|
||||
import { presentPaneViewportPreservingSynchronizedOutput } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
import {
|
||||
buildMainModelSnapshotReplayWrites,
|
||||
hasPositiveTerminalDimensions,
|
||||
@@ -22,6 +23,7 @@ import { HIDDEN_OUTPUT_RESTORE_UNAVAILABLE_WARNING } from './hidden-output-resto
|
||||
import { shouldWritePtyOutputForeground } from './foreground-output-scan'
|
||||
import { isRemoteRuntimePtyId } from './paired-parked-terminal-restore'
|
||||
import { restoredSnapshotPaintsPrintableContent } from '../restored-snapshot-coverage'
|
||||
import { recordTerminalFreezeBreadcrumb } from '../terminal-freeze-breadcrumbs'
|
||||
|
||||
import type { ConnectPanePtySession } from './connect-pane-pty-session'
|
||||
|
||||
@@ -152,6 +154,12 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
|
||||
session.recordRendererOrderedSeq(snapshot)
|
||||
recordTerminalOutput(session.pane.terminal)
|
||||
await waitForTerminalReplayWritesParsed(session.pane.terminal)
|
||||
if (session.deps.isVisibleRef.current) {
|
||||
presentPaneViewportPreservingSynchronizedOutput(session.pane)
|
||||
recordTerminalFreezeBreadcrumb('stale-pixel-restore-present', {
|
||||
paneId: session.pane.id
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
shouldRestore: () =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
|
||||
export const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
|
||||
export const pendingSpawnGenerationByPaneKey = new Map<string, number>()
|
||||
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
// Why: relay requests expire at 30s; leave one second for their fallback before re-arming locally.
|
||||
export const DIRECT_SSH_PANE_RETRY_SETTLEMENT_TIMEOUT_MS = 31_000
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { resolveTerminalTabId } from './terminal-tab-id'
|
||||
|
||||
describe('resolveTerminalTabId', () => {
|
||||
it('maps a unified terminal tab id to its legacy owner id', () => {
|
||||
const getTab = vi.fn(() => ({ contentType: 'terminal', entityId: 'legacy-tab' }))
|
||||
|
||||
expect(resolveTerminalTabId({ getTab }, 'unified-tab')).toBe('legacy-tab')
|
||||
expect(getTab).toHaveBeenCalledWith('unified-tab')
|
||||
})
|
||||
|
||||
it('keeps non-terminal and unknown ids unchanged', () => {
|
||||
expect(
|
||||
resolveTerminalTabId(
|
||||
{ getTab: () => ({ contentType: 'editor', entityId: '/tmp/file.ts' }) },
|
||||
'editor-tab'
|
||||
)
|
||||
).toBe('editor-tab')
|
||||
expect(resolveTerminalTabId({}, 'legacy-tab')).toBe('legacy-tab')
|
||||
})
|
||||
|
||||
it('keeps an exact terminal owner ahead of a stale unified alias', () => {
|
||||
expect(
|
||||
resolveTerminalTabId(
|
||||
{
|
||||
getTab: () => ({ contentType: 'terminal', entityId: 'old-owner' }),
|
||||
hasTerminalTab: (tabId) => tabId === 'live-owner'
|
||||
},
|
||||
'live-owner'
|
||||
)
|
||||
).toBe('live-owner')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
type TerminalTabLookup = {
|
||||
getTab?: (tabId: string) => { contentType: string; entityId: string } | null
|
||||
hasTerminalTab?: (tabId: string) => boolean
|
||||
}
|
||||
|
||||
/** Resolve a renderer tab id to the legacy terminal-tab id used by PTY state. */
|
||||
export function resolveTerminalTabId(state: TerminalTabLookup, tabId: string): string {
|
||||
// The terminal-tab table is authoritative when both id forms are briefly present.
|
||||
// A stale unified entry must not redirect a live PTY owner to an older entity id.
|
||||
if (state.hasTerminalTab?.(tabId)) {
|
||||
return tabId
|
||||
}
|
||||
const unifiedTab = state.getTab?.(tabId)
|
||||
return unifiedTab?.contentType === 'terminal' ? unifiedTab.entityId : tabId
|
||||
}
|
||||
@@ -38,6 +38,18 @@ describe('createIpcPtyTransport', () => {
|
||||
transport.disconnect()
|
||||
})
|
||||
|
||||
it('does not create a PTY when the pane generation is stale', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const spawn = window.api.pty.spawn as unknown as ReturnType<typeof vi.fn>
|
||||
const transport = createIpcPtyTransport({})
|
||||
|
||||
await expect(
|
||||
transport.connect({ url: '', shouldContinue: () => false, callbacks: {} })
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('threads provider command ownership through the spawn IPC', async () => {
|
||||
const { createIpcPtyTransport } = await import('./pty-transport')
|
||||
const transport = createIpcPtyTransport({
|
||||
|
||||
@@ -155,6 +155,8 @@ export type PtyTransport = {
|
||||
startupCommandDelivery?: StartupCommandDelivery
|
||||
/** Reject a stale restored identity before this transport can publish global PTY handlers. */
|
||||
admitPtyId?: (ptyId: string) => boolean
|
||||
/** Reject a stale pane after any pre-spawn test gate but before creating a PTY. */
|
||||
shouldContinue?: () => boolean
|
||||
callbacks: PtyCallbacks
|
||||
}) => void | Promise<void | string | PtyConnectResult>
|
||||
attach: (options: {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
isPreHandlerPtyStateDiscarded
|
||||
} from './pty-pre-handler-buffer'
|
||||
import { createPtyInputWriteQueue } from './pty-input-write-queue'
|
||||
import { waitAtTerminalPtyPreSpawnE2EBarrier } from './terminal-pty-pre-spawn-e2e-barrier'
|
||||
import type { PtyDataMeta } from './pty-dispatcher'
|
||||
import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types'
|
||||
import { createBellDetector } from '../../../../shared/terminal-bell-detector'
|
||||
@@ -811,6 +812,16 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
|
||||
}
|
||||
|
||||
try {
|
||||
const preSpawnBarrier = waitAtTerminalPtyPreSpawnE2EBarrier()
|
||||
if (preSpawnBarrier) {
|
||||
await preSpawnBarrier
|
||||
if (destroyed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (options.shouldContinue && !options.shouldContinue()) {
|
||||
return
|
||||
}
|
||||
// Why: cwd fallback is only for fresh local spawns — reattach keeps the session's cwd and SSH transports resolve cwd on the remote host.
|
||||
const shouldSendLocalCwdFallback =
|
||||
cwdFallback === 'worktree' && !connectionId && !admittedSessionId
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
|
||||
type BarrierState = {
|
||||
blocked: boolean
|
||||
release: () => void
|
||||
released: Promise<void>
|
||||
reportBlocked: () => void
|
||||
blockedReported: Promise<void>
|
||||
}
|
||||
|
||||
export type TerminalPtyPreSpawnE2EBarrier = {
|
||||
arm: () => void
|
||||
waitUntilBlocked: () => Promise<void>
|
||||
release: () => void
|
||||
status: () => 'idle' | 'armed' | 'blocked'
|
||||
}
|
||||
|
||||
let barrier: BarrierState | null = null
|
||||
|
||||
function createBarrier(): BarrierState {
|
||||
let release = (): void => {}
|
||||
let reportBlocked = (): void => {}
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const blockedReported = new Promise<void>((resolve) => {
|
||||
reportBlocked = resolve
|
||||
})
|
||||
return { blocked: false, release, released, reportBlocked, blockedReported }
|
||||
}
|
||||
|
||||
function exposeBarrier(): void {
|
||||
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.__terminalPtyPreSpawnE2EBarrier ??= {
|
||||
arm: () => {
|
||||
if (barrier) {
|
||||
throw new Error('Terminal PTY pre-spawn E2E barrier is already armed')
|
||||
}
|
||||
barrier = createBarrier()
|
||||
},
|
||||
waitUntilBlocked: async () => {
|
||||
if (!barrier) {
|
||||
throw new Error('Terminal PTY pre-spawn E2E barrier is not armed')
|
||||
}
|
||||
await barrier.blockedReported
|
||||
},
|
||||
release: () => {
|
||||
barrier?.release()
|
||||
},
|
||||
status: () => (barrier ? (barrier.blocked ? 'blocked' : 'armed') : 'idle')
|
||||
}
|
||||
}
|
||||
|
||||
export function waitAtTerminalPtyPreSpawnE2EBarrier(): Promise<void> | null {
|
||||
if (!e2eConfig.exposeStore || typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
exposeBarrier()
|
||||
const current = barrier
|
||||
if (!current || current.blocked) {
|
||||
return null
|
||||
}
|
||||
current.blocked = true
|
||||
current.reportBlocked()
|
||||
return current.released.then(() => {
|
||||
if (barrier === current) {
|
||||
barrier = null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
vi.mock('@/lib/pane-manager/pane-manager-registry', () => ({
|
||||
resetAndRefreshAllTerminalWebglAtlases: vi.fn()
|
||||
}))
|
||||
const presentPaneViewport = vi.fn()
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
|
||||
presentPaneViewport: (pane: unknown) => presentPaneViewport(pane)
|
||||
}))
|
||||
vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({
|
||||
flushTerminalOutput: vi.fn(),
|
||||
requestTerminalBacklogRecovery: vi.fn()
|
||||
@@ -21,10 +25,6 @@ vi.mock('./pane-helpers', () => ({
|
||||
fitPanes: vi.fn(),
|
||||
focusActivePane: vi.fn()
|
||||
}))
|
||||
const scheduleTabRevealWebglAtlasRecovery = vi.fn()
|
||||
vi.mock('./terminal-webgl-atlas-recovery', () => ({
|
||||
scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery()
|
||||
}))
|
||||
const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false)
|
||||
vi.mock('@/lib/pane-manager/pane-fit', () => ({
|
||||
flushDeferredPaneMetricOptionsIfMeasurable: (pane: unknown) =>
|
||||
@@ -77,18 +77,19 @@ function resumeArgs(manager: FakeManager, shouldUseLightTabResume: boolean) {
|
||||
describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
repairPaneWebglCanvasDprMismatch.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('schedules a pane-scoped repaint on a light tab reveal', () => {
|
||||
it('schedules an atlas-preserving present on a light tab reveal', () => {
|
||||
// The light path is the "click the tab that was not open" gesture: it has
|
||||
// no rendering resume or fit, so without this repaint a hidden-while-
|
||||
// working pane keeps compositing pre-hide pixels.
|
||||
const manager = createManager()
|
||||
resumeTerminalVisibility(resumeArgs(manager, true))
|
||||
|
||||
expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1)
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
|
||||
expect(manager.resumeRendering).not.toHaveBeenCalled()
|
||||
expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('captures native trim movement before enforcing viewport intent', async () => {
|
||||
@@ -147,6 +148,29 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
expect(flushDeferredPaneMetricOptionsIfMeasurable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers a heavy-reveal dpr present until the shared atlas recovery', async () => {
|
||||
const pane = { terminal: {} }
|
||||
const manager = createManager()
|
||||
manager.getPanes.mockReturnValue([pane])
|
||||
repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true)
|
||||
const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked(
|
||||
await import('@/lib/pane-manager/pane-manager-registry')
|
||||
)
|
||||
|
||||
resumeTerminalVisibility(resumeArgs(manager, false))
|
||||
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledWith(pane)
|
||||
expect(presentPaneViewport).not.toHaveBeenCalled()
|
||||
expect(resetAndRefreshAllTerminalWebglAtlases).toHaveBeenCalledTimes(1)
|
||||
const atlasResetCallOrder = resetAndRefreshAllTerminalWebglAtlases.mock.invocationCallOrder[0]
|
||||
if (atlasResetCallOrder === undefined) {
|
||||
throw new Error('Shared atlas recovery call order missing')
|
||||
}
|
||||
expect(repairPaneWebglCanvasDprMismatch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
atlasResetCallOrder
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fit on a light tab reveal', () => {
|
||||
const manager = createManager()
|
||||
resumeTerminalVisibility(resumeArgs(manager, true))
|
||||
@@ -193,6 +217,27 @@ describe('resumeTerminalVisibility reveal repaint', () => {
|
||||
expect(manager.fitAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repairs WebGL canvas backing-store dpr on window wake', () => {
|
||||
// Clamshell undock: dpr changes while the pane stayed "visible" with a
|
||||
// stale backing store; tab-reveal is not in the path.
|
||||
const first = { terminal: { name: 'pane-a' } }
|
||||
const second = { terminal: { name: 'pane-b' } }
|
||||
const manager = createManager()
|
||||
manager.getPanes.mockReturnValue([first, second])
|
||||
repairPaneWebglCanvasDprMismatch.mockReturnValueOnce(true)
|
||||
|
||||
recoverVisibleTerminalWindowWake({
|
||||
manager: manager as never as PaneManager,
|
||||
isActive: true,
|
||||
clearGlyphAtlases: false
|
||||
})
|
||||
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenCalledTimes(2)
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(1, first)
|
||||
expect(repairPaneWebglCanvasDprMismatch).toHaveBeenNthCalledWith(2, second)
|
||||
expect(presentPaneViewport).toHaveBeenCalledWith(first)
|
||||
})
|
||||
|
||||
it('latches viewport intent before refocus recovery flushes streaming output', async () => {
|
||||
const terminal = { name: 'streaming-terminal' }
|
||||
const manager = createManager()
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
resetTerminalLinkifierHoverState
|
||||
} from '@/lib/pane-manager/terminal-linkifier-hover-reset'
|
||||
import { focusActivePane } from './pane-helpers'
|
||||
import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
|
||||
import { flushDeferredPaneMetricOptionsIfMeasurable } from '@/lib/pane-manager/pane-fit'
|
||||
import { repairPaneWebglCanvasDprMismatch } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
|
||||
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
|
||||
const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024
|
||||
@@ -88,8 +88,6 @@ export function resumeTerminalVisibility({
|
||||
// overlay's delayed geometry fit. Still request hidden-output recovery:
|
||||
// agent TUIs can suppress hidden bytes until the pane is foregrounded.
|
||||
requestLightTabBacklogRecovery(manager)
|
||||
// Why: reveal is the lifecycle boundary that owns hidden renderer repair.
|
||||
scheduleTabRevealWebglAtlasRecovery()
|
||||
if (flushedDeferredMetrics) {
|
||||
// Why: the light path normally skips fitting, but flushed metrics changed
|
||||
// cell size — refit so cols/rows match before the overlay settles.
|
||||
@@ -109,10 +107,15 @@ export function resumeTerminalVisibility({
|
||||
// terminals; refresh after reset so rebuilt atlases repaint from xterm.
|
||||
resetAndRefreshAllTerminalWebglAtlases('visibility-resume')
|
||||
}
|
||||
// Why: the synchronous recovery above can fire before the revealed pane is
|
||||
// attached and laid out. Follow up after layout with one shared-atlas-safe
|
||||
// recovery covering every visible terminal manager.
|
||||
manager.scheduleRevealRepaint()
|
||||
if (shouldUseLightTabResume) {
|
||||
// Why: preserve the last coherent frame while a TUI holds DEC 2026. The
|
||||
// settled refresh arms xterm's watchdog without clearing shared GPU data.
|
||||
manager.scheduleRevealPresent()
|
||||
} else {
|
||||
// Why: rendering was recreated, so the heavy path still needs the proven
|
||||
// shared-atlas recovery after layout settles.
|
||||
manager.scheduleRevealRepaint()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +164,13 @@ export function recoverVisibleTerminalWindowWake({
|
||||
// Why: backlog writes can expose transient viewport geometry while parsing.
|
||||
syncTerminalViewportIntents(manager)
|
||||
for (const pane of manager.getPanes()) {
|
||||
// Why: clamshell undock / monitor move changes devicePixelRatio while the
|
||||
// pane can stay "visible" with a stale WebGL backing store. The addon's
|
||||
// device-pixel observer misses that (no CSS-box change, or no box while
|
||||
// the lid was closed). Repair here — not only on tab reveal.
|
||||
if (repairPaneWebglCanvasDprMismatch(pane)) {
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
requestTerminalBacklogRecovery(pane.terminal)
|
||||
flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS })
|
||||
// Why: window blur fires mouseleave, clearing xterm's current link but not
|
||||
@@ -214,6 +224,12 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
|
||||
// Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume
|
||||
// would paint a stretched DOM-fallback flash, which is worse UX.
|
||||
manager.resumeRendering()
|
||||
// Why: unchanged grid geometry can skip the reveal fit, but a retained WebGL
|
||||
// canvas may still carry the previous display's backing-store DPR. The
|
||||
// caller's atlas recovery presents the final shared-atlas generation.
|
||||
for (const pane of manager.getPanes()) {
|
||||
repairPaneWebglCanvasDprMismatch(pane)
|
||||
}
|
||||
// Why: resumeRendering just re-attached WebGL, whose cell metrics briefly differ
|
||||
// from the DOM renderer's; a raw fit here reflows on a transient one-column-off
|
||||
// grid and garbles diff-painting inline TUIs (grok minimize→restore).
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
} from '@/lib/pane-manager/pane-manager-registry'
|
||||
import * as terminalWebglAtlasRecovery from './terminal-webgl-atlas-recovery'
|
||||
|
||||
const { scheduleImagePasteWebglAtlasRecovery, scheduleTabRevealWebglAtlasRecovery } =
|
||||
terminalWebglAtlasRecovery
|
||||
const { scheduleImagePasteWebglAtlasRecovery } = terminalWebglAtlasRecovery
|
||||
|
||||
describe('terminal WebGL atlas recovery', () => {
|
||||
const registeredManagers: { resetWebglTextureAtlases(): void }[] = []
|
||||
@@ -151,26 +150,4 @@ describe('terminal WebGL atlas recovery', () => {
|
||||
expect(healthyManager.refreshAllPanes).toHaveBeenCalledTimes(3)
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('recovers immediately on a tab reveal', () => {
|
||||
vi.useFakeTimers()
|
||||
const rafCallbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal(
|
||||
'requestAnimationFrame',
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
rafCallbacks.push(callback)
|
||||
return rafCallbacks.length
|
||||
})
|
||||
)
|
||||
const manager = registerManager()
|
||||
|
||||
scheduleTabRevealWebglAtlasRecovery()
|
||||
rafCallbacks[0]?.(0)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
vi.advanceTimersByTime(120)
|
||||
vi.advanceTimersByTime(380)
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,9 +33,3 @@ export function scheduleImagePasteWebglAtlasRecovery(): void {
|
||||
// one-shot event, so recover immediately rather than debouncing.
|
||||
scheduleAtlasRecoveryBurst('image-paste')
|
||||
}
|
||||
|
||||
export function scheduleTabRevealWebglAtlasRecovery(): void {
|
||||
// Why: a tab reveal is an explicit renderer lifecycle boundary where hidden
|
||||
// GPU state can be stale; ordinary PTY output must not clear the shared atlas.
|
||||
scheduleAtlasRecoveryBurst('tab-reveal')
|
||||
}
|
||||
|
||||
+12
-4
@@ -260,6 +260,8 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
|
||||
manager.resumeRendering.mockClear()
|
||||
manager.resetWebglTextureAtlases.mockClear()
|
||||
manager.scheduleRevealRepaint.mockClear()
|
||||
manager.scheduleRevealPresent.mockClear()
|
||||
manager.refreshAllPanes.mockClear()
|
||||
manager.suspendRendering.mockClear()
|
||||
mocks.fitAndFocusPanes.mockClear()
|
||||
@@ -289,8 +291,10 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
expect(manager.resumeRendering).not.toHaveBeenCalled()
|
||||
expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled()
|
||||
expect(mocks.fitPanes).not.toHaveBeenCalled()
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.focusActivePane).toHaveBeenCalledWith(manager)
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
@@ -342,6 +346,8 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
|
||||
manager.resumeRendering.mockClear()
|
||||
manager.resetWebglTextureAtlases.mockClear()
|
||||
manager.scheduleRevealRepaint.mockClear()
|
||||
manager.scheduleRevealPresent.mockClear()
|
||||
manager.refreshAllPanes.mockClear()
|
||||
mocks.fitAndFocusPanes.mockClear()
|
||||
mocks.fitPanes.mockClear()
|
||||
@@ -361,8 +367,10 @@ describe('useTerminalPaneGlobalEffects', () => {
|
||||
expect(manager.resumeRendering).not.toHaveBeenCalled()
|
||||
expect(mocks.fitAndFocusPanes).not.toHaveBeenCalled()
|
||||
expect(mocks.fitPanes).not.toHaveBeenCalled()
|
||||
expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1)
|
||||
expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1)
|
||||
expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(manager.refreshAllPanes).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealRepaint).not.toHaveBeenCalled()
|
||||
expect(manager.scheduleRevealPresent).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.focusActivePane).toHaveBeenCalledWith(manager)
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
+134
-2
@@ -2,15 +2,30 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import type { PaneWebglCanvasDprRepairState } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
|
||||
const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({
|
||||
recoverVisibleTerminalWindowWakeMock: vi.fn()
|
||||
const {
|
||||
recoverVisibleTerminalWindowWakeMock,
|
||||
repairPaneWebglCanvasDprMock,
|
||||
presentPaneViewportMock
|
||||
} = vi.hoisted(() => ({
|
||||
recoverVisibleTerminalWindowWakeMock: vi.fn(),
|
||||
repairPaneWebglCanvasDprMock: vi.fn<() => PaneWebglCanvasDprRepairState>(() => 'current'),
|
||||
presentPaneViewportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./terminal-visibility-resume', () => ({
|
||||
recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/terminal-canvas-dpr-repair', () => ({
|
||||
repairPaneWebglCanvasDpr: repairPaneWebglCanvasDprMock
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/pane-manager/pane-webgl-renderer', () => ({
|
||||
presentPaneViewport: presentPaneViewportMock
|
||||
}))
|
||||
|
||||
import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery'
|
||||
import {
|
||||
getTerminalFreezeBreadcrumbs,
|
||||
@@ -29,6 +44,9 @@ describe('useTerminalWindowWakeRecovery', () => {
|
||||
beforeEach(() => {
|
||||
systemResumedCallback = null
|
||||
recoverVisibleTerminalWindowWakeMock.mockClear()
|
||||
repairPaneWebglCanvasDprMock.mockClear()
|
||||
presentPaneViewportMock.mockClear()
|
||||
repairPaneWebglCanvasDprMock.mockReturnValue('current')
|
||||
unsubscribeSystemResumed.mockClear()
|
||||
onSystemResumed.mockClear()
|
||||
resetTerminalFreezeBreadcrumbsForTesting()
|
||||
@@ -180,4 +198,118 @@ describe('useTerminalWindowWakeRecovery', () => {
|
||||
|
||||
expect(onSystemResumed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('repairs WebGL canvas dpr on window resize without wiping the glyph atlas', () => {
|
||||
// Chromium emits resize when devicePixelRatio changes (undock / monitor
|
||||
// move) even if the CSS box is unchanged.
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const resizeManager = { getPanes: () => [pane] } as unknown as PaneManager
|
||||
repairPaneWebglCanvasDprMock.mockReturnValue('repaired')
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
const { unmount } = renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: resizeManager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled()
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane)
|
||||
expect(presentPaneViewportMock).toHaveBeenCalledWith(pane)
|
||||
expect(recoverVisibleTerminalWindowWakeMock).not.toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
vi.stubGlobal('devicePixelRatio', 3)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retains a dpr transition until the visible manager is available', () => {
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const managerRef: { current: PaneManager | null } = { current: null }
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef,
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).not.toHaveBeenCalled()
|
||||
|
||||
managerRef.current = { getPanes: () => [pane] } as unknown as PaneManager
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledWith(pane)
|
||||
})
|
||||
|
||||
it('deduplicates a deferred dpr retry and consumes it after repair succeeds', () => {
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const callbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback)
|
||||
return callbacks.length
|
||||
})
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
repairPaneWebglCanvasDprMock.mockReturnValueOnce('deferred').mockReturnValue('repaired')
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(1)
|
||||
expect(callbacks).toHaveLength(1)
|
||||
|
||||
callbacks.shift()?.(performance.now())
|
||||
expect(presentPaneViewportMock).toHaveBeenCalledWith(pane)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('caps a deferred dpr transition at sixteen animation-frame retries', () => {
|
||||
const pane = { id: 1, terminal: {} }
|
||||
const callbacks: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
callbacks.push(callback)
|
||||
return callbacks.length
|
||||
})
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
repairPaneWebglCanvasDprMock.mockReturnValue('deferred')
|
||||
renderHook(() =>
|
||||
useTerminalWindowWakeRecovery({
|
||||
isVisible: true,
|
||||
managerRef: { current: { getPanes: () => [pane] } as unknown as PaneManager },
|
||||
isActiveRef: { current: true },
|
||||
isVisibleRef: { current: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.stubGlobal('devicePixelRatio', 2)
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
let callbackCount = 0
|
||||
while (callbacks.length > 0) {
|
||||
callbacks.shift()?.(performance.now())
|
||||
callbackCount += 1
|
||||
}
|
||||
|
||||
expect(callbackCount).toBe(16)
|
||||
expect(repairPaneWebglCanvasDprMock).toHaveBeenCalledTimes(17)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume'
|
||||
import { repairPaneWebglCanvasDpr } from '@/lib/pane-manager/terminal-canvas-dpr-repair'
|
||||
import { presentPaneViewport } from '@/lib/pane-manager/pane-webgl-renderer'
|
||||
import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
|
||||
@@ -16,6 +18,8 @@ type WindowWakePtyBinding = IDisposable & {
|
||||
reassertPtySizeAfterWindowWake?: () => void
|
||||
}
|
||||
|
||||
const DPR_RECOVERY_RETRY_FRAMES = 16
|
||||
|
||||
export function useTerminalWindowWakeRecovery({
|
||||
isVisible,
|
||||
managerRef,
|
||||
@@ -28,7 +32,10 @@ export function useTerminalWindowWakeRecovery({
|
||||
return
|
||||
}
|
||||
let wakeRecoveryFrameId: number | null = null
|
||||
let dprRecoveryFrameId: number | null = null
|
||||
let dprRecoveryFramesRemaining = 0
|
||||
let settledClearGlyphAtlases = false
|
||||
let observedDevicePixelRatio = window.devicePixelRatio
|
||||
const cancelScheduledWakeRecovery = (): void => {
|
||||
if (wakeRecoveryFrameId === null || typeof cancelAnimationFrame !== 'function') {
|
||||
wakeRecoveryFrameId = null
|
||||
@@ -37,6 +44,13 @@ export function useTerminalWindowWakeRecovery({
|
||||
cancelAnimationFrame(wakeRecoveryFrameId)
|
||||
wakeRecoveryFrameId = null
|
||||
}
|
||||
const cancelScheduledDprRecovery = (): void => {
|
||||
if (dprRecoveryFrameId !== null && typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(dprRecoveryFrameId)
|
||||
}
|
||||
dprRecoveryFrameId = null
|
||||
dprRecoveryFramesRemaining = 0
|
||||
}
|
||||
const reassertPanePtySizes = (): void => {
|
||||
for (const binding of panePtyBindingsRef?.current.values() ?? []) {
|
||||
// Why: one settled read avoids duplicate SSH RPCs while still detecting a dropped resize.
|
||||
@@ -111,7 +125,59 @@ export function useTerminalWindowWakeRecovery({
|
||||
recoverVisibleWake(true, 'system-resumed')
|
||||
}
|
||||
}
|
||||
const repairVisiblePanesForDpr = (devicePixelRatio: number): boolean => {
|
||||
const manager = managerRef.current
|
||||
if (!manager || !isVisibleRef.current) {
|
||||
return false
|
||||
}
|
||||
let deferred = false
|
||||
for (const pane of manager.getPanes?.() ?? []) {
|
||||
const state = repairPaneWebglCanvasDpr(pane)
|
||||
deferred ||= state === 'deferred'
|
||||
if (state === 'repaired') {
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
}
|
||||
if (!deferred) {
|
||||
observedDevicePixelRatio = devicePixelRatio
|
||||
}
|
||||
return !deferred
|
||||
}
|
||||
const scheduleDprRecovery = (): void => {
|
||||
if (dprRecoveryFrameId !== null || typeof requestAnimationFrame !== 'function') {
|
||||
return
|
||||
}
|
||||
dprRecoveryFramesRemaining = DPR_RECOVERY_RETRY_FRAMES
|
||||
const retry = (): void => {
|
||||
dprRecoveryFrameId = null
|
||||
if (repairVisiblePanesForDpr(window.devicePixelRatio)) {
|
||||
dprRecoveryFramesRemaining = 0
|
||||
return
|
||||
}
|
||||
dprRecoveryFramesRemaining -= 1
|
||||
if (dprRecoveryFramesRemaining > 0) {
|
||||
dprRecoveryFrameId = requestAnimationFrame(retry)
|
||||
}
|
||||
}
|
||||
dprRecoveryFrameId = requestAnimationFrame(retry)
|
||||
}
|
||||
const onWindowResize = (): void => {
|
||||
// Why: Chromium emits window resize on devicePixelRatio changes even when
|
||||
// the CSS box is unchanged (monitor move / undock). xterm's own observer
|
||||
// misses that while the canvas had no box (laptop lid closed).
|
||||
const devicePixelRatio = window.devicePixelRatio
|
||||
if (devicePixelRatio === observedDevicePixelRatio) {
|
||||
return
|
||||
}
|
||||
if (dprRecoveryFrameId !== null) {
|
||||
return
|
||||
}
|
||||
if (!repairVisiblePanesForDpr(devicePixelRatio)) {
|
||||
scheduleDprRecovery()
|
||||
}
|
||||
}
|
||||
window.addEventListener('focus', onFocus)
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
@@ -125,7 +191,9 @@ export function useTerminalWindowWakeRecovery({
|
||||
: null
|
||||
return () => {
|
||||
cancelScheduledWakeRecovery()
|
||||
cancelScheduledDprRecovery()
|
||||
window.removeEventListener('focus', onFocus)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
+73
-1
@@ -7,6 +7,7 @@ import type {
|
||||
WorkspaceCleanupScanProgress,
|
||||
WorkspaceCleanupScanResult
|
||||
} from '../../../../shared/workspace-cleanup'
|
||||
import type { WorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state'
|
||||
import { createTestStore, seedStore } from '../../store/slices/store-test-helpers'
|
||||
import { resetWorkspaceCleanupBrowsePersistTimer } from '../../store/slices/workspace-cleanup-browse'
|
||||
import { NOW, makeCandidate } from '../../store/slices/workspace-cleanup-slice-test-harness'
|
||||
@@ -349,6 +350,35 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => {
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('reports selections removed by facet filters', async () => {
|
||||
installApi(cachedFleet())
|
||||
await renderDialog()
|
||||
await openDialog()
|
||||
|
||||
await act(async () => rowCheckbox('alpha')?.click())
|
||||
const browse = holders.store.getState().workspaceCleanupBrowse as WorkspaceCleanupBrowseState
|
||||
await act(async () => {
|
||||
holders.store.setState({
|
||||
workspaceCleanupBrowse: {
|
||||
...browse,
|
||||
filters: {
|
||||
...browse.filters,
|
||||
safety: { ...browse.filters.safety, tiers: ['review'] }
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(holders.infoToasts).toContain(
|
||||
'1 selected workspace is hidden by the current filters and was deselected.'
|
||||
)
|
||||
|
||||
await act(async () => holders.store.setState({ workspaceCleanupBrowse: browse }))
|
||||
await flush()
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('adopts the in-flight scan on reopen instead of starting a duplicate', async () => {
|
||||
const rig = installApi(cachedFleet())
|
||||
await renderDialog()
|
||||
@@ -399,7 +429,16 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => {
|
||||
})
|
||||
await flush()
|
||||
|
||||
// Both rows are default-selected suggestions after the first settle.
|
||||
// Nothing is pre-selected: the dialog no longer acts on its own verdict.
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('false')
|
||||
expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
// The user selects both, which is what must survive the refresh.
|
||||
await act(async () => {
|
||||
rowCheckbox('alpha')?.click()
|
||||
rowCheckbox('beta')?.click()
|
||||
})
|
||||
await flush()
|
||||
expect(rowCheckbox('alpha')?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(rowCheckbox('beta')?.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
@@ -424,4 +463,37 @@ describe('WorkspaceCleanupDialog stale-while-revalidate', () => {
|
||||
expect(rowNames()).not.toContain('beta')
|
||||
expect(holders.infoToasts).toContain('1 selected workspace no longer exists.')
|
||||
})
|
||||
|
||||
it('keeps selected review rows outside the select-all scope', async () => {
|
||||
installApi({
|
||||
scannedAt: CACHED_AT,
|
||||
candidates: [
|
||||
makeCandidate({ worktreeId: 'repo1::/tmp/ready', displayName: 'ready' }),
|
||||
makeCandidate({
|
||||
worktreeId: 'repo1::/tmp/review',
|
||||
displayName: 'review',
|
||||
tier: 'review',
|
||||
selectedByDefault: false,
|
||||
git: { clean: null, upstreamAhead: 0, upstreamBehind: 0, checkedAt: null }
|
||||
})
|
||||
],
|
||||
errors: []
|
||||
})
|
||||
await renderDialog()
|
||||
await openDialog()
|
||||
|
||||
await act(async () => rowCheckbox('review')?.click())
|
||||
const selectAll = container?.querySelector<HTMLElement>(
|
||||
'[aria-label="Select 1 safety-checked workspace"]'
|
||||
)
|
||||
expect(selectAll?.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
await act(async () => selectAll?.click())
|
||||
expect(rowCheckbox('ready')?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(rowCheckbox('review')?.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
await act(async () => selectAll?.click())
|
||||
expect(rowCheckbox('ready')?.getAttribute('aria-checked')).toBe('false')
|
||||
expect(rowCheckbox('review')?.getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useWorkspaceCleanupGitEvidence } from './use-workspace-cleanup-git-evid
|
||||
import { useWorkspaceCleanupRowOrder } from './use-workspace-cleanup-row-order'
|
||||
import {
|
||||
formatVanishedSelectionNotice,
|
||||
getDefaultSelectedWorkspaceCleanupIdentities,
|
||||
formatWithheldSelectionNotice,
|
||||
toggleSetMember
|
||||
} from './workspace-cleanup-selection-model'
|
||||
|
||||
@@ -84,12 +84,7 @@ function WorkspaceCleanupDialogContent({
|
||||
const [facetPanelOpen, setFacetPanelOpen] = useState(false)
|
||||
const [expandedRowIds, setExpandedRowIds] = useState<Set<string>>(() => new Set())
|
||||
const [rowsScrollElement, setRowsScrollElement] = useState<HTMLDivElement | null>(null)
|
||||
const selectedDefaultsScanAtRef = useRef<number | null>(null)
|
||||
const wasOpenRef = useRef(open)
|
||||
// Why: a refresh completing mid-open must not clobber a selection the user
|
||||
// already made (or was given) for this open — defaults apply at most once.
|
||||
const defaultsAppliedForOpenRef = useRef(false)
|
||||
const selectionTouchedRef = useRef(false)
|
||||
const mountedRef = useMountedRef()
|
||||
|
||||
// Why: the facet clock must be stable across renders but never older than
|
||||
@@ -99,8 +94,6 @@ function WorkspaceCleanupDialogContent({
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !wasOpenRef.current) {
|
||||
defaultsAppliedForOpenRef.current = false
|
||||
selectionTouchedRef.current = false
|
||||
setOpenedAt(Date.now())
|
||||
}
|
||||
wasOpenRef.current = open
|
||||
@@ -193,33 +186,20 @@ function WorkspaceCleanupDialogContent({
|
||||
}, [deletingIdentities, rows, selectedIds])
|
||||
const selectedCount = selectedCandidates.length
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !scan || selectedDefaultsScanAtRef.current === scan.scannedAt) {
|
||||
return
|
||||
}
|
||||
selectedDefaultsScanAtRef.current = scan.scannedAt
|
||||
if (removalInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
if (defaultsAppliedForOpenRef.current || selectionTouchedRef.current) {
|
||||
return
|
||||
}
|
||||
defaultsAppliedForOpenRef.current = true
|
||||
setSelectedIds(
|
||||
getDefaultSelectedWorkspaceCleanupIdentities(scan.candidates, deletingIdentities)
|
||||
)
|
||||
}, [deletingIdentities, loading, removalInFlightRef, scan, setSelectedIds])
|
||||
// A destructive dialog leaves selection to the user.
|
||||
|
||||
const pruneSelectionToVisibleRows = useEffectEvent(() => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(
|
||||
[...current].filter(
|
||||
(identity) =>
|
||||
facetRows.facetMatchedIdentities.has(identity) && !deletingIdentities.has(identity)
|
||||
)
|
||||
const next = new Set(
|
||||
[...selectedIds].filter(
|
||||
(identity) =>
|
||||
facetRows.facetMatchedIdentities.has(identity) && !deletingIdentities.has(identity)
|
||||
)
|
||||
return next.size === current.size ? current : next
|
||||
})
|
||||
)
|
||||
if (next.size === selectedIds.size) {
|
||||
return
|
||||
}
|
||||
setSelectedIds(next)
|
||||
toast.info(formatWithheldSelectionNotice(selectedIds.size - next.size))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -292,7 +272,6 @@ function WorkspaceCleanupDialogContent({
|
||||
|
||||
const toggleSelectedRow = useCallback(
|
||||
(identity: string) => {
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds((current) => toggleSetMember(current, identity))
|
||||
},
|
||||
[setSelectedIds]
|
||||
@@ -305,10 +284,30 @@ function WorkspaceCleanupDialogContent({
|
||||
: facetRows.selectableIdentities.filter((identity) => !deletingIdentities.has(identity)),
|
||||
[deletingIdentities, facetRows.selectableIdentities, removal.removalInFlight]
|
||||
)
|
||||
// Header state is scoped to the same rows the header action controls.
|
||||
const selectedSelectableCount = useMemo(() => {
|
||||
let count = 0
|
||||
for (const identity of selectableIdentities) {
|
||||
if (selectedIds.has(identity)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, [selectableIdentities, selectedIds])
|
||||
|
||||
const toggleSelectAll = useCallback(
|
||||
(selectAll: boolean) => {
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds(selectAll ? new Set(selectableIdentities) : new Set())
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current)
|
||||
for (const identity of selectableIdentities) {
|
||||
if (selectAll) {
|
||||
next.add(identity)
|
||||
} else {
|
||||
next.delete(identity)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[selectableIdentities, setSelectedIds]
|
||||
)
|
||||
@@ -323,7 +322,6 @@ function WorkspaceCleanupDialogContent({
|
||||
if (removalInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
selectionTouchedRef.current = true
|
||||
setSelectedIds(new Set([getWorkspaceCleanupCandidateIdentity(candidate)]))
|
||||
openConfirmRemove([candidate])
|
||||
},
|
||||
@@ -390,7 +388,7 @@ function WorkspaceCleanupDialogContent({
|
||||
facetPanelOpen={facetPanelOpen}
|
||||
onFacetPanelOpenChange={setFacetPanelOpen}
|
||||
selectableCount={selectableIdentities.length}
|
||||
selectedCount={selectedCount}
|
||||
selectedCount={selectedSelectableCount}
|
||||
spaceScanning={workspaceSpaceScanning}
|
||||
spaceProgress={workspaceSpaceProgress}
|
||||
gitPendingCount={gitEvidence.pendingWorktreeIds.size}
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { create } from 'zustand'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
createWorkspaceCleanupBrowseSlice,
|
||||
resetWorkspaceCleanupBrowsePersistTimer
|
||||
} from '@/store/slices/workspace-cleanup-browse'
|
||||
import { createDefaultWorkspaceCleanupBrowseState } from '../../../../shared/workspace-cleanup-browse-state'
|
||||
import { useWorkspaceCleanupBrowseState } from './use-workspace-cleanup-browse-state'
|
||||
import type { WorkspaceCleanupBrowseController } from './use-workspace-cleanup-browse-state'
|
||||
|
||||
const store = create<AppState>()(
|
||||
(...a) =>
|
||||
({
|
||||
workspaceCleanupDismissals: {},
|
||||
...createWorkspaceCleanupBrowseSlice(...a)
|
||||
}) as unknown as AppState
|
||||
)
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: <T,>(selector: (state: AppState) => T): T => store(selector)
|
||||
}))
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function mountController(): { current: WorkspaceCleanupBrowseController | null } {
|
||||
const ref: { current: WorkspaceCleanupBrowseController | null } = { current: null }
|
||||
function Probe(): null {
|
||||
ref.current = useWorkspaceCleanupBrowseState()
|
||||
return null
|
||||
}
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => root!.render(<Probe />))
|
||||
return ref
|
||||
}
|
||||
|
||||
describe('useWorkspaceCleanupBrowseState', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
;(globalThis as { window: unknown }).window = {
|
||||
...globalThis.window,
|
||||
api: { ui: { set: vi.fn().mockResolvedValue(undefined) } }
|
||||
}
|
||||
store.setState({
|
||||
workspaceCleanupBrowse: createDefaultWorkspaceCleanupBrowseState()
|
||||
} as Partial<AppState>)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root!.unmount())
|
||||
}
|
||||
root = null
|
||||
container = null
|
||||
document.body.replaceChildren()
|
||||
resetWorkspaceCleanupBrowsePersistTimer()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('keeps both patches when two groups are written in one tick', () => {
|
||||
const controller = mountController()
|
||||
|
||||
// Why one act(): a checkbox toggle writes several groups at once. Reading the
|
||||
// rendered `browse` snapshot per call would make the second patch overwrite
|
||||
// the first, which is what this asserts against.
|
||||
act(() => {
|
||||
controller.current!.patchFilters('activity', { idleMinDays: 20 })
|
||||
controller.current!.patchFilters('size', { maxBytes: 500 })
|
||||
})
|
||||
|
||||
const filters = store.getState().workspaceCleanupBrowse.filters
|
||||
expect(filters.activity.idleMinDays).toBe(20)
|
||||
expect(filters.size.maxBytes).toBe(500)
|
||||
})
|
||||
|
||||
it('keeps both patches when the same group is written twice in one tick', () => {
|
||||
const controller = mountController()
|
||||
|
||||
act(() => {
|
||||
controller.current!.patchFilters('git', { minAhead: 2 })
|
||||
controller.current!.patchFilters('git', { branchQuery: 'release' })
|
||||
})
|
||||
|
||||
const git = store.getState().workspaceCleanupBrowse.filters.git
|
||||
expect(git.minAhead).toBe(2)
|
||||
expect(git.branchQuery).toBe('release')
|
||||
})
|
||||
|
||||
it('flips sort direction against the latest state, not the rendered snapshot', () => {
|
||||
const controller = mountController()
|
||||
|
||||
act(() => {
|
||||
controller.current!.toggleSortField('size')
|
||||
controller.current!.toggleSortField('size')
|
||||
})
|
||||
|
||||
expect(store.getState().workspaceCleanupBrowse.sort).toEqual({
|
||||
field: 'size',
|
||||
direction: 'desc'
|
||||
})
|
||||
})
|
||||
})
|
||||
+23
-18
@@ -26,37 +26,42 @@ export function useWorkspaceCleanupBrowseState(): WorkspaceCleanupBrowseControll
|
||||
const browse = useAppStore((s) => s.workspaceCleanupBrowse)
|
||||
const updateBrowse = useAppStore((s) => s.updateWorkspaceCleanupBrowseState)
|
||||
|
||||
// Why the updater form: a checkbox toggle writes several fields at once, so two
|
||||
// patches can land in one tick. Reading `browse` from the render would make the
|
||||
// second overwrite the first.
|
||||
const patchFilters = useCallback<WorkspaceCleanupBrowseController['patchFilters']>(
|
||||
(key, value) => {
|
||||
const current = browse.filters[key]
|
||||
const next =
|
||||
typeof current === 'object' && current !== null
|
||||
? { ...current, ...(value as object) }
|
||||
: value
|
||||
// Cast: a computed key over a union widens the spread result past
|
||||
// WorkspaceCleanupFilterState even though `key` is constrained to it.
|
||||
const filters = { ...browse.filters, [key]: next } as WorkspaceCleanupFilterState
|
||||
updateBrowse({ ...browse, filters })
|
||||
updateBrowse((current) => {
|
||||
const group = current.filters[key]
|
||||
const next =
|
||||
typeof group === 'object' && group !== null ? { ...group, ...(value as object) } : value
|
||||
// Cast: a computed key over a union widens the spread result past
|
||||
// WorkspaceCleanupFilterState even though `key` is constrained to it.
|
||||
const filters = { ...current.filters, [key]: next } as WorkspaceCleanupFilterState
|
||||
return { ...current, filters }
|
||||
})
|
||||
},
|
||||
[browse, updateBrowse]
|
||||
[updateBrowse]
|
||||
)
|
||||
|
||||
// Why: re-picking the active sort flips its direction.
|
||||
const toggleSortField = useCallback(
|
||||
(field: WorkspaceCleanupSortField) => {
|
||||
const direction =
|
||||
browse.sort.field === field && browse.sort.direction === 'asc' ? 'desc' : 'asc'
|
||||
updateBrowse({ ...browse, sort: { field, direction } })
|
||||
updateBrowse((current) => {
|
||||
const direction =
|
||||
current.sort.field === field && current.sort.direction === 'asc' ? 'desc' : 'asc'
|
||||
return { ...current, sort: { field, direction } }
|
||||
})
|
||||
},
|
||||
[browse, updateBrowse]
|
||||
[updateBrowse]
|
||||
)
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
updateBrowse({
|
||||
...browse,
|
||||
updateBrowse((current) => ({
|
||||
...current,
|
||||
filters: createDefaultWorkspaceCleanupFilterState()
|
||||
})
|
||||
}, [browse, updateBrowse])
|
||||
}))
|
||||
}, [updateBrowse])
|
||||
|
||||
return {
|
||||
filters: browse.filters,
|
||||
|
||||
+1
-7
@@ -56,19 +56,13 @@ export function useWorkspaceCleanupScanLifecycle({
|
||||
return
|
||||
}
|
||||
latestReadyToastScanAtRef.current = result.scannedAt
|
||||
const suggestedCount = result.candidates.filter(
|
||||
(candidate) => candidate.selectedByDefault
|
||||
).length
|
||||
toast.success(
|
||||
translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0e2d235c63',
|
||||
'Workspace scan ready'
|
||||
),
|
||||
{
|
||||
description: formatWorkspaceCleanupReadyToast(
|
||||
result.candidates.length,
|
||||
suggestedCount
|
||||
),
|
||||
description: formatWorkspaceCleanupReadyToast(result.candidates.length),
|
||||
action: {
|
||||
label: translate(
|
||||
'auto.components.workspace.cleanup.WorkspaceCleanupDialog.4a35c08764',
|
||||
|
||||
@@ -166,11 +166,3 @@ export function formatWorkspaceCleanupContextDetail(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWorkspaceCleanupContextCount(count: number): string {
|
||||
return translate(
|
||||
'auto.components.workspace.cleanup.candidateRow.contextCount',
|
||||
'Context: {{value0}}',
|
||||
{ value0: count }
|
||||
)
|
||||
}
|
||||
|
||||
+6
-10
@@ -2,12 +2,10 @@ import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import {
|
||||
getWorkspaceCleanupGitLabel,
|
||||
hasWorkspaceCleanupLocalContext,
|
||||
type WorkspaceCleanupReviewInfo
|
||||
} from './workspace-cleanup-presentation'
|
||||
import {
|
||||
formatUnpushedCommitCount,
|
||||
formatWorkspaceCleanupContextCount,
|
||||
formatWorkspaceCleanupContextDetail,
|
||||
formatWorkspaceCleanupGitStatusLabel,
|
||||
getGitStatusUnknownLabel,
|
||||
@@ -85,7 +83,7 @@ export function formatBranchSafetyDetails(candidate: WorkspaceCleanupCandidate):
|
||||
return details
|
||||
}
|
||||
|
||||
export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null {
|
||||
export function formatContextDetailLabels(candidate: WorkspaceCleanupCandidate): string[] {
|
||||
const parts: string[] = []
|
||||
if (candidate.localContext.terminalTabCount > 0) {
|
||||
parts.push(
|
||||
@@ -110,6 +108,11 @@ export function formatContextDetails(candidate: WorkspaceCleanupCandidate): stri
|
||||
formatWorkspaceCleanupContextDetail('agent', candidate.localContext.retainedDoneAgentCount)
|
||||
)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
export function formatContextDetails(candidate: WorkspaceCleanupCandidate): string | null {
|
||||
const parts = formatContextDetailLabels(candidate)
|
||||
return parts.length > 0 ? parts.join(', ') : null
|
||||
}
|
||||
|
||||
@@ -166,13 +169,6 @@ export function getReviewPillTone(reviewInfo: WorkspaceCleanupReviewInfo): Statu
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
export function getContextPillLabel(candidate: WorkspaceCleanupCandidate): string | null {
|
||||
if (!hasWorkspaceCleanupLocalContext(candidate)) {
|
||||
return null
|
||||
}
|
||||
return formatWorkspaceCleanupContextCount(getContextCount(candidate))
|
||||
}
|
||||
|
||||
export function getContextCount(candidate: WorkspaceCleanupCandidate): number {
|
||||
return (
|
||||
candidate.localContext.terminalTabCount +
|
||||
|
||||
+43
@@ -84,6 +84,49 @@ describe('WorkspaceCleanupConfirmRemove', () => {
|
||||
expect(container.querySelectorAll('span.truncate.text-sm.font-medium')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('names each local context type separately instead of totalling them', () => {
|
||||
const candidate = makeFacetCandidate({
|
||||
worktreeId: 'repo-1::/repo/with-context',
|
||||
displayName: 'Has context',
|
||||
localContext: {
|
||||
terminalTabCount: 1,
|
||||
cleanEditorTabCount: 2,
|
||||
browserTabCount: 3,
|
||||
diffCommentCount: 4,
|
||||
newestDiffCommentAt: null,
|
||||
retainedDoneAgentCount: 5
|
||||
}
|
||||
})
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<WorkspaceCleanupConfirmRemove
|
||||
candidates={[candidate]}
|
||||
now={Date.now()}
|
||||
reviewInfoByWorktreeId={new Map()}
|
||||
progress={null}
|
||||
onBack={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const labels = [
|
||||
'Terminal tabs: 1',
|
||||
'Editor tabs: 2',
|
||||
'Browser tabs: 3',
|
||||
'Diff notes: 4',
|
||||
'Completed agents: 5'
|
||||
]
|
||||
const renderedLabels = Array.from(container.querySelectorAll('span'))
|
||||
.map((element) => element.textContent)
|
||||
.filter((label): label is string => labels.includes(label ?? ''))
|
||||
|
||||
expect(renderedLabels).toEqual(labels)
|
||||
expect(container.textContent).not.toContain('Context: ')
|
||||
})
|
||||
|
||||
it('visibly and accessibly identifies each host for colliding confirmations', () => {
|
||||
const local = makeFacetCandidate({ executionHostId: 'local' })
|
||||
const remote = makeFacetCandidate({
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cle
|
||||
import type { WorkspaceCleanupRemovalProgress } from './workspace-cleanup-background-removal'
|
||||
import {
|
||||
getCandidateFactStatus,
|
||||
getContextPillLabel,
|
||||
formatContextDetailLabels,
|
||||
getDirtyGitLabel,
|
||||
getReviewPillTone,
|
||||
shouldShowGitMetadataChip
|
||||
@@ -204,7 +204,7 @@ function ConfirmRemoveRow({
|
||||
}): React.JSX.Element {
|
||||
const dirtyLabel = getDirtyGitLabel(candidate)
|
||||
const branchDiffersFromName = candidate.branch !== candidate.displayName
|
||||
const contextPillLabel = getContextPillLabel(candidate)
|
||||
const contextPillLabels = formatContextDetailLabels(candidate)
|
||||
const showGitMetadataChip = shouldShowGitMetadataChip(candidate)
|
||||
const factStatus = getCandidateFactStatus(candidate)
|
||||
const hostLabel = getWorkspaceCleanupCandidateHostLabel(candidate)
|
||||
@@ -234,7 +234,9 @@ function ConfirmRemoveRow({
|
||||
{reviewInfo.label ? (
|
||||
<StatusPill tone={getReviewPillTone(reviewInfo)}>{reviewInfo.label}</StatusPill>
|
||||
) : null}
|
||||
{contextPillLabel ? <StatusPill>{contextPillLabel}</StatusPill> : null}
|
||||
{contextPillLabels.map((label) => (
|
||||
<StatusPill key={label}>{label}</StatusPill>
|
||||
))}
|
||||
{dirtyLabel && showGitMetadataChip ? (
|
||||
<StatusPill tone="destructive">{dirtyLabel}</StatusPill>
|
||||
) : null}
|
||||
|
||||
+24
-1
@@ -108,11 +108,34 @@ describe('workspace cleanup facet controls', () => {
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), onPatch)
|
||||
|
||||
const input = control('Idle for at least') as HTMLInputElement | null
|
||||
expect(input?.type).toBe('number')
|
||||
act(() => typeInto(input, '17'))
|
||||
expect(onPatch).toHaveBeenCalledWith('activity', { idleMinDays: 17 })
|
||||
})
|
||||
|
||||
it('keeps every numeric threshold off type=number so a wheel tick cannot set it', () => {
|
||||
// Why assert the type rather than dispatch a wheel event: the spinner is a
|
||||
// Chromium behaviour happy-dom does not implement, so a dispatched wheel
|
||||
// changes nothing here either way and the test would pass without the fix.
|
||||
// The type is the property that makes the whole class impossible.
|
||||
renderFacets(createDefaultWorkspaceCleanupFilterState(), createPatchMock())
|
||||
|
||||
const numericFacets = [
|
||||
'Idle for at least',
|
||||
'At least',
|
||||
'At most',
|
||||
'Commits ahead ≥',
|
||||
'Commits behind ≥'
|
||||
]
|
||||
const found = numericFacets
|
||||
.map((label) => control(label) as HTMLInputElement | null)
|
||||
.filter((input): input is HTMLInputElement => input !== null)
|
||||
expect(found).toHaveLength(numericFacets.length)
|
||||
for (const input of found) {
|
||||
expect(input.type).toBe('text')
|
||||
expect(input.inputMode).toBe('numeric')
|
||||
}
|
||||
})
|
||||
|
||||
it('multi-selects git states', () => {
|
||||
const onPatch = createPatchMock()
|
||||
const filters = createDefaultWorkspaceCleanupFilterState()
|
||||
|
||||
@@ -139,10 +139,13 @@ export function FacetNumberField({
|
||||
return (
|
||||
<FacetField label={label}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Why text, not number: Chromium mutates a focused number input on every
|
||||
wheel tick, which silently walked a threshold up inside the scrollable
|
||||
facet panel. preventDefault would stop that but also stop the panel
|
||||
scrolling, re-breaking #14629. */}
|
||||
<Input
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
aria-label={label}
|
||||
value={value === null ? '' : String(value)}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -64,36 +64,17 @@ export function formatWorkspaceCleanupScanProgress(
|
||||
)
|
||||
}
|
||||
|
||||
export function formatWorkspaceCleanupReadyToast(
|
||||
workspaceCount: number,
|
||||
suggestedCount: number
|
||||
): string {
|
||||
/** Reports scan size without classifying the user's work. */
|
||||
export function formatWorkspaceCleanupReadyToast(workspaceCount: number): string {
|
||||
if (workspaceCount === 0) {
|
||||
return translate('components.workspace.cleanup.scan.noWorkspaces', 'No workspaces found.')
|
||||
}
|
||||
if (workspaceCount === 1) {
|
||||
return suggestedCount === 1
|
||||
? translate(
|
||||
'components.workspace.cleanup.scan.readyOneOne',
|
||||
'1 workspace found, with 1 cleanup suggestion.'
|
||||
)
|
||||
: translate(
|
||||
'components.workspace.cleanup.scan.readyOneMany',
|
||||
'1 workspace found, with {{value0}} cleanup suggestions.',
|
||||
{ value0: suggestedCount }
|
||||
)
|
||||
return translate('components.workspace.cleanup.scan.readyOne', '1 workspace found.')
|
||||
}
|
||||
return suggestedCount === 1
|
||||
? translate(
|
||||
'components.workspace.cleanup.scan.readyManyOne',
|
||||
'{{value0}} workspaces found, with 1 cleanup suggestion.',
|
||||
{ value0: workspaceCount }
|
||||
)
|
||||
: translate(
|
||||
'components.workspace.cleanup.scan.readyManyMany',
|
||||
'{{value0}} workspaces found, with {{value1}} cleanup suggestions.',
|
||||
{ value0: workspaceCount, value1: suggestedCount }
|
||||
)
|
||||
return translate('components.workspace.cleanup.scan.readyMany', '{{value0}} workspaces found.', {
|
||||
value0: workspaceCount
|
||||
})
|
||||
}
|
||||
|
||||
function formatScanErrorRepoName(
|
||||
|
||||
+11
-15
@@ -1,6 +1,4 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup'
|
||||
import { getWorkspaceCleanupCandidateIdentity } from '../../../../shared/workspace-cleanup-host-identity'
|
||||
|
||||
export function formatVanishedSelectionNotice(count: number): string {
|
||||
return count === 1
|
||||
@@ -15,20 +13,18 @@ export function formatVanishedSelectionNotice(count: number): string {
|
||||
)
|
||||
}
|
||||
|
||||
/** Host-qualified identities, so a default selection cannot span two hosts' rows. */
|
||||
export function getDefaultSelectedWorkspaceCleanupIdentities(
|
||||
candidates: readonly WorkspaceCleanupCandidate[],
|
||||
deletingIdentities: ReadonlySet<string> = new Set()
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
candidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
candidate.selectedByDefault &&
|
||||
!deletingIdentities.has(getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
/** Explains selection removed when a facet filter hides rows. */
|
||||
export function formatWithheldSelectionNotice(count: number): string {
|
||||
return count === 1
|
||||
? translate(
|
||||
'components.workspace.cleanup.browse.selectionWithheldOne',
|
||||
'1 selected workspace is hidden by the current filters and was deselected.'
|
||||
)
|
||||
: translate(
|
||||
'components.workspace.cleanup.browse.selectionWithheld',
|
||||
'{{value0}} selected workspaces are hidden by the current filters and were deselected.',
|
||||
{ value0: count }
|
||||
)
|
||||
.map((candidate) => getWorkspaceCleanupCandidateIdentity(candidate))
|
||||
)
|
||||
}
|
||||
|
||||
export function toggleSetMember(current: Set<string>, value: string): Set<string> {
|
||||
|
||||
@@ -92,4 +92,29 @@ describe('WorkspaceCleanupSortHeader', () => {
|
||||
container?.querySelector<HTMLButtonElement>('[role="checkbox"]')?.getAttribute('aria-checked')
|
||||
).toBe('mixed')
|
||||
})
|
||||
it('states how many workspaces select-all would take', () => {
|
||||
render({ field: 'name', direction: 'asc' }, { selectableCount: 12 })
|
||||
|
||||
expect(container?.textContent).toContain('Select all 12 safety-checked workspaces')
|
||||
})
|
||||
|
||||
it('uses singular copy for one safety-checked workspace', () => {
|
||||
render({ field: 'name', direction: 'asc' }, { selectableCount: 1 })
|
||||
|
||||
expect(container?.textContent).toContain('Select 1 safety-checked workspace')
|
||||
})
|
||||
|
||||
it('does not report all-selected when the selected rows are not the selectable ones', () => {
|
||||
const onToggleSelectAll = vi.fn()
|
||||
render(
|
||||
{ field: 'name', direction: 'asc' },
|
||||
{ selectableCount: 3, selectedCount: 0, onToggleSelectAll }
|
||||
)
|
||||
|
||||
const checkbox = container?.querySelector('[role="checkbox"]')
|
||||
expect(checkbox?.getAttribute('aria-checked')).toBe('false')
|
||||
|
||||
act(() => (checkbox as HTMLElement | null)?.click())
|
||||
expect(onToggleSelectAll).toHaveBeenCalledWith(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,17 @@ export function WorkspaceCleanupSortHeader({
|
||||
}): React.JSX.Element {
|
||||
const allSelected = selectableCount > 0 && selectedCount >= selectableCount
|
||||
const someSelected = selectableCount > 0 && selectedCount > 0 && !allSelected
|
||||
const selectAllLabel =
|
||||
selectableCount === 1
|
||||
? translate(
|
||||
'components.workspace.cleanup.browse.selectAllCountOne',
|
||||
'Select 1 safety-checked workspace'
|
||||
)
|
||||
: translate(
|
||||
'components.workspace.cleanup.browse.selectAllCount',
|
||||
'Select all {{value0}} safety-checked workspaces',
|
||||
{ value0: selectableCount }
|
||||
)
|
||||
return (
|
||||
<div className="flex items-center gap-1 border-b border-border bg-muted/25 px-3 py-1.5">
|
||||
<button
|
||||
@@ -38,10 +49,7 @@ export function WorkspaceCleanupSortHeader({
|
||||
role="checkbox"
|
||||
aria-checked={someSelected ? 'mixed' : allSelected}
|
||||
disabled={selectableCount === 0}
|
||||
aria-label={translate(
|
||||
'components.workspace.cleanup.browse.selectAll',
|
||||
'Select all matching workspaces'
|
||||
)}
|
||||
aria-label={selectAllLabel}
|
||||
onClick={() => onToggleSelectAll(!allSelected)}
|
||||
className="flex size-4 shrink-0 items-center justify-center rounded border border-border bg-background text-primary hover:bg-accent disabled:opacity-40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
@@ -51,12 +59,7 @@ export function WorkspaceCleanupSortHeader({
|
||||
<Minus className="size-3" strokeWidth={3} />
|
||||
) : null}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'components.workspace.cleanup.browse.selectAll',
|
||||
'Select all matching workspaces'
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-xs text-muted-foreground">{selectAllLabel}</span>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
Vendored
+2
@@ -5,6 +5,7 @@ import type { OnboardingFeatureSetupDeps } from '@/components/onboarding/onboard
|
||||
import type { languages } from 'monaco-editor'
|
||||
import type { MonacoE2EProbe } from './components/editor/monaco-e2e-probe'
|
||||
import type { TerminalWorktreeParkingDebugVerdict } from './components/terminal-pane/terminal-parking-e2e-overrides'
|
||||
import type { TerminalPtyPreSpawnE2EBarrier } from './components/terminal-pane/terminal-pty-pre-spawn-e2e-barrier'
|
||||
|
||||
declare module 'monaco-editor/esm/vs/basic-languages/python/python.js' {
|
||||
export const conf: languages.LanguageConfiguration
|
||||
@@ -77,6 +78,7 @@ declare global {
|
||||
}
|
||||
__monacoEditorE2E?: MonacoE2EProbe
|
||||
__e2ePtyAppliedSizeReadDelayMs?: number
|
||||
__terminalPtyPreSpawnE2EBarrier?: TerminalPtyPreSpawnE2EBarrier
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getWorktreeMapFromState, getRepoMapFromState } from '@/store/selectors'
|
||||
import { applyUIZoom } from '@/lib/ui-zoom'
|
||||
import { activateAndRevealWorktree, activateAndRevealWorkspace } from '@/lib/worktree-activation'
|
||||
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
|
||||
import { runWorktreeDelete } from '@/components/sidebar/delete-worktree-flow'
|
||||
import { deleteHoveredWorkspaceImmediately } from '@/components/sidebar/hovered-workspace-delete'
|
||||
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
|
||||
import { createBackgroundSleepingAgentWakeDispatcher } from '@/lib/wake-sleeping-agents-in-background'
|
||||
import { TOGGLE_WORKSPACE_BOARD_EVENT } from '@/components/sidebar/useWorkspaceBoardPanel'
|
||||
@@ -1426,20 +1426,10 @@ export function useIpcEvents(): void {
|
||||
if (window.api.ui.onDeleteCurrentWorkspace) {
|
||||
unsubs.push(
|
||||
window.api.ui.onDeleteCurrentWorkspace(() => {
|
||||
const store = useAppStore.getState()
|
||||
if (
|
||||
store.activeModal !== 'none' ||
|
||||
store.activeView !== 'terminal' ||
|
||||
!store.activeWorktreeId
|
||||
) {
|
||||
if (isFloatingWorkspacePanelFocused()) {
|
||||
return
|
||||
}
|
||||
runWorktreeDelete(
|
||||
store.activeWorktreeId,
|
||||
store.activeWorkspaceExecutionHostId
|
||||
? { expectedHostId: store.activeWorkspaceExecutionHostId }
|
||||
: {}
|
||||
)
|
||||
deleteHoveredWorkspaceImmediately(useAppStore.getState())
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15008,6 +15008,10 @@
|
||||
"0e1de0358b": "Month",
|
||||
"77e96bded6": "Weekday"
|
||||
},
|
||||
"AutomationPromptDisclosure": {
|
||||
"showLess": "Show less",
|
||||
"showMore": "Show more"
|
||||
},
|
||||
"AutomationDetail": {
|
||||
"007c8ad874": "Prompt",
|
||||
"a1d52c2189": "Usage coverage",
|
||||
@@ -16226,7 +16230,11 @@
|
||||
"measuringSizesProgress": "Scanning {{value0}}/{{value1}}",
|
||||
"measuringSizes": "Scanning sizes",
|
||||
"measureSizesFailed": "Could not scan workspace sizes",
|
||||
"selectedCount": "{{value0}} selected"
|
||||
"selectedCount": "{{value0}} selected",
|
||||
"selectionWithheldOne": "1 selected workspace is hidden by the current filters and was deselected.",
|
||||
"selectionWithheld": "{{value0}} selected workspaces are hidden by the current filters and were deselected.",
|
||||
"selectAllCountOne": "Select 1 safety-checked workspace",
|
||||
"selectAllCount": "Select all {{value0}} safety-checked workspaces"
|
||||
},
|
||||
"scan": {
|
||||
"progress": "Scanning workspaces ({{value0}}/{{value1}})",
|
||||
@@ -16239,7 +16247,9 @@
|
||||
"readyManyOne": "{{value0}} workspaces found, with 1 cleanup suggestion.",
|
||||
"readyManyMany": "{{value0}} workspaces found, with {{value1}} cleanup suggestions.",
|
||||
"fallbackRepository": "a repository",
|
||||
"gitListFailed": "Git could not list worktrees"
|
||||
"gitListFailed": "Git could not list worktrees",
|
||||
"readyOne": "1 workspace found.",
|
||||
"readyMany": "{{value0}} workspaces found."
|
||||
},
|
||||
"relativeTime": {
|
||||
"never": "Never",
|
||||
|
||||
@@ -13612,6 +13612,10 @@
|
||||
"0e1de0358b": "Mes",
|
||||
"77e96bded6": "Día de la semana"
|
||||
},
|
||||
"AutomationPromptDisclosure": {
|
||||
"showLess": "Mostrar menos",
|
||||
"showMore": "Mostrar más"
|
||||
},
|
||||
"AutomationDetail": {
|
||||
"007c8ad874": "Inmediato",
|
||||
"a1d52c2189": "Cobertura de uso",
|
||||
|
||||
@@ -13612,6 +13612,10 @@
|
||||
"0e1de0358b": "月",
|
||||
"77e96bded6": "曜日"
|
||||
},
|
||||
"AutomationPromptDisclosure": {
|
||||
"showLess": "折りたたむ",
|
||||
"showMore": "さらに表示"
|
||||
},
|
||||
"AutomationDetail": {
|
||||
"007c8ad874": "プロンプト",
|
||||
"a1d52c2189": "使用範囲",
|
||||
|
||||
@@ -13641,6 +13641,10 @@
|
||||
"0e1de0358b": "월",
|
||||
"77e96bded6": "요일"
|
||||
},
|
||||
"AutomationPromptDisclosure": {
|
||||
"showLess": "접기",
|
||||
"showMore": "더 보기"
|
||||
},
|
||||
"AutomationDetail": {
|
||||
"007c8ad874": "프롬프트",
|
||||
"a1d52c2189": "사용 범위",
|
||||
@@ -14546,6 +14550,16 @@
|
||||
},
|
||||
"workspace": {
|
||||
"cleanup": {
|
||||
"browse": {
|
||||
"selectionWithheldOne": "선택한 워크스페이스 1개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.",
|
||||
"selectionWithheld": "선택한 워크스페이스 {{value0}}개가 현재 필터에 의해 숨겨져 선택이 해제되었습니다.",
|
||||
"selectAllCountOne": "안전 검사를 통과한 워크스페이스 1개 선택",
|
||||
"selectAllCount": "안전 검사를 통과한 워크스페이스 {{value0}}개 모두 선택"
|
||||
},
|
||||
"scan": {
|
||||
"readyOne": "워크스페이스 1개를 찾았습니다.",
|
||||
"readyMany": "워크스페이스 {{value0}}개를 찾았습니다."
|
||||
},
|
||||
"presentationFixtures": {
|
||||
"reviewAlphaCleanup": "알파 정리 검토"
|
||||
},
|
||||
|
||||
@@ -13632,6 +13632,10 @@
|
||||
"0e1de0358b": "月",
|
||||
"77e96bded6": "星期"
|
||||
},
|
||||
"AutomationPromptDisclosure": {
|
||||
"showLess": "收起",
|
||||
"showMore": "显示更多"
|
||||
},
|
||||
"AutomationDetail": {
|
||||
"007c8ad874": "提示词",
|
||||
"a1d52c2189": "使用范围",
|
||||
@@ -14537,6 +14541,16 @@
|
||||
},
|
||||
"workspace": {
|
||||
"cleanup": {
|
||||
"browse": {
|
||||
"selectionWithheldOne": "当前筛选条件隐藏了 1 个已选工作区,已取消选择。",
|
||||
"selectionWithheld": "当前筛选条件隐藏了 {{value0}} 个已选工作区,已取消选择。",
|
||||
"selectAllCountOne": "选择 1 个通过安全检查的工作区",
|
||||
"selectAllCount": "选择全部 {{value0}} 个通过安全检查的工作区"
|
||||
},
|
||||
"scan": {
|
||||
"readyOne": "找到 1 个工作区。",
|
||||
"readyMany": "找到 {{value0}} 个工作区。"
|
||||
},
|
||||
"presentationFixtures": {
|
||||
"reviewAlphaCleanup": "评审 Alpha 清理"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeTerminalSend, RuntimeTerminalWait } from '../../../shared/runtime-types'
|
||||
import { sanitizeTerminalPasteText } from '@/components/terminal-pane/terminal-bracketed-paste'
|
||||
import { useAppStore } from '@/store'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { getSettingsForWorktreeRuntimeOwner } from '@/lib/worktree-runtime-owner'
|
||||
@@ -10,8 +11,7 @@ import {
|
||||
import {
|
||||
BRACKETED_PASTE_BEGIN,
|
||||
BRACKETED_PASTE_END,
|
||||
POST_PASTE_SUBMIT_DELAY_MS,
|
||||
sanitizeBracketedPasteContent
|
||||
POST_PASTE_SUBMIT_DELAY_MS
|
||||
} from './agent-paste-draft'
|
||||
import type { ActiveAgentNotesSendResult } from './active-agent-note-send-result'
|
||||
import {
|
||||
@@ -182,7 +182,7 @@ async function sendPromptWithGuardedPasteAndEnter(
|
||||
return { status: initialAgentStatus.status }
|
||||
}
|
||||
|
||||
const pastePayload = `${BRACKETED_PASTE_BEGIN}${sanitizeBracketedPasteContent(prompt)}${BRACKETED_PASTE_END}`
|
||||
const pastePayload = `${BRACKETED_PASTE_BEGIN}${sanitizeTerminalPasteText(prompt)}${BRACKETED_PASTE_END}`
|
||||
try {
|
||||
const { send } = await callRuntimeRpc<{ send: RuntimeTerminalSend }>(
|
||||
runtimeTarget,
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
} from '@/runtime/runtime-terminal-inspection'
|
||||
import {
|
||||
BRACKETED_PASTE_END,
|
||||
BRACKETED_PASTE_START,
|
||||
sanitizeTerminalPasteText
|
||||
BRACKETED_PASTE_START
|
||||
} from '@/components/terminal-pane/terminal-bracketed-paste'
|
||||
import { runTerminalPtyInputTransaction } from '@/components/terminal-pane/terminal-pty-input-transaction'
|
||||
import { waitForAgentReady } from './agent-ready-wait'
|
||||
@@ -36,10 +35,6 @@ export const BRACKETED_PASTE_BEGIN = BRACKETED_PASTE_START
|
||||
export { BRACKETED_PASTE_END }
|
||||
export const POST_PASTE_SUBMIT_DELAY_MS = 50
|
||||
|
||||
export function sanitizeBracketedPasteContent(content: string): string {
|
||||
return sanitizeTerminalPasteText(content)
|
||||
}
|
||||
|
||||
// Why: "the tab has a PTY" and "the agent's composer accepts input" are separate
|
||||
// states with separate failure modes, so they get separate budgets. A PTY that
|
||||
// hasn't appeared in 8s means the launch itself failed — waiting the (longer)
|
||||
|
||||
@@ -85,9 +85,43 @@ describe('pane manager registry', () => {
|
||||
expect(order).toEqual(['first-reset', 'second-reset', 'first-refresh', 'second-refresh'])
|
||||
})
|
||||
|
||||
it('clears every recovered atlas before presenting any pane', () => {
|
||||
// Why: per-pane clear+present interleaves a present against atlas generation
|
||||
// N with the next pane's wipe to N+1. The first synchronized-output column
|
||||
// then keeps pre-hide footer pixels. Wipe first, present once the generation
|
||||
// is final.
|
||||
const order: string[] = []
|
||||
const first = {
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-reset')),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('first-clear')),
|
||||
presentForcedViewports: vi.fn<() => void>(() => order.push('first-present')),
|
||||
refreshAllPanes: vi.fn<() => void>(() => order.push('first-refresh')),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
const second = {
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-reset')),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(() => order.push('second-clear')),
|
||||
presentForcedViewports: vi.fn<() => void>(() => order.push('second-present')),
|
||||
refreshAllPanes: vi.fn<() => void>(() => order.push('second-refresh')),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
registerLivePaneManager(first)
|
||||
registeredManagers.push(first)
|
||||
registerLivePaneManager(second)
|
||||
registeredManagers.push(second)
|
||||
|
||||
resetAndRefreshAllTerminalWebglAtlases()
|
||||
|
||||
expect(order).toEqual(['first-clear', 'second-clear', 'first-present', 'second-present'])
|
||||
expect(first.resetWebglTextureAtlases).not.toHaveBeenCalled()
|
||||
expect(second.refreshAllPanes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('bounds atlas recovery to visible managers', () => {
|
||||
const visible = {
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(),
|
||||
presentForcedViewports: vi.fn<() => void>(),
|
||||
refreshAllPanes: vi.fn<() => void>(),
|
||||
isVisibleForAtlasRecovery: () => true
|
||||
}
|
||||
@@ -95,6 +129,8 @@ describe('pane manager registry', () => {
|
||||
registeredManagers.push(visible)
|
||||
const hidden = Array.from({ length: 64 }, () => ({
|
||||
resetWebglTextureAtlases: vi.fn<() => void>(),
|
||||
clearWebglTextureAtlases: vi.fn<() => void>(),
|
||||
presentForcedViewports: vi.fn<() => void>(),
|
||||
refreshAllPanes: vi.fn<() => void>(),
|
||||
isVisibleForAtlasRecovery: () => false
|
||||
}))
|
||||
@@ -105,12 +141,14 @@ describe('pane manager registry', () => {
|
||||
|
||||
resetAndRefreshAllTerminalWebglAtlases()
|
||||
|
||||
expect(visible.resetWebglTextureAtlases).toHaveBeenCalledOnce()
|
||||
expect(visible.refreshAllPanes).toHaveBeenCalledOnce()
|
||||
expect(visible.clearWebglTextureAtlases).toHaveBeenCalledOnce()
|
||||
expect(visible.presentForcedViewports).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
hidden.every((manager) => manager.resetWebglTextureAtlases.mock.calls.length === 0)
|
||||
hidden.every((manager) => manager.clearWebglTextureAtlases.mock.calls.length === 0)
|
||||
).toBe(true)
|
||||
expect(hidden.every((manager) => manager.refreshAllPanes.mock.calls.length === 0)).toBe(true)
|
||||
expect(hidden.every((manager) => manager.presentForcedViewports.mock.calls.length === 0)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('continues reset-and-refresh recovery when one manager throws', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { PaneRenderingDiagnostics } from './pane-manager-types'
|
||||
|
||||
type RegisteredPaneManager = {
|
||||
resetWebglTextureAtlases(): void
|
||||
clearWebglTextureAtlases?: () => void
|
||||
presentForcedViewports?: () => void
|
||||
fitAllPanes?: () => void
|
||||
refreshAllPanes?: () => void
|
||||
getRenderingDiagnostics?: () => PaneRenderingDiagnostics[]
|
||||
@@ -60,9 +62,17 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
|
||||
...(reason ? { reason } : {})
|
||||
})
|
||||
const resetManagers: RegisteredPaneManager[] = []
|
||||
// Why: clearTextureAtlas() is module-global. Clearing then presenting per
|
||||
// pane interleaves a present against generation N with the next pane's wipe
|
||||
// to N+1, so the first synchronized-output column keeps pre-hide footer pixels.
|
||||
// Wipe every recovered atlas first; present only once the generation is final.
|
||||
for (const manager of recoveryManagers) {
|
||||
try {
|
||||
manager.resetWebglTextureAtlases()
|
||||
if (manager.clearWebglTextureAtlases) {
|
||||
manager.clearWebglTextureAtlases()
|
||||
} else {
|
||||
manager.resetWebglTextureAtlases()
|
||||
}
|
||||
resetManagers.push(manager)
|
||||
} catch {
|
||||
// Why: recovery is best-effort during pane teardown; a disposed manager
|
||||
@@ -71,7 +81,11 @@ export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void {
|
||||
}
|
||||
for (const manager of resetManagers) {
|
||||
try {
|
||||
manager.refreshAllPanes?.()
|
||||
if (manager.presentForcedViewports) {
|
||||
manager.presentForcedViewports()
|
||||
} else {
|
||||
manager.refreshAllPanes?.()
|
||||
}
|
||||
} catch {
|
||||
// Why: a pane can unmount between atlas reset and repaint; later
|
||||
// managers still need to repaint from their xterm buffers.
|
||||
|
||||
@@ -26,6 +26,8 @@ import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
|
||||
import { rebuildAttachedWebgl } from './pane-webgl-reattach'
|
||||
import {
|
||||
markPaneComplexScriptOutput,
|
||||
clearPaneWebglTextureAtlases,
|
||||
presentPaneViewports,
|
||||
resetPaneWebglTextureAtlases,
|
||||
resumePaneRendering,
|
||||
setPaneGpuRenderingState,
|
||||
@@ -280,6 +282,14 @@ export class PaneManager {
|
||||
resetPaneWebglTextureAtlases(this.panes.values())
|
||||
}
|
||||
|
||||
clearWebglTextureAtlases(): void {
|
||||
clearPaneWebglTextureAtlases(this.panes.values())
|
||||
}
|
||||
|
||||
presentForcedViewports(): void {
|
||||
presentPaneViewports(this.panes.values())
|
||||
}
|
||||
|
||||
setAtlasRecoveryVisible(visible: boolean): void {
|
||||
this.atlasRecoveryVisible = visible
|
||||
}
|
||||
@@ -296,8 +306,7 @@ export class PaneManager {
|
||||
}
|
||||
|
||||
scheduleRevealPresent(): void {
|
||||
// Why: same destroy guard as scheduleRevealRepaint, but presents without
|
||||
// clearing the shared glyph atlas — used by the plain-refocus recovery.
|
||||
// Why: ordinary reveal keeps the coherent canvas until DEC 2026 releases.
|
||||
schedulePaneRevealPresent(() => (this.destroyed ? [] : this.panes.values()))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
disposeWebgl,
|
||||
isPaneWebglContextLost,
|
||||
markComplexScriptOutput,
|
||||
clearWebglTextureAtlas,
|
||||
presentPaneViewport,
|
||||
resetWebglTextureAtlas
|
||||
} from './pane-webgl-renderer'
|
||||
import { rebuildAttachedWebgl, reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
@@ -101,3 +103,15 @@ export function resetPaneWebglTextureAtlases(panes: Iterable<ManagedPaneInternal
|
||||
resetWebglTextureAtlas(pane)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPaneWebglTextureAtlases(panes: Iterable<ManagedPaneInternal>): void {
|
||||
for (const pane of panes) {
|
||||
clearWebglTextureAtlas(pane)
|
||||
}
|
||||
}
|
||||
|
||||
export function presentPaneViewports(panes: Iterable<ManagedPaneInternal>): void {
|
||||
for (const pane of panes) {
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import { reattachWebglIfNeeded } from './pane-webgl-reattach'
|
||||
import { presentPaneViewportPreservingSynchronizedOutput } from './pane-webgl-renderer'
|
||||
import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry'
|
||||
|
||||
type PaneGetter = () => Iterable<ManagedPaneInternal>
|
||||
@@ -83,21 +84,10 @@ export function schedulePaneRevealRepaint(getPanes: () => Iterable<ManagedPaneIn
|
||||
scheduleSettledFrame(flushPaneRevealRepaints)
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents already-visible panes without clearing the shared glyph atlas.
|
||||
*
|
||||
* Why: a plain window refocus never hid its panes, so their WebGL model is
|
||||
* already current — a `refresh` re-presents the live buffer (covering a
|
||||
* compositor that dropped frames while occluded). Using the atlas-clearing
|
||||
* reveal repaint here would wipe the atlas shared by every same-config pane and
|
||||
* re-arm the mid-stream page-merge garble race (xterm.js issue 4480); this path
|
||||
* must stay texture-atlas-preserving.
|
||||
*/
|
||||
/** Presents panes without clearing the shared glyph atlas or bypassing DEC 2026. */
|
||||
export function schedulePaneRevealPresent(getPanes: () => Iterable<ManagedPaneInternal>): void {
|
||||
forEachPaneOnSettledFrame(getPanes, (pane) => {
|
||||
reattachWebglIfNeeded(pane)
|
||||
if (pane.terminal.rows > 0) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
}
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import {
|
||||
attachWebgl,
|
||||
clearTerminalWebglAttachBackoff,
|
||||
presentPaneViewport,
|
||||
presentPaneViewportPreservingSynchronizedOutput,
|
||||
resetTerminalWebglSuggestion,
|
||||
resetWebglTextureAtlas
|
||||
} from './pane-webgl-renderer'
|
||||
@@ -75,6 +77,7 @@ type FakeRenderService = {
|
||||
function createPausedPane(display: 'block' | 'none'): {
|
||||
pane: ManagedPaneInternal
|
||||
renderService: FakeRenderService
|
||||
setDisplay: (next: 'block' | 'none') => void
|
||||
} {
|
||||
const pane = createPane()
|
||||
const renderService: FakeRenderService = {
|
||||
@@ -87,7 +90,8 @@ function createPausedPane(display: 'block' | 'none'): {
|
||||
}
|
||||
})
|
||||
}
|
||||
const view = { getComputedStyle: () => ({ display }) }
|
||||
let currentDisplay = display
|
||||
const view = { getComputedStyle: () => ({ display: currentDisplay }) }
|
||||
const element = { ownerDocument: { defaultView: view }, parentElement: null }
|
||||
pane.container = element as never
|
||||
pane.xtermContainer = element as never
|
||||
@@ -96,7 +100,13 @@ function createPausedPane(display: 'block' | 'none'): {
|
||||
refresh: vi.fn(() => renderService.refreshRows(0, pane.terminal.rows - 1)),
|
||||
_core: { _renderService: renderService }
|
||||
} as never
|
||||
return { pane, renderService }
|
||||
return {
|
||||
pane,
|
||||
renderService,
|
||||
setDisplay: (next) => {
|
||||
currentDisplay = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal WebGL addon lifecycle', () => {
|
||||
@@ -168,6 +178,58 @@ describe('terminal WebGL addon lifecycle', () => {
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
|
||||
})
|
||||
|
||||
it('falls back to terminal.refresh on an unpaused, unsynchronized pane', () => {
|
||||
// Restore-after-replay and other callers use presentPaneViewport even when
|
||||
// forceFullViewportPresent is a no-op (not paused, no DEC 2026).
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const pane = createPane()
|
||||
pane.terminal = {
|
||||
...pane.terminal,
|
||||
refresh: vi.fn(),
|
||||
_core: {
|
||||
_renderService: {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
},
|
||||
coreService: { decPrivateModes: { synchronizedOutput: false } }
|
||||
}
|
||||
} as never
|
||||
|
||||
presentPaneViewport(pane)
|
||||
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a synchronized frame on an ordinary reveal present', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const pane = createPane()
|
||||
pane.terminal = {
|
||||
...pane.terminal,
|
||||
refresh: vi.fn(),
|
||||
_core: {
|
||||
_renderService: {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
},
|
||||
coreService: { decPrivateModes: { synchronizedOutput: true } }
|
||||
}
|
||||
} as never
|
||||
|
||||
presentPaneViewportPreservingSynchronizedOutput(pane)
|
||||
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
expect(pane.terminal.refresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the render pause latched when resetting a pane that has no layout box', () => {
|
||||
// Regression: the atlas reset released xterm's pause for every pane of a
|
||||
// visible manager, including a collapsed sibling of an expanded pane. That
|
||||
@@ -185,6 +247,52 @@ describe('terminal WebGL addon lifecycle', () => {
|
||||
expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23)
|
||||
})
|
||||
|
||||
it('retries a display:none present after the overlay actually shows a box', () => {
|
||||
// Field traces show paused=true needFull=true because the
|
||||
// first present ran while the overlay was still display:none. Selection
|
||||
// later healed the inner rows; only a resize cleared the sides. Flush the
|
||||
// retry after the box exists so the full present actually runs.
|
||||
const queued: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
queued.push(callback)
|
||||
return queued.length
|
||||
})
|
||||
const { pane, renderService, setDisplay } = createPausedPane('none')
|
||||
|
||||
presentPaneViewport(pane)
|
||||
|
||||
expect(renderService._isPaused).toBe(true)
|
||||
expect(queued).toHaveLength(1)
|
||||
|
||||
setDisplay('block')
|
||||
queued.shift()?.(16)
|
||||
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
expect(renderService.refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
})
|
||||
|
||||
it('caps display:none present retries', () => {
|
||||
const queued: FrameRequestCallback[] = []
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
queued.push(callback)
|
||||
return queued.length
|
||||
})
|
||||
const { pane, renderService } = createPausedPane('none')
|
||||
|
||||
presentPaneViewport(pane)
|
||||
presentPaneViewport(pane)
|
||||
expect(queued).toHaveLength(1)
|
||||
let callbacks = 0
|
||||
while (queued.length > 0) {
|
||||
queued.shift()?.(callbacks * 16)
|
||||
callbacks += 1
|
||||
}
|
||||
|
||||
expect(callbacks).toBe(16)
|
||||
expect(renderService._isPaused).toBe(true)
|
||||
expect(renderService.refreshRows).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('still forces the paused render through for a pane that has a layout box', () => {
|
||||
// The reveal case the release exists for: DOM-visible, but xterm's observer
|
||||
// has not caught up, so a plain refresh() would be swallowed.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
import type { ManagedPaneInternal } from './pane-manager-types'
|
||||
import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types'
|
||||
import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics'
|
||||
import { getLivePaneCensus } from './pane-manager-registry'
|
||||
import { isManagedPaneDisplayNone } from './pane-display-visibility'
|
||||
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
import {
|
||||
getTerminalWebglAutoDecision,
|
||||
resetTerminalWebglAutoDecision
|
||||
@@ -138,7 +141,7 @@ export function markComplexScriptOutput(pane: ManagedPaneInternal): void {
|
||||
pane.hasComplexScriptOutput = true
|
||||
}
|
||||
|
||||
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
export function clearWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
if (pane.webglDisabledAfterContextLoss) {
|
||||
return
|
||||
}
|
||||
@@ -147,22 +150,89 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
// context-loss event. Clearing the atlas preserves GPU rendering and forces
|
||||
// a fresh paint when the pane becomes visible/focused again.
|
||||
pane.webglAddon?.clearTextureAtlas()
|
||||
} catch {
|
||||
/* ignore — pane may have been disposed in the meantime */
|
||||
}
|
||||
}
|
||||
|
||||
const DISPLAYED_PRESENT_RETRY_FRAMES = 16
|
||||
type ViewportPresentMode = 'preserve-synchronized-output' | 'force-current-buffer'
|
||||
type DisplayedPresentRetry = { frames: number; mode: ViewportPresentMode }
|
||||
const pendingDisplayedPresentRetries = new WeakMap<ManagedPaneInternal, DisplayedPresentRetry>()
|
||||
|
||||
function schedulePresentWhenDisplayed(pane: ManagedPaneInternal, mode: ViewportPresentMode): void {
|
||||
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
||||
return
|
||||
}
|
||||
const pending = pendingDisplayedPresentRetries.get(pane)
|
||||
if (pending) {
|
||||
if (mode === 'force-current-buffer') {
|
||||
pending.mode = mode
|
||||
}
|
||||
return
|
||||
}
|
||||
pendingDisplayedPresentRetries.set(pane, {
|
||||
frames: DISPLAYED_PRESENT_RETRY_FRAMES,
|
||||
mode
|
||||
})
|
||||
const tick = (): void => {
|
||||
const retry = pendingDisplayedPresentRetries.get(pane)
|
||||
if (!retry || retry.frames <= 0 || !pane.terminal) {
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
return
|
||||
}
|
||||
if (isManagedPaneDisplayNone(pane)) {
|
||||
if (retry.frames === 1) {
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
return
|
||||
}
|
||||
retry.frames -= 1
|
||||
globalThis.requestAnimationFrame(tick)
|
||||
return
|
||||
}
|
||||
pendingDisplayedPresentRetries.delete(pane)
|
||||
presentPaneViewportWithMode(pane, retry.mode)
|
||||
}
|
||||
globalThis.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function presentPaneViewportWithMode(pane: ManagedPane, mode: ViewportPresentMode): void {
|
||||
const internal = pane as ManagedPaneInternal
|
||||
if (internal.webglDisabledAfterContextLoss) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
// Why: on reveal xterm's IntersectionObserver can still report the pane as
|
||||
// not intersecting, so a plain refresh() is swallowed by RenderService's
|
||||
// paused-render gate and the cleared model never repaints (stale bottom rows
|
||||
// until a drag-select forces a redraw). Force the paused render through
|
||||
// first; only fall back to refresh() when the terminal was not gated.
|
||||
// paused-render gate and the pending model never repaints (stale bottom rows
|
||||
// until a drag-select forces a redraw). Request one synchronous full present
|
||||
// even if the observer already unpaused; only fall back to refresh() when
|
||||
// internals are unavailable.
|
||||
//
|
||||
// Why the display check: that release is only right for a pane that is
|
||||
// DOM-visible. A pane with no box at all (collapsed sibling of an expanded
|
||||
// pane, a restore that stays display:none for its whole reattach) is
|
||||
// legitimately paused, and releasing it paints the just-cleared model into
|
||||
// nothing and then leaves the service unpaused for good — the observer only
|
||||
// legitimately paused, and releasing it paints into nothing and then leaves
|
||||
// the service unpaused for good — the observer only
|
||||
// fires on a change, so it never re-pauses. Clearing _needsFullRefresh with
|
||||
// it also drops the full repaint the observer owes the pane on reveal, and
|
||||
// the deferred _pausedResizeTask that flushes alongside it. Latching is what
|
||||
// xterm's own gate does, and the reveal repaints from the latch.
|
||||
if (isManagedPaneDisplayNone(pane) || !forceRepaintThroughRenderPause(pane.terminal)) {
|
||||
if (isManagedPaneDisplayNone(pane)) {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
// Why: light tab reveal runs while the overlay is still display:none
|
||||
// (field trace: paused=true needFull=true at click). A plain refresh only
|
||||
// latches _needsFullRefresh; if IntersectionObserver never fires, the
|
||||
// canvas keeps pre-hide pixels until a user resize. Retry once the box
|
||||
// exists so the full present actually runs.
|
||||
schedulePresentWhenDisplayed(internal, mode)
|
||||
return
|
||||
}
|
||||
const presented =
|
||||
mode === 'force-current-buffer'
|
||||
? forceFullViewportPresent(pane.terminal)
|
||||
: requestFullViewportPresent(pane.terminal)
|
||||
if (!presented) {
|
||||
// Why: refresh even without a WebGL addon so recovery never silently
|
||||
// no-ops — a DOM-rendered pane can hold stale pixels after reveal too.
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
@@ -172,6 +242,19 @@ export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function presentPaneViewport(pane: ManagedPane): void {
|
||||
presentPaneViewportWithMode(pane, 'force-current-buffer')
|
||||
}
|
||||
|
||||
export function presentPaneViewportPreservingSynchronizedOutput(pane: ManagedPane): void {
|
||||
presentPaneViewportWithMode(pane, 'preserve-synchronized-output')
|
||||
}
|
||||
|
||||
export function resetWebglTextureAtlas(pane: ManagedPaneInternal): void {
|
||||
clearWebglTextureAtlas(pane)
|
||||
presentPaneViewport(pane)
|
||||
}
|
||||
|
||||
function refitAfterFitAnchoredWebglAttach(pane: ManagedPaneInternal): void {
|
||||
// Why: the fit that triggered this attach measured DOM cell metrics, but WebGL
|
||||
// floors the device cell width — keeping that grid leaves an unpainted right
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ManagedPane } from './pane-manager-types'
|
||||
import { repairPaneWebglCanvasDprMismatch } from './terminal-canvas-dpr-repair'
|
||||
import {
|
||||
repairPaneWebglCanvasDpr,
|
||||
repairPaneWebglCanvasDprMismatch
|
||||
} from './terminal-canvas-dpr-repair'
|
||||
|
||||
function makePane(args: {
|
||||
backingWidth: number
|
||||
expectedWidth: number
|
||||
dpr: number
|
||||
cachedDpr?: number
|
||||
backingHeight?: number
|
||||
expectedHeight?: number
|
||||
connected?: boolean
|
||||
@@ -32,6 +36,7 @@ function makePane(args: {
|
||||
(args.hasRenderer ?? true)
|
||||
? {
|
||||
_canvas: canvas,
|
||||
_devicePixelRatio: args.cachedDpr ?? args.dpr,
|
||||
dimensions: {
|
||||
device: {
|
||||
canvas: { width: args.expectedWidth, height: args.expectedHeight ?? 1200 }
|
||||
@@ -79,6 +84,19 @@ describe('repairPaneWebglCanvasDprMismatch', () => {
|
||||
expect(handleResize).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('repairs when the canvas and renderer dimensions share a stale dpr cache', () => {
|
||||
const { pane, handleDevicePixelRatioChange, handleResize } = makePane({
|
||||
backingWidth: 1080,
|
||||
expectedWidth: 1080,
|
||||
dpr: 2,
|
||||
cachedDpr: 1
|
||||
})
|
||||
|
||||
expect(repairPaneWebglCanvasDprMismatch(pane)).toBe(true)
|
||||
expect(handleDevicePixelRatioChange).toHaveBeenCalledTimes(1)
|
||||
expect(handleResize).toHaveBeenCalledWith(120, 40)
|
||||
})
|
||||
|
||||
it('is a no-op when backing matches the renderer device dimensions', () => {
|
||||
const { pane, handleResize, refresh } = makePane({
|
||||
backingWidth: 2160,
|
||||
@@ -136,6 +154,24 @@ describe('repairPaneWebglCanvasDprMismatch', () => {
|
||||
expect(repairPaneWebglCanvasDprMismatch(noRenderer.pane)).toBe(false)
|
||||
})
|
||||
|
||||
it('defers an unmeasurable WebGL canvas but accepts a renderer-less pane', () => {
|
||||
const detached = makePane({
|
||||
backingWidth: 2160,
|
||||
expectedWidth: 1080,
|
||||
dpr: 1,
|
||||
connected: false
|
||||
})
|
||||
const noRenderer = makePane({
|
||||
backingWidth: 2160,
|
||||
expectedWidth: 1080,
|
||||
dpr: 1,
|
||||
hasRenderer: false
|
||||
})
|
||||
|
||||
expect(repairPaneWebglCanvasDpr(detached.pane)).toBe('deferred')
|
||||
expect(repairPaneWebglCanvasDpr(noRenderer.pane)).toBe('current')
|
||||
})
|
||||
|
||||
it('reports failure without throwing when the repair path throws mid-teardown', () => {
|
||||
const { pane, handleResize } = makePane({
|
||||
backingWidth: 2160,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl
|
||||
*/
|
||||
type XtermRendererInternals = {
|
||||
_canvas?: HTMLCanvasElement
|
||||
_devicePixelRatio?: number
|
||||
_gl?: { canvas?: HTMLCanvasElement }
|
||||
dimensions?: {
|
||||
device?: { canvas?: { width?: number; height?: number } }
|
||||
@@ -22,33 +23,42 @@ type XtermRendererInternals = {
|
||||
handleResize?: (cols: number, rows: number) => void
|
||||
}
|
||||
|
||||
export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
|
||||
export type PaneWebglCanvasDprRepairState = 'current' | 'deferred' | 'repaired'
|
||||
|
||||
export function repairPaneWebglCanvasDpr(pane: ManagedPane): PaneWebglCanvasDprRepairState {
|
||||
const renderer = (
|
||||
pane.terminal as unknown as {
|
||||
_core?: { _renderService?: { _renderer?: { value?: XtermRendererInternals } } }
|
||||
}
|
||||
)._core?._renderService?._renderer?.value
|
||||
const canvas = renderer?._canvas ?? renderer?._gl?.canvas
|
||||
if (!renderer || !canvas?.isConnected) {
|
||||
return false
|
||||
if (!renderer || !canvas) {
|
||||
return 'current'
|
||||
}
|
||||
if (!canvas.isConnected) {
|
||||
return 'deferred'
|
||||
}
|
||||
const view = canvas.ownerDocument?.defaultView
|
||||
const expected = renderer.dimensions?.device?.canvas
|
||||
const expectedWidth = expected?.width ?? 0
|
||||
const expectedHeight = expected?.height ?? 0
|
||||
if (!view || expectedWidth <= 0 || expectedHeight <= 0) {
|
||||
return false
|
||||
return 'deferred'
|
||||
}
|
||||
const staleBackingWidth = canvas.width
|
||||
const staleBackingHeight = canvas.height
|
||||
const cachedDevicePixelRatio = renderer._devicePixelRatio
|
||||
const devicePixelRatioChanged =
|
||||
typeof cachedDevicePixelRatio === 'number' && cachedDevicePixelRatio !== view.devicePixelRatio
|
||||
// xterm rounds its CSS canvas size before ResizeObserver converts it back to
|
||||
// device pixels; allow that round trip without forcing layout on every fit.
|
||||
const roundingTolerance = Math.max(1, Math.ceil(view.devicePixelRatio / 2))
|
||||
if (
|
||||
!devicePixelRatioChanged &&
|
||||
Math.abs(staleBackingWidth - expectedWidth) <= roundingTolerance &&
|
||||
Math.abs(staleBackingHeight - expectedHeight) <= roundingTolerance
|
||||
) {
|
||||
return false
|
||||
return 'current'
|
||||
}
|
||||
try {
|
||||
// Order matters: refresh the renderer's cached dpr/dimensions first, then
|
||||
@@ -58,13 +68,18 @@ export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
|
||||
pane.terminal.refresh(0, pane.terminal.rows - 1)
|
||||
} catch {
|
||||
// Pane may be mid-teardown; the next reveal/fit retries the check.
|
||||
return false
|
||||
return 'deferred'
|
||||
}
|
||||
recordTerminalWebglDiagnostic('webgl-canvas-dpr-repair', {
|
||||
paneId: pane.id,
|
||||
staleBackingWidth,
|
||||
expectedBackingWidth: expectedWidth,
|
||||
...(cachedDevicePixelRatio === undefined ? {} : { cachedDevicePixelRatio }),
|
||||
devicePixelRatio: view.devicePixelRatio
|
||||
})
|
||||
return true
|
||||
return 'repaired'
|
||||
}
|
||||
|
||||
export function repairPaneWebglCanvasDprMismatch(pane: ManagedPane): boolean {
|
||||
return repairPaneWebglCanvasDpr(pane) === 'repaired'
|
||||
}
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { forceRepaintThroughRenderPause } from './terminal-render-pause-release'
|
||||
import {
|
||||
forceFullViewportPresent,
|
||||
forceRepaintThroughRenderPause,
|
||||
requestFullViewportPresent
|
||||
} from './terminal-render-pause-release'
|
||||
|
||||
type FakeRenderService = {
|
||||
_isPaused?: boolean
|
||||
_needsFullRefresh?: boolean
|
||||
refreshRows?: ReturnType<typeof vi.fn>
|
||||
_renderer?: {
|
||||
value?: { clear?: ReturnType<typeof vi.fn>; renderRows?: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
}
|
||||
|
||||
function createTerminal(options: {
|
||||
rows?: number
|
||||
renderService?: FakeRenderService | null
|
||||
withoutCore?: boolean
|
||||
synchronizedOutput?: boolean
|
||||
}): unknown {
|
||||
const { rows = 24, renderService, withoutCore } = options
|
||||
const { rows = 24, renderService, withoutCore, synchronizedOutput } = options
|
||||
if (withoutCore) {
|
||||
return { rows }
|
||||
}
|
||||
return {
|
||||
rows,
|
||||
_core: { _renderService: renderService ?? null }
|
||||
_core: {
|
||||
_renderService: renderService ?? null,
|
||||
coreService: { decPrivateModes: { synchronizedOutput: synchronizedOutput === true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,3 +96,131 @@ describe('forceRepaintThroughRenderPause', () => {
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forceFullViewportPresent', () => {
|
||||
it('fails closed when xterm internals are unavailable', () => {
|
||||
expect(forceFullViewportPresent(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves an unpaused, unsynchronized terminal to the normal refresh path', () => {
|
||||
// A forced sync renderRows on first splash paints before cell metrics
|
||||
// settle and shows a 1px black gutter under the TUI composer.
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const terminal = createTerminal({
|
||||
rows: 24,
|
||||
renderService: {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
})
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(false)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('paints through the renderer so DEC 2026 cannot swallow the reveal present', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const renderService = {
|
||||
_isPaused: false,
|
||||
_needsFullRefresh: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
const terminal = createTerminal({
|
||||
rows: 24,
|
||||
renderService,
|
||||
synchronizedOutput: true
|
||||
})
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(true)
|
||||
expect(renderRows).toHaveBeenCalledWith(0, 23)
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses RenderService refreshRows when paused without DEC 2026, matching production splash', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const renderService = {
|
||||
_isPaused: true,
|
||||
_needsFullRefresh: true,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
const terminal = createTerminal({ rows: 24, renderService })
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(true)
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves pause cleared when the forced render throws so callers can refresh()', () => {
|
||||
const renderService = {
|
||||
_isPaused: true,
|
||||
_needsFullRefresh: true,
|
||||
refreshRows: vi.fn(() => {
|
||||
throw new Error('terminal disposed')
|
||||
}),
|
||||
_renderer: { value: { renderRows: vi.fn() } }
|
||||
}
|
||||
const terminal = createTerminal({ rows: 24, renderService })
|
||||
|
||||
expect(forceFullViewportPresent(terminal)).toBe(false)
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
expect(renderService._needsFullRefresh).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('requestFullViewportPresent', () => {
|
||||
it('fails closed when xterm internals are unavailable', () => {
|
||||
expect(requestFullViewportPresent(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a normal visible terminal on the debounced refresh path', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const terminal = createTerminal({
|
||||
renderService: { _isPaused: false, refreshRows }
|
||||
})
|
||||
|
||||
expect(requestFullViewportPresent(terminal)).toBe(false)
|
||||
expect(refreshRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes synchronized output through RenderService instead of the renderer', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderRows = vi.fn()
|
||||
const terminal = createTerminal({
|
||||
rows: 24,
|
||||
synchronizedOutput: true,
|
||||
renderService: {
|
||||
_isPaused: false,
|
||||
refreshRows,
|
||||
_renderer: { value: { renderRows } }
|
||||
}
|
||||
})
|
||||
|
||||
expect(requestFullViewportPresent(terminal)).toBe(true)
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 23, true)
|
||||
expect(renderRows).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('releases observer pause before requesting the synchronized frame', () => {
|
||||
const refreshRows = vi.fn()
|
||||
const renderService = {
|
||||
_isPaused: true,
|
||||
_needsFullRefresh: true,
|
||||
refreshRows
|
||||
}
|
||||
const terminal = createTerminal({ rows: 30, renderService, synchronizedOutput: true })
|
||||
|
||||
expect(requestFullViewportPresent(terminal)).toBe(true)
|
||||
expect(renderService._isPaused).toBe(false)
|
||||
expect(renderService._needsFullRefresh).toBe(false)
|
||||
expect(refreshRows).toHaveBeenCalledWith(0, 29, true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,10 +17,15 @@
|
||||
* never throws into a render frame.
|
||||
*/
|
||||
|
||||
type MaybeWebglRenderer = {
|
||||
renderRows?: (start: number, end: number) => void
|
||||
}
|
||||
|
||||
type MaybePausableRenderService = {
|
||||
_isPaused?: boolean
|
||||
_needsFullRefresh?: boolean
|
||||
refreshRows?: (start: number, end: number, sync?: boolean) => void
|
||||
_renderer?: { value?: MaybeWebglRenderer | null } | MaybeWebglRenderer | null
|
||||
}
|
||||
|
||||
type PausableRenderService = MaybePausableRenderService & {
|
||||
@@ -31,6 +36,8 @@ type TerminalWithRenderService = {
|
||||
rows?: number
|
||||
_core?: {
|
||||
_renderService?: MaybePausableRenderService
|
||||
coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
|
||||
_coreService?: { decPrivateModes?: { synchronizedOutput?: boolean } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,3 +78,108 @@ export function forceRepaintThroughRenderPause(terminal: unknown): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a full viewport while preserving a TUI's synchronized-output frame.
|
||||
*
|
||||
* Why: ordinary reveal must not publish a half-built DEC 2026 frame. Routing
|
||||
* through RenderService keeps the previous canvas coherent and arms xterm's
|
||||
* bounded safety timeout if the TUI never closes the frame.
|
||||
*/
|
||||
export function requestFullViewportPresent(terminal: unknown): boolean {
|
||||
const service = getRenderService(terminal)
|
||||
if (!service) {
|
||||
return false
|
||||
}
|
||||
const rows = (terminal as TerminalWithRenderService).rows
|
||||
if (typeof rows !== 'number' || rows < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const paused = service._isPaused === true
|
||||
if (!paused && !isSynchronizedOutputHeld(terminal)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
service._isPaused = false
|
||||
service._needsFullRefresh = false
|
||||
}
|
||||
|
||||
try {
|
||||
service.refreshRows(0, rows - 1, true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getRenderer(service: MaybePausableRenderService): MaybeWebglRenderer | null {
|
||||
const holder = service._renderer
|
||||
if (!holder) {
|
||||
return null
|
||||
}
|
||||
if (typeof (holder as MaybeWebglRenderer).renderRows === 'function') {
|
||||
return holder as MaybeWebglRenderer
|
||||
}
|
||||
const wrapped = (holder as { value?: MaybeWebglRenderer | null }).value
|
||||
return wrapped ?? null
|
||||
}
|
||||
|
||||
function isSynchronizedOutputHeld(terminal: unknown): boolean {
|
||||
const core = (terminal as TerminalWithRenderService)._core
|
||||
return (
|
||||
(core?.coreService?.decPrivateModes ?? core?._coreService?.decPrivateModes)
|
||||
?.synchronizedOutput === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One synchronous full-viewport present when xterm would otherwise swallow it:
|
||||
* IntersectionObserver pause, or DEC 2026 synchronized output.
|
||||
*
|
||||
* Why not on every paint: a forced renderer.renderRows on a fresh, unpaused
|
||||
* splash paints before cell metrics settle and leaves a 1px black gutter under
|
||||
* a TUI composer — production never does that. Callers fall back to
|
||||
* terminal.refresh() for the normal path.
|
||||
*/
|
||||
export function forceFullViewportPresent(terminal: unknown): boolean {
|
||||
const service = getRenderService(terminal)
|
||||
if (!service) {
|
||||
return false
|
||||
}
|
||||
const rows = (terminal as TerminalWithRenderService).rows
|
||||
if (typeof rows !== 'number' || rows < 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const paused = service._isPaused === true
|
||||
const syncHeld = isSynchronizedOutputHeld(terminal)
|
||||
if (!paused && !syncHeld) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (paused) {
|
||||
service._isPaused = false
|
||||
service._needsFullRefresh = false
|
||||
}
|
||||
|
||||
const renderer = getRenderer(service)
|
||||
try {
|
||||
// Why: a new TUI tab is often still paused (observer lag). Painting
|
||||
// via renderer.renderRows skips RenderService's dimension clamp and draws
|
||||
// 1px short of the composer box. Production uses refreshRows here.
|
||||
// renderer.renderRows is only for DEC 2026, which swallows refreshRows.
|
||||
if (syncHeld && typeof renderer?.renderRows === 'function') {
|
||||
renderer.renderRows(0, rows - 1)
|
||||
return true
|
||||
}
|
||||
service.refreshRows(0, rows - 1, true)
|
||||
return true
|
||||
} catch {
|
||||
// Why: same as forceRepaintThroughRenderPause — leave the latch cleared so
|
||||
// the caller's terminal.refresh() fallback can still paint. Restoring
|
||||
// _isPaused would swallow that refresh until IntersectionObserver fires.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ type IndexedProjectGroupOwnerResolution =
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
function catalogOwnerHostId(owner: {
|
||||
export function getCatalogOwnerHostId(owner: {
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
}): ExecutionHostId {
|
||||
@@ -74,11 +74,11 @@ function buildCatalogOwnerIndex<
|
||||
const next = new Map<string, { kind: 'resolved'; owner: T } | { kind: 'ambiguous' }>()
|
||||
for (const record of records) {
|
||||
const id = record.id
|
||||
const hostId = catalogOwnerHostId(record)
|
||||
const hostId = getCatalogOwnerHostId(record)
|
||||
const current = next.get(id)
|
||||
if (!current) {
|
||||
next.set(id, { kind: 'resolved', owner: record })
|
||||
} else if (current.kind === 'resolved' && catalogOwnerHostId(current.owner) !== hostId) {
|
||||
} else if (current.kind === 'resolved' && getCatalogOwnerHostId(current.owner) !== hostId) {
|
||||
next.set(id, { kind: 'ambiguous' })
|
||||
}
|
||||
next.set(`${id}\0${hostId}`, {
|
||||
|
||||
@@ -427,5 +427,39 @@ describe('folder workspace owner-routed mutations', () => {
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(folderWorkspacesDelete).not.toHaveBeenCalled()
|
||||
expect(store.getState().folderWorkspaces).toEqual([])
|
||||
})
|
||||
|
||||
it('deletes only the host-qualified folder when ids collide', async () => {
|
||||
const localFolder = makeFolderWorkspace({ executionHostId: 'local' })
|
||||
const runtimeFolder = makeFolderWorkspace({ executionHostId: 'runtime:env-owner' })
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-delete-folder',
|
||||
ok: true,
|
||||
result: { deleted: true },
|
||||
_meta: { runtimeId: 'runtime-owner' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
activeWorktreeId: `folder:${runtimeFolder.id}`,
|
||||
activeWorkspaceExecutionHostId: 'runtime:env-owner',
|
||||
settings: { activeRuntimeEnvironmentId: 'env-owner' } as never,
|
||||
projectGroups: [{ ...projectGroup, executionHostId: 'local' }],
|
||||
folderWorkspaces: [localFolder, runtimeFolder]
|
||||
})
|
||||
|
||||
await expect(
|
||||
store
|
||||
.getState()
|
||||
.deleteFolderWorkspace(runtimeFolder.id, { executionHostId: 'runtime:env-owner' })
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-owner',
|
||||
method: 'folderWorkspace.delete',
|
||||
params: { folderWorkspaceId: runtimeFolder.id },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(store.getState().folderWorkspaces).toEqual([localFolder])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,6 +112,7 @@ import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh'
|
||||
import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status'
|
||||
import {
|
||||
findFolderWorkspaceOwner,
|
||||
getExecutionHostIdForFolderWorkspace,
|
||||
getRuntimeEnvironmentIdForFolderWorkspace
|
||||
} from '@/lib/folder-workspace-runtime-owner'
|
||||
import {
|
||||
@@ -1908,7 +1909,10 @@ export type RepoSlice = {
|
||||
updates: FolderWorkspaceUpdates,
|
||||
options?: { executionHostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
deleteFolderWorkspace: (folderWorkspaceId: string) => Promise<boolean>
|
||||
deleteFolderWorkspace: (
|
||||
folderWorkspaceId: string,
|
||||
options?: { executionHostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
// options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the group's own host owns the call.
|
||||
updateProjectGroup: (
|
||||
groupId: string,
|
||||
@@ -2980,12 +2984,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
deleteFolderWorkspace: async (folderWorkspaceId) => {
|
||||
deleteFolderWorkspace: async (folderWorkspaceId, options) => {
|
||||
const state = get()
|
||||
if (!findFolderWorkspaceOwner(state, folderWorkspaceId)) {
|
||||
const executionHostId = options?.executionHostId
|
||||
if (!findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId)) {
|
||||
return false
|
||||
}
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace(state, folderWorkspaceId)
|
||||
const ownerHostId = getExecutionHostIdForFolderWorkspace(
|
||||
state,
|
||||
folderWorkspaceId,
|
||||
executionHostId
|
||||
)
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace(
|
||||
state,
|
||||
folderWorkspaceId,
|
||||
executionHostId
|
||||
)
|
||||
try {
|
||||
// Why: deletion targets the folder's owner; focus may be on a different host.
|
||||
const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId })
|
||||
@@ -3006,11 +3020,15 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
set((s) => ({
|
||||
folderWorkspaces: s.folderWorkspaces.filter(
|
||||
(workspace) => workspace.id !== folderWorkspaceId
|
||||
(workspace) =>
|
||||
workspace.id !== folderWorkspaceId ||
|
||||
getFolderWorkspaceHostId(workspace, s.projectGroups) !== ownerHostId
|
||||
),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
get().purgeWorktreeTerminalState([workspaceKey])
|
||||
if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) {
|
||||
get().purgeWorktreeTerminalState([workspaceKey])
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete folder workspace:', err)
|
||||
|
||||
@@ -28,6 +28,20 @@ describe('remountTerminalTabForRecovery', () => {
|
||||
expect(after?.pendingActivationSpawn).toBeTruthy()
|
||||
})
|
||||
|
||||
it('gives a remounted pane a distinct queued startup owner', () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedWorktreeWithTab(store)
|
||||
const startup = { command: 'echo recover' }
|
||||
store.getState().queueTabStartupCommand(tabId, startup)
|
||||
const before = store.getState().pendingStartupByTabId[tabId]
|
||||
|
||||
expect(store.getState().remountTerminalTabForRecovery(tabId)).toBe(true)
|
||||
|
||||
const after = store.getState().pendingStartupByTabId[tabId]
|
||||
expect(after).toEqual(before)
|
||||
expect(after).not.toBe(before)
|
||||
})
|
||||
|
||||
it('leaves sibling tabs untouched', () => {
|
||||
const store = createTestStore()
|
||||
const tabId = seedWorktreeWithTab(store)
|
||||
|
||||
@@ -649,6 +649,8 @@ export type TerminalSlice = {
|
||||
setTabBarOrder: (worktreeId: string, order: string[]) => void
|
||||
setActiveTab: (tabId: string) => void
|
||||
setActiveTabForWorktree: (worktreeId: string, tabId: string) => void
|
||||
/** Resolve the canonical legacy terminal-tab owner key for renderer lifecycle guards. */
|
||||
getTerminalTabOwnerWorktreeId?: (tabId: string) => string | null
|
||||
updateTabTitle: (tabId: string, title: string) => void
|
||||
updateTabTitles: (updates: readonly TerminalTabTitleUpdate[]) => void
|
||||
setAiVaultTabTitle: (tabId: string, aiVaultTitle: AiVaultSessionTitle | null) => void
|
||||
@@ -1498,6 +1500,9 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
||||
return tab
|
||||
},
|
||||
|
||||
getTerminalTabOwnerWorktreeId: (tabId) =>
|
||||
getTerminalTabOwnerWorktreeId(get().tabsByWorktree, tabId),
|
||||
|
||||
openNewTerminalTabInActiveWorkspace: async (groupId) => {
|
||||
const state = get()
|
||||
const worktreeId = state.activeWorktreeId
|
||||
|
||||
@@ -11,9 +11,14 @@ export const WORKSPACE_CLEANUP_BROWSE_PERSIST_DEBOUNCE_MS = 250
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** Updater form exists so two patches in one tick cannot read the same stale snapshot. */
|
||||
export type WorkspaceCleanupBrowseUpdate =
|
||||
| WorkspaceCleanupBrowseState
|
||||
| ((current: WorkspaceCleanupBrowseState) => WorkspaceCleanupBrowseState)
|
||||
|
||||
export type WorkspaceCleanupBrowseSlice = {
|
||||
workspaceCleanupBrowse: WorkspaceCleanupBrowseState
|
||||
updateWorkspaceCleanupBrowseState: (next: WorkspaceCleanupBrowseState) => void
|
||||
updateWorkspaceCleanupBrowseState: (next: WorkspaceCleanupBrowseUpdate) => void
|
||||
}
|
||||
|
||||
export const createWorkspaceCleanupBrowseSlice: StateCreator<
|
||||
@@ -25,7 +30,9 @@ export const createWorkspaceCleanupBrowseSlice: StateCreator<
|
||||
workspaceCleanupBrowse: createDefaultWorkspaceCleanupBrowseState(),
|
||||
|
||||
updateWorkspaceCleanupBrowseState: (next) => {
|
||||
set({ workspaceCleanupBrowse: next })
|
||||
set((state) => ({
|
||||
workspaceCleanupBrowse: typeof next === 'function' ? next(state.workspaceCleanupBrowse) : next
|
||||
}))
|
||||
if (persistTimer !== null) {
|
||||
clearTimeout(persistTimer)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export function createRemountTerminalTabForRecovery(
|
||||
}
|
||||
const tab = tabs[index]
|
||||
const nextTabs = tabs.slice()
|
||||
const pendingStartup = s.pendingStartupByTabId[tabId]
|
||||
nextTabs[index] = {
|
||||
...tab,
|
||||
// Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns.
|
||||
@@ -44,7 +45,17 @@ export function createRemountTerminalTabForRecovery(
|
||||
tabsByWorktree: {
|
||||
...s.tabsByWorktree,
|
||||
[worktreeId]: nextTabs
|
||||
}
|
||||
},
|
||||
...(pendingStartup
|
||||
? {
|
||||
// Why: a remounted pane must own a distinct one-shot startup record so a stale
|
||||
// pane cannot consume the successor's command during teardown.
|
||||
pendingStartupByTabId: {
|
||||
...s.pendingStartupByTabId,
|
||||
[tabId]: { ...pendingStartup }
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
return {}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getAgentLabel } from './agent-title-identity'
|
||||
|
||||
/**
|
||||
* Characterization of `getAgentLabel` before the identity refactor.
|
||||
*
|
||||
* `getAgentLabel` is an ordered first-match-wins scan of title predicates. Detector precedence
|
||||
* and targeted exceptions — not a unified evidence model — decide identity. These tests pin
|
||||
* the current answers, including the wrong ones, so the resolver change shows up as a
|
||||
* reviewable diff of assertions rather than as silent behavior drift.
|
||||
*
|
||||
* Cases marked DEFECT are minimized from real recorded pane titles
|
||||
* (`terminal-history/<id>/checkpoint.json` -> `lastTitle`). At the time of writing the live
|
||||
* corpus held 2,846 checkpoints / 1,084 populated titles / 747 distinct, of which 5 real titles
|
||||
* resolved to the wrong agent. Task text is minimized here; the corpus stays local.
|
||||
*/
|
||||
|
||||
/** Orca's own owner suffix: the agent that owns the pane is named after the final `- `. */
|
||||
const ownerSuffix = (task: string, agent: string): string => `${task}… - ${agent}`
|
||||
|
||||
describe('getAgentLabel — characterization (pre-refactor)', () => {
|
||||
describe('the owner suffix loses to a foreign name in task text', () => {
|
||||
// DEFECT. In each case the pane owner is Grok, named by Orca's own `- grok` suffix
|
||||
// grammar, while the competing agent appears only inside free-form task text. Codex is
|
||||
// checked before Grok, so the weaker evidence wins.
|
||||
it.each([
|
||||
['Switch Claude and Codex off the load balancer', 'Codex'],
|
||||
['Codex structured chat revalidation', 'Codex'],
|
||||
['Swap Codex off the load balancer', 'Codex']
|
||||
])('reads %j as %s instead of Grok', (task, current) => {
|
||||
expect(getAgentLabel(ownerSuffix(task, 'grok'))).toBe(current)
|
||||
})
|
||||
|
||||
it('reads a spinner-prefixed Grok pane as Codex', () => {
|
||||
// DEFECT. Real shape: a status spinner and phase precede the task text.
|
||||
expect(getAgentLabel(`⠸ - Thinking - ${ownerSuffix('Codex native-chat work', 'grok')}`)).toBe(
|
||||
'Codex'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves a Grok pane naming two other agents, but only by a targeted exception', () => {
|
||||
// Fixed by #15535, which teaches the Gemini token branch to decline when an Antigravity
|
||||
// name is present. Correct here only because that exception happens to clear the path to
|
||||
// Grok — drop the word "Antigravity" and it reads as Gemini CLI again (next case).
|
||||
expect(
|
||||
getAgentLabel(ownerSuffix('Electron QA: Antigravity tab vs Gemini label', 'grok'))
|
||||
).toBe('Grok')
|
||||
})
|
||||
|
||||
it('still reads a Grok pane as Gemini CLI when no exception covers the pair', () => {
|
||||
// DEFECT, and the general form of the case above: one narrowing fixes one collision.
|
||||
expect(getAgentLabel(ownerSuffix('Electron QA: check the Gemini label', 'grok'))).toBe(
|
||||
'Gemini CLI'
|
||||
)
|
||||
})
|
||||
|
||||
it('resolves the owner suffix correctly only when no earlier agent is named', () => {
|
||||
// Why this passes today: nothing earlier in the chain matches, so position never comes up.
|
||||
expect(getAgentLabel(ownerSuffix('Fix the sidebar row', 'grok'))).toBe('Grok')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a hyphenated worktree name is correctly not identity', () => {
|
||||
// Not a defect — pinned so the resolver does not start claiming these.
|
||||
it.each(['review-14600-codex', 'sta4779-review-codex', 'codex-split-core'])(
|
||||
'declines %j',
|
||||
(title) => {
|
||||
expect(getAgentLabel(title)).toBeNull()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('title order does not decide between two names', () => {
|
||||
// Both orderings of each pair agree: detector precedence and its targeted exceptions,
|
||||
// rather than name order within the title, choose the label.
|
||||
it.each([
|
||||
['codex', 'grok', 'Codex'],
|
||||
['grok', 'codex', 'Codex'],
|
||||
['gemini', 'antigravity', 'Antigravity'],
|
||||
['antigravity', 'gemini', 'Antigravity'],
|
||||
['copilot', 'devin', 'GitHub Copilot'],
|
||||
['devin', 'copilot', 'GitHub Copilot']
|
||||
])('%s + %s both resolve to %s', (first, second, winner) => {
|
||||
expect(getAgentLabel(`${first} and ${second}`)).toBe(winner)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a vendor glyph is claimed by whichever check sits earliest', () => {
|
||||
it('claims a Claude glyph even when the task text names another agent', () => {
|
||||
// Correct today (the pane really is Claude) but for the wrong reason: the glyph is not
|
||||
// consulted as vendor evidence, the `✳ ` prefix branch simply sits first.
|
||||
expect(getAgentLabel('✳ Fix Codex false attention notifications on Windows')).toBe(
|
||||
'Claude Code'
|
||||
)
|
||||
})
|
||||
|
||||
it('gives a bare agent name no way to outrank an earlier glyph check', () => {
|
||||
// DEFECT. `agy` is the entire undecorated remainder — the strongest possible name
|
||||
// evidence — and still loses to Claude's prefix glyph.
|
||||
expect(getAgentLabel('✳ agy')).toBe('Claude Code')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Antigravity model names', () => {
|
||||
it('reads a bare Antigravity model title as Gemini CLI', () => {
|
||||
// DEFECT. Antigravity models are named `Gemini <n.n> <Name>`, so an agy pane's own title
|
||||
// carries a whole `gemini` token, and Gemini CLI is checked first.
|
||||
expect(getAgentLabel('Gemini 3.7 Flash · high')).toBe('Gemini CLI')
|
||||
})
|
||||
|
||||
it('resolves once the Antigravity name is also present', () => {
|
||||
// Fixed by #15535. The resolver reaches the same answer structurally — the agy segment is
|
||||
// the anchored identity and `Gemini <n.n> <Name>` is model metadata — rather than by
|
||||
// teaching one detector about one competitor.
|
||||
expect(getAgentLabel('agy · Gemini 3.7 Flash')).toBe('Antigravity')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -144,6 +144,8 @@ describe('keybindings', () => {
|
||||
it('formats keybindings with platform labels', () => {
|
||||
expect(formatKeybindingList(['Mod+Shift+J'], 'darwin')).toBe('⌘⇧J')
|
||||
expect(formatKeybindingList(['Mod+Shift+J'], 'linux')).toBe('Ctrl+Shift+J')
|
||||
expect(formatKeybindingList(['Mod+Shift+Backspace'], 'darwin')).toBe('⌘⇧⌫')
|
||||
expect(formatKeybindingList(['Mod+Shift+Backspace'], 'linux')).toBe('Ctrl+Shift+Backspace')
|
||||
expect(formatKeybindingList([], 'win32')).toBe('Unassigned')
|
||||
})
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('keybindings', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps workspace delete unassigned until users customize it', () => {
|
||||
it('binds immediate workspace delete without claiming terminal split right', () => {
|
||||
const binding = {
|
||||
key: 'Backspace',
|
||||
code: 'Backspace',
|
||||
@@ -66,13 +66,28 @@ describe('keybindings', () => {
|
||||
shift: true
|
||||
}
|
||||
|
||||
expect(getEffectiveKeybindingsForAction('workspace.delete', 'linux')).toEqual([])
|
||||
expect(keybindingMatchesAction('workspace.delete', binding, 'linux')).toBe(false)
|
||||
expect(getEffectiveKeybindingsForAction('workspace.delete', 'darwin')).toEqual([
|
||||
'Mod+Shift+Backspace'
|
||||
])
|
||||
expect(getEffectiveKeybindingsForAction('workspace.delete', 'linux')).toEqual([
|
||||
'Mod+Shift+Backspace'
|
||||
])
|
||||
expect(getEffectiveKeybindingsForAction('workspace.delete', 'win32')).toEqual([
|
||||
'Mod+Shift+Backspace'
|
||||
])
|
||||
expect(keybindingMatchesAction('workspace.delete', binding, 'linux')).toBe(true)
|
||||
expect(
|
||||
keybindingMatchesAction('workspace.delete', binding, 'linux', {
|
||||
'workspace.delete': ['Mod+Shift+Backspace']
|
||||
'workspace.delete': []
|
||||
})
|
||||
).toBe(true)
|
||||
).toBe(false)
|
||||
expect(
|
||||
keybindingMatchesAction(
|
||||
'workspace.delete',
|
||||
{ key: 'd', code: 'KeyD', control: false, meta: true, alt: true, shift: false },
|
||||
'darwin'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps workspace board unassigned until users customize it', () => {
|
||||
|
||||
@@ -293,8 +293,8 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [
|
||||
'remove',
|
||||
'trash'
|
||||
],
|
||||
// Why: ship now without a default chord; user overrides still win when a future default is assigned.
|
||||
defaultBindings: platformBindings([]),
|
||||
// Why: Backspace avoids the terminal pane's D-based split shortcuts on every platform.
|
||||
defaultBindings: platformBindings(['Mod+Shift+Backspace']),
|
||||
allowInTerminal: true
|
||||
},
|
||||
{
|
||||
@@ -2281,7 +2281,7 @@ export function formatKeybinding(binding: string, platform: NodeJS.Platform): st
|
||||
if (parsed.shift) {
|
||||
parts.push(isMac ? '⇧' : 'Shift')
|
||||
}
|
||||
parts.push(formatKeyToken(parsed.key))
|
||||
parts.push(formatKeyToken(parsed.key, isMac))
|
||||
return parts
|
||||
}
|
||||
|
||||
@@ -2317,7 +2317,7 @@ export function findKeybindingActionsForBinding(
|
||||
).map((definition) => definition.id)
|
||||
}
|
||||
|
||||
function formatKeyToken(token: string): string {
|
||||
function formatKeyToken(token: string, isMac: boolean): string {
|
||||
const labels: Record<string, string> = {
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
@@ -2341,7 +2341,7 @@ function formatKeyToken(token: string): string {
|
||||
Quote: "'",
|
||||
Backquote: '`',
|
||||
Enter: 'Enter',
|
||||
Backspace: 'Backspace',
|
||||
Backspace: isMac ? '⌫' : 'Backspace',
|
||||
Delete: 'Delete',
|
||||
Insert: 'Insert',
|
||||
Tab: 'Tab',
|
||||
|
||||
@@ -5,10 +5,11 @@ import type { LinearIssue } from '../linear/issue-types'
|
||||
import type { LinearCollectionResult } from '../linear/workspace-types'
|
||||
import type { BaseRefSearchResult } from '../repo-types'
|
||||
import { JIRA_ISSUE_KEY_PATTERN, parseJiraIssueUrl } from '../jira-issue-url'
|
||||
import type { GitHubIssueOrPRLink } from '../github/links'
|
||||
import {
|
||||
isSmartWorkspaceLinearIssueIntentMatch,
|
||||
parseBoundedSmartWorkspaceLinearIssueUrlIntent
|
||||
} from './smart-workspace-linear-intent'
|
||||
buildSmartWorkspaceUrlSourceRows,
|
||||
type SmartWorkspaceGitLabUrlIntent
|
||||
} from './smart-workspace-url-source-results'
|
||||
import { isSmartWorkspaceSourceQueryWithinLimit } from './smart-workspace-source-query'
|
||||
|
||||
export {
|
||||
@@ -59,10 +60,31 @@ export function isBlockingJiraUrlIntent(mode: SmartNameMode, value: string): boo
|
||||
return (mode === 'smart' || mode === 'jira') && parseJiraIssueUrl(value) !== null
|
||||
}
|
||||
|
||||
export function isBlockingTaskUrlResolution({
|
||||
sourceIntent,
|
||||
isQueryStale,
|
||||
githubLoading,
|
||||
gitlabLoading
|
||||
}: {
|
||||
sourceIntent: 'github' | 'gitlab' | null
|
||||
isQueryStale: boolean
|
||||
githubLoading: boolean
|
||||
gitlabLoading: boolean
|
||||
}): boolean {
|
||||
if (sourceIntent === null) {
|
||||
return false
|
||||
}
|
||||
return isQueryStale || (sourceIntent === 'github' ? githubLoading : gitlabLoading)
|
||||
}
|
||||
|
||||
function toJiraSourceRow(issue: JiraIssue): SmartWorkspaceSourceRow {
|
||||
return { kind: 'jira', value: `jira-${issue.siteId ?? ''}-${issue.key}`, issue }
|
||||
}
|
||||
|
||||
function toGitHubSourceRow(item: GitHubWorkItem): SmartWorkspaceSourceRow {
|
||||
return { kind: 'github', value: `github-${item.repoId}-${item.type}-${item.number}`, item }
|
||||
}
|
||||
|
||||
export function getBranchSearchRequest({
|
||||
branchesEnabled,
|
||||
disabled,
|
||||
@@ -186,8 +208,10 @@ export function shouldHoldSourceResultsForQuery({
|
||||
export function buildSmartWorkspaceSourceRows({
|
||||
branches,
|
||||
githubItems,
|
||||
githubUrlIntent,
|
||||
gitlabAvailable,
|
||||
gitlabItems,
|
||||
gitlabUrlIntent,
|
||||
jiraIntent = false,
|
||||
jiraIssue,
|
||||
jiraIssues = [],
|
||||
@@ -200,8 +224,10 @@ export function buildSmartWorkspaceSourceRows({
|
||||
}: {
|
||||
branches: BaseRefSearchResult[]
|
||||
githubItems: GitHubWorkItem[]
|
||||
githubUrlIntent?: GitHubIssueOrPRLink | null
|
||||
gitlabAvailable: boolean
|
||||
gitlabItems: GitLabWorkItem[]
|
||||
gitlabUrlIntent?: SmartWorkspaceGitLabUrlIntent | null
|
||||
jiraIntent?: boolean
|
||||
jiraIssue?: JiraIssue | null
|
||||
jiraIssues?: JiraIssue[]
|
||||
@@ -219,34 +245,30 @@ export function buildSmartWorkspaceSourceRows({
|
||||
if (!isSmartWorkspaceSourceQueryWithinLimit(value)) {
|
||||
return []
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
const nextRows: SmartWorkspaceSourceRow[] = []
|
||||
const resolvedLinearIssues = Array.isArray(linearIssues)
|
||||
? linearIssues
|
||||
: Array.isArray(linearIssues?.items)
|
||||
? linearIssues.items
|
||||
: []
|
||||
const linearUrlIntent = parseBoundedSmartWorkspaceLinearIssueUrlIntent(trimmed)
|
||||
if (
|
||||
linearUrlIntentOwnsResults &&
|
||||
linearAvailable &&
|
||||
linearUrlIntent &&
|
||||
(mode === 'smart' || mode === 'linear')
|
||||
) {
|
||||
const linearRows = resolvedLinearIssues
|
||||
.filter((issue) => isSmartWorkspaceLinearIssueIntentMatch(linearUrlIntent, issue))
|
||||
.map((issue) => ({
|
||||
kind: 'linear' as const,
|
||||
value: `linear-${issue.id}`,
|
||||
issue
|
||||
}))
|
||||
.slice(0, resultLimit)
|
||||
// Why: keep "use as workspace name" available; sourceIntent focuses the issue.
|
||||
if (trimmed && mode === 'smart') {
|
||||
return [{ kind: 'use-name' as const, value: 'use-name', name: trimmed }, ...linearRows]
|
||||
}
|
||||
return linearRows
|
||||
// Why: a full task URL is unambiguous, so unrelated held rows must never remain selectable.
|
||||
const urlSourceRows = buildSmartWorkspaceUrlSourceRows({
|
||||
githubItems,
|
||||
githubUrlIntent,
|
||||
gitlabAvailable,
|
||||
gitlabItems,
|
||||
gitlabUrlIntent,
|
||||
linearAvailable,
|
||||
linearIssues: resolvedLinearIssues,
|
||||
linearUrlIntentOwnsResults,
|
||||
mode,
|
||||
resultLimit,
|
||||
value
|
||||
})
|
||||
if (urlSourceRows !== null) {
|
||||
return urlSourceRows
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
const nextRows: SmartWorkspaceSourceRow[] = []
|
||||
if (trimmed && mode === 'smart') {
|
||||
// Why: stable cmdk value — embedding the query remounted the row every keystroke.
|
||||
nextRows.push({ kind: 'use-name', value: 'use-name', name: trimmed })
|
||||
@@ -255,13 +277,7 @@ export function buildSmartWorkspaceSourceRows({
|
||||
return nextRows
|
||||
}
|
||||
if (mode === 'smart' || mode === 'github') {
|
||||
nextRows.push(
|
||||
...githubItems.map((item) => ({
|
||||
kind: 'github' as const,
|
||||
value: `github-${item.repoId}-${item.type}-${item.number}`,
|
||||
item
|
||||
}))
|
||||
)
|
||||
nextRows.push(...githubItems.map(toGitHubSourceRow))
|
||||
}
|
||||
if (gitlabAvailable && (mode === 'smart' || mode === 'gitlab')) {
|
||||
nextRows.push(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user