diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml new file mode 100644 index 00000000000..8014145939b --- /dev/null +++ b/.github/workflows/release-policy.yml @@ -0,0 +1,98 @@ +name: Release Policy + +on: + release: + types: + - published + - edited + +permissions: + contents: write + +concurrency: + group: release-policy + cancel-in-progress: false + +jobs: + enforce: + if: github.repository == 'stablyai/orca' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Enforce release policy + uses: actions/github-script@v8 + with: + script: | + const release = context.payload.release; + const tag = release.tag_name; + const author = release.author?.login; + const number = "(?:0|[1-9][0-9]*)"; + const version = `${number}\\.${number}\\.${number}`; + const stableTag = new RegExp(`^v${version}$`); + const prereleaseTag = new RegExp( + `^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`, + ); + const expectedPrerelease = prereleaseTag.test(tag); + const allowed = author === "github-actions[bot]" && + (stableTag.test(tag) || expectedPrerelease); + const { owner, repo } = context.repo; + + async function restoreLatestStable() { + const releases = await github.paginate(github.rest.repos.listReleases, { + owner, + repo, + per_page: 100, + }); + const stable = releases + .filter((candidate) => + !candidate.draft && + !candidate.prerelease && + candidate.author?.login === "github-actions[bot]" && + stableTag.test(candidate.tag_name), + ) + .sort((left, right) => { + const a = left.tag_name.slice(1).split(".").map(Number); + const b = right.tag_name.slice(1).split(".").map(Number); + return a.reduce((result, part, index) => result || part - b[index], 0); + }) + .at(-1); + if (stable) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: stable.id, + make_latest: "true", + }); + } + } + + if (allowed) { + if (release.prerelease !== expectedPrerelease) { + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + prerelease: expectedPrerelease, + make_latest: expectedPrerelease ? "false" : "legacy", + }); + } + await restoreLatestStable(); + return; + } + + await github.rest.repos.updateRelease({ + owner, + repo, + release_id: release.id, + draft: true, + prerelease: true, + make_latest: "false", + }); + await restoreLatestStable(); + await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id }); + try { + await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` }); + } catch (error) { + if (error.status !== 404) throw error; + } + core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`); diff --git a/.gitignore b/.gitignore index fc40e495e7a..17bb18529af 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,7 @@ docs/** !docs/readme/ !docs/readme/** !docs/STYLEGUIDE.md +!docs/ai-vault-process-isolation-plan.md !docs/mobile-terminal-shortcut-bar.md !docs/reference/ !docs/reference/git-compatibility.md diff --git a/README.md b/README.md index 670b54e2835..b679cb95c3d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere. -[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) +[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile) @@ -230,7 +230,7 @@ yay -S stably-orca-bin Pair with your desktop app to monitor and steer your agents from your phone. - **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA) -- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) +- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) --- diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 9933df8156e..ebc372c480a 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -182,6 +182,7 @@ module.exports = { 'out/main/grok/**', 'out/main/hermes/**', 'out/main/daemon-entry.js', + 'out/main/session-scanner-service-entry.js', 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index b37d3374bf5..d3c9f3b0864 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -7,7 +7,7 @@ "count": 1 }, { - "filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx", + "filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx", "kind": "jsx-attribute:label", "text": "sidebar", "dynamic": false, diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 3275e33987a..8710b4e82e3 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -235,7 +235,6 @@ inline src/renderer/src/components/settings/Settings.tsx inline src/renderer/src/components/settings/SettingsFormControls.tsx inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx -inline src/renderer/src/components/sidebar/WorktreeCard.tsx inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts inline src/renderer/src/components/sidebar/WorktreeList.tsx @@ -270,7 +269,6 @@ inline src/renderer/src/hooks/useComposerState.ts inline src/renderer/src/hooks/useEditorExternalWatch.ts inline src/renderer/src/hooks/useIpcEvents.test.ts inline src/renderer/src/hooks/useIpcEvents.ts -inline src/renderer/src/hooks/useIssueMetadata.ts inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts inline src/renderer/src/lib/active-agent-note-send.test.ts inline src/renderer/src/lib/file-type-icons.ts @@ -342,7 +340,6 @@ mobile-config app/h/*/source-control/*.tsx mobile-config app/h/*/tasks.tsx mobile-config app/index.tsx mobile-config app/pair-scan.tsx -mobile-config app/terminal-settings.tsx mobile-config app/troubleshoot.tsx mobile-config scripts/mock-server.ts mobile-config scripts/repro-terminal-colors.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 2e244697586..f5e5a39caeb 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-08-09", + "updatedAt": "2026-08-10", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -1969,7 +1969,7 @@ "invariant": "Close permanently removes the owned provider session, agent descendants, and resume authority even when no TerminalPane is mounted; 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 the exact PTY before parking, prove it remains listed while the view is absent, close through the product state boundary, and poll the provider inventory until that exact ID disappears; an agent-marked PTY's detached-pgid child is alive before close and absent afterward; unit tests keep a naturally exited id reserved without re-killing its PID or signalling its captured tree, upgrade pending and post-snapshot graceful kills to immediate, force ps into the C locale, coalesce each bounded bulk-shutdown batch, share duplicate teardown completion, coalesce 20 same-turn process-table requests, start one shared successor without waiting for the prior scan, terminate cyclic-looking traversal, retain the source scan's timestamp, bound both read phases, avoid ambiguous SIGKILL, and assert canonical owner dedupe, exact pane tombstones, chained detach transfer, and restart alias restoration.", "commands": [ - "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", + "pnpm dlx node@24 ./node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/agent-hooks/server-pane-authority.test.ts src/main/ipc/agent-hooks.test.ts src/main/ipc/agent-pane-authority-ownership.test.ts src/main/ipc/pty-management.test.ts src/main/persistence.test.ts src/renderer/src/store/slices/agent-pane-authority.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/renderer/src/store/slices/terminal-tab-retirement.test.ts src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/pty-descendant-termination.test.ts src/main/daemon/session.test.ts src/main/daemon/terminal-host.test.ts src/main/providers/local-pty-provider.test.ts src/main/runtime/worktree-teardown.test.ts", "pnpm run test:e2e -- tests/e2e/terminal-parked-close-retirement.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/agent-descendant-process-kill.spec.ts --workers=1" @@ -1989,11 +1989,18 @@ "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement.test.ts", "src/renderer/src/store/slices/terminal-tab-retirement-store.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/renderer/src/components/shared/kill-all-terminal-surfaces.test.ts", "tests/e2e/terminal-parked-close-retirement.spec.ts", "tests/e2e/agent-descendant-process-kill.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "explicit close revokes the exact live recovery record both before and after PTY exit removes the tab, while PTY exit alone preserves interrupted-session recovery" + ] + }, { "file": "tests/e2e/terminal-parked-close-retirement.spec.ts", "assertions": [ @@ -2711,11 +2718,13 @@ "oracle": "Renderer-state tests assert provider-session ownership across preserved and queued panes. Main/provider contracts assert atomic attach-only adoption, stable host/worktree/tab/leaf identity, no fresh spawn on adoption, and safe paired-runtime cancellation. The Electron oracle creates inactive runtime-owned Codex and Setup PTYs plus an unrelated canary, seeds an exact resumable provider session, removes only the target renderer projections, and activates the workspace. It requires byte-stable handle, PTY, incarnation, tab, leaf, process PID, renderer graph, persisted binding, runtime id, graph epoch, and daemon PID across first mount and reload; PID-specific DOM keyboard I/O must remain live with one launch, zero resume argv, zero signals, zero interruption text, and no canary mutation.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose", "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/resume-sleeping-agent-session.test.ts src/main/providers/local-pty-provider.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/ipc/pty.test.ts src/main/runtime/orca-runtime.test.ts src/renderer/src/lib/pane-manager/pane-fit.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts", "pnpm exec electron-vite build --mode e2e && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/live-background-terminal-mount-authority.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts", "src/main/providers/local-pty-provider.test.ts", "src/main/daemon/terminal-host.test.ts", "src/main/daemon/daemon-pty-adapter.test.ts", @@ -2727,6 +2736,13 @@ "tests/e2e/live-background-terminal-mount-authority.spec.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "completed and explicitly retired workers retain no provider-resume authority on first workspace activation", + "the original tab, leaf, PTY, provider session, release state, replacement count, queued resume argv, emitted restored banner, and unrelated canary remain exact across restart" + ] + }, { "file": "src/renderer/src/lib/resume-sleeping-agent-session.test.ts", "assertions": [ @@ -3656,9 +3672,7 @@ }, { "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", - "assertions": [ - "risky-output atlas recovery is bounded, coalesced, and survives a throwing manager" - ] + "assertions": ["continues recovery when a manager throws after scheduling"] }, { "file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", @@ -4315,14 +4329,16 @@ "providers": ["paired-runtime", "ssh"], "coveredPlatforms": ["macos"], "coveredProviders": ["paired-runtime"], - "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", + "coverageNotes": "Deterministic headed macOS runs launch an isolated Orca desktop server and a separate paired web client. A byte-identical headless run uses an isolated `orca serve` host. Both create six real paired host PTYs with bounded high-output scrollback, prove sustained output while all six client xterms are warm-mounted but hidden causes zero renderer scheduler work, then drive 180 timed real-PTY spinner frames through a second production `session.tabs.subscribeAll` transport and the real Zustand store. Each worktree must publish and apply only its exact initial-working and final-idle titles. Both topologies ordinary-park five xterms without enabling the lossy retention budget, assert bounded cells/heap/timer lag, then restore the bounded authoritative tail exactly once on the original PTY including output produced while parked and continued input/output. The headed oracle additionally keeps every remote target workspace and terminal unmounted in the host renderer before and after client recovery. Production-parity React contracts reproduce and remove the independent cold-park watcher/title feedback loop, including memo-cache discard, StrictMode cleanup replay, detached split-PTY replacement, and the zero-park fast path. A deterministic cross-boundary contract drives 24 real PTY trackers in distinct worktrees through 2,880 decorative spinner and bare-native redraw chunks and counts host publications, serialized bytes, renderer apply calls, and store mutations while preserving exact per-PTY raw chunks and per-worktree semantic transitions. Fake-clock continuation beyond the 30-minute stale boundary requires one deduplicated refresh per worktree every 15 minutes, globally spaced by at least 50 ms, with every mirrored working status still fresh. Exact-identical Pi plus rotating normalized Pi/Grok frames renew coarse working/permission state; rich prompt/tool/question fields retain their original 30-minute lease, and `stateStartedAt`/acknowledgement identity remain stable across title/hook source swaps. A separate headed desktop-client journey relaunches the host main process while retaining the daemon and a daemon-backgrounded PTY, then requires first-observation foreground sync and continued input/output on the same PTY. Unit coverage keeps layout versions out of snapshot output high-waters when a restored snapshot has no output sequence and retries an existing subscriber only after authoritative provider inventory proves the retained PTY exists. Separate headed/headless ACK-starvation tests recover one stalled stream without replacing its PTY. A headed output-drop oracle proves a successful snapshot probe cannot certify a stale live stream when the authoritative PTY sequence advanced beyond the client high-water. Unit coverage requires an unsequenced client to establish a first-probe baseline, remain attached at the same sequence, and recover only after a later probe advances. A capability-disabled run proves legacy hosts retain the prior lossy limit/TTL fallback. Unit tests cover exact-owner capability routing, raw-stream release, singleton side-effect facts with timed handoff cleanup, 64 simultaneous synthetic-title sources with zero decorative client events after convergence, per-client late-subscribe recovery, semantic bell/title transitions, local animation-frame preservation, legacy no-output-pause delivery gating with exact snapshot restore, provider-authoritative snapshots, 128 active streams plus retry after a 129th-stream capacity rejection, capacity-pressure backoff, full split-leaf remint reconciliation, truncation, and manual-disconnect queue fencing. Linux, Windows, live SSH, and production-scale paired hosts remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/8652", "https://github.com/stablyai/orca/pull/10625" ], - "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After a paired client's first semantic title state, decorative spinner frequency must add zero encrypted client-event frames; local title animation and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", - "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", + "invariant": "A host advertising terminal.paired-parking.v1 keeps the PTY and bounded authoritative history alive while an ordinary hidden-view park destroys the client xterm and releases its raw per-PTY stream. Reveal must restore up to the requested 5,000 rows, parked-time side effects/output, the same PTY identity, and continued input/output. Parked watcher synchronization compares parking semantics rather than fresh object identity, owns setup and cleanup across StrictMode replay, reconciles real PTY/layout changes, and performs no terminal-state scan when nothing is parked. After a host main-process relaunch, unknown local delivery-sync state must not be treated as proof that a surviving daemon PTY is already foregrounded. If subscription precedes provider readiness, authoritative inventory must reconsider that existing subscriber without spawning, resizing, or changing reconnect state. A snapshot without an output high-water must keep it unknown rather than borrowing a layout version that can suppress newer live output. Hosts without the capability must gate hidden raw output before xterm scheduling and repaint from the authoritative snapshot on reveal while retaining the existing limit/TTL force-parking fallback. After the first semantic title state, decorative spinner frequency must add zero paired client-event frames and zero full session-tabs work between status-freshness leases regardless of hidden worktree count. Continuous working or permission evidence may refresh each affected worktree once per 15 minutes through one globally spaced FIFO so current and legacy viewers do not decay before 30 minutes; sibling PTYs share that worktree refresh, while local title animation, raw output, and semantic title, status, bell, completion, and query facts remain intact. A paired terminal stream whose delivery credits stop progressing must replace only that stream; command silence first probes authoritative state and replaces the stream if the probe times out or proves the PTY advanced beyond the client's delivered output sequence. When no comparable delivery high-water exists, the first sequenced probe establishes a baseline and only later advancement proves staleness. A same-sequence snapshot remains valid proof of a responsive silent command. A successful status probe may replace a pre-ready shared-control socket without rejecting or duplicating calls already waiting for that transport. Manual disconnect must retain pairing while preventing queued or passive calls and subscriptions from recreating transport until explicit Connect.", + "oracle": "Run one byte-identical six-terminal oracle against an isolated headed desktop host and an isolated headless `orca serve` host. Stage at least 1,000,000 xterm cells, hide all six warm managers, emit an initial working title followed by 30 timed real-PTY spinner frames and a final idle title per worktree, and observe a second real session-tabs subscription plus the production renderer store. Require exactly two transport envelopes and two title mutations per worktree, no decorative title crossing either boundary, and positive decoded-envelope bytes. Enable ordinary parking with the lossy retention budget disabled, require exactly one mounted manager and five parked tabs, at most 45% retained cells, no more than 16 MiB heap growth, and under 500 ms timer drift. In headed mode, require the host renderer to remain on its original workspace with zero target terminal managers mounted throughout client park, reveal, and live I/O. While a tab is parked, require authoritative terminal.read to observe new PTY output; reveal it and require the original PTY, a marker within the requested 5,000-row history, the parked marker, and post-reveal input/output. Drive 24 real PTY title trackers in distinct worktrees through 40 decorative spinner frames each, then 40 spinner frames alternating with Cursor's bare native identity redraw, using fake clocks. Require all 2,880 raw chunks exactly and zero host session-tabs publications, serialized bytes, renderer apply calls, and renderer store mutations. Continue decorative evidence beyond 30 minutes, require exactly one refresh per worktree per 15-minute lease through a FIFO spaced by at least 50 ms, and require all 24 viewer statuses to remain working and fresh; then require one exact semantic idle transition and one visible-output chunk per PTY. Require ordinary and interior-Braille title changes to publish. Drive 64 host PTYs through ten 80 ms synthetic title frames, require zero paired client events after the first frame per PTY while all 640 local frames remain observable, attach a late client and require one current frame per PTY, then require semantic bell and idle transitions on both clients. Reconstruct a legacy subscribed stream with no outputPause capability, hide a chatty pane, require no hidden xterm writes, reveal it, and require one authoritative snapshot plus continued live output. Drop and acknowledge output only for one original paired-client stream, prove the fixture process consumed input while the host model advanced and the client stayed stale, then require the command snapshot probe to replace that stream, repaint exact fixture output, preserve authoritative PTY identity and target-tab cardinality, and resume live I/O. For a client without a delivered sequence, require the first numeric probe to establish a baseline without replacement, a same-sequence probe to remain attached, and a later advanced probe to replace only that stream. Withhold the first encrypted shared-control ready frame, start one RPC, trigger a status-probe refresh, and require two connections, one host delivery, successful response, and zero retained request bytes. Admit 128 active streams, reject the 129th as retryable, release one stream, then require the retry to attach and publish a snapshot without multiplying retained subscribers. Disable terminal.paired-parking.v1 and require the same oracle to fail before parking, while the legacy limit-one fallback separately passes. Also preserve truncated first paint, ACK-starved same-PTY recovery, responsive silent-command snapshot probes, dead-stream probe timeout recovery, and queued manual-disconnect fencing.", "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-watcher-partial-reconciliation.test.ts src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts src/renderer/src/runtime/remote-runtime-terminal-stall-recovery.test.ts src/renderer/src/runtime/runtime-client-events.test.ts src/renderer/src/web/web-preload-api.test.ts src/main/ipc/runtime-environments.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/shared/remote-runtime-shared-control-connection.test.ts", @@ -4335,11 +4351,20 @@ "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-probe-gap-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "SKIP_BUILD=1 pnpm exec playwright test tests/e2e/terminal-parked-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ + "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts", + "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts", + "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts", + "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts", + "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx", + "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx", + "src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx", + "src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/renderer/src/web/web-preload-api.test.ts", "src/main/ipc/runtime-environments.test.ts", @@ -4371,6 +4396,59 @@ "tests/e2e/paired-remote-terminal-host-restart-background-sync.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts", + "assertions": [ + "one globally spaced FIFO refreshes each continuously active worktree before status expiry", + "sibling PTYs deduplicate to one worktree refresh while semantic transitions bypass the queue", + "sole-source PTY teardown removes queued work while sibling PTY sources remain deduplicated", + "worktree, pending-queue, and timer state is removed on teardown" + ] + }, + { + "file": "src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts", + "assertions": [ + "a live pending question remains visible under the current live shell title but cannot cross a later working interval, identity-only owner title, or provider generation", + "inherited tool names, stale rows, resume-only rows, and unconfirmed restored rows cannot reopen a shell-reclaimed pane" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx", + "assertions": [ + "parked terminal and unified-label title republication settles without a maximum-depth loop", + "equivalent semantic inputs remain stable when React discards memoized identities" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx", + "assertions": [ + "StrictMode cleanup replay restores watcher ownership", + "detached split-PTY and layout replacement reconciles", + "unparked unrelated writes perform zero PTY-map reads" + ] + }, + { + "file": "tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts", + "assertions": [ + "2,880 decorative spinner and bare-native real-PTY OSC chunks publish zero session-tabs snapshots or serialized bytes after semantic convergence", + "the viewer performs zero snapshot applies and zero store mutations during decorative activity", + "after 30 minutes, one globally spaced refresh per worktree keeps every mirrored working status fresh", + "exact Pi and rotating normalized Pi/Grok frames renew explicit and fallback status without changing stateStartedAt or acknowledgement semantics", + "matching title evidence renews retained OSC hook status without resetting stateStartedAt, then drops unverifiable prompt metadata after the hook row expires", + "renderer-authored rich status keeps its original lease, then renews only coarse title-confirmed state and provider identity", + "ordinary-shell title transitions replace working stateStartedAt with the completion transition time", + "decorative heartbeats remain blocked until unknown foreground ownership resolves, then resume at the bounded interval", + "tracked terminal state reset clears delayed foreground ownership markers", + "raw chunks, semantic idle transitions, and visible output remain exact" + ] + }, + { + "file": "tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts", + "assertions": [ + "pending renderer, retained OSC, and live-hook questions survive the shell title that currently obscures them", + "a new working interval, provider generation, or identity owner strips the prior question" + ] + }, { "file": "src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts", "assertions": ["retries an existing subscriber when provider inventory becomes ready"] @@ -4425,6 +4503,7 @@ "assertions": [ "ordinary-parks paired terminals and restores authoritative host scrollback", "warm-mounted hidden paired streams schedule zero renderer output work under sustained host output", + "six hidden real PTYs publish and apply only exact working and idle title transitions across paired transport", "client recovery completes while every target terminal remains unmounted in the host renderer" ] }, @@ -4433,6 +4512,7 @@ "assertions": [ "ordinary-parks paired terminals against an isolated headless Orca host", "cold-activates only visible paired terminals against an isolated headless host", + "the headless host bounds real-PTY decorative title transport and production store application", "the headless host shares the zero-hidden-renderer-work and exact-restoration contract" ] }, @@ -4527,6 +4607,42 @@ } ], "evidenceRuns": [ + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 1.56, + "summary": "All 16 production-parity parking/watcher contracts passed on the combined candidate, covering the maximum-depth loop, semantic stability, StrictMode ownership, split-PTY reconciliation, and zero-park fast path." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 pnpm exec playwright test tests/e2e/paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 27.6, + "summary": "The isolated headed desktop host and separate encrypted paired client drove 180 timed decorative real-PTY titles across six hidden worktrees and observed exactly 12 semantic transport envelopes and 12 production store title mutations, then reduced six managers to one, stayed within heap/cell/timer budgets, restored the bounded host tail exactly, and continued input/output while the host renderer retained zero target managers." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_WEB_CLIENT=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 33.9, + "summary": "The isolated headless `orca serve` host passed the same six-worktree real-title transport/store bound, parking, and exact-restoration oracle; a second scenario proved cold activation mounted only the visible paired terminal." + }, + { + "date": "2026-08-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 5.62, + "summary": "Forty-two deterministic contracts passed. The real-PTY cross-boundary contract delivered all 2,880 decorative spinner and bare-native redraw chunks with zero hot-path host publications, serialized bytes, renderer applies, or store mutations; continued past 30 minutes with one globally spaced freshness refresh per worktree every 15 minutes; kept all 24 mirrored statuses working; and preserved stable stateStartedAt. Exact-identical Pi and rotating normalized Pi/Grok frames renewed renderer-authored and retained-hook status without changing acknowledgement semantics. Rich status could not cross task completion, permission, provider-generation, or identity-owner boundaries, while neutral titles preserved same-task prompt and acknowledgement identity until authoritative shell evidence. Fresh renderer, retained OSC, and live-hook questions remained visible under the current shell title, but a later working interval, identity-only owner title, or provider generation stripped them; inherited tools and stale, resume-only, restored, or prior-interval questions stayed suppressed. After each rich source's original lease expired, title evidence kept only coarse working state and provider identity fresh while dropping unverifiable prompts. Unknown foreground ownership held publications at zero until resolution, then resumed the bounded heartbeat; tracked terminal state reset removed its delayed marker. The focused heartbeat contracts proved worktree deduplication, FIFO fairness, semantic bypass, PTY-source teardown, and timer cleanup. Causal-disable controls made ownership, teardown, retained source-switch, renderer rich-TTL, completion-transition, boundary-ordering, and live-question oracles fail; restoring the production corrections returned green." + }, { "date": "2026-08-07", "runner": "local", @@ -4691,11 +4807,11 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." + "evidence": "The six-PTY ordinary-parking oracle was red in two independently observed stages: the parked paired remount first used detached attach and restored only rows 5,963–5,999, then the requested snapshot still lost to the current-screen replay until park reveals explicitly entered the capability-gated reattach coordinator. The candidate is green on headed and headless hosts, while disabling terminal.paired-parking.v1 makes the byte-identical oracle fail at its capability precondition and leaves the separately tested legacy lossy fallback green. The parked-title production-parity control throws Maximum update depth exceeded on the affected parking synchronization and settles with semantic dependencies; forced memo-cache discard, StrictMode cleanup replay, and split-PTY replacement are independently red before their corrections and green on the combined candidate. The real-PTY session-tabs oracle produces 96 full snapshots, 64,032 serialized bytes, 96 renderer applies, and 96 store mutations on unmodified main and on a one-line causal revert before reaching the alternating native-title phase; the candidate produces zero in all four dimensions while preserving all 2,880 redraw chunks and 24 exact semantic transitions. The 64-PTY decorative-title control delivered 576 client events after convergence on unmodified main; the candidate delivers zero while preserving all 640 local frames, one current frame to a late client, and bell/idle transitions to both clients. The legacy no-output-pause control failed to request a reveal snapshot and kept hidden bytes on the xterm path; the candidate gates those bytes locally and restores one authoritative snapshot before live output. With stalled-stream recovery disabled, the host cursor advanced but the client remained frozen; enabling it repainted the marker with the same PTY and tab. The sequence-gap unit and headed paired oracle are red on current main and with only the new probe decision reverted: the authoritative host advances while the client stays stale after a successful probe. The candidate replaces the stream, repaints the marker, preserves PTY identity, and resumes I/O. Reverting the active-stream limit to 64 makes the exact 128-stream contract fail at 64; restoring 128 admits every intended stream, rejects 129, and reattaches it after one slot releases. The host-relaunch oracle fails when unknown delivery state is coerced to foregrounded or a layout version becomes an output high-water; the candidate foregrounds the preserved daemon PTY once and resumes same-PTY input/output. When authoritative inventory overlaps a refused in-flight attach, removing only the post-settlement retry leaves the existing subscriber without daemon output; the candidate retains one waiter across repeated inventory publications, retries exactly once, and restores live output." }, "performanceBudget": { "required": true, - "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Decorative title ticks are collapsed per paired subscriber before JSON serialization and encryption; semantic facts remain on the singleton client-event stream. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. Legacy hosts retain the prior 12-worktree/45-minute lossy force-parking policy but now gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." + "evidence": "Before ordinary parking, six warm-mounted hidden paired xterms receive zero raw frames and schedule zero renderer output drains while host models continue ingesting sustained output. The headed and headless production transports each admit only the initial and final semantic title snapshots per worktree across 180 timed decorative frames, and the real store performs only those 12 exact title mutations. Ordinary parking then retains the host PTY and bounded 5,000-row provider model while destroying five client xterms. Parked watcher synchronization is keyed by bounded parking semantics rather than fresh object identities; unrelated store writes perform zero PTY-map reads when no tab is parked, while parked reconciliation remains bounded by the worktree's terminal tabs and assignments. Decorative title ticks are collapsed both on the paired client-event channel and at the authoritative real-PTY session-tabs classifier before snapshot construction, serialization, transport, recovery, or store application; semantic facts remain exact. Headed and headless oracles require exact reduction from six managers to one, at least 55% staged xterm-cell release, no more than 16 MiB heap growth, and under 500 ms timer drift. The forced hidden-worktree retention policy keeps at most four otherwise un-parkable worktrees mounted and force-parks all after 15 minutes; the separate ordinary hot-retain policy keeps at most four recently visible worktrees warm for five minutes. Legacy hosts still gate hidden raw output before xterm scheduling and restore from a bounded snapshot on reveal. Stream recovery adds no polling and is scoped to one stream. Provider-ready reconciliation runs only for PTYs returned by a successful controller inventory, requires an existing remote subscriber, and retains at most one post-settlement waiter per PTY while attach deduplication remains active." }, "promotionCriteria": [ "Collect 100 consecutive CI passes or 14 days of soak history for the headed and headless parking commands.", @@ -4712,7 +4828,7 @@ "The legacy protocol shape is deterministic unit coverage; a packaged pre-v1.4.163 host has not yet run the full headed memory oracle.", "The live windows-issues incident recorded two fast snapshot-probe successes while fresh output remained stale, but that release did not log the returned snapshot sequence; the deterministic sequence-gap oracle matches the observed false-health boundary without proving the field transport's lower-level drop mechanism." ], - "demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." + "demotionRule": "Keep experimental or demote to protection none if decorative title frequency changes paired client-event or session-tabs publication volume after convergence, a semantic fact is dropped, paired first paint is blank, passive work reconnects after manual disconnect, a capable hidden paired manager survives ordinary parking, a legacy hidden pane schedules raw xterm output or bypasses its fallback, reveal loses requested bounded history or live PTY identity, retained buffer cells do not fall, or either headed/headless scenario flakes." }, { "id": "terminal-performance.no-hot-list-sessions", @@ -7767,16 +7883,24 @@ "invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.", "oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot", + "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose" ], "testFiles": [ "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts", "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts", "src/main/runtime/orchestration/worker-transcript-read.test.ts", - "src/renderer/src/lib/worker-terminal-takeover-report.test.ts" + "src/renderer/src/lib/worker-terminal-takeover-report.test.ts", + "tests/e2e/completed-worker-retirement-resume.unit.test.ts" ], "assertionRefs": [ + { + "file": "tests/e2e/completed-worker-retirement-resume.unit.test.ts", + "assertions": [ + "an already-exited succeeded Dispatch reaches released while its late exact close revokes the missing tab's stale resume record" + ] + }, { "file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts", "assertions": [ @@ -8881,7 +9005,7 @@ }, { "id": "terminal-render.atlas-recovery-fanout", - "title": "Terminal atlas recovery stays bounded to visible renderers", + "title": "Ordinary terminal output avoids shared atlas recovery fanout", "maturity": "experimental", "protection": "partial", "owner": "terminal-rendering", @@ -8890,31 +9014,33 @@ "WebGL rendering", "document visibility", "hidden terminal output", + "alternate-screen redraws", "paired terminal traffic" ], "platforms": ["macos", "linux", "windows"], "providers": ["local", "daemon", "ssh", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "A deterministic renderer contract models one visible manager plus 64 mounted hidden managers, hidden synchronized/TUI output, and five minutes of sustained streaming recovery requests. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels. Production v1.4.163 evidence linked the same 49-manager fanout to paired traffic, but the candidate has not been rerun against an isolated live paired server.", + "coverageNotes": "Deterministic renderer contracts require foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output to stay on pane-local repaint paths without a global atlas reset. Separate contracts preserve explicit paste, reveal, visibility-resume, system-resume, and confirmed-desync recovery. A macOS Electron run verifies that a document visibility cycle preserves real WebGL atlases and terminal pixels; the candidate has not been rerun against an isolated live paired server.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/7054", "https://github.com/stablyai/orca/pull/7604", "https://github.com/stablyai/orca/issues/12094" ], - "invariant": "Ordinary document visibility transitions and hidden terminal output must not clear the shared WebGL glyph atlas. Heavy reset-and-refresh recovery may touch only managers with visible terminal surfaces; hidden managers recover when revealed. Sustained streaming recovery requests must keep resets rate-bounded without starving repair. Genuine OS resume remains a heavy recovery trigger.", - "oracle": "Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require exactly one reset and one refresh; parse hidden synchronized and high-confidence TUI output and require zero global atlas-recovery schedules. Across five minutes of 300ms streaming recovery requests, require more than one but at most 75 resets, no internal or trailing repair gap above 6.5 seconds, atlas-preserving presentation while a reset is suppressed, and one delayed wipe for a final suppressed settle. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", + "invariant": "Ordinary PTY output must never clear the shared WebGL glyph atlas, including synchronized frames, hidden output, renderer-risk glyphs, and alternate-screen entry, redraw, or exit. Affected visible panes still receive pane-local refresh after parsing. Explicit paste, reveal, visibility-resume, system-resume, context-loss, and confirmed-desync recovery remain available and may reset visible renderers.", + "oracle": "Parse foreground renderer-risk, synchronized, hidden, and alternate-screen PTY output and require pane-local refresh where needed with zero calls to shared reset-and-refresh recovery. Require the atlas-recovery module to expose only explicit lifecycle entry points, then verify paste and tab reveal still perform their recovery bursts. Dispatch a visible document visibilitychange and require atlas-preserving wake recovery; register one visible manager and 64 hidden managers and require heavy recovery to reset and refresh only the visible manager. Separately, drive an Electron visibility cycle with two real WebGL panes, require zero atlas clears, and retain at least 85% of each pane's baseline ink pixels.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" ], "testFiles": [ "src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts", "src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts", - "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", + "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts", + "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", "src/renderer/src/components/terminal-pane/pty-connection.test.ts", "tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts" ], @@ -8930,22 +9056,28 @@ ] }, { - "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", - "assertions": [ - "sustained streaming requests keep atlas resets rate-bounded with no repair starvation", - "suppressed resets present live buffers, cancel repair during resumed output, receive a trailing repair after settle, and clean up pending timers" - ] + "file": "src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts", + "assertions": ["limits atlas recovery to explicit renderer lifecycle events"] }, { "file": "src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts", "assertions": ["bounds atlas recovery to visible managers"] }, + { + "file": "src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts", + "assertions": ["persists and recovers after the same cells stay missing twice"] + }, + { + "file": "src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts", + "assertions": ["recovers a context-lost pane on the next rendering resume"] + }, { "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts", "assertions": [ - "defers hidden synchronized-output atlas recovery until reveal", - "defers hidden high-confidence TUI redraw recovery until reveal", - "advances hidden rewrite state without scheduling atlas recovery" + "keeps hidden synchronized output off the global atlas recovery path", + "refreshes renderer-risk output without clearing the shared glyph atlas", + "refreshes alternate-screen redraws without clearing the shared glyph atlas", + "avoids atlas clears when one chunk enters and exits alternate screen" ] }, { @@ -8957,22 +9089,13 @@ ], "evidenceRuns": [ { - "date": "2026-08-04", + "date": "2026-08-10", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/terminal-render-desync-sentinel.test.ts src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", "result": "passed", - "durationSeconds": 0.5, - "summary": "Seven deterministic tests passed for sustained reset cadence, bounded repair gaps, suppressed presentation with trailing repair, clock rollback, one-shot bypasses, and pending-timer cleanup." - }, - { - "date": "2026-08-01", - "runner": "local", - "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts src/renderer/src/lib/pane-manager/pane-manager-registry.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts", - "result": "passed", - "durationSeconds": 26, - "summary": "All 577 focused renderer contracts passed, including atlas-preserving visibility, visible-only recovery fanout, and hidden-output rewrite-state coverage." + "durationSeconds": 47, + "summary": "All 625 focused renderer contracts passed on the final head for atlas-preserving visibility, visible-only heavy recovery fanout, confirmed-desync and context-loss recovery, explicit lifecycle recovery, pane-local foreground redraws, and zero ordinary-output atlas resets." }, { "date": "2026-08-01", @@ -9003,15 +9126,15 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "With the fix disabled, the byte-identical unit oracle observed clearGlyphAtlases=true, reset/refreshed all 65 managers, and scheduled hidden synchronized/TUI recovery one to three times. Restoring the fix made every assertion pass." + "evidence": "Before the change, foreground renderer-risk and alternate-screen PTY writes requested terminal-output atlas recovery after parsing; hidden synchronized/TUI output also drove the same path. The updated oracle spies on that request before its removed 200ms debounce and on the eventual shared reset, while explicit lifecycle recovery remains required." }, "performanceBudget": { "required": true, - "evidence": "A recovery with one visible and 64 hidden managers performs one reset and one refresh instead of 65 of each. Hidden synchronized/TUI output schedules zero global recovery work. Sustained 300ms recovery requests keep full atlas resets at or below 75 over five minutes while preserving a 6.5-second maximum repair gap. A final suppressed settle schedules one cancelable repair at the next budget boundary; resumed streaming cancels it. The rate budget adds no polling, recurring timer, provider call, or parking change." + "evidence": "A 120-frame foreground redraw at 300ms cadence produced 13 terminal-output atlas reset cycles before the change and zero afterward over 37.470 seconds. Ordinary PTY output now adds no atlas debounce, retry timer, buffer-switch listener, global manager traversal, provider call, or recurring work; pane-local refresh remains for renderer-risk output. Explicit heavy recovery still touches visible managers only." }, "promotionCriteria": [ "Accumulate stable macOS Electron runs with real BrowserWindow visibility transitions.", - "Run an isolated headed paired-server terminal flood and verify bounded renderer CPU and atlas diagnostics.", + "Run an isolated headed paired-server terminal flood and verify renderer CPU and atlas diagnostics remain flat under ordinary output.", "Add Linux and Windows WebGL visibility evidence before claiming cross-platform visual coverage." ], "knownGaps": [ @@ -9019,7 +9142,7 @@ "No isolated live paired-server candidate run is recorded; remote-runtime coverage is a provider-agnostic renderer contract plus production incident evidence.", "The gate counts recovery fanout and pixel retention but does not impose an end-to-end renderer frame-latency threshold." ], - "demotionRule": "Demote if hidden managers re-enter reset/refresh recovery, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic." + "demotionRule": "Demote if ordinary PTY output re-enters shared reset/refresh recovery, pane-local redraw coverage regresses, ordinary visibility clears the atlas, real WebGL pixels regress, or the focused Electron test cannot remain deterministic." }, { "id": "terminal-render.atlas-identity-invalidation", diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index a63cce53e95..23ad74ab47a 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -18,6 +18,7 @@ const __dirname = import.meta.dirname const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts') +const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts') const MANAGED_HOOK_RUNTIME_ENTRY = join( ROOT, 'src', @@ -88,6 +89,21 @@ for (const platform of PLATFORMS) { } }) + await build({ + entryPoints: [AI_VAULT_SERVICE_ENTRY], + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + outfile: join(outDir, 'relay-ai-vault-service.js'), + external: ['electron'], + sourcemap: false, + minify: true, + define: { + 'process.env.NODE_ENV': '"production"' + } + }) + await build({ entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY], bundle: true, @@ -110,10 +126,12 @@ for (const platform of PLATFORMS) { // so a companion-only change always deploys beside the matching relay host. const relayContent = readFileSync(join(outDir, 'relay.js')) const watcherContent = readFileSync(join(outDir, 'relay-watcher.js')) + const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js')) const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js')) const hash = createHash('sha256') .update(relayContent) .update(watcherContent) + .update(aiVaultServiceContent) .update(managedHookRuntimeContent) // Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory. if (platform.startsWith('win32-')) { diff --git a/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs new file mode 100644 index 00000000000..5f4160600cc --- /dev/null +++ b/config/scripts/legacy-worker-recovery-persistence-benchmark.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +// Run: node config/scripts/legacy-worker-recovery-persistence-benchmark.mjs +import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtemp, open, readFile, rename } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const repoRoot = fileURLToPath(new URL('../..', import.meta.url)) +const runtimePath = join(repoRoot, 'src/main/runtime/orca-runtime.ts') +const args = new Map( + process.argv.slice(2).map((value, index, values) => [value, values[index + 1]]) +) +const fixtureMiB = Number(args.get('--fixture-mib') ?? 24) +const trials = Number(args.get('--trials') ?? 3) +const jsonOutput = process.argv.includes('--json') + +if (!Number.isInteger(fixtureMiB) || fixtureMiB < 1 || !Number.isInteger(trials) || trials < 1) { + throw new Error('fixture-mib and trials must be positive integers') +} + +const runtimeSource = await readFile(runtimePath, 'utf8') +const recoveryStart = runtimeSource.indexOf( + 'private async persistLegacyWorkerTerminalRecoveryBatch' +) +const recoveryEnd = runtimeSource.indexOf( + 'private reconcileMissingLegacyWorkerTerminal', + recoveryStart +) +const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd) +if ( + recoveryStart < 0 || + recoveryEnd < 0 || + !recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') || + recoverySource.includes('flushOrThrow()') +) { + throw new Error('recovery persistence implementation changed; update this benchmark') +} + +const root = await mkdtemp(join(tmpdir(), 'orca-legacy-recovery-benchmark-')) +const filler = 'x'.repeat(fixtureMiB * 1024 * 1024) + +function payload(state) { + return JSON.stringify({ state, filler }) +} + +function writeDurableSync(path, body) { + const tempPath = `${path}.sync.tmp` + writeFileSync(tempPath, body) + const fd = openSync(tempPath, 'r') + try { + fsyncSync(fd) + } finally { + closeSync(fd) + } + renameSync(tempPath, path) +} + +async function writeDurableAsync(path, body) { + const tempPath = `${path}.async.tmp` + const handle = await open(tempPath, 'w') + try { + await handle.writeFile(body) + await handle.sync() + } finally { + await handle.close() + } + await rename(tempPath, path) +} + +async function measure(run) { + let maxEventLoopDelayMs = 0 + let expected = performance.now() + 1 + const timer = setInterval(() => { + const now = performance.now() + maxEventLoopDelayMs = Math.max(maxEventLoopDelayMs, now - expected) + expected = now + 1 + }, 1) + await new Promise((resolve) => setTimeout(resolve, 5)) + const startedAt = performance.now() + await run() + const durationMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 5)) + clearInterval(timer) + return { durationMs, maxEventLoopDelayMs } +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + return sorted[Math.floor(sorted.length / 2)] +} + +async function runLegacy(path) { + const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true } + state.fenced = true + writeDurableSync(path, payload(state)) + state.surfacePresent = false + writeDurableSync(path, payload(state)) + state.recoveryRecordPresent = false + writeDurableSync(path, payload(state)) +} + +async function runBatched(path) { + const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true } + state.fenced = true + state.surfacePresent = false + state.recoveryRecordPresent = false + await writeDurableAsync(path, payload(state)) +} + +try { + const legacy = [] + const batched = [] + for (let index = 0; index < trials; index += 1) { + legacy.push(await measure(() => runLegacy(join(root, `legacy-${index}.json`)))) + batched.push(await measure(() => runBatched(join(root, `batched-${index}.json`)))) + } + const result = { + benchmark: 'legacy-worker-recovery-persistence', + fixtureMiB, + trials, + legacy: { + durableWrites: 3, + medianDurationMs: median(legacy.map((sample) => sample.durationMs)), + medianMaxEventLoopDelayMs: median(legacy.map((sample) => sample.maxEventLoopDelayMs)) + }, + batchedAsync: { + durableWrites: 1, + medianDurationMs: median(batched.map((sample) => sample.durationMs)), + medianMaxEventLoopDelayMs: median(batched.map((sample) => sample.maxEventLoopDelayMs)) + } + } + if (jsonOutput) { + console.log(JSON.stringify(result)) + } else { + console.log(JSON.stringify(result, null, 2)) + } +} finally { + rmSync(root, { recursive: true, force: true }) +} diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index e8d13941ffa..bd0c3c15aee 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -1058,36 +1058,6 @@ "auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": { "ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "auto.components.feature.wall.FeatureTourPreview.04d54d50ec": { - "ko": "orca · zsh" - }, - "auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": { - "ko": "생각 중..." - }, - "auto.components.feature.wall.FeatureTourPreview.3822d8d14b": { - "ko": "fix/worktree-picker-truncates" - }, - "auto.components.feature.wall.FeatureTourPreview.47f16ecf34": { - "ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다." - }, - "auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": { - "ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요." - }, - "auto.components.feature.wall.FeatureTourPreview.6218a9014d": { - "ko": "pnpm playwright test" - }, - "auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": { - "ko": "dashboard.spec.ts" - }, - "auto.components.feature.wall.FeatureTourPreview.70aa182266": { - "ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다." - }, - "auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": { - "ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요." - }, - "auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": { - "ko": "작업 중" - }, "auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": { "ko": "마일스톤" }, @@ -1502,12 +1472,6 @@ "auto.components.new.workspace.ProjectCombobox.search": { "ko": "프로젝트 검색..." }, - "auto.components.new.workspace.ProjectHostSetupCombobox.empty": { - "ko": "이 프로젝트에 준비된 호스트가 없습니다." - }, - "auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": { - "ko": "호스트 선택" - }, "auto.components.notification.sound.options.0acd3d384e": { "ko": "클랙" }, @@ -1520,9 +1484,6 @@ "auto.components.notification.sound.options.86af8d938c": { "ko": "봉 소리" }, - "auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": { - "ko": "기능 활성화" - }, "auto.components.onboarding.AgentStep.1eee1c7bd8": { "ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요." }, @@ -1538,12 +1499,6 @@ "auto.components.onboarding.AgentStep.yoloPermissionsTooltip": { "ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "auto.components.onboarding.FeatureSetupChecklist.01426f3a23": { - "ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다." - }, - "auto.components.onboarding.FeatureSetupChecklist.c5292c409d": { - "ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다." - }, "auto.components.onboarding.IntegrationsStep.0b4a7d23ab": { "ko": "로그인 중" }, @@ -1556,18 +1511,6 @@ "auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": { "ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요." }, - "auto.components.onboarding.RepoStep.466108ab89": { - "ko": "선택한 호스트에 존재하는 경로를 입력하세요." - }, - "auto.components.onboarding.RepoStep.6558d50c69": { - "ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요." - }, - "auto.components.onboarding.RepoStep.c33b190ca3": { - "ko": "호스트 경로만" - }, - "auto.components.onboarding.RepoStep.cf23006ba7": { - "ko": "선택한 호스트" - }, "auto.components.onboarding.ThemeStep.7ee9234e54": { "ko": "Ghostty 설정을 찾았습니다." }, @@ -4976,9 +4919,6 @@ "auto.components.ui.color.picker.faa855a582": { "ko": "Hex" }, - "auto.components.ui.team.multi.combobox.18ec58881e": { - "ko": "팀 검색..." - }, "auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": { "ko": "리뷰 필요" }, diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 2c8dc4555c0..1b695086808 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => { expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'") expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')") expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))") + expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')") + expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))") expect(builderConfig).toContain("from: 'out/relay'") expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')") + expect(remoteCommands).toContain( + "joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')" + ) const assertRelayGate = (steps, publishStepName) => { const names = steps.map((step) => step.name) diff --git a/config/scripts/run-ai-vault-typing-bench.mjs b/config/scripts/run-ai-vault-typing-bench.mjs new file mode 100644 index 00000000000..e4dd25b24ed --- /dev/null +++ b/config/scripts/run-ai-vault-typing-bench.mjs @@ -0,0 +1,50 @@ +import { spawn } from 'node:child_process' + +const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx' +const knobByFlag = { + '--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS', + '--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS', + '--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB', + '--keys': 'ORCA_AI_VAULT_BENCH_KEYS', + '--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS', + '--label': 'ORCA_AI_VAULT_BENCH_LABEL' +} + +const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' } +const passthroughArgs = [] +const argv = process.argv.slice(2) +for (let index = 0; index < argv.length; index += 1) { + if (argv[index] === '--') { + continue + } + const knob = knobByFlag[argv[index]] + if (knob) { + env[knob] = argv[++index] + } else { + passthroughArgs.push(argv[index]) + } +} + +const child = spawn( + npxCommand, + [ + 'playwright', + 'test', + 'tests/e2e/terminal-ai-vault-typing-latency.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...passthroughArgs + ], + { stdio: 'inherit', env } +) + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) +}) diff --git a/config/scripts/verify-computer-native.mjs b/config/scripts/verify-computer-native.mjs index 91c27e03a5b..ba23a49cdd0 100644 --- a/config/scripts/verify-computer-native.mjs +++ b/config/scripts/verify-computer-native.mjs @@ -27,6 +27,12 @@ const checks = [ ], enabled: true }, + { + name: 'Linux snapshot renderer tests', + command: 'python3', + args: ['native/computer-use-linux/runtime_render_test.py'], + enabled: true + }, { name: 'native provider argument guardrails', run: verifyNativeArgumentGuardrails, @@ -69,6 +75,19 @@ const checks = [ run: verifyWindowsProviderHandshake, enabled: process.platform === 'win32' }, + { + name: 'Windows snapshot renderer tests', + command: 'powershell.exe', + args: [ + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + 'native/computer-use-windows/runtime-render.test.ps1' + ], + enabled: process.platform === 'win32' + }, { name: 'macOS helper app bundle and signature', run: verifyMacOSHelperApp, diff --git a/config/scripts/windows-ssh-attach-console-repro.mjs b/config/scripts/windows-ssh-attach-console-repro.mjs index db3a7de23d2..ce0b9b1d40a 100644 --- a/config/scripts/windows-ssh-attach-console-repro.mjs +++ b/config/scripts/windows-ssh-attach-console-repro.mjs @@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) { for (const filename of [ 'relay.js', 'relay-watcher.js', + 'relay-ai-vault-service.js', 'managed-hook-runtime.js', NODE_PTY_PATCH_FILENAME, '.version' diff --git a/config/scripts/worktree-refresh-churn-benchmark.mjs b/config/scripts/worktree-refresh-churn-benchmark.mjs new file mode 100644 index 00000000000..67265305322 --- /dev/null +++ b/config/scripts/worktree-refresh-churn-benchmark.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { performance } from 'node:perf_hooks' +import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts' +import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts' + +const WORKTREE_COUNT = 655 +const TAB_COUNT = 1_895 +const REFRESH_COUNT = 10 +const ROUNDS = 15 +const BATCHES_PER_ROUND = 25 + +function makeFixture() { + const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => ({ + id: `repo-${index % 15}::/workspace/${index}`, + sortOrder: WORKTREE_COUNT - index, + lastActivityAt: index % 19, + isArchived: false + })) + const tabs = Array.from({ length: TAB_COUNT }, (_, index) => ({ + worktreeId: worktrees[index % worktrees.length].id, + live: index % 3 === 0 + })) + return { worktrees, tabs } +} + +function runSidebarProjection(worktrees, tabs) { + const liveTabsByWorktree = new Map() + for (const tab of tabs) { + if (tab.live) { + liveTabsByWorktree.set(tab.worktreeId, (liveTabsByWorktree.get(tab.worktreeId) ?? 0) + 1) + } + } + const sorted = [...worktrees].sort( + (left, right) => + (liveTabsByWorktree.get(right.id) ?? 0) - (liveTabsByWorktree.get(left.id) ?? 0) || + right.lastActivityAt - left.lastActivityAt || + right.sortOrder - left.sortOrder + ) + return sorted.map((worktree) => worktree.id) +} + +function runBaseline() { + const fixture = makeFixture() + const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree])) + const requestedIds = fixture.worktrees.map((worktree) => worktree.id) + let catalog = fixture.worktrees + let publications = 0 + let persistenceWrites = 0 + for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) { + const now = 1_000_000 + refresh + requestedIds.forEach((worktreeId, index) => { + metaById.set(worktreeId, { + ...metaById.get(worktreeId), + sortOrder: now - index * 1000 + }) + persistenceWrites += 1 + }) + const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId))) + const reconciled = reuseEqualCatalogRows(catalog, incoming) + if (reconciled !== catalog) { + catalog = reconciled + publications += 1 + runSidebarProjection(catalog, fixture.tabs) + } + } + return { publications, persistenceWrites } +} + +function runCandidate() { + const fixture = makeFixture() + const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree])) + const requestedIds = fixture.worktrees.map((worktree) => worktree.id) + let catalog = fixture.worktrees + let publications = 0 + let persistenceWrites = 0 + for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) { + const updates = planWorktreeSortOrderUpdates( + requestedIds, + (worktreeId) => metaById.get(worktreeId), + 1_000_000 + refresh + ) + for (const update of updates) { + metaById.set(update.worktreeId, { + ...metaById.get(update.worktreeId), + sortOrder: update.sortOrder + }) + } + persistenceWrites += updates.length + const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId))) + const reconciled = reuseEqualCatalogRows(catalog, incoming) + if (reconciled !== catalog) { + catalog = reconciled + publications += 1 + runSidebarProjection(catalog, fixture.tabs) + } + } + return { publications, persistenceWrites } +} + +function measureBatch(run) { + let counters + const startedAt = performance.now() + for (let batch = 0; batch < BATCHES_PER_ROUND; batch += 1) { + counters = run() + } + return { elapsedMs: (performance.now() - startedAt) / BATCHES_PER_ROUND, counters } +} + +function measureComparison() { + runBaseline() + runCandidate() + const samples = Array.from({ length: ROUNDS }, (_, round) => { + let baseline + let candidate + if (round % 2 === 0) { + baseline = measureBatch(runBaseline) + candidate = measureBatch(runCandidate) + } else { + candidate = measureBatch(runCandidate) + baseline = measureBatch(runBaseline) + } + return { baseline, candidate, ratio: candidate.elapsedMs / baseline.elapsedMs } + }).sort((left, right) => left.ratio - right.ratio) + return samples[Math.floor(samples.length / 2)] +} + +const { baseline, candidate } = measureComparison() +if (baseline.counters.publications !== REFRESH_COUNT || candidate.counters.publications !== 0) { + throw new Error('Benchmark fixture no longer reproduces the refresh feedback loop') +} + +console.log( + `Worktree refresh churn: ${WORKTREE_COUNT} worktrees, ${TAB_COUNT} tabs, ${REFRESH_COUNT} refreshes` +) +console.log( + `Before: ${baseline.elapsedMs.toFixed(3)} ms, ${baseline.counters.publications} catalog publications, ${baseline.counters.persistenceWrites} rank writes` +) +console.log( + `After: ${candidate.elapsedMs.toFixed(3)} ms, ${candidate.counters.publications} catalog publications, ${candidate.counters.persistenceWrites} rank writes` +) +console.log( + `Reduction: ${((1 - candidate.elapsedMs / baseline.elapsedMs) * 100).toFixed(1)}% CPU, 100% feedback publications` +) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 14295cb475a..747780e416b 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -12,6 +12,7 @@ "../src/main/agent-hooks/local-agent-cli-presence.ts", "../src/main/agent-hooks/managed-agent-hook-controls.ts", "../src/main/agent-hooks/managed-agent-hook-registry.ts", + "../src/main/agent-hooks/managed-hook-script-refresh.ts", "../src/main/amp/hook-service.ts", "../src/main/antigravity/hook-service.ts", "../src/main/claude/hook-settings.ts", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 735e1b2a0af..d23bee128c9 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -16,7 +16,8 @@ export default defineConfig({ test: { environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. - execArgv: ['--no-experimental-webstorage'], + // Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds. + execArgv: ['--no-experimental-webstorage', '--expose-gc'], // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md index d795f74039a..c21496b40bc 100644 --- a/docs/STYLEGUIDE.md +++ b/docs/STYLEGUIDE.md @@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you' Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern: -- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) +- Most carry a `data-slot=""` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner` and `repo-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.) - Use `cn()` for class merging. Pass user `className` last so callers can override. - Use `class-variance-authority` (CVA) for variants when there are multiple. @@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork: | Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered | | Single choice from a known list | `Select` | Custom listbox | | Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) | -| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one | +| Multi-select with search | `repo-multi-combobox` (mirror its pattern) | Roll a new one | | Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner | | Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) | diff --git a/docs/ai-vault-process-isolation-plan.md b/docs/ai-vault-process-isolation-plan.md new file mode 100644 index 00000000000..f9be048a12a --- /dev/null +++ b/docs/ai-vault-process-isolation-plan.md @@ -0,0 +1,596 @@ +# AI Vault process isolation architecture and implementation plan + +Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete. + +Last updated: 2026-08-09. + +## Decision summary + +AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary. + +The target has three rules: + +1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process. +2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data. +3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer. + +This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified. + +The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal. + +## Goals + +- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host. +- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active. +- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time. +- The service can be restarted without restarting the app, runtime, relay, or terminals. +- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible. +- Mixed-version remote peers continue to work. +- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services. + +## Non-goals + +- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity. +- Rewriting the scanner or changing session discovery semantics during the process migration. +- Moving xterm into another renderer. +- Adding a process per window, worktree, repository, or SSH request. +- Removing the old-relay compatibility fallback in the same change. +- Making pagination a public wire dependency before mixed-version fallback behavior exists. + +## Current architecture and coupling + +| Path | Current execution | Remaining coupling | +| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main | +| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host | +| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process | +| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay | +| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm | + +The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API. + +The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign. + +## Feature-parity contract + +The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics. + +| Capability | Required behavior after migration | Owner | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters | +| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today | +| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection | +| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router | +| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them | +| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation | +| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing | +| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate | +| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate | +| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result | +| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration | +| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success | +| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling | +| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports | + +Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow. + +No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list. + +## Target topology + +```text +Desktop / runtime host + + Integrated renderer + ├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon + └─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process + │ + └─ host merge + compatibility routing + +SSH execution host + + Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar + └────────────────── PTY handler/event loop +``` + +The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar. + +## Process ownership + +- Desktop: one Vault service per Electron main process, shared by every window and workspace. +- Orca runtime: one Vault service per runtime host process, shared by all connected clients. +- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests. +- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration. +- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop. + +## Service responsibilities + +The service owns: + +- Source discovery and filesystem metadata reads. +- Transcript streaming and parsing for all supported agents. +- OpenCode SQLite reads and its nested worker lifecycle. +- In-memory and persisted session parse caches. +- The bounded title index and transcript title resolution. +- On-demand Claude/OMP subagent listing and full first-user-prompt extraction. +- Scan parse concurrency and cooperative cancellation. +- Result construction, deduplication, sorting, and internal paging. +- Per-request metrics that contain counts and timings, not session content. + +The router owns: + +- Electron IPC, runtime RPC, or relay method registration. +- Authentication and connection ownership already enforced by that transport. +- Public argument validation and execution-host selection. +- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration. +- Multi-host result merging. +- Request coalescing and mapping caller cancellation to the service request. +- Validation of service responses before publishing them across a public boundary. +- Compatibility fallback selection. +- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation. + +The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication. + +## Internal process protocol + +Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol. + +Parent to service: + +```ts +type ServiceRequest = + | { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions } + | { type: 'request'; id: number; operation: 'scan'; options: ScanOptions } + | { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] } + | { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest } + | { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest } + | { type: 'invalidate'; paths: string[]; generation: number } + | { type: 'cancel'; id: number } + | { type: 'shutdown' } +``` + +Service to parent: + +```ts +type ServiceResponse = + | { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities } + | { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics } + | { type: 'result'; id: number; operation: 'titles'; value: TitlesResult } + | { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult } + | { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult } + | { type: 'invalidated'; generation: number } + | { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean } + | { type: 'fatal'; code: ServiceErrorCode; message: string } +``` + +Protocol rules: + +- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch. +- Use monotonically increasing safe-integer request IDs scoped to the child lifetime. +- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth. +- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes. +- Coalesce equivalent scans. Replace an older queued background scan with the newest one. +- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does. +- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy. +- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck. +- Continue returning the last good renderer result when a refresh fails. +- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state. +- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented. + +The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers. + +## Lifecycle and supervision + +### Startup + +- Start lazily on the first scan or title request. +- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader. +- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows. +- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready. +- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child. + +### Steady state + +- Reuse the process and caches across calls. +- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency. +- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal. +- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive. +- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper. + +### Faults and restart + +- Reject the active request on exit, disconnect, malformed protocol, or timeout. +- Retry queued work in a new process only when it is safe and has not been cancelled. +- Back off at 250 ms, 1 second, then 5 seconds. +- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt. +- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely. + +## Resource and quality-of-service policy + +The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness. + +- CPU: below-normal process priority, scan concurrency eight, one scan at a time. +- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry. +- Queue: 16 calls, coalescing, background replacement, explicit priority. +- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior. +- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve. +- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending. +- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap. + +Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation. + +## Renderer publication + +`ai-vault-session-refresh.ts` should stop applying a completed result immediately. + +Add `ai-vault-session-publication-gate.ts` with this behavior: + +1. Cache the validated result immediately so another caller can reuse it. +2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`. +3. Otherwise retain only the newest pending result and wait for quiet. +4. Publish after 1 second even if input continues, using a transition and one bounded page. +5. Cancel a pending publication when the scope, host, or component request token changes. + +Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel. + +A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization. + +## Desktop and runtime implementation + +Create these concrete modules: + +- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown. +- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy. +- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions. +- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution. +- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes. +- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy. + +Reuse the scanner and cache modules without moving their business logic. Replace imports in: + +- `src/main/ai-vault/cached-session-list.ts` +- `src/main/ai-vault/session-title-resolver.ts` +- `src/main/ipc/ai-vault-subagent-list.ts` +- `src/main/ai-vault/session-first-user-prompt-read.ts` +- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete +- `src/main/ipc/ai-vault.ts` +- `src/main/runtime/rpc/methods/ai-vault.ts` + +Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period. + +Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration. + +## SSH relay sidecar implementation + +Create: + +- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader. +- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter. +- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports. + +Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process. + +`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness. + +If the sidecar cannot start or crashes: + +- Return a normal `AiVaultListResult` containing a host issue and no new sessions. +- Do not scan inline in the relay. +- Keep all PTYs, reconnect state, and other relay methods available. +- Allow the next manual refresh to cross the restart circuit breaker once. + +Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals. + +## Remote compatibility + +The first implementation adds no public method, stream opcode, required field, or changed result meaning. + +- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar. +- New desktop with current relay: calls the same methods and receives the same result shape. +- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback. +- Old desktop with new relay: unknown internal sidecar details never cross the wire. + +Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability. + +The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision. + +## Folder workspaces, WSL, and paths + +- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata. +- Continue using `path.join` and the existing host-platform path adapters. +- Keep Windows batch and shell selection unrelated to terminal-shell preference. +- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows. +- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths. +- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript. +- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected. + +## Security boundary + +The service is a performance and reliability boundary, not a privilege boundary. + +- It has the same OS user and must access the same agent stores. +- Do not expose its IPC channel to renderer code or the network. +- The parent passes validated options; the service validates again before filesystem access. +- Do not log prompts, titles, transcript paths, environment values, or raw service messages. +- Preserve symlink and allowed-root checks for title reads. +- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further. + +## Packaging + +Desktop packaging changes: + +- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`. +- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`. +- Reuse the existing unpacked entry-path pattern used by other forked processes. +- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down. + +Relay packaging changes: + +- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture. +- Include it in `.version` content hashing and deploy completeness assertions. +- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together. +- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier. + +## Observability + +Add spans and counters for: + +- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit. +- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls. +- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count. +- Parent/child serialization duration and serialized bytes. +- Child RSS and heap-used snapshots at request completion. +- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count. +- Relay sidecar failures separately from PTY and relay-loop health. + +Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs. + +## Verification matrix + +The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable. + +| Layer | Required coverage | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts | +| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ | +| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush | +| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown | +| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP | +| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior | +| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions | +| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state | +| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency | +| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata | + +The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals. + +## Failure behavior + +| Failure | User-visible Vault behavior | Terminal behavior | +| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- | +| Service fails to start | Keep last result and show a retryable host issue | Unchanged | +| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged | +| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged | +| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged | +| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged | +| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available | +| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged | +| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains | + +## Benchmark methodology + +The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home. + +Each iteration: + +1. Keeps the Vault panel mounted in both arms. +2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home. +3. Alternates arm order to reduce warm-up bias. +4. Runs paced raw-mode terminal echo typing as the control. +5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment. +6. Verifies the newest seeded title is visible, proving the full scan and UI path completed. + +The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key. + +Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory. + +## Current measurements + +Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work. + +Pre-implementation parity baseline: + +- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts. +- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests. +- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes. + +### Representative pass + +Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data. + +| Metric | Control | Refresh active | +| ------------------------------ | -------------------: | ------------------: | +| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms | +| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms | +| Worst timer drift | 14.2 ms | 24.5 ms | +| Worst frame gap | 9.4 ms | 24.9 ms | +| Renderer long tasks over 50 ms | 0 | 0 | +| Missing echoes | 0 / 300 | 0 / 300 | +| Refresh duration p50 / max | — | 103.5 / 204.2 ms | + +There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration. + +### Stress pass + +Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data. + +| Metric | Control | Refresh active | +| ------------------------------ | ------------------: | -------------------: | +| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms | +| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms | +| Worst timer drift | 9.3 ms | 43.6 ms | +| Worst frame gap | 9.4 ms | 50.0 ms | +| Renderer long tasks over 50 ms | 0 | 0 | +| Missing echoes | 0 / 240 | 0 / 240 | +| Refresh duration p50 / max | — | 276.3 / 453.5 ms | + +The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer. + +### Measurement limits + +- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior. +- The benchmark covers the local service path, not the currently unisolated SSH relay path. +- Raw shell echo is a stable latency probe, not a full-screen TUI workload. +- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost. + +## Post-implementation A/B + +Run the same build with the rollout switch off and on, alternating launch order: + +```bash +ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control +ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment +``` + +Run at least five representative and five stress passes on: + +- macOS Apple Silicon. +- Windows x64 with a folder workspace and a WSL-backed source present. +- Ubuntu 20.04-compatible x64 packaging/runtime. +- A 4-core or smaller machine or constrained CI runner. + +Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy. + +## Implemented result + +The implementation now matches the target topology: + +- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback. +- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry. +- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue. +- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes. +- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second. +- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker. + +Local validation on 2026-08-09: + +- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully. +- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane. +- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone. +- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled. + +Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence. + +| Metric | Worker control | Worker refresh | Process control | Process refresh | +| --------------------- | -------------: | -------------: | --------------: | --------------: | +| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms | +| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms | +| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms | +| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms | +| Long tasks over 50 ms | 0 | 0 | 0 | 0 | +| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 | +| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms | + +The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness. + +Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable. + +## Acceptance criteria + +Correctness: + +- Zero missing terminal echoes. +- The latest seeded Vault session is visibly published in every treatment arm. +- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner. +- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics. + +Performance: + +- Treatment parse p95 is no more than 10 ms above its paired control. +- Treatment render p95 is no more than 15 ms above its paired control. +- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms. +- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run. +- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes. + +Isolation: + +- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon. +- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods. +- The service respects queue, timeout, idle, and heap bounds. +- Packaged app and relay artifacts contain and launch the correct entry for every supported platform. + +## Phased implementation plan + +### Phase 0 — measurement harness (completed in this change) + +- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes. +- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`. +- Capture representative and stress baselines. + +Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes. + +### Phase 1 — desktop and runtime service behind a switch + +- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor. +- Reuse current scanner/cache implementations. +- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled. +- Keep the worker path as the disabled-switch fallback. +- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution. +- Add E2E fault injection that kills the service during active terminal typing. + +Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets. + +### Phase 2 — make the desktop/runtime process the default + +- Enable the process by default in development, E2E, and canary builds. +- Collect process RSS, restart, timeout, and latency telemetry without content. +- Validate Windows, Linux, WSL, and constrained-runner results. +- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle. + +Exit: no unexplained crash or timeout increase and all acceptance gates green. + +### Phase 3 — SSH relay sidecar + +- Add the relay sidecar entry and supervisor. +- Remove scanner/parser/cache imports from the relay handler. +- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test. +- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests. +- Make inline relay scanning impossible; sidecar failure returns a host issue. + +Exit: remote typing and fault gates green; current public relay methods unchanged. + +### Phase 4 — renderer quiet publication + +- Add the shared quiet-publication gate and React transition. +- Retain only the newest pending result per scope/host. +- Add supersession, starvation timeout, unmount, and rapid-host-switch tests. +- Re-run the Electron benchmark with large result counts and active filtering/grouping. + +Exit: no Vault-attributable renderer long task over 50 ms in the representative gate. + +### Phase 5 — hardening and cleanup + +- Remove the worker implementation after one stable release if the kill switch was unused. +- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry. +- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate. +- Document operational recovery and service diagnostics. + +Exit: kill switch and dead worker code removed only after evidence supports it. + +## Rollback + +- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data. +- Renderer: disable quiet publication and apply the validated complete result directly. +- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails. +- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion. + +## Confidence and remaining questions + +Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation. + +The remaining implementation questions are deliberately narrow: + +1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on. +2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry. +3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets. +4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop. diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 7414e4cea7a..a3074230367 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 17m + + downloads: 18m @@ -15,7 +15,7 @@ downloads downloads - 17m - 17m + 18m + 18m diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ff8360a138d..7db7df7ca69 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -220,6 +220,9 @@ export const electronViteConfig: UserConfig = { 'session-scanner-worker-entry': resolve( 'src/main/ai-vault/session-scanner-worker-entry.ts' ), + 'session-scanner-service-entry': resolve( + 'src/main/ai-vault/session-scanner-service-entry.ts' + ), // Why: libuv spawns processes inline on the calling loop, so the port // scan's probe commands run on a worker thread instead of the UI one. 'port-scan-command-worker-entry': resolve( diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index 51dd82305f1..877a6d7e123 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -93,12 +93,6 @@ "max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }] } }, - { - "files": ["app/terminal-settings.tsx"], - "rules": { - "max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }] - } - }, { "files": ["scripts/mock-server.ts"], "rules": { diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 677ded5aa78..553490b4748 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -115,13 +115,15 @@ import { import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' import { triggerMediumImpact } from '../../../src/platform/haptics' import { - type GitHubProjectSortDirection, - type GitHubProjectTable as SharedGitHubProjectTable, groupRows, isIterationCurrent, sortRows, type ProjectGroup -} from '../../../src/tasks/mobile-github-project-group-sort' +} from '../../../../src/shared/github-project-group-sort' +import type { + GitHubProjectSortDirection, + GitHubProjectTable as SharedGitHubProjectTable +} from '../../../../src/shared/github-project-types' import { CROSS_REPO_DISPLAY_LIMIT, isGitHubWorkItemsSshRemoteRequiredError, diff --git a/mobile/app/terminal-settings.tsx b/mobile/app/terminal-settings.tsx index e55819aa22a..860f906e77b 100644 --- a/mobile/app/terminal-settings.tsx +++ b/mobile/app/terminal-settings.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { View, Text, StyleSheet, Pressable, Switch } from 'react-native' +import { View, Text, Pressable, Switch } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { GestureHandlerRootView } from 'react-native-gesture-handler' import Animated, { @@ -9,7 +9,7 @@ import Animated, { } from 'react-native-reanimated' import { useRouter } from 'expo-router' import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' +import { colors, spacing } from '../src/theme/mobile-theme' import { loadHosts } from '../src/transport/host-store' import type { HostProfile } from '../src/transport/types' import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections' @@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client' import { PickerModal, type PickerOption } from '../src/components/PickerModal' import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings' import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state' +import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles' import { loadTerminalAutocompleteEnabled, loadTerminalTextScale, @@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() { ) } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: colors.bgBase, - paddingHorizontal: spacing.lg, - paddingTop: 0 - }, - topRow: { - flexDirection: 'row', - alignItems: 'center', - marginTop: spacing.sm, - marginBottom: spacing.lg - }, - backButton: { - width: 36, - height: 36, - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - marginRight: spacing.sm - }, - heading: { - fontSize: 20, - fontWeight: '700', - color: colors.textPrimary - }, - scrollContent: { - paddingBottom: spacing.xl - }, - groupHeading: { - fontSize: 11, - fontWeight: '600', - color: colors.textMuted, - letterSpacing: 0.5, - marginBottom: spacing.xs, - paddingHorizontal: spacing.xs - }, - groupDescription: { - fontSize: typography.bodySize - 1, - color: colors.textSecondary, - lineHeight: 20, - paddingHorizontal: spacing.xs - }, - section: { - backgroundColor: colors.bgPanel, - borderRadius: radii.card, - overflow: 'hidden' - }, - sectionTopGap: { - marginTop: spacing.sm - }, - inputGroupGap: { - marginTop: spacing.xl - }, - emptyText: { - fontSize: typography.bodySize, - color: colors.textSecondary, - padding: spacing.md - }, - row: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm + 2, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md + 2 - }, - rowPressed: { - backgroundColor: colors.bgRaised - }, - rowContent: { - flex: 1 - }, - rowLabel: { - fontSize: typography.bodySize, - fontWeight: '500', - color: colors.textPrimary - }, - rowSublabel: { - fontSize: typography.bodySize - 2, - color: colors.textSecondary, - marginTop: 2 - }, - separator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - } -}) diff --git a/mobile/src/components/MobilePrBasePicker.tsx b/mobile/src/components/MobilePrBasePicker.tsx deleted file mode 100644 index be9f92d65fc..00000000000 --- a/mobile/src/components/MobilePrBasePicker.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native' -import { Check, ChevronDown } from 'lucide-react-native' -import { colors, radii, spacing, typography } from '../theme/mobile-theme' -import type { RpcClient } from '../transport/rpc-client' -import { searchBaseRefs } from '../source-control/mobile-base-ref-search' - -type Props = { - client: RpcClient | null - worktreeId: string - value: string - onChange: (ref: string) => void - editable?: boolean -} - -// Base-branch field for the create-PR composer: a free-text input that also searches -// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's -// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still -// be entered. -export function MobilePrBasePicker({ - client, - worktreeId, - value, - onChange, - editable = true -}: Props) { - const [results, setResults] = useState([]) - const [focused, setFocused] = useState(false) - const timer = useRef | null>(null) - // Guards: drop results after unmount, and ignore an earlier search whose response - // arrives after a later one (out-of-order network) so stale matches can't clobber. - const mounted = useRef(true) - const seq = useRef(0) - - useEffect(() => { - return () => { - mounted.current = false - if (timer.current) { - clearTimeout(timer.current) - } - } - }, []) - - const queryRefs = useCallback( - (query: string) => { - if (timer.current) { - clearTimeout(timer.current) - } - if (!client || query.trim().length === 0) { - // Advance the generation so an earlier in-flight search can't land and - // repopulate results after the input was cleared. - seq.current += 1 - setResults([]) - return - } - timer.current = setTimeout(() => { - const requestSeq = ++seq.current - void searchBaseRefs(client, worktreeId, query.trim()) - .then((refs) => { - if (!mounted.current || requestSeq !== seq.current) { - return - } - setResults(refs.filter((r) => r !== query).slice(0, 6)) - }) - // Why: a rejected ref search must not escape as an unhandled rejection; - // drop to an empty result set (free text stays valid to submit). - .catch(() => { - if (mounted.current && requestSeq === seq.current) { - setResults([]) - } - }) - }, 200) - }, - [client, worktreeId] - ) - - return ( - - - { - onChange(text) - queryRefs(text) - }} - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - placeholder="main" - placeholderTextColor={colors.textMuted} - autoCapitalize="none" - autoCorrect={false} - editable={editable} - /> - - - {focused && results.length > 0 ? ( - - {results.map((ref) => ( - [styles.resultRow, pressed && styles.resultRowPressed]} - onPress={() => { - onChange(ref) - setResults([]) - }} - > - - {ref} - - {ref === value ? ( - - ) : null} - - ))} - - ) : null} - - ) -} - -const styles = StyleSheet.create({ - inputShell: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.xs - }, - inputShellDisabled: { - opacity: 0.6 - }, - input: { - flex: 1, - minWidth: 0, - padding: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - }, - results: { - marginTop: spacing.xs, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.input, - backgroundColor: colors.bgPanel, - overflow: 'hidden' - }, - resultRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - paddingHorizontal: spacing.md, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle - }, - resultRowPressed: { backgroundColor: colors.bgRaised }, - resultText: { - flex: 1, - minWidth: 0, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontFamily: typography.monoFamily - } -}) diff --git a/mobile/src/components/MobileSearchField.tsx b/mobile/src/components/MobileSearchField.tsx index 2dfd6bf26e4..cbeab92c208 100644 --- a/mobile/src/components/MobileSearchField.tsx +++ b/mobile/src/components/MobileSearchField.tsx @@ -149,8 +149,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.sm, - // Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious - // control, matching TextInputModal / MobilePrBasePicker input shells. + // Keep search visually distinct from surrounding panels. backgroundColor: colors.bgRaised, borderWidth: 1, borderColor: colors.borderSubtle, diff --git a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx b/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx deleted file mode 100644 index d832d38c88d..00000000000 --- a/mobile/src/components/pr-sidebar/MobilePrComposeForm.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useCallback, useState } from 'react' -import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native' -import { - ArrowRight, - GitMerge, - GitPullRequestArrow, - Sparkles, - TriangleAlert, - X -} from 'lucide-react-native' -import { colors } from '../../theme/mobile-theme' -import type { RpcClient } from '../../transport/rpc-client' -import type { RpcSuccess } from '../../transport/types' -import { triggerError, triggerSuccess } from '../../platform/haptics' -import { - createMobilePr, - getMobilePrCreateSuccessWarning, - shouldPushBeforeMobilePrCreate, - type MobilePrPrefill -} from '../../source-control/mobile-pr-create' -import { hostedReviewCopy } from '../../source-control/hosted-review-copy' -import { - getPrComposeDisabledReason, - isBaseHeadDistinct -} from '../../source-control/pr-compose-validation' -import { MobilePrBasePicker } from '../MobilePrBasePicker' -import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles' - -export type PrComposePrefill = MobilePrPrefill - -type Props = { - client: RpcClient | null - worktreeId: string - prefill: PrComposePrefill - // Head branch — enables the base≠head guard and the "from " hint. - head?: string | null - onCancel: () => void - onCreated: (url: string, warning?: string) => void -} - -// A plain View keeps the compose form usable inside the PR sidebar ScrollView. -export function MobilePrComposeForm({ - client, - worktreeId, - prefill, - head, - onCancel, - onCreated -}: Props) { - const copy = hostedReviewCopy(prefill.provider) - const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow - const [title, setTitle] = useState(prefill.title) - const [body, setBody] = useState(prefill.body) - const [base, setBase] = useState(prefill.base) - const [draft, setDraft] = useState(false) - const [generating, setGenerating] = useState(false) - const [submitting, setSubmitting] = useState(false) - const [error, setError] = useState(null) - const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill) - - const generate = useCallback(async () => { - if (!client || generating) { - return - } - setGenerating(true) - setError(null) - try { - const response = await client.sendRequest('git.generatePullRequestFields', { - worktree: `id:${worktreeId}`, - base, - title, - body, - draft - }) - if (!response.ok) { - setError(response.error?.message || 'Failed to generate PR fields') - return - } - const result = (response as RpcSuccess).result as { - success?: boolean - fields?: { base: string; title: string; body: string; draft: boolean } - error?: string - } - if (result.success && result.fields) { - setBase(result.fields.base || base) - setTitle(result.fields.title || title) - setBody(result.fields.body || body) - setDraft(result.fields.draft) - } else if (result.error) { - setError(result.error) - } - } catch (err) { - triggerError() - setError(err instanceof Error ? err.message : 'Failed to generate PR fields') - } finally { - setGenerating(false) - } - }, [base, body, client, draft, generating, title, worktreeId]) - - const headRef = head ?? '' - const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef) - const submitDisabledReason = getPrComposeDisabledReason({ - title, - base, - head: headRef, - generating, - reviewLabel: copy.reviewLabel - }) - const canSubmit = submitDisabledReason === null - const fieldsLocked = submitting || generating - - const submit = useCallback(async () => { - if (!client || submitting || !canSubmit) { - return - } - setSubmitting(true) - setError(null) - try { - const outcome = await createMobilePr(client, worktreeId, { - provider: prefill.provider, - base, - // Send the same head the submit guard validated against, so the PR opens - // from the validated branch instead of a host-inferred one. - ...(head ? { head } : {}), - title, - body, - draft, - pushBeforeCreate - }) - if (outcome.ok) { - triggerSuccess() - const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider) - if (warning) { - setError(warning) - } - onCreated(outcome.url, warning) - } else { - triggerError() - setError(outcome.error) - } - } finally { - setSubmitting(false) - } - }, [ - base, - body, - canSubmit, - client, - copy.titleLabel, - draft, - head, - onCreated, - prefill.provider, - pushBeforeCreate, - submitting, - title, - worktreeId - ]) - - return ( - - - - - New {copy.reviewLabel} - - - [styles.genButton, pressed && styles.genButtonPressed]} - disabled={generating || submitting} - onPress={() => void generate()} - accessibilityRole="button" - accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`} - > - {generating ? ( - - ) : ( - - )} - {generating ? 'Generating…' : 'Generate'} - - - - - - - - {head ? ( - - - {head} - - - - {base || 'base'} - - - ) : null} - - - - - - - {generating ? ( - - - Generating title and description… - - ) : null} - - - Base - - - - - - - Create as draft - - - {error || submitDisabledReason ? ( - - - {error ?? submitDisabledReason} - - ) : null} - [ - styles.submit, - (submitting || !canSubmit) && styles.submitDisabled, - pressed && styles.submitPressed - ]} - disabled={submitting || !canSubmit} - onPress={() => void submit()} - accessibilityRole="button" - > - {submitting ? ( - - ) : ( - - )} - - {pushBeforeCreate - ? draft - ? `Push & create draft ${copy.shortLabel}` - : `Push & create ${copy.shortLabel}` - : draft - ? `Create draft ${copy.shortLabel}` - : `Create ${copy.shortLabel}`} - - - - ) -} diff --git a/mobile/src/components/pr-sidebar/PRCommentCard.tsx b/mobile/src/components/pr-sidebar/PRCommentCard.tsx index ce1ef33cb96..23d768b7814 100644 --- a/mobile/src/components/pr-sidebar/PRCommentCard.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentCard.tsx @@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac import { ConfirmModal } from '../ConfirmModal' import { CommentMarkdown } from './CommentMarkdown' import { PRCommentComposer } from './PRCommentComposer' -import { formatPrCommentRelativeTime } from './pr-comment-time' +import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time' import { prCommentsStyles as styles } from './pr-comments-styles' export type PRCommentRepoSlug = { owner: string; repo: string; host?: string } diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx index bb473307619..8fb5de31037 100644 --- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx @@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown' import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard' import { PRCommentComposer } from './PRCommentComposer' import { - PR_COMMENT_AUDIENCE_FILTERS, filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, type PRCommentAudienceFilter -} from './pr-comment-audience' +} from '../../../../src/shared/pr-comment-audience' +import { + PR_COMMENT_AUDIENCE_FILTERS, + getPRCommentAudienceEmptyLabel +} from './pr-comment-audience-labels' import { getPRCommentGroupCount, getPRCommentGroupId, @@ -25,7 +27,7 @@ import { groupPRComments, isResolvedPRCommentGroup, type PRCommentGroup -} from './pr-comment-groups' +} from '../../../../src/shared/pr-comment-groups' import { prCommentsStyles as styles } from './pr-comments-styles' import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles' diff --git a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts deleted file mode 100644 index 587082d972f..00000000000 --- a/mobile/src/components/pr-sidebar/mobile-pr-compose-form-styles.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { StyleSheet } from 'react-native' -import { colors, radii, spacing, typography } from '../../theme/mobile-theme' - -export const mobilePrComposeFormStyles = StyleSheet.create({ - root: { - gap: spacing.sm - }, - headingRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - marginBottom: spacing.xs - }, - headingTitle: { - flex: 1, - minWidth: 0, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - heading: { - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '700' - }, - headingActions: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - genButton: { - minHeight: 32, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs, - paddingHorizontal: spacing.sm, - borderRadius: radii.button, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - genButtonPressed: { - opacity: 0.7 - }, - genButtonText: { - color: colors.textSecondary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - iconButton: { - minWidth: 32, - minHeight: 32, - alignItems: 'center', - justifyContent: 'center', - borderRadius: radii.button - }, - branchFlow: { - minHeight: 28, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs - }, - branchToken: { - maxWidth: 116, - color: colors.textPrimary, - fontSize: typography.metaSize, - fontFamily: typography.monoFamily - }, - branchTokenError: { - color: colors.statusRed - }, - fieldStack: { - gap: spacing.sm - }, - titleInput: { - minHeight: 40, - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - fontWeight: '600' - }, - bodyInput: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm, - color: colors.textPrimary, - fontSize: typography.bodySize, - // Why: a moderate fixed height avoids over-expanding inside the sidebar scroll. - minHeight: 120, - textAlignVertical: 'top' - }, - baseRow: { - minHeight: 40, - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm - }, - baseLabel: { - color: colors.textSecondary, - fontSize: typography.metaSize, - width: 36 - }, - baseControl: { - flex: 1, - minWidth: 0 - }, - draftRow: { - minHeight: 36, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - gap: spacing.sm, - borderWidth: StyleSheet.hairlineWidth, - borderColor: colors.borderSubtle, - borderRadius: radii.button, - backgroundColor: colors.bgPanel, - paddingHorizontal: spacing.sm - }, - draftText: { - color: colors.textPrimary, - fontSize: typography.metaSize, - fontWeight: '700' - }, - notice: { - flexDirection: 'row', - alignItems: 'flex-start', - gap: spacing.xs - }, - noticeText: { - flex: 1, - color: colors.textSecondary, - fontSize: typography.metaSize, - lineHeight: 18 - }, - errorText: { - color: colors.statusRed - }, - submit: { - marginTop: spacing.xs, - minHeight: 44, - borderRadius: radii.button, - backgroundColor: colors.textPrimary, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: spacing.xs - }, - submitDisabled: { - opacity: 0.45 - }, - submitPressed: { - opacity: 0.8 - }, - submitText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '700' - } -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts new file mode 100644 index 00000000000..43f0d54289f --- /dev/null +++ b/mobile/src/components/pr-sidebar/pr-comment-audience-labels.ts @@ -0,0 +1,18 @@ +import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience' + +export const PR_COMMENT_AUDIENCE_FILTERS = [ + { value: 'all', label: 'All' }, + { value: 'human', label: 'Humans' }, + { value: 'bot', label: 'Bots' } +] satisfies { value: PRCommentAudienceFilter; label: string }[] + +export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { + switch (filter) { + case 'bot': + return 'No bot comments.' + case 'human': + return 'No human comments.' + case 'all': + return 'No comments yet.' + } +} diff --git a/mobile/src/components/pr-sidebar/pr-comment-groups.ts b/mobile/src/components/pr-sidebar/pr-comment-groups.ts deleted file mode 100644 index 78bff853478..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-groups.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PRComment } from '../../../../src/shared/types' - -// Thread grouping for the PR comment timeline, ported from the desktop helper -// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants. -// Inline review comments sharing a threadId collapse into a root + replies; all -// other comments are standalone. Upstream order is preserved. -export type PRCommentGroup = - | { kind: 'standalone'; comment: PRComment } - | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } - -export function groupPRComments(comments: PRComment[]): PRCommentGroup[] { - const threadMap = new Map() - const groupsByFirstComment = new Map() - - for (const comment of comments) { - if (!comment.threadId) { - groupsByFirstComment.set(comment, { kind: 'standalone', comment }) - continue - } - const existing = threadMap.get(comment.threadId) - if (existing) { - existing.replies.push(comment) - continue - } - threadMap.set(comment.threadId, { root: comment, replies: [] }) - } - - const emitted = new Set() - const groups: PRCommentGroup[] = [] - for (const comment of comments) { - if (!comment.threadId) { - const group = groupsByFirstComment.get(comment) - if (group) { - groups.push(group) - } - continue - } - if (emitted.has(comment.threadId)) { - continue - } - emitted.add(comment.threadId) - const thread = threadMap.get(comment.threadId) - if (thread) { - groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) - } - } - return groups -} - -export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] { - return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment] -} - -export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { - return group.kind === 'thread' ? group.root : group.comment -} - -export function getPRCommentGroupCount(group: PRCommentGroup): number { - return getPRCommentGroupComments(group).length -} - -export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { - return getPRCommentGroupRoot(group).isResolved === true -} - -export function getPRCommentGroupId(group: PRCommentGroup): string { - return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` -} diff --git a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts b/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts deleted file mode 100644 index e01fb4166dc..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-presentation.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../../src/shared/types' -import { - createBotAuthorOverrideSet, - filterPRCommentsByAudience, - getPRCommentAudienceCounts, - isBotPRComment -} from './pr-comment-audience' -import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups' -import { formatPrCommentRelativeTime } from './pr-comment-time' - -function comment(overrides: Partial & { id: number }): PRComment { - return { - author: 'octocat', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - ...overrides - } -} - -describe('pr comment audience', () => { - it('classifies app bots, [bot] suffix, and known automation logins', () => { - expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false) - expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true) - expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true) - expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true) - }) - - it('counts and filters by audience', () => { - const comments = [ - comment({ id: 1, author: 'alice' }), - comment({ id: 2, author: 'dependabot[bot]' }), - comment({ id: 3, author: 'bob' }) - ] - expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2]) - expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3]) - expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3) - }) - - it('classifies desktop-marked bot authors via overrides (desktop parity)', () => { - const overrides = createBotAuthorOverrideSet([' GretelFlux ']) - const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })] - expect(isBotPRComment(comments[1])).toBe(false) - expect(isBotPRComment(comments[1], overrides)).toBe(true) - expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 }) - expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2]) - }) -}) - -describe('pr comment groups', () => { - it('threads comments sharing a threadId as root + replies, preserving order', () => { - const comments = [ - comment({ id: 1, author: 'a' }), - comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }), - comment({ id: 3, author: 'c', threadId: 't1' }) - ] - const groups = groupPRComments(comments) - expect(groups).toHaveLength(2) - expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] }) - expect(groups[1].kind).toBe('thread') - if (groups[1].kind === 'thread') { - expect(groups[1].root.id).toBe(2) - expect(groups[1].replies.map((r) => r.id)).toEqual([3]) - } - expect(isResolvedPRCommentGroup(groups[1])).toBe(true) - }) -}) - -describe('pr comment relative time', () => { - const now = Date.parse('2026-06-16T12:00:00Z') - it('formats buckets and rejects bad input', () => { - expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now') - expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago') - expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago') - expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago') - expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('') - }) -}) diff --git a/mobile/src/components/pr-sidebar/pr-comment-time.ts b/mobile/src/components/pr-sidebar/pr-comment-time.ts deleted file mode 100644 index 47d78ea8a2f..00000000000 --- a/mobile/src/components/pr-sidebar/pr-comment-time.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out), -// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure + -// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts). -export function formatPrCommentRelativeTime(iso: string, nowMs: number): string { - const ts = Date.parse(iso) - if (Number.isNaN(ts)) { - return '' - } - const delta = nowMs - ts - if (delta < 60_000) { - return 'just now' - } - const minutes = Math.floor(delta / 60_000) - if (minutes < 60) { - return `${minutes}m ago` - } - const hours = Math.floor(minutes / 60) - if (hours < 24) { - return `${hours}h ago` - } - const days = Math.floor(hours / 24) - if (days < 30) { - return `${days}d ago` - } - const months = Math.floor(days / 30) - if (months < 12) { - return `${months}mo ago` - } - return `${Math.floor(months / 12)}y ago` -} diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx index 97e92decc4d..0a676061e5b 100644 --- a/mobile/src/session/MobileNativeChatMessage.tsx +++ b/mobile/src/session/MobileNativeChatMessage.tsx @@ -276,7 +276,6 @@ function AgentControls({ function MobileNativeChatMessageImpl({ message, - queued, toolsExpanded = false, fontScale = 1, messageIndex, @@ -284,7 +283,6 @@ function MobileNativeChatMessageImpl({ onOpenFile }: { message: NativeChatMessage - queued?: boolean toolsExpanded?: boolean /** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */ fontScale?: number @@ -328,27 +326,24 @@ function MobileNativeChatMessageImpl({ // Copy + scroll-to-top, shown inline with the first tool call (or after the // prose when there are no tools). - const controls = - isAgent && !queued ? ( - onScrollToMessage(messageIndex) - : undefined - } - /> - ) : null + const controls = isAgent ? ( + onScrollToMessage(messageIndex) + : undefined + } + /> + ) : null return ( - {isUser && queued ? Queued : null} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index fb4072c4f2d..1021671b837 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -71,6 +71,7 @@ type Overrides = { onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null onSend?: (text: string) => Promise + pending?: Parameters[0]['pending'] } function assistantTurn(id: string, text: string): NativeChatMessage { @@ -117,6 +118,13 @@ describe('MobileNativeChatView', () => { return (list.props.data as { id: string }[]).map((row) => row.id) } + function renderedRow(id: string): ReturnType { + const list = renderer!.root.find((node) => node.type === 'FlatList') + const data = list.props.data as NativeChatMessage[] + const index = data.findIndex((row) => row.id === id) + return list.props.renderItem({ item: data[index], index }) + } + function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } @@ -187,6 +195,15 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + it('renders an accepted optimistic image send without a queued state', async () => { + await render({ + pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }] + }) + + expect(listIds()).toEqual(['pending-1']) + expect(renderedRow('pending-1').props).not.toHaveProperty('queued') + }) + it('keeps a visible lock through a subscribed-end lease blip', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 697887bfe75..190001417a3 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -58,8 +58,7 @@ type Props = { loadingEarlier?: boolean onLoadEarlier?: () => void onSend: (text: string) => Promise - /** Optimistic queued sends (owned by the route so they survive view switches). */ - /** Optimistic user echoes, including any ridden-along image preview URIs. */ + /** Accepted user echoes awaiting transcript replacement, including image previews. */ pending: MobileNativeChatPendingItem[] /** Local photo URIs retained when the authoritative transcript replaces an * optimistic image bubble. */ @@ -176,9 +175,8 @@ export function MobileNativeChatView({ [] ) - const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending]) // `data` is the list source: folded transcript + synthetic streaming bubble + - // route-owned optimistic queued messages. Memoize on the same deps so the + // route-owned accepted echoes. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( () => @@ -248,7 +246,6 @@ export function MobileNativeChatView({ ({ item, index }: { item: NativeChatMessage; index: number }) => ( ), - [pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile] + [toolsExpanded, fontScale, onScrollToMessage, onOpenFile] ) const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts index df1bad92815..ad7cf4b4009 100644 --- a/mobile/src/session/mobile-native-chat-message-styles.ts +++ b/mobile/src/session/mobile-native-chat-message-styles.ts @@ -49,15 +49,6 @@ export const styles = StyleSheet.create({ reasoning: { opacity: 0.7 }, - queued: { - opacity: 0.55 - }, - queuedTag: { - color: colors.textMuted, - fontSize: 11, - fontWeight: '600', - marginBottom: 2 - }, toolRun: { marginTop: spacing.xs }, diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 1ab2c0b03c3..73a45db6962 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -51,7 +51,7 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat /** Assemble the list data the chat renders: the folded transcript, then a * synthetic bubble for the streaming text the gate let through, then the - * route-owned optimistic "queued" messages at the tail. */ + * route-owned accepted optimistic messages at the tail. */ export function buildMobileNativeChatTransientData({ folded, streaming, diff --git a/mobile/src/session/mobile-native-chat-send-classification.ts b/mobile/src/session/mobile-native-chat-send-classification.ts index 18568c05789..2a34efabdd2 100644 --- a/mobile/src/session/mobile-native-chat-send-classification.ts +++ b/mobile/src/session/mobile-native-chat-send-classification.ts @@ -2,7 +2,7 @@ // (src/renderer/src/components/native-chat/NativeChatComposer.tsx): slash/skill // sends are TUI control actions, not chat turns — they never echo as a user // bubble, because the transcript will never contain a matching user turn and -// the optimistic echo would sit at "Queued" forever. +// the optimistic echo would never reconcile. import { getNativeChatAgentProfile, diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index ccdacfd7b3e..1c7a661cda5 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' @@ -7,10 +7,13 @@ import { openMobileNativeChatSendBudget, clearMobileNativeChatInput, sendMobileNativeChatMessage, - sendMobileNativeChatMessageWithOutcome + sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome } from './mobile-native-chat-send' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' +afterEach(() => vi.useRealTimers()) + function clientWithResponse(response: unknown): RpcClient { return { sendRequest: vi.fn().mockResolvedValue(response) @@ -290,6 +293,37 @@ describe('sendMobileNativeChatMessage', () => { }) }) +describe('typeMobileNativeChatCommandWithOutcome', () => { + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + vi.useFakeTimers() + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + const result = typeMobileNativeChatCommandWithOutcome({ + client, + terminal: 'term', + command: '/model' + }) + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe('accepted') + expect( + vi.mocked(client.sendRequest).mock.calls.map((call) => { + const params = call[1] as { text: string; enter: boolean } + return { text: params.text, enter: params.enter } + }) + ).toEqual( + ['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'].map((text) => ({ + text, + enter: false + })) + ) + }) +}) + describe('clearMobileNativeChatInput', () => { const accepted = { id: 'request', diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index ce432c3efb9..e7cb0e45beb 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -2,6 +2,7 @@ import type { RpcClient } from '../transport/rpc-client' import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { typeAgentTuiCommand } from '../../../src/shared/agent-tui-command-typing' type MobileTerminalClient = { id: string @@ -93,6 +94,27 @@ export async function sendMobileNativeChatMessage( return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted' } +export async function typeMobileNativeChatCommandWithOutcome(args: { + client: RpcClient + terminal: string + command: string + mobileClient?: MobileTerminalClient + deadline?: number +}): Promise { + return typeAgentTuiCommand({ + command: args.command, + write: (key) => + sendMobileNativeChatMessageWithOutcome({ + client: args.client, + terminal: args.terminal, + text: key, + enter: false, + ...(args.mobileClient ? { mobileClient: args.mobileClient } : {}), + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) + }) + }) +} + /** * Clear the agent's input line as its OWN write, before any body. * diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index 06b30f5cdb4..6e92169b819 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -7,8 +7,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const sendWithOutcome = vi.fn() const clearInputWrite = vi.fn() +const typeCommandWithOutcome = vi.fn() vi.mock('./mobile-native-chat-send', () => ({ sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args), + typeMobileNativeChatCommandWithOutcome: (...args: unknown[]) => typeCommandWithOutcome(...args), clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args), openMobileNativeChatSendBudget: () => Date.now() + 15_000, MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000, @@ -84,6 +86,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + typeCommandWithOutcome.mockReset() + typeCommandWithOutcome.mockResolvedValue('accepted') acceptSend.mockReset() holdUnconfirmedSend.mockReset() onCommandSend.mockReset() @@ -260,6 +264,19 @@ describe('useMobileNativeChatMessageSend', () => { expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) + it('routes typed picker commands around the pasted composer-text send', async () => { + mount(() => null, 'codex') + let outcome: string | undefined + await act(async () => { + outcome = await api!.dispatchCommand('/model', { delivery: 'type' }) + }) + expect(outcome).toBe('accepted') + expect(typeCommandWithOutcome).toHaveBeenCalledWith( + expect.objectContaining({ command: '/model', terminal: 'term' }) + ) + expect(sendWithOutcome).not.toHaveBeenCalled() + }) + it('binds classification to the agent that started the send', async () => { let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void sendWithOutcome.mockReturnValue( diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 1d59e25431c..e3bc6c0cabd 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -4,8 +4,10 @@ import { clearMobileNativeChatInput, openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, + typeMobileNativeChatCommandWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import type { CatalogCommandDelivery } from '../../../src/shared/agent-session-option-catalog' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification' import { @@ -32,7 +34,10 @@ export type MobileNativeChatMessageSend = { answerQuestion: (text: string) => Promise /** Session-option command dispatch (e.g. `/model sonnet`) — never touches the * composer draft; callers need the outcome to track dispatched state. */ - dispatchCommand: (text: string) => Promise + dispatchCommand: ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise } /** The native-chat send seam: one write path shared by composer sends, image @@ -181,7 +186,7 @@ export function useMobileNativeChatMessageSend(args: { }) // Why (desktop parity): a slash/skill send dispatches into the agent's own // TUI, not the conversation — the transcript never echoes it as a user - // turn, so an optimistic bubble would sit at "Queued" forever and the + // turn, so an optimistic bubble would never reconcile and the // unconfirmed hold could never observe a landing. const classification = classifyMobileNativeChatSend(agent, text) if (outcome === 'unknown') { @@ -269,12 +274,41 @@ export function useMobileNativeChatMessageSend(args: { // spaces a send's body and its Enter ~500ms apart — so without this lock an // apply lands between them and is submitted as part of the user's prompt. const dispatchCommand = useCallback( - async (text: string): Promise => { + async ( + text: string, + options?: { delivery?: CatalogCommandDelivery } + ): Promise => { const terminal = handleRef.current if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { return 'rejected' } try { + if (options?.delivery === 'type') { + if (!client || !terminal || !enabled) { + return 'rejected' + } + const deadline = openMobileNativeChatSendBudget() + const mobileClient = deviceTokenRef.current + ? { id: deviceTokenRef.current, type: 'mobile' as const } + : undefined + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + return 'rejected' + } + return typeMobileNativeChatCommandWithOutcome({ + client, + terminal, + command: text, + ...(mobileClient ? { mobileClient } : {}), + deadline + }) + } return await sendMessage(text, undefined, false, false) } finally { if (terminal) { @@ -282,7 +316,7 @@ export function useMobileNativeChatMessageSend(args: { } } }, - [handleRef, sendMessage] + [client, deviceTokenRef, enabled, handleRef, sendMessage] ) return { send, sendWithOutcome, answerQuestion, dispatchCommand } diff --git a/mobile/src/session/use-mobile-native-chat-session-options.test.ts b/mobile/src/session/use-mobile-native-chat-session-options.test.ts index d7cab9a4c37..a587f3c5747 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.test.ts @@ -14,7 +14,7 @@ describe('useMobileNativeChatSessionOptions', () => { let renderer: ReactTestRenderer | null = null let api: MobileNativeChatSessionOptionsController | null = null let hookArgs: HookArgs - const dispatchCommand = vi.fn<(command: string) => Promise>() + const dispatchCommand = vi.fn() const onAgentPicker = vi.fn() function Probe(): null { @@ -97,14 +97,23 @@ describe('useMobileNativeChatSessionOptions', () => { expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) }) - it('applies Codex model changes through the native command', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mount({ agent: 'codex' }) - expect(api!.snapshot[0]?.action).toBeUndefined() + expect(api!.snapshot[0]?.action).toEqual({ type: 'agent-picker' }) await act(async () => { - await api!.setOption('model', 'gpt-5.5') + await api!.invokeAction('model') }) - expect(dispatchCommand).toHaveBeenCalledWith('/model gpt-5.5') - expect(onAgentPicker).not.toHaveBeenCalled() + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() + }) + + it('types the Codex effort picker command', async () => { + mount({ agent: 'codex', reportedModel: 'gpt-5.5' }) + await act(async () => { + await api!.invokeAction('effort') + }) + expect(dispatchCommand).toHaveBeenCalledWith('/model', { delivery: 'type' }) + expect(onAgentPicker).toHaveBeenCalledOnce() }) it('seeds the current model from a hook-reported provider model', () => { diff --git a/mobile/src/session/use-mobile-native-chat-session-options.ts b/mobile/src/session/use-mobile-native-chat-session-options.ts index 09779756631..66a929aeb56 100644 --- a/mobile/src/session/use-mobile-native-chat-session-options.ts +++ b/mobile/src/session/use-mobile-native-chat-session-options.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { getAgentSessionOptionCatalog, type AgentSessionOptionCatalog, + type CatalogCommandDelivery, type CatalogModel } from '../../../src/shared/agent-session-option-catalog' import type { @@ -95,7 +96,10 @@ export function useMobileNativeChatSessionOptions(args: { scopeKey: string | null /** Provider model from live agent status, when the hook reported one. */ reportedModel: string | null - dispatchCommand: (command: string) => Promise + dispatchCommand: ( + command: string, + options?: { delivery?: CatalogCommandDelivery } + ) => Promise /** A model change that must happen in the agent's own TUI picker was * dispatched — bring the terminal view forward. */ onAgentPicker?: () => void @@ -297,7 +301,9 @@ export function useMobileNativeChatSessionOptions(args: { ?.options.find((option) => option.id === id)?.apply const midSession = apply?.midSession if (midSession?.kind === 'agent-picker') { - const outcome = await dispatchCommand(midSession.command) + const outcome = midSession.delivery + ? await dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : await dispatchCommand(midSession.command) if (outcome === 'rejected') { return false } diff --git a/mobile/src/source-control/mobile-base-ref-search.test.ts b/mobile/src/source-control/mobile-base-ref-search.test.ts deleted file mode 100644 index b59b4fe8da2..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { mapBaseRefResults } from './mobile-base-ref-search' - -describe('mapBaseRefResults', () => { - it('extracts a clean string list from a well-formed payload', () => { - expect(mapBaseRefResults({ refs: ['main', 'dev'] })).toEqual(['main', 'dev']) - }) - - it('returns [] for malformed payloads and drops bad entries', () => { - expect(mapBaseRefResults(null)).toEqual([]) - expect(mapBaseRefResults({})).toEqual([]) - expect(mapBaseRefResults({ refs: 'x' })).toEqual([]) - expect(mapBaseRefResults({ refs: ['ok', 2, '', null, 'two'] })).toEqual(['ok', 'two']) - }) -}) diff --git a/mobile/src/source-control/mobile-base-ref-search.ts b/mobile/src/source-control/mobile-base-ref-search.ts deleted file mode 100644 index 84a962a41e5..00000000000 --- a/mobile/src/source-control/mobile-base-ref-search.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' -import { mobileRepoSelectorFromWorktreeId } from './mobile-pr-create' - -export function mapBaseRefResults(raw: unknown): string[] { - if (raw === null || typeof raw !== 'object') { - return [] - } - const refs = (raw as { refs?: unknown }).refs - if (!Array.isArray(refs)) { - return [] - } - return refs.filter((r): r is string => typeof r === 'string' && r.length > 0) -} - -export async function searchBaseRefs( - client: Pick, - worktreeId: string, - query: string, - limit = 20 -): Promise { - const response = await client.sendRequest('repo.searchRefs', { - repo: mobileRepoSelectorFromWorktreeId(worktreeId), - query, - limit - }) - if (!response.ok) { - return [] - } - return mapBaseRefResults((response as RpcSuccess).result) -} diff --git a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts b/mobile/src/source-control/mobile-source-control-primary-action-decision.ts deleted file mode 100644 index eb0927bcff6..00000000000 --- a/mobile/src/source-control/mobile-source-control-primary-action-decision.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { MobileGitUpstreamStatus } from './mobile-git-status' - -export type MobileSourceControlPrimaryActionKind = - | 'commit' - | 'stage' - | 'push' - | 'pull' - | 'sync' - | 'publish' - -export type MobileSourceControlRemoteOpKind = - | 'push' - | 'force_push' - | 'pull' - | 'sync' - | 'fetch' - | 'fast_forward' - | 'publish' - | 'rebase' - -export type MobileSourceControlPrimaryActionTitleIntent = - | 'commit_in_progress' - | 'force_push_in_progress' - | 'action_in_progress' - | 'remote_operation_in_progress' - | 'remote_operation_blocks_commit' - | 'resolve_conflicts_before_commit' - | 'commit_staged_changes' - | 'enter_commit_message' - | 'stage_all_changes' - | 'stage_file_to_commit' - | 'checkout_branch_before_publish' - | 'publish_branch' - | 'force_push_with_lease' - | 'sync_counts' - | 'pull_count' - | 'push_count' - | 'nothing_to_commit_up_to_date' - -export type MobileSourceControlPrimaryActionDecision = { - kind: MobileSourceControlPrimaryActionKind - disabled: boolean - labelIntent: MobileSourceControlPrimaryActionKind | 'force_push' - titleIntent: MobileSourceControlPrimaryActionTitleIntent - count?: number - ahead?: number - behind?: number - upstreamName?: string - requiresForceWithLease?: boolean -} - -export type MobileSourceControlPrimaryActionDecisionInputs = { - stagedCount: number - hasUnstagedChanges: boolean - hasStageableChanges: boolean - hasPartiallyStagedChanges: boolean - hasMessage: boolean - hasUnresolvedConflicts: boolean - isCommitting: boolean - isRemoteOperationActive: boolean - upstreamStatus: MobileGitUpstreamStatus | undefined - inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null - branchCommitsAhead?: number - hasCurrentBranch?: boolean -} - -// Why: Metro cannot load runtime modules from the desktop/root `src/shared` -// tree. Keep this mobile mirror narrow and parity-tested against the shared -// commit-area decision core so the semantic ladder cannot drift silently. -export function resolveMobileSourceControlCommitAreaPrimaryActionDecision( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { - stagedCount, - hasUnstagedChanges, - hasStageableChanges, - hasMessage, - hasUnresolvedConflicts, - isCommitting, - isRemoteOperationActive, - upstreamStatus, - branchCommitsAhead, - hasCurrentBranch = true - } = inputs - - if (isCommitting) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_in_progress', - disabled: true - } - } - - if (isRemoteOperationActive) { - return resolveMobilePrimaryActionDuringRemoteOp(inputs) - } - - if (hasUnresolvedConflicts) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'resolve_conflicts_before_commit', - disabled: true - } - } - - const hasStaged = stagedCount > 0 - - if (hasStaged && hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'commit_staged_changes', - disabled: false - } - } - - if (hasStaged && !hasMessage) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'enter_commit_message', - disabled: true - } - } - - if (!hasStaged && hasStageableChanges) { - return { - kind: 'stage', - labelIntent: 'stage', - titleIntent: 'stage_all_changes', - disabled: false - } - } - - if (!upstreamStatus) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'stage_file_to_commit', - disabled: true - } - } - - if (!upstreamStatus.hasUpstream) { - if (!hasCurrentBranch) { - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: 'checkout_branch_before_publish', - disabled: true - } - } - return { - kind: 'publish', - labelIntent: 'publish', - titleIntent: 'publish_branch', - disabled: false - } - } - - if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) { - if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_with_lease', - disabled: false, - count: branchCommitsAhead, - upstreamName: upstreamStatus.upstreamName, - requiresForceWithLease: true - } - } - return { - kind: 'sync', - labelIntent: 'sync', - titleIntent: 'sync_counts', - disabled: false, - ahead: upstreamStatus.ahead, - behind: upstreamStatus.behind - } - } - - if (upstreamStatus.behind > 0) { - return { - kind: 'pull', - labelIntent: 'pull', - titleIntent: 'pull_count', - disabled: false, - count: upstreamStatus.behind - } - } - - if (upstreamStatus.ahead > 0) { - return { - kind: 'push', - labelIntent: 'push', - titleIntent: 'push_count', - disabled: false, - count: upstreamStatus.ahead - } - } - - return { - kind: 'commit', - labelIntent: 'commit', - titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date', - disabled: true - } -} - -function resolveMobilePrimaryActionDuringRemoteOp( - inputs: MobileSourceControlPrimaryActionDecisionInputs -): MobileSourceControlPrimaryActionDecision { - const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs - const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ - ...inputs, - isRemoteOperationActive: false - }) - const inFlightIsPrimaryKind = - inFlightRemoteOpKind === 'push' || - inFlightRemoteOpKind === 'pull' || - inFlightRemoteOpKind === 'sync' || - inFlightRemoteOpKind === 'publish' - - if (inFlightRemoteOpKind === 'force_push') { - return { - kind: 'push', - labelIntent: 'force_push', - titleIntent: 'force_push_in_progress', - disabled: true, - requiresForceWithLease: true - } - } - - if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) { - return { - kind: inFlightRemoteOpKind, - labelIntent: inFlightRemoteOpKind, - titleIntent: 'action_in_progress', - disabled: true - } - } - - const titleIntent = hasUnresolvedConflicts - ? 'resolve_conflicts_before_commit' - : candidate.kind === 'commit' - ? 'remote_operation_blocks_commit' - : 'remote_operation_in_progress' - - return { - ...candidate, - titleIntent, - disabled: true - } -} - -function shouldForcePushWithLeaseForMobileUpstream( - status: MobileGitUpstreamStatus | undefined -): boolean { - return ( - status?.hasUpstream === true && - status.ahead > 0 && - status.behind > 0 && - status.behindCommitsArePatchEquivalent === true - ) -} diff --git a/mobile/src/source-control/mobile-source-control-primary-action.test.ts b/mobile/src/source-control/mobile-source-control-primary-action.test.ts index 0b127d9bde3..8314ecb2d80 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.test.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.test.ts @@ -1,9 +1,4 @@ import { describe, expect, it, vi } from 'vitest' -import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision' -import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecisionInputs -} from './mobile-source-control-primary-action-decision' import { buildMobileSourceControlPrimaryAction, type MobileSourceControlPrimaryActionArgs, @@ -159,113 +154,3 @@ describe('buildMobileSourceControlPrimaryAction', () => { expect(action.disabled).toBe(false) }) }) - -function decisionInputs( - overrides: Partial = {} -): MobileSourceControlPrimaryActionDecisionInputs { - return { - stagedCount: 0, - hasUnstagedChanges: false, - hasStageableChanges: false, - hasPartiallyStagedChanges: false, - hasMessage: false, - hasUnresolvedConflicts: false, - isCommitting: false, - isRemoteOperationActive: false, - upstreamStatus: undefined, - ...overrides - } -} - -describe('mobile source-control primary action decision parity', () => { - it.each([ - { - name: 'dirty tree stages first', - input: decisionInputs({ - hasUnstagedChanges: true, - hasStageableChanges: true, - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 } - }) - }, - { - name: 'staged message commits', - input: decisionInputs({ stagedCount: 1, hasMessage: true }) - }, - { - name: 'staged without message blocks commit', - input: decisionInputs({ stagedCount: 1, hasMessage: false }) - }, - { - name: 'unresolved conflicts block commit', - input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true }) - }, - { - name: 'unpublished branch publishes', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: true - }) - }, - { - name: 'detached head blocks publish', - input: decisionInputs({ - upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }, - hasCurrentBranch: false - }) - }, - { - name: 'tracked ahead pushes', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } }) - }, - { - name: 'tracked behind pulls', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } }) - }, - { - name: 'tracked diverged syncs', - input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } }) - }, - { - name: 'patch-equivalent diverged force-pushes with lease', - input: decisionInputs({ - branchCommitsAhead: 4, - upstreamStatus: { - hasUpstream: true, - upstreamName: 'origin/feature', - ahead: 10, - behind: 2, - behindCommitsArePatchEquivalent: true - } - }) - }, - { - name: 'in-flight pull mirrors pull', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'pull', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight force push mirrors force push', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'force_push', - upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } - }) - }, - { - name: 'in-flight push blocks committable candidate', - input: decisionInputs({ - isRemoteOperationActive: true, - inFlightRemoteOpKind: 'push', - stagedCount: 1, - hasMessage: true - }) - } - ])('matches the shared commit-area decision for $name', ({ input }) => { - expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual( - resolveSourceControlCommitAreaPrimaryActionDecision(input) - ) - }) -}) diff --git a/mobile/src/source-control/mobile-source-control-primary-action.ts b/mobile/src/source-control/mobile-source-control-primary-action.ts index f62a7770d16..2a993967b52 100644 --- a/mobile/src/source-control/mobile-source-control-primary-action.ts +++ b/mobile/src/source-control/mobile-source-control-primary-action.ts @@ -1,14 +1,17 @@ import { - resolveMobileSourceControlCommitAreaPrimaryActionDecision, - type MobileSourceControlPrimaryActionDecision, - type MobileSourceControlPrimaryActionKind, - type MobileSourceControlRemoteOpKind -} from './mobile-source-control-primary-action-decision' + resolveSourceControlCommitAreaPrimaryActionDecision, + type SourceControlCommitAreaPrimaryActionDecision, + type SourceControlRemoteOpKind +} from '../../../src/shared/source-control-primary-action-decision' import type { MobileGitBranchCompareResult } from './mobile-branch-compare' import type { MobileGitStatusResult } from './mobile-git-status' type GitStep = { method: string; params?: Record } +type MobileSourceControlPrimaryActionKind = SourceControlCommitAreaPrimaryActionDecision['kind'] +type MobileSourceControlPrimaryActionDecision = SourceControlCommitAreaPrimaryActionDecision +type MobileSourceControlRemoteOpKind = SourceControlRemoteOpKind + export type MobileSourceControlPrimaryAction = { kind: MobileSourceControlPrimaryActionKind label: string @@ -44,7 +47,7 @@ export type MobileSourceControlPrimaryActionArgs = { export function buildMobileSourceControlPrimaryAction( args: MobileSourceControlPrimaryActionArgs ): MobileSourceControlPrimaryAction { - const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({ + const decision = resolveSourceControlCommitAreaPrimaryActionDecision({ stagedCount: args.stagedCount, hasUnstagedChanges: args.unstagedCount > 0, hasStageableChanges: args.stageablePaths.length > 0, @@ -167,8 +170,16 @@ function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDe return 'Stage at least one file to commit.' case 'checkout_branch_before_publish': return 'Check out a branch before publishing commits.' + case 'checking_review_status': + return 'Checking review status.' + case 'review_already_merged': + return 'Nothing to commit. The review is already merged.' case 'publish_branch': return 'Publish this branch to origin.' + case 'push_linked_review': + return 'Push updates to the linked review branch.' + case 'linked_review_target_unavailable': + return 'The linked review branch is unavailable.' case 'force_push_with_lease': return 'Force push with lease to update the remote branch.' case 'sync_counts': diff --git a/mobile/src/source-control/pr-compose-validation.test.ts b/mobile/src/source-control/pr-compose-validation.test.ts deleted file mode 100644 index d0f76f55e4b..00000000000 --- a/mobile/src/source-control/pr-compose-validation.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - canSubmitPrCompose, - getPrComposeDisabledReason, - isBaseHeadDistinct -} from './pr-compose-validation' - -describe('isBaseHeadDistinct', () => { - it('is true when base and head differ', () => { - expect(isBaseHeadDistinct('main', 'feature')).toBe(true) - }) - - it('is false when they match after normalization (case-insensitive, prefix-stripped)', () => { - expect(isBaseHeadDistinct('main', 'main')).toBe(false) - expect(isBaseHeadDistinct('refs/heads/main', 'main')).toBe(false) - expect(isBaseHeadDistinct('origin/Main', 'main')).toBe(false) - expect(isBaseHeadDistinct('upstream/main', 'refs/heads/main')).toBe(false) - }) - - it('is false for an empty base', () => { - expect(isBaseHeadDistinct('', 'feature')).toBe(false) - }) -}) - -describe('canSubmitPrCompose', () => { - it('requires a non-empty title and a distinct base', () => { - expect(canSubmitPrCompose('Title', 'main', 'feature')).toBe(true) - expect(canSubmitPrCompose(' ', 'main', 'feature')).toBe(false) - expect(canSubmitPrCompose('Title', 'main', 'main')).toBe(false) - }) -}) - -describe('getPrComposeDisabledReason', () => { - it('returns null when the form can submit', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBeNull() - }) - - it('names the active blocker', () => { - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'feature', - generating: true, - reviewLabel: 'pull request' - }) - ).toBe('Wait for generation to finish.') - expect( - getPrComposeDisabledReason({ - title: '', - base: 'main', - head: 'feature', - generating: false, - reviewLabel: 'merge request' - }) - ).toBe('Enter a merge request title.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: '', - head: 'feature', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Choose a base branch.') - expect( - getPrComposeDisabledReason({ - title: 'Title', - base: 'main', - head: 'main', - generating: false, - reviewLabel: 'pull request' - }) - ).toBe('Base branch must differ from the head branch.') - }) -}) diff --git a/mobile/src/source-control/pr-compose-validation.ts b/mobile/src/source-control/pr-compose-validation.ts deleted file mode 100644 index c04f5c3f5b2..00000000000 --- a/mobile/src/source-control/pr-compose-validation.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Ref normalizers ported from src/shared/hosted-review-refs.ts (value-imported, not -// referenced across the package boundary — Metro's resolver is rooted at mobile/ and -// can't bundle a runtime import from repo-root/src, unlike erased `import type`s). -// Keep in sync with the shared versions so mobile and desktop compare refs identically. -function normalizeHeadRef(ref: string): string { - return ref - .trim() - .replace(/^refs\/heads\//, '') - .replace(/^refs\/remotes\/[^/]+\//, '') -} - -function normalizeBaseRef(ref: string): string { - return normalizeHeadRef(ref).replace(/^(origin|upstream)\//, '') -} - -// Submit-gating for the create-PR composer, matching desktop CreatePullRequestDialog: -// the base ref must be non-empty and must differ from the head branch after ref -// normalization (strip refs/heads, remote prefixes, origin/upstream), case-insensitive. -export function isBaseHeadDistinct(base: string, head: string): boolean { - const b = normalizeBaseRef(base).toLowerCase() - const h = normalizeHeadRef(head).toLowerCase() - return b.length > 0 && b !== h -} - -export function canSubmitPrCompose(title: string, base: string, head: string): boolean { - return title.trim().length > 0 && isBaseHeadDistinct(base, head) -} - -export function getPrComposeDisabledReason({ - title, - base, - head, - generating, - reviewLabel -}: { - title: string - base: string - head: string - generating: boolean - reviewLabel: string -}): string | null { - if (generating) { - return 'Wait for generation to finish.' - } - if (title.trim().length === 0) { - return `Enter a ${reviewLabel} title.` - } - if (base.trim().length === 0) { - return 'Choose a base branch.' - } - if (!isBaseHeadDistinct(base, head)) { - return 'Base branch must differ from the head branch.' - } - return null -} diff --git a/mobile/src/tasks/mobile-github-project-group-sort.ts b/mobile/src/tasks/mobile-github-project-group-sort.ts deleted file mode 100644 index 7b86d3c38c0..00000000000 --- a/mobile/src/tasks/mobile-github-project-group-sort.ts +++ /dev/null @@ -1,261 +0,0 @@ -export type GitHubProjectSortDirection = 'ASC' | 'DESC' - -export type GitHubProjectFieldValue = - | { kind: 'text'; text: string } - | { kind: 'number'; number: number } - | { kind: 'date'; date: string } - | { kind: 'single-select'; optionId: string; name: string } - | { kind: 'iteration'; iterationId: string; title: string } - | { kind: 'labels'; labels: Array<{ name: string }> } - | { kind: 'users'; users: Array<{ login: string }> } - -export type GitHubProjectField = { - id: string - name: string - kind: string - options?: Array<{ id: string }> - iterations?: Array<{ - id: string - title?: string - startDate: string - duration: number - completed: boolean - }> -} - -export type GitHubProjectRow = { - position?: number | null - fieldValuesByFieldId: Record -} - -export type GitHubProjectSort = { - direction: GitHubProjectSortDirection - field: GitHubProjectField -} - -export type GitHubProjectTable = { - selectedView: { - groupByFields: GitHubProjectField[] - sortByFields: GitHubProjectSort[] - } - rows: GitHubProjectRow[] -} - -export type ProjectGroup = { - key: string - label: string - iteration: { - startDate: string - duration: number - completed: boolean - } | null - rows: GitHubProjectRow[] -} - -const EMPTY_GROUP_KEY = '__empty__' -const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER - -function labelForEmpty(field: GitHubProjectField): string { - return `No ${field.name}` -} - -function deriveStringValue(value: GitHubProjectFieldValue): string { - switch (value.kind) { - case 'text': - return value.text - case 'number': - return String(value.number) - case 'date': - return value.date - case 'single-select': - return value.name - case 'iteration': - return value.title - case 'labels': - return value.labels.map((label) => label.name).join(', ') - case 'users': - return value.users.map((user) => user.login).join(', ') - } -} - -function getFieldValueForGrouping( - row: GitHubProjectRow, - field: GitHubProjectField -): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { - const value = row.fieldValuesByFieldId[field.id] - if (!value) { - return { - key: EMPTY_GROUP_KEY, - label: labelForEmpty(field), - orderHint: UNKNOWN_INDEX_SENTINEL, - iteration: null - } - } - if (field.kind === 'iteration' && value.kind === 'iteration') { - const iterations = field.iterations ?? [] - const idx = iterations.findIndex((it) => it.id === value.iterationId) - const meta = iterations.find((it) => it.id === value.iterationId) - return { - key: value.iterationId, - label: value.title || meta?.title || 'Iteration', - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: meta - ? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed } - : null - } - } - if (field.kind === 'single-select' && value.kind === 'single-select') { - const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId) - return { - key: value.optionId, - label: value.name, - orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, - iteration: null - } - } - const label = deriveStringValue(value) - return { key: `raw:${label}`, label, orderHint: 0, iteration: null } -} - -export function groupRows( - table: GitHubProjectTable, - rowsInOrder: GitHubProjectRow[] -): ProjectGroup[] { - const groupField = table.selectedView.groupByFields[0] - if (!groupField) { - return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }] - } - const buckets = new Map< - string, - { - label: string - orderHint: number - iteration: ProjectGroup['iteration'] - rows: GitHubProjectRow[] - } - >() - for (const row of rowsInOrder) { - const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField) - let bucket = buckets.get(key) - if (!bucket) { - bucket = { label, orderHint, iteration, rows: [] } - buckets.set(key, bucket) - } - bucket.rows.push(row) - } - const entries = Array.from(buckets.entries()) - entries.sort((a, b) => { - if (a[0] === EMPTY_GROUP_KEY) { - return 1 - } - if (b[0] === EMPTY_GROUP_KEY) { - return -1 - } - if (groupField.kind === 'iteration' || groupField.kind === 'single-select') { - return a[1].orderHint - b[1].orderHint - } - return a[1].label.localeCompare(b[1].label) - }) - return entries.map(([key, value]) => ({ - key, - label: value.label, - iteration: value.iteration, - rows: value.rows - })) -} - -function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number { - const field = sort.field - const aValue = a.fieldValuesByFieldId[field.id] - const bValue = b.fieldValuesByFieldId[field.id] - if (!aValue && !bValue) { - return 0 - } - if (!aValue) { - return 1 - } - if (!bValue) { - return -1 - } - - let cmp = 0 - if ( - field.kind === 'single-select' && - aValue.kind === 'single-select' && - bValue.kind === 'single-select' - ) { - const options = field.options ?? [] - const aIdx = options.findIndex((option) => option.id === aValue.optionId) - const bIdx = options.findIndex((option) => option.id === bValue.optionId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if ( - field.kind === 'iteration' && - aValue.kind === 'iteration' && - bValue.kind === 'iteration' - ) { - const iterations = field.iterations ?? [] - const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId) - const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId) - cmp = - (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) - } else if (aValue.kind === 'number' && bValue.kind === 'number') { - cmp = aValue.number - bValue.number - } else if (aValue.kind === 'date' && bValue.kind === 'date') { - cmp = aValue.date.localeCompare(bValue.date) - } else if (aValue.kind === 'text' && bValue.kind === 'text') { - cmp = aValue.text.localeCompare(bValue.text) - } else if (aValue.kind === 'users' && bValue.kind === 'users') { - const aLogin = aValue.users[0]?.login ?? '' - const bLogin = bValue.users[0]?.login ?? '' - if (!aLogin && !bLogin) { - cmp = 0 - } else if (!aLogin) { - cmp = 1 - } else if (!bLogin) { - cmp = -1 - } else { - cmp = aLogin.localeCompare(bLogin) - } - } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { - const aName = aValue.labels[0]?.name ?? '' - const bName = bValue.labels[0]?.name ?? '' - if (!aName && !bName) { - cmp = 0 - } else if (!aName) { - cmp = 1 - } else if (!bName) { - cmp = -1 - } else { - cmp = aName.localeCompare(bName) - } - } else { - return 0 - } - return sort.direction === 'DESC' ? -cmp : cmp -} - -export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] { - const sorts = table.selectedView.sortByFields - const out = [...rows] - out.sort((a, b) => { - for (const sort of sorts) { - const cmp = compareSort(a, b, sort) - if (cmp !== 0) { - return cmp - } - } - return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL) - }) - return out -} - -export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean { - const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime() - if (Number.isNaN(start)) { - return false - } - const end = start + iteration.duration * 86_400_000 - const now = Date.now() - return now >= start && now < end -} diff --git a/mobile/src/terminal/terminal-settings-screen-styles.ts b/mobile/src/terminal/terminal-settings-screen-styles.ts new file mode 100644 index 00000000000..a3ff5c8dd6d --- /dev/null +++ b/mobile/src/terminal/terminal-settings-screen-styles.ts @@ -0,0 +1,91 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const terminalSettingsScreenStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase, + paddingHorizontal: spacing.lg, + paddingTop: 0 + }, + topRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: spacing.sm, + marginBottom: spacing.lg + }, + backButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.sm + }, + heading: { + fontSize: 20, + fontWeight: '700', + color: colors.textPrimary + }, + scrollContent: { + paddingBottom: spacing.xl + }, + groupHeading: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + letterSpacing: 0.5, + marginBottom: spacing.xs, + paddingHorizontal: spacing.xs + }, + groupDescription: { + fontSize: typography.bodySize - 1, + color: colors.textSecondary, + lineHeight: 20, + paddingHorizontal: spacing.xs + }, + section: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden' + }, + sectionTopGap: { + marginTop: spacing.sm + }, + inputGroupGap: { + marginTop: spacing.xl + }, + emptyText: { + fontSize: typography.bodySize, + color: colors.textSecondary, + padding: spacing.md + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + 2, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + rowContent: { + flex: 1 + }, + rowLabel: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textPrimary + }, + rowSublabel: { + fontSize: typography.bodySize - 2, + color: colors.textSecondary, + marginTop: 2 + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/transport/pairing-connection-attempt.test.ts b/mobile/src/transport/pairing-connection-attempt.test.ts deleted file mode 100644 index b05867eb7c0..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { startPairingConnectionAttempt } from './pairing-connection-attempt' - -describe('pairing connection attempt cleanup', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('closes the temporary client when the overall pairing timeout fires', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - - expect(attempt.timedOut).toBe(false) - vi.advanceTimersByTime(24_999) - expect(closeClient).not.toHaveBeenCalled() - - vi.advanceTimersByTime(1) - expect(attempt.timedOut).toBe(true) - expect(closeClient).toHaveBeenCalledTimes(1) - - attempt.dispose() - expect(closeClient).toHaveBeenCalledTimes(1) - }) - - it('clears the timeout and closes the temporary client when disposed early', () => { - vi.useFakeTimers() - const closeClient = vi.fn() - - const attempt = startPairingConnectionAttempt({ timeoutMs: 25_000, closeClient }) - attempt.dispose() - - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - - vi.advanceTimersByTime(25_000) - expect(attempt.timedOut).toBe(false) - expect(closeClient).toHaveBeenCalledTimes(1) - }) -}) diff --git a/mobile/src/transport/pairing-connection-attempt.ts b/mobile/src/transport/pairing-connection-attempt.ts deleted file mode 100644 index 0a2d564949a..00000000000 --- a/mobile/src/transport/pairing-connection-attempt.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type PairingConnectionAttempt = { - readonly timedOut: boolean - dispose: () => void -} - -export function startPairingConnectionAttempt({ - timeoutMs, - closeClient -}: { - timeoutMs: number - closeClient: () => void -}): PairingConnectionAttempt { - let disposed = false - let clientClosed = false - let timedOut = false - let timer: ReturnType | null = null - - function closeClientOnce() { - if (clientClosed) { - return - } - clientClosed = true - closeClient() - } - - function dispose() { - if (disposed) { - return - } - disposed = true - if (timer) { - clearTimeout(timer) - timer = null - } - closeClientOnce() - } - - timer = setTimeout(() => { - timer = null - timedOut = true - dispose() - }, timeoutMs) - - return { - get timedOut() { - return timedOut - }, - dispose - } -} diff --git a/native/computer-use-linux/runtime.py b/native/computer-use-linux/runtime.py index baeed544bd6..ac53f6cf65f 100644 --- a/native/computer-use-linux/runtime.py +++ b/native/computer-use-linux/runtime.py @@ -408,8 +408,11 @@ def render_accessibility_tree(root, window_rect, root_path, compact_browser_tabs item = record(node, len(records), path, window_rect) child_items = list(children(node)) role_key = (item["controlType"] or "").lower() - summary_values = text_snippets(node, limit=8, max_depth=4) - generic_summary = " ".join(summary_values) if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"] and len(summary_values) >= 2 and is_plain_text_subtree(node) else None + generic_summary = None + if role_key in {"panel", "filler", "unknown", "section"} and not item["name"] and not item["value"]: + summary_values = text_snippets(node, limit=8, max_depth=4) + if len(summary_values) >= 2 and is_plain_text_subtree(node): + generic_summary = " ".join(summary_values) if should_elide(item, len(child_items), generic_summary): for child_index, child in child_items: walk(child, depth, path + [child_index]) diff --git a/native/computer-use-linux/runtime_render_test.py b/native/computer-use-linux/runtime_render_test.py new file mode 100644 index 00000000000..83a600c7d2d --- /dev/null +++ b/native/computer-use-linux/runtime_render_test.py @@ -0,0 +1,198 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +class FakeStateType: + PROTECTED = 1 + SELECTED = 2 + + +class FakeText: + @staticmethod + def get_character_count(node): + return len(node.value) + + @staticmethod + def get_text(node, start, end): + return node.value[start:end] + + +def load_runtime(): + gi = types.ModuleType("gi") + repository = types.ModuleType("gi.repository") + gi.require_version = lambda *_: None + repository.Atspi = types.SimpleNamespace(StateType=FakeStateType, Text=FakeText) + repository.Gdk = types.SimpleNamespace() + repository.GdkPixbuf = types.SimpleNamespace() + gi.repository = repository + sys.modules["gi"] = gi + sys.modules["gi.repository"] = repository + + path = Path(__file__).with_name("runtime.py") + spec = importlib.util.spec_from_file_location("orca_linux_runtime_test", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +runtime = load_runtime() + + +class FakeAccessible: + def __init__(self, role, name="", value="", children=(), counter=None): + self.role = role + self.name = name + self.value = value + self.children = list(children) + self.counter = counter or {"child_reads": 0} + for child in self.children: + child.use_counter(self.counter) + + def use_counter(self, counter): + self.counter = counter + for child in self.children: + child.use_counter(counter) + + def get_child_count(self): + self.counter["child_reads"] += 1 + return len(self.children) + + def get_child_at_index(self, index): + self.counter["child_reads"] += 1 + return self.children[index] + + def get_role_name(self): + return self.role + + def get_name(self): + return self.name + + def get_accessible_id(self): + return "" + + def get_toolkit_name(self): + return "fake" + + def get_component_iface(self): + return None + + def get_state_set(self): + return None + + def get_n_actions(self): + return 0 + + def is_text(self): + return bool(self.value) + + def get_text_iface(self): + return self if self.value else None + + def get_value_iface(self): + return None + + +class FailingChildrenAccessible(FakeAccessible): + def get_child_count(self): + raise RuntimeError("defunct node") + + +def render(root): + return runtime.render_accessibility_tree(root, None, [0]) + + +class RuntimeRenderTest(unittest.TestCase): + def test_named_non_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("button", "Save", children=[FakeAccessible("text", "unused")]) + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Save"]) + self.assertEqual(lines, ["0 button Save"]) + self.assertEqual(root.counter["child_reads"], 2) + self.assertFalse(truncation["truncated"]) + + def test_named_generic_node_skips_unused_summary_walk(self): + root = FakeAccessible("section", "Details", children=[FakeAccessible("text", "body")]) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Details", "body"]) + self.assertEqual(lines, ["0 section Details", "\t1 text body"]) + self.assertEqual(root.counter["child_reads"], 3) + + def test_unnamed_generic_node_keeps_plain_text_summary(self): + root = FakeAccessible( + "section", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual(len(records), 1) + self.assertEqual(lines, ["0 section, Text: Alpha Beta"]) + self.assertEqual(root.counter["child_reads"], 13) + + def test_row_keeps_its_specific_summary_walk(self): + root = FakeAccessible( + "row", + "Invoice", + children=[FakeAccessible("text", "Alpha"), FakeAccessible("text", "Beta")], + ) + + records, lines, _ = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice", "Alpha", "Beta"]) + self.assertEqual(lines, ["0 row Invoice, Text: Alpha Beta", "\t1 text Alpha", "\t2 text Beta"]) + self.assertEqual(root.counter["child_reads"], 10) + + def test_empty_generic_wrapper_keeps_elision_path_and_depth(self): + root = FakeAccessible("section", children=[FakeAccessible("button", "Continue")]) + + records, lines, _ = render(root) + + self.assertEqual([record["runtimeId"] for record in records], [[0, 0]]) + self.assertEqual(lines, ["0 button Continue"]) + + def test_child_failure_keeps_fail_soft_row_output(self): + root = FailingChildrenAccessible("row", "Invoice") + + records, lines, truncation = render(root) + + self.assertEqual([record["name"] for record in records], ["Invoice"]) + self.assertEqual(lines, ["0 row Invoice"]) + self.assertFalse(truncation["truncated"]) + + def test_node_limit_keeps_exact_prefix_and_truncation(self): + root = FakeAccessible( + "document", + "Results", + children=[FakeAccessible("text", f"Item {index}") for index in range(runtime.MAX_NODES)], + ) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_NODES) + self.assertEqual(records[-1]["name"], f"Item {runtime.MAX_NODES - 2}") + self.assertTrue(truncation["truncated"]) + self.assertFalse(truncation["maxDepthReached"]) + + def test_depth_limit_keeps_exact_prefix_and_flag(self): + root = FakeAccessible("document", "Depth 65") + for depth in range(runtime.MAX_DEPTH, -1, -1): + root = FakeAccessible("document", f"Depth {depth}", children=[root]) + + records, _, truncation = render(root) + + self.assertEqual(len(records), runtime.MAX_DEPTH + 1) + self.assertEqual(records[-1]["name"], f"Depth {runtime.MAX_DEPTH}") + self.assertTrue(truncation["truncated"]) + self.assertTrue(truncation["maxDepthReached"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/native/computer-use-windows/runtime-render.test.ps1 b/native/computer-use-windows/runtime-render.test.ps1 new file mode 100644 index 00000000000..3e52b1a203e --- /dev/null +++ b/native/computer-use-windows/runtime-render.test.ps1 @@ -0,0 +1,224 @@ +$ErrorActionPreference = "Stop" + +function Assert-TestEqual($Actual, $Expected, [string]$Label) { + if ($Actual -ne $Expected) { + throw "$Label expected '$Expected', received '$Actual'" + } +} + +Add-Type -TypeDefinition @" +using System.Collections; +using System.Collections.Generic; + +public sealed class OrcaRenderTestCounter { + public int FindAll; +} + +public sealed class OrcaRenderTestBounds { + public bool IsEmpty = true; + public double X; + public double Y; + public double Width; + public double Height; +} + +public sealed class OrcaRenderTestControlType { + public string ProgrammaticName = ""; +} + +public sealed class OrcaRenderTestCurrent { + public string AutomationId = ""; + public OrcaRenderTestBounds BoundingRectangle = new OrcaRenderTestBounds(); + public string ClassName = "fake"; + public OrcaRenderTestControlType ControlType = new OrcaRenderTestControlType(); + public bool IsPassword; + public string LocalizedControlType = ""; + public string Name = ""; + public long NativeWindowHandle; +} + +public sealed class OrcaRenderTestPatternCurrent { + public bool IsSelected; + public string Value = ""; +} + +public sealed class OrcaRenderTestPattern { + public OrcaRenderTestPatternCurrent Current = new OrcaRenderTestPatternCurrent(); +} + +public sealed class OrcaRenderTestCollection : IEnumerable { + private readonly OrcaRenderTestElement[] values; + + public OrcaRenderTestCollection(OrcaRenderTestElement[] values) { + this.values = values; + } + + public int Count { + get { return values.Length; } + } + + public OrcaRenderTestElement Item(int index) { + return values[index]; + } + + public IEnumerator GetEnumerator() { + return values.GetEnumerator(); + } +} + +public sealed class OrcaRenderTestElement { + public readonly List Children = + new List(); + public OrcaRenderTestCounter Counter = new OrcaRenderTestCounter(); + public readonly OrcaRenderTestCurrent Current = new OrcaRenderTestCurrent(); + public bool FailFindAll; + public int RuntimeIdValue; + public string ValueText = ""; + + public OrcaRenderTestCollection FindAll(object scope, object condition) { + Counter.FindAll++; + if (FailFindAll) { + throw new System.InvalidOperationException("defunct node"); + } + return new OrcaRenderTestCollection(Children.ToArray()); + } + + public OrcaRenderTestPattern GetCurrentPattern(object pattern) { + OrcaRenderTestPattern result = new OrcaRenderTestPattern(); + result.Current.Value = ValueText; + return result; + } + + public int[] GetRuntimeId() { + return new int[] { RuntimeIdValue }; + } + + public object[] GetSupportedPatterns() { + return new object[0]; + } +} +"@ + +function New-TestCounter { + New-Object -TypeName OrcaRenderTestCounter +} + +function New-TestElement { + param( + [string]$Role, + [string]$Name = "", + [string]$Value = "", + [object[]]$Children = @(), + $Counter = $(New-TestCounter), + [switch]$FailFindAll, + [int]$RuntimeId = 1 + ) + $element = New-Object -TypeName OrcaRenderTestElement + $element.Counter = $Counter + $element.FailFindAll = [bool]$FailFindAll + $element.RuntimeIdValue = $RuntimeId + $element.ValueText = $Value + $element.Current.ControlType.ProgrammaticName = $Role + $element.Current.LocalizedControlType = $Role + $element.Current.Name = $Name + foreach ($child in @($Children)) { + [void]$element.Children.Add($child) + } + $element +} + +$operationPath = Join-Path ([IO.Path]::GetTempPath()) ("orca-runtime-render-test-" + [guid]::NewGuid() + ".json") +try { + Set-Content -LiteralPath $operationPath -Encoding UTF8 -Value '{"tool":"handshake"}' + $runtimeOutput = . (Join-Path $PSScriptRoot "runtime.ps1") -OperationPath $operationPath + $handshake = $runtimeOutput | ConvertFrom-Json + Assert-TestEqual $handshake.ok $true "runtime handshake" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "unused" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "button" -Name "Save" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "named control record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Save" "named control line" + Assert-TestEqual $counter.findAll 1 "named control child enumeration" + + $counter = New-TestCounter + $leaf = New-TestElement -Role "text" -Name "body" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Name "Details" -Children @($leaf) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Details|body" "named generic records" + Assert-TestEqual (@($tree.lines) -join "|") "0 group Details|`t1 text body" "named generic lines" + Assert-TestEqual $counter.findAll 2 "named generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "group" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "anonymous generic record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 group, Text: Alpha Beta" "anonymous generic summary" + Assert-TestEqual $counter.findAll 7 "anonymous generic child enumeration" + + $counter = New-TestCounter + $alpha = New-TestElement -Role "text" -Name "Alpha" -Counter $counter -RuntimeId 2 + $beta = New-TestElement -Role "text" -Name "Beta" -Counter $counter -RuntimeId 3 + $root = New-TestElement -Role "row" -Name "Invoice" -Children @($alpha, $beta) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual (($tree.elements | ForEach-Object { $_.name }) -join "|") "Invoice|Alpha|Beta" "row records" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice, Text: Alpha Beta" "row summary" + Assert-TestEqual $counter.findAll 6 "row child enumeration" + + $counter = New-TestCounter + $button = New-TestElement -Role "button" -Name "Continue" -Counter $counter -RuntimeId 2 + $root = New-TestElement -Role "group" -Children @($button) -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "elided wrapper record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 button Continue" "elided wrapper line" + Assert-TestEqual $counter.findAll 4 "elided wrapper child enumeration" + + $counter = New-TestCounter + $root = New-TestElement -Role "row" -Name "Invoice" -Counter $counter -FailFindAll + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 1 "failed child read record count" + Assert-TestEqual ([string]$tree.lines[0]) "0 row Invoice" "failed child read line" + Assert-TestEqual $counter.findAll 2 "failed child read retries" + + $originalMaxNodes = $MaxNodes + try { + $MaxNodes = 3 + $counter = New-TestCounter + $children = @() + for ($index = 0; $index -lt 3; $index++) { + $children += New-TestElement -Role "text" -Name "Item $index" -Counter $counter -RuntimeId ($index + 2) + } + $root = New-TestElement -Role "document" -Name "Results" -Children $children -Counter $counter + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "node limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Item 1" "node limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "node limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $false "node limit depth flag" + } finally { + $MaxNodes = $originalMaxNodes + } + + $originalMaxDepth = $MaxDepth + try { + $MaxDepth = 2 + $counter = New-TestCounter + $root = New-TestElement -Role "document" -Name "Depth 3" -Counter $counter -RuntimeId 4 + for ($depth = 2; $depth -ge 0; $depth--) { + $root = New-TestElement -Role "document" -Name "Depth $depth" -Children @($root) -Counter $counter -RuntimeId ($depth + 1) + } + $tree = Render-OrcaTree $root $null + Assert-TestEqual $tree.elements.Count 3 "depth limit record count" + Assert-TestEqual ([string]$tree.elements[-1].name) "Depth 2" "depth limit prefix" + Assert-TestEqual $tree.truncation.truncated $true "depth limit truncation" + Assert-TestEqual $tree.truncation.maxDepthReached $true "depth limit flag" + } finally { + $MaxDepth = $originalMaxDepth + } + + Write-Output "windows-snapshot-render-tests-ok" +} finally { + Remove-Item -LiteralPath $operationPath -Force -ErrorAction SilentlyContinue +} diff --git a/native/computer-use-windows/runtime.ps1 b/native/computer-use-windows/runtime.ps1 index 39ab77e1704..2aa581901e6 100644 --- a/native/computer-use-windows/runtime.ps1 +++ b/native/computer-use-windows/runtime.ps1 @@ -561,10 +561,12 @@ function Render-OrcaTree($RootElement, $WindowFrame, [bool]$CompactBrowserTabs = $title = if ([string]::IsNullOrWhiteSpace($record.name)) { $record.automationId } else { $record.name } $role = if ([string]::IsNullOrWhiteSpace($record.localizedControlType)) { $record.controlType } else { $record.localizedControlType } $roleKey = $role.ToLowerInvariant() - $snippets = @(Get-OrcaTextSnippets $Node 8 4) $genericSummary = $null - if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { - $genericSummary = ($snippets -join " ") + if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value)) { + $snippets = @(Get-OrcaTextSnippets $Node 8 4) + if ($snippets.Count -ge 2 -and (Test-OrcaPlainTextSubtree $Node)) { + $genericSummary = ($snippets -join " ") + } } if (($roleKey -in @("pane", "group", "custom", "unknown")) -and [string]::IsNullOrWhiteSpace($title) -and [string]::IsNullOrWhiteSpace($record.value) -and $meaningfulActions.Count -eq 0 -and $null -eq $genericSummary -and $children.Count -le 1) { for ($i = 0; $i -lt $children.Count; $i++) { diff --git a/package.json b/package.json index 06407e81144..83cd7597e5f 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,9 @@ "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/main-thread-jank-bench.mjs", "bench:worktree-deletion": "node tests/tools/benchmarks/worktree-deletion-dev-bench.mjs", "bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs", + "bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs", "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", + "bench:ai-vault-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-ai-vault-typing-bench.mjs", "bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs", "bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs", diff --git a/src/main/agent-hooks/managed-agent-hook-controls.test.ts b/src/main/agent-hooks/managed-agent-hook-controls.test.ts index f87fd8a06dd..60004f94df0 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.test.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.test.ts @@ -7,7 +7,9 @@ const mocks = vi.hoisted(() => ({ removeClaude: vi.fn(), removeCodex: vi.fn(), statusClaude: vi.fn(), - statusCodex: vi.fn() + statusCodex: vi.fn(), + refreshClaude: vi.fn(), + refreshCodex: vi.fn() })) vi.mock('./local-agent-cli-presence', () => ({ @@ -26,6 +28,10 @@ vi.mock('./managed-agent-hook-registry', () => ({ MANAGED_AGENT_HOOK_STATUS_READERS: [ ['claude', mocks.statusClaude], ['codex', mocks.statusCodex] + ], + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: [ + ['claude', mocks.refreshClaude], + ['codex', mocks.refreshCodex] ] })) @@ -51,6 +57,8 @@ describe('managed agent hook controls', () => { mocks.installCodex.mockReturnValue(status('codex', 'installed')) mocks.removeClaude.mockReturnValue(status('claude', 'not_installed')) mocks.removeCodex.mockReturnValue(status('codex', 'not_installed')) + mocks.refreshClaude.mockResolvedValue(undefined) + mocks.refreshCodex.mockResolvedValue(undefined) }) it('installs only agents with positively detected CLIs', async () => { @@ -73,6 +81,78 @@ describe('managed agent hook controls', () => { ]) }) + it('refreshes existing scripts for agents whose CLI is no longer detected', async () => { + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'found' } + }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + // Why (#11549 aftermath): the skipped agent's user-wide config still invokes the + // script, so a stale (leaking) script must not be frozen by the presence gate. + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + expect(mocks.installClaude).not.toHaveBeenCalled() + }) + + it('refreshes existing scripts even when CLI detection rejects', async () => { + mocks.detect.mockRejectedValue(new Error('detection unavailable')) + + await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.refreshClaude).toHaveBeenCalledTimes(1) + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + + it('awaits each script refresh before probing for CLIs', async () => { + let releaseRefresh: (() => void) | undefined + mocks.refreshClaude.mockImplementation( + () => + new Promise((resolve) => { + releaseRefresh = resolve + }) + ) + mocks.detect.mockResolvedValue({ + claude: { state: 'missing' }, + codex: { state: 'missing' } + }) + + const install = installManagedAgentHooks({ agentCmdOverrides: {} }) + await Promise.resolve() + + expect(mocks.detect).not.toHaveBeenCalled() + releaseRefresh?.() + await install + expect(mocks.detect).toHaveBeenCalledTimes(1) + }) + + it('keeps installing when a script refresh throws', async () => { + mocks.refreshClaude.mockRejectedValue(new Error('disk full')) + mocks.detect.mockResolvedValue({ + claude: { state: 'found' }, + codex: { state: 'found' } + }) + + const results = await installManagedAgentHooks({ agentCmdOverrides: {} }) + + expect(mocks.installClaude).toHaveBeenCalledTimes(1) + expect(mocks.installCodex).toHaveBeenCalledTimes(1) + expect(results).toEqual([ + expect.objectContaining({ agent: 'claude', state: 'installed' }), + expect.objectContaining({ agent: 'codex', state: 'installed' }) + ]) + }) + + it('only refreshes scripts for the selected agents', async () => { + mocks.detect.mockResolvedValue({ codex: { state: 'found' } }) + + await installManagedAgentHooks({ agentCmdOverrides: {} }, { agents: ['codex'] }) + + expect(mocks.refreshClaude).not.toHaveBeenCalled() + expect(mocks.refreshCodex).toHaveBeenCalledTimes(1) + }) + it('fails closed when CLI detection rejects', async () => { mocks.detect.mockRejectedValue(new Error('detection unavailable')) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 0e767ee72b0..0de10c6417c 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -9,6 +9,7 @@ import { detectLocalManagedAgentCliPresence } from './local-agent-cli-presence' import { MANAGED_AGENT_HOOK_INSTALLERS, MANAGED_AGENT_HOOK_REMOVERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS, MANAGED_AGENT_HOOK_STATUS_READERS, type ManagedAgentHookInstaller } from './managed-agent-hook-registry' @@ -87,10 +88,29 @@ function runInstaller( } } +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script, but the presence gate below then skips install() forever, freezing the +// script at whatever Orca generated last. Existing scripts are Orca-owned, so bring them +// current before any gating; creating new ones remains install()'s presence-gated job. +async function refreshExistingManagedScripts(options: InstallOptions): Promise { + const allowed = options.agents ? new Set(options.agents) : null + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + if (allowed !== null && !allowed.has(agent)) { + continue + } + try { + await refresh() + } catch (error) { + console.error(`[agent-hooks] Failed to refresh ${agent} managed script:`, error) + } + } +} + export async function installManagedAgentHooks( settings: ManagedHookSettings = null, options: InstallOptions = {} ): Promise { + await refreshExistingManagedScripts(options) const installers = selectedInstallers(options) const disabled = new Set(normalizeDisabledTuiAgents(settings?.disabledTuiAgents)) const enabledInstallers = installers.filter(([agent]) => !disabled.has(agent)) diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 1c17da381ce..4772a763b6c 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -16,6 +16,7 @@ import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus] +export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise] export type ManagedAgentHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] export type ManagedAgentHookStatusReader = readonly [HookInstallAgent, () => AgentHookInstallStatus] @@ -36,6 +37,27 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['kimi', () => kimiHookService.install()] ] +// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a +// user-wide agent config keeps invoking after the CLI falls off PATH. Amp and Hermes write +// provider-native plugin code into their own config dirs with their own install lifecycles, +// not shared launchers, so they are deliberately absent. Enforced by the coverage test in +// managed-hook-script-refresh.test.ts: a new installer that writes a launcher without adding +// a refresher here fails that test. +export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScriptRefresher[] = [ + ['claude', () => claudeHookService.refreshManagedScripts()], + ['openclaude', () => openClaudeHookService.refreshManagedScripts()], + ['codex', () => codexHookService.refreshManagedScripts()], + ['gemini', () => geminiHookService.refreshManagedScripts()], + ['antigravity', () => antigravityHookService.refreshManagedScripts()], + ['cursor', () => cursorHookService.refreshManagedScripts()], + ['droid', () => droidHookService.refreshManagedScripts()], + ['command-code', () => commandCodeHookService.refreshManagedScripts()], + ['grok', () => grokHookService.refreshManagedScripts()], + ['copilot', () => copilotHookService.refreshManagedScripts()], + ['devin', () => devinHookService.refreshManagedScripts()], + ['kimi', () => kimiHookService.refreshManagedScripts()] +] + export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['claude', () => claudeHookService.remove()], ['openclaude', () => openClaudeHookService.remove()], diff --git a/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts new file mode 100644 index 00000000000..e80011d803f --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh-main-thread.test.ts @@ -0,0 +1,101 @@ +import type * as NodeFsModule from 'node:fs' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import type * as NodeFsPromisesModule from 'node:fs/promises' +import { tmpdir } from 'node:os' +import type * as NodeOsModule from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + home: '', + syncCalls: [] as { name: string; target: string }[], + blockAsyncReads: false +})) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + const wrapped: Record = { ...actual } + for (const [name, value] of Object.entries(actual)) { + if (!name.endsWith('Sync') || typeof value !== 'function') { + continue + } + const original = value as ((...args: unknown[]) => unknown) & Record + const recorder = (...args: unknown[]): unknown => { + state.syncCalls.push({ name, target: typeof args[0] === 'string' ? args[0] : '' }) + return original(...args) + } + Object.assign(recorder, original) + wrapped[name] = recorder + } + return { ...wrapped, default: wrapped } +}) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: actual, + readFile: (...args: Parameters) => + state.blockAsyncReads && String(args[0]).startsWith(state.home) + ? new Promise(() => {}) + : actual.readFile(...args) + } +}) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, default: actual, homedir: () => state.home } +}) + +vi.mock('electron', () => ({ app: { getPath: () => '/tmp/orca-user-data' } })) + +import { MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS } from './managed-agent-hook-registry' + +function syncCallsUnderHome(): string[] { + return state.syncCalls + .filter((call) => call.target.startsWith(state.home)) + .map((call) => `${call.name}(${call.target})`) +} + +describe('managed hook script refresh stays off the main thread', () => { + beforeEach(async () => { + state.home = await mkdtemp(join(tmpdir(), 'orca-hook-refresh-main-thread-')) + state.syncCalls = [] + state.blockAsyncReads = false + }) + + afterEach(async () => { + state.blockAsyncReads = false + await rm(state.home, { recursive: true, force: true }) + }) + + it('uses no synchronous HOME filesystem calls for missing or stale scripts', async () => { + const hooksDir = join(state.home, '.orca', 'agent-hooks') + const claudeScript = join( + hooksDir, + process.platform === 'win32' ? 'claude-hook.cmd' : 'claude-hook.sh' + ) + await mkdir(hooksDir, { recursive: true }) + await writeFile(claudeScript, 'stale', 'utf-8') + state.syncCalls = [] + + for (const [, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await refresh() + } + + expect(syncCallsUnderHome()).toEqual([]) + }) + + it('keeps the event loop responsive while a HOME read is stalled', async () => { + state.blockAsyncReads = true + const refresh = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS[0][1]() + let settled = false + void refresh.then(() => { + settled = true + }) + + await new Promise((resolve) => setTimeout(resolve, 5)) + + expect(settled).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.test.ts b/src/main/agent-hooks/managed-hook-script-refresh.test.ts new file mode 100644 index 00000000000..799f8acf58c --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.test.ts @@ -0,0 +1,206 @@ +// Why (#11549 aftermath): a CLI that falls off PATH keeps its user-wide config invoking +// Orca's script while the presence gate skips install() forever. These tests pin the +// repair — existing scripts come current, missing ones are never created. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + existsSync, + chmodSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + utimesSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type * as osModule from 'node:os' + +let isolatedUserDataDir = '' +let previousUserDataPath: string | undefined + +beforeEach(() => { + previousUserDataPath = process.env.ORCA_USER_DATA_PATH + isolatedUserDataDir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-user-data-')) + process.env.ORCA_USER_DATA_PATH = isolatedUserDataDir +}) + +afterEach(() => { + if (previousUserDataPath === undefined) { + delete process.env.ORCA_USER_DATA_PATH + } else { + process.env.ORCA_USER_DATA_PATH = previousUserDataPath + } + rmSync(isolatedUserDataDir, { recursive: true, force: true }) +}) + +const { homedirMock } = vi.hoisted(() => ({ + homedirMock: vi.fn<() => string>() +})) + +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-user-data' + } +})) + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + homedir: homedirMock.mockImplementation(actual.homedir) + } +}) + +import { refreshManagedScriptIfPresent } from './managed-hook-script-refresh' +import { + MANAGED_AGENT_HOOK_INSTALLERS, + MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS +} from './managed-agent-hook-registry' +import { ClaudeHookService } from '../claude/hook-service' + +async function withPlatform(platform: NodeJS.Platform, run: () => T | Promise): Promise { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) + try { + return await run() + } finally { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +const STALE_WINDOWS_HOOK = [ + '@echo off', + 'setlocal', + 'if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin', + ':orca_agent_hook_drain_stdin', + '"%SystemRoot%\\System32\\more.com" >nul 2>nul', + 'exit /b 0', + '' +].join('\r\n') + +describe('refreshManagedScriptIfPresent', () => { + it('rewrites an existing script and refuses to create a missing one', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-unit-')) + try { + const present = join(dir, 'present.cmd') + writeFileSync(present, 'stale') + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(readFileSync(present, 'utf8')).toBe('fresh') + + if (process.platform !== 'win32') { + chmodSync(present, 0o600) + } + const fixedTime = new Date(1_000) + utimesSync(present, fixedTime, fixedTime) + expect(await refreshManagedScriptIfPresent(present, 'fresh')).toBe(true) + expect(statSync(present).mtimeMs).toBe(fixedTime.getTime()) + if (process.platform !== 'win32') { + expect(statSync(present).mode & 0o777).toBe(0o755) + } + + const missing = join(dir, 'missing.cmd') + expect(await refreshManagedScriptIfPresent(missing, 'fresh')).toBe(false) + expect(existsSync(missing)).toBe(false) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + +describe('managed hook script refresh', () => { + it('brings a stale leaking script current without touching agent config', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-')) + homedirMock.mockReturnValue(home) + try { + // Why: the bug population has a script (from a past install) but no reachable + // CLI — and possibly no config dir Orca may create. Seed only the script. + const hooksDir = join(home, '.orca', 'agent-hooks') + mkdirSync(hooksDir, { recursive: true }) + writeFileSync(join(hooksDir, 'claude-hook.cmd'), STALE_WINDOWS_HOOK) + + await withPlatform('win32', () => new ClaudeHookService().refreshManagedScripts()) + + const refreshed = readFileSync(join(hooksDir, 'claude-hook.cmd'), 'utf8') + expect(refreshed).toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0') + expect(refreshed).not.toContain('if "%ORCA_AGENT_HOOK_PORT%"=="" goto') + // Why: refresh must not resurrect config for a CLI the user may have removed. + expect(existsSync(join(home, '.claude'))).toBe(false) + // Why: the statusline script was never installed here, so it must not appear. + expect(existsSync(join(hooksDir, 'claude-statusline.cmd'))).toBe(false) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) + + it('covers every shared launcher script with a refresher', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-coverage-')) + homedirMock.mockReturnValue(home) + const previousGrokHome = process.env.GROK_HOME + const previousKimiHome = process.env.KIMI_CODE_HOME + delete process.env.GROK_HOME + delete process.env.KIMI_CODE_HOME + try { + await withPlatform('win32', () => { + for (const [, install] of MANAGED_AGENT_HOOK_INSTALLERS) { + install() + } + }) + const hooksDir = join(home, '.orca', 'agent-hooks') + const files = readdirSync(hooksDir) + expect(files.length).toBeGreaterThan(0) + const refresherAgents = MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS.map(([agent]) => agent) + // Why: an installer that writes a shared launcher but skips the refresher list would + // recreate the frozen-stale-script class this suite exists to prevent. + for (const file of files) { + expect( + refresherAgents.some((agent) => file.startsWith(`${agent}-`)), + `${file} is written to ~/.orca/agent-hooks but no refresher owns it` + ).toBe(true) + } + // Why: the reverse direction — a refresher naming an agent that writes nothing is a + // stale registry entry, likely a renamed script file. + for (const agent of refresherAgents) { + expect( + files.some((file) => file.startsWith(`${agent}-`)), + `refresher for ${agent} matches no installed script` + ).toBe(true) + } + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + if (previousGrokHome === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previousGrokHome + } + if (previousKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = previousKimiHome + } + rmSync(home, { recursive: true, force: true }) + } + }) + + it('creates nothing anywhere when no managed scripts exist', async () => { + const home = mkdtempSync(join(tmpdir(), 'orca-hook-refresh-empty-')) + homedirMock.mockReturnValue(home) + try { + await withPlatform('win32', async () => { + for (const [agent, refresh] of MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS) { + await expect(refresh(), `${agent} refresh on empty home`).resolves.toBeUndefined() + } + }) + // Why: a refresh pass on a machine with no prior installs must be a strict no-op. + expect(readdirSync(home)).toEqual([]) + } finally { + homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) + rmSync(home, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/managed-hook-script-refresh.ts b/src/main/agent-hooks/managed-hook-script-refresh.ts new file mode 100644 index 00000000000..5842a9dfbfe --- /dev/null +++ b/src/main/agent-hooks/managed-hook-script-refresh.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto' +import { chmod, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { grantDirAclAsync, isPermissionError } from '../win32-utils' + +type ExistingScript = { exists: false } | { exists: true; content: string | null } + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function readExistingScript(scriptPath: string): Promise { + try { + return { exists: true, content: await readFile(scriptPath, 'utf-8') } + } catch (error) { + if (isMissingPathError(error)) { + return { exists: false } + } + try { + await stat(scriptPath) + return { exists: true, content: null } + } catch (statError) { + if (isMissingPathError(statError)) { + return { exists: false } + } + throw error + } + } +} + +async function scriptStillExists(scriptPath: string): Promise { + try { + await stat(scriptPath) + return true + } catch (error) { + if (isMissingPathError(error)) { + return false + } + throw error + } +} + +async function writeScriptWithAclRetry(scriptPath: string, content: string): Promise { + try { + await writeFile(scriptPath, content, 'utf-8') + } catch (error) { + if (isPermissionError(error) && process.platform === 'win32') { + try { + await grantDirAclAsync(dirname(scriptPath)) + await writeFile(scriptPath, content, 'utf-8') + return + } catch { + // Re-throw the original permission error. + } + } + throw error + } +} + +// Why: refresh must not block Electron's main thread or create state for an absent CLI. +export async function refreshManagedScriptIfPresent( + scriptPath: string, + content: string +): Promise { + const existing = await readExistingScript(scriptPath) + if (!existing.exists) { + return false + } + if (existing.content === content) { + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755) + } + return true + } + + const tmpPath = join(dirname(scriptPath), `.${Date.now()}-${randomUUID()}.tmp`) + try { + await writeScriptWithAclRetry(tmpPath, content) + if (process.platform !== 'win32') { + await chmod(tmpPath, 0o755) + } + if (!(await scriptStillExists(scriptPath))) { + return false + } + await rename(tmpPath, scriptPath) + return true + } finally { + await rm(tmpPath, { force: true }).catch(() => undefined) + } +} diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index ad7e81893b3..960bbc5576c 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,8 +1,8 @@ import { join } from 'node:path' import { - resetAiVaultScannerWorkerForTests, - scanAiVaultSessionsInWorker -} from './session-scanner-worker-spawn' + resetAiVaultScannerBackgroundForTests, + scanAiVaultSessionsInBackground +} from './session-scanner-background' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -77,7 +77,7 @@ export async function listAiVaultSessions( start: async (scanSignal) => { const additionalCodexSessionsDirs = sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] - const result = await scanAiVaultSessionsInWorker( + const result = await scanAiVaultSessionsInBackground( { limit: args?.limit, unlimited: args?.unlimited, @@ -142,5 +142,5 @@ export function resetAiVaultSessionListCacheForTests(): void { invalidateAiVaultSessionListCache() scanCoordinator = new AiVaultScanCoordinator() sources = {} - resetAiVaultScannerWorkerForTests() + resetAiVaultScannerBackgroundForTests() } diff --git a/src/main/ai-vault/session-first-user-prompt-handler.ts b/src/main/ai-vault/session-first-user-prompt-handler.ts new file mode 100644 index 00000000000..d20a3508202 --- /dev/null +++ b/src/main/ai-vault/session-first-user-prompt-handler.ts @@ -0,0 +1,20 @@ +import type { + AiVaultFirstUserPromptArgs, + AiVaultFirstUserPromptResult +} from '../../shared/ai-vault-types' +import { readAiVaultFirstUserPromptInBackground } from './session-scanner-background' + +export function handleAiVaultGetFirstUserPrompt( + args?: AiVaultFirstUserPromptArgs +): Promise { + if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') { + return Promise.resolve({ prompt: null }) + } + return readAiVaultFirstUserPromptInBackground({ + agent: args.agent, + filePath: args.filePath, + sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined, + executionHostId: args.executionHostId, + codexHome: args.codexHome + }) +} diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index b656494f61c..f250846a20e 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -1,7 +1,6 @@ import { stat } from 'node:fs/promises' import type { AiVaultAgent, - AiVaultFirstUserPromptArgs, AiVaultFirstUserPromptResult, AiVaultSession } from '../../shared/ai-vault-types' @@ -22,22 +21,6 @@ export type ReadAiVaultFirstUserPromptArgs = { export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult -/** IPC-safe entry: validates untyped payload then reads the full first prompt. */ -export async function handleAiVaultGetFirstUserPrompt( - args?: AiVaultFirstUserPromptArgs -): Promise { - if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') { - return { prompt: null } - } - return readAiVaultFirstUserPrompt({ - agent: args.agent, - filePath: args.filePath, - sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined, - executionHostId: args.executionHostId, - codexHome: args.codexHome - }) -} - /** * Re-parse one session transcript under full first-prompt capture and return * the untruncated first real user ask for copy/reuse. diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 9239d4ccfe2..93e17941ab8 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -86,8 +86,8 @@ export function scheduleSessionParseCachePersist(stats: SessionParseStats): void } } -/** Run any pending debounced save immediately and wait for it. Test-only. */ -export async function flushSessionParseCachePersistForTests(): Promise { +/** Run any pending debounced save immediately and wait for it before process exit. */ +export async function flushSessionParseCachePersist(): Promise { if (saveTimer) { clearTimeout(saveTimer) saveTimer = null @@ -99,6 +99,8 @@ export async function flushSessionParseCachePersistForTests(): Promise { await lastSave } +export const flushSessionParseCachePersistForTests = flushSessionParseCachePersist + async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise { await sweepOrphanedTempFiles(current.filePath) try { diff --git a/src/main/ai-vault/session-scanner-background.test.ts b/src/main/ai-vault/session-scanner-background.test.ts new file mode 100644 index 00000000000..0b0ca493db4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-background.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { shouldUseAiVaultServiceProcess } from './session-scanner-background' + +const originalBackend = process.env.ORCA_AI_VAULT_SERVICE_PROCESS +const originalNodeEnv = process.env.NODE_ENV + +afterEach(() => { + if (originalBackend === undefined) { + delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS + } else { + process.env.ORCA_AI_VAULT_SERVICE_PROCESS = originalBackend + } + process.env.NODE_ENV = originalNodeEnv +}) + +describe('shouldUseAiVaultServiceProcess', () => { + it('keeps unit tests on the worker fallback by default', () => { + delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS + process.env.NODE_ENV = 'test' + expect(shouldUseAiVaultServiceProcess()).toBe(false) + }) + + it('defaults non-test hosts to the service process', () => { + delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS + process.env.NODE_ENV = 'production' + expect(shouldUseAiVaultServiceProcess()).toBe(true) + }) + + it.each([ + ['1', true], + ['0', false] + ] as const)('honors the explicit %s kill switch', (value, expected) => { + process.env.ORCA_AI_VAULT_SERVICE_PROCESS = value + expect(shouldUseAiVaultServiceProcess()).toBe(expected) + }) +}) diff --git a/src/main/ai-vault/session-scanner-background.ts b/src/main/ai-vault/session-scanner-background.ts new file mode 100644 index 00000000000..fb80afa2def --- /dev/null +++ b/src/main/ai-vault/session-scanner-background.ts @@ -0,0 +1,80 @@ +import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import { + readAiVaultFirstUserPrompt, + type ReadAiVaultFirstUserPromptArgs, + type ReadAiVaultFirstUserPromptResult +} from './session-first-user-prompt-read' +import { + invalidateAiVaultServiceCache, + listAiVaultSubagentSessionsInService, + readAiVaultFirstUserPromptInService, + resetAiVaultScannerServiceForTests, + resolveAiVaultSessionTitlesInService, + scanAiVaultSessionsInService +} from './session-scanner-service-spawn' +import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' +import { + resetAiVaultScannerWorkerForTests, + resolveAiVaultSessionTitlesInWorker, + scanAiVaultSessionsInWorker +} from './session-scanner-worker-spawn' +import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' +import { listLocalAiVaultSubagentSessions } from './session-subagent-reader' + +export function shouldUseAiVaultServiceProcess(): boolean { + const configured = process.env.ORCA_AI_VAULT_SERVICE_PROCESS + if (configured === '1') { + return true + } + if (configured === '0') { + return false + } + return process.env.NODE_ENV !== 'test' +} + +export function scanAiVaultSessionsInBackground( + options: AiVaultWorkerScanOptions, + signal?: AbortSignal +): Promise { + return shouldUseAiVaultServiceProcess() + ? scanAiVaultSessionsInService(options, signal) + : scanAiVaultSessionsInWorker(options, signal) +} + +export function resolveAiVaultSessionTitlesInBackground( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal +): Promise { + return shouldUseAiVaultServiceProcess() + ? resolveAiVaultSessionTitlesInService(requests, signal) + : resolveAiVaultSessionTitlesInWorker(requests, signal) +} + +export function listAiVaultSubagentSessionsInBackground( + request: AiVaultServiceSubagentRequest +): Promise { + return shouldUseAiVaultServiceProcess() + ? listAiVaultSubagentSessionsInService(request) + : listLocalAiVaultSubagentSessions(request) +} + +export function readAiVaultFirstUserPromptInBackground( + request: ReadAiVaultFirstUserPromptArgs +): Promise { + return shouldUseAiVaultServiceProcess() + ? readAiVaultFirstUserPromptInService(request) + : readAiVaultFirstUserPrompt(request) +} + +export function invalidateAiVaultBackgroundCache(paths: string[]): Promise { + return shouldUseAiVaultServiceProcess() ? invalidateAiVaultServiceCache(paths) : Promise.resolve() +} + +export function resetAiVaultScannerBackgroundForTests(): void { + resetAiVaultScannerServiceForTests() + resetAiVaultScannerWorkerForTests() +} diff --git a/src/main/ai-vault/session-scanner-service-client-state.ts b/src/main/ai-vault/session-scanner-service-client-state.ts new file mode 100644 index 00000000000..ea56bd534f5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-client-state.ts @@ -0,0 +1,184 @@ +import type { ChildProcess } from 'node:child_process' +import type { + AiVaultServiceInit, + AiVaultServiceLane, + AiVaultServiceRequest +} from './session-scanner-service-protocol' + +export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000 +export const AI_VAULT_SERVICE_SCAN_TIMEOUT_MS = 130_000 +export const AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS = 15_000 +export const AI_VAULT_SERVICE_MAX_CALLS = 16 +export const AI_VAULT_SERVICE_IDLE_TIMEOUT_MS = 10 * 60_000 +export const AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000 + +export type AiVaultServiceProcessFactory = () => ChildProcess +export type AiVaultServiceClientOptions = { + processFactory: AiVaultServiceProcessFactory + init: Omit + idleTimeoutMs?: number + onStderr?: (text: string) => void +} + +export type AiVaultServiceInvalidation = { + resolve: () => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +export class AiVaultServiceInvalidations { + private readonly pending = new Map() + private generation = 0 + + get size(): number { + return this.pending.size + } + + open( + timeoutMs: number, + onTimeout: (generation: number) => void, + send: (generation: number) => void + ): Promise { + const generation = ++this.generation + return new Promise((resolve, reject) => { + const timer = setTimeout(() => onTimeout(generation), timeoutMs) + timer.unref?.() + this.pending.set(generation, { resolve, reject, timer }) + send(generation) + }) + } + + settle(generation: number): boolean { + const entry = this.pending.get(generation) + if (!entry) { + return false + } + clearTimeout(entry.timer) + this.pending.delete(generation) + entry.resolve() + return true + } + + rejectAll(error: Error): void { + for (const entry of this.pending.values()) { + clearTimeout(entry.timer) + entry.reject(error) + } + this.pending.clear() + } +} + +export function createAiVaultServiceReadyWaiter( + timeoutMs: number, + onTimeout: () => void +): AiVaultServiceReadyWaiter { + let resolve!: (child: ChildProcess) => void + let reject!: (error: Error) => void + const promise = new Promise((resolveReady, rejectReady) => { + resolve = resolveReady + reject = rejectReady + }) + const timer = setTimeout(onTimeout, timeoutMs) + timer.unref?.() + return { promise, resolve, reject, timer } +} + +export function retireAiVaultServiceChild(child: ChildProcess): void { + child.removeAllListeners('message') + child.removeAllListeners('disconnect') + child.removeAllListeners('error') + child.removeAllListeners('exit') + const killTimer = setTimeout(() => child.kill(), AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS) + killTimer.unref?.() + child.once('exit', () => clearTimeout(killTimer)) + child.send({ type: 'shutdown' }, () => undefined) + child.unref() +} + +export function armAiVaultServiceCancellationTimeout( + call: AiVaultServicePendingCall, + onExpired: () => void +): void { + if (call.timer) { + clearTimeout(call.timer) + } + call.timer = setTimeout(onExpired, AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS) + call.timer.unref?.() +} + +/** + * A cold start that faults before the request reached the child self-heals on + * the scheduled respawn. Requeue once; the caller rejects when this returns false. + */ +export function requeueAiVaultServiceStart( + call: AiVaultServicePendingCall, + queue: AiVaultServicePendingCall[] +): boolean { + if (call.sent || call.cancelled || call.startRetried) { + return false + } + call.startRetried = true + queue.unshift(call) + return true +} + +export function clearAiVaultServiceCall(call: AiVaultServicePendingCall): void { + if (call.timer) { + clearTimeout(call.timer) + call.timer = null + } + if (call.signal && call.onAbort) { + call.signal.removeEventListener('abort', call.onAbort) + call.onAbort = null + } +} + +export function rejectAiVaultServiceCall(call: AiVaultServicePendingCall, error: Error): void { + clearAiVaultServiceCall(call) + if (!call.cancelled) { + call.reject(error) + } +} + +export type AiVaultServicePendingCall = { + request: AiVaultServiceRequest + lane: AiVaultServiceLane + signal?: AbortSignal + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: NodeJS.Timeout | null + onAbort: (() => void) | null + cancelled: boolean + /** Whether the child received the request; an unsent call gets no reply. */ + sent: boolean + startRetried: boolean +} + +export type AiVaultServiceReadyWaiter = { + promise: Promise + resolve: (child: ChildProcess) => void + reject: (error: Error) => void + timer: NodeJS.Timeout +} + +export class AiVaultServiceIdleRetirement { + private timer: NodeJS.Timeout | null = null + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + schedule(busy: boolean, timeoutMs: number, retire: () => void): void { + if (busy || this.timer) { + return + } + this.timer = setTimeout(() => { + this.timer = null + retire() + }, timeoutMs) + this.timer.unref?.() + } +} diff --git a/src/main/ai-vault/session-scanner-service-client.test.ts b/src/main/ai-vault/session-scanner-service-client.test.ts new file mode 100644 index 00000000000..6dd415cafa5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-client.test.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AiVaultScannerServiceClient } from './session-scanner-service-client' +import { AI_VAULT_SERVICE_READY_TIMEOUT_MS } from './session-scanner-service-client-state' +import { + AiVaultServiceTestChild, + aiVaultServiceRequestId, + readyAiVaultServiceChild +} from './session-scanner-service-test-child' + +function setup(idleTimeoutMs?: number): { + child: AiVaultServiceTestChild + client: AiVaultScannerServiceClient +} { + const child = new AiVaultServiceTestChild() + const client = new AiVaultScannerServiceClient({ + processFactory: () => child.asChildProcess(), + init: { sessionParseCache: null }, + idleTimeoutMs + }) + return { child, client } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('AiVaultScannerServiceClient', () => { + it('waits for ready and runs cache and interactive lanes independently', async () => { + const { child, client } = setup() + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + const subagents = client.request({ + type: 'request', + operation: 'subagents', + request: { agent: 'claude', parentFilePath: '/tmp/parent.jsonl' } + }) + + expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })]) + readyAiVaultServiceChild(child) + await Promise.resolve() + expect(child.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ operation: 'titles' }), + expect.objectContaining({ operation: 'subagents' }) + ]) + ) + + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'subagents'), + operation: 'subagents', + value: { sessions: [], issues: [] } + }) + await expect(titles).resolves.toEqual({ titles: [] }) + await expect(subagents).resolves.toEqual({ sessions: [], issues: [] }) + client.dispose() + }) + + it('bounds active and queued calls together at sixteen', async () => { + const { child, client } = setup() + const calls = Array.from({ length: 16 }, () => + client.request({ type: 'request', operation: 'titles', requests: [] }) + ) + + await expect( + client.request({ type: 'request', operation: 'titles', requests: [] }) + ).rejects.toThrow('queue is full') + readyAiVaultServiceChild(child) + client.dispose() + await Promise.all(calls.map((call) => expect(call).rejects.toThrow('disposed'))) + }) + + it('cancels active work and kills a child that ignores cancellation', async () => { + vi.useFakeTimers() + const { child, client } = setup() + const controller = new AbortController() + const request = client.request( + { type: 'request', operation: 'titles', requests: [] }, + controller.signal + ) + readyAiVaultServiceChild(child) + await Promise.resolve() + const id = aiVaultServiceRequestId(child, 'titles') + + controller.abort() + await expect(request).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.sent).toContainEqual({ type: 'cancel', id }) + vi.advanceTimersByTime(1_999) + expect(child.killed).toBe(false) + vi.advanceTimersByTime(1) + expect(child.killed).toBe(true) + client.dispose() + }) + + it('drops a call cancelled before the child received it', async () => { + vi.useFakeTimers() + const { child, client } = setup() + const controller = new AbortController() + const cancelled = client.request( + { type: 'request', operation: 'titles', requests: [] }, + controller.signal + ) + + controller.abort() + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' })) + + readyAiVaultServiceChild(child) + const next = client.request({ type: 'request', operation: 'titles', requests: [] }) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await expect(next).resolves.toEqual({ titles: [] }) + vi.advanceTimersByTime(2_000) + + expect(child.killed).toBe(false) + client.dispose() + }) + + it('retries once when the first cold start misses the ready deadline', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + expect(children).toHaveLength(1) + + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + expect(children[0]!.killed).toBe(true) + await Promise.resolve() + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + children[1]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[1]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + + await expect(titles).resolves.toEqual({ titles: [] }) + client.dispose() + }) + + it('surfaces the startup error when the retried cold start also fails', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await Promise.resolve() + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + + await expect(titles).rejects.toThrow('did not become ready') + client.dispose() + }) + + it('acknowledges cache invalidation through the running child', async () => { + const { child, client } = setup() + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + readyAiVaultServiceChild(child) + await vi.waitFor(() => + expect(child.sent).toContainEqual({ + type: 'invalidate', + generation: 1, + paths: ['/tmp/deleted.jsonl'] + }) + ) + child.emit('message', { type: 'invalidated', generation: 1 }) + + await expect(invalidation).resolves.toBeUndefined() + client.dispose() + }) + + it('leaves a scanning child alone when cache invalidation is slow to acknowledge', async () => { + vi.useFakeTimers() + const { child, client } = setup() + const scan = client.request({ type: 'request', operation: 'scan', options: {} }) + readyAiVaultServiceChild(child) + await Promise.resolve() + + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + await Promise.resolve() + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + + // The scan owns liveness through its own 130s deadline; killing the child + // here would abort it and burn a slot toward the restart circuit. + await expect(invalidation).resolves.toBeUndefined() + expect(child.killed).toBe(false) + + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'scan'), + operation: 'scan', + value: { result: { sessions: [], issues: [], scannedAt: '2026-08-10' }, durationMs: 1 } + }) + await expect(scan).resolves.toMatchObject({ result: { sessions: [] } }) + client.dispose() + }) + + it('replaces a child that does not acknowledge cache invalidation', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + + await expect(invalidation).rejects.toThrow('cache invalidation timed out') + expect(children[0]!.killed).toBe(true) + const titles = client.request({ type: 'request', operation: 'titles', requests: [] }) + expect(children).toHaveLength(1) + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + children[1]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[1]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + + await expect(titles).resolves.toEqual({ titles: [] }) + client.dispose() + }) + + it('keeps invalidation-only children alive through acknowledgement, then retires them', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(12_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null }, + idleTimeoutMs: 100 + }) + + const first = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + children[0]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[0]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await first + vi.advanceTimersByTime(100) + + const invalidation = client.invalidate(['/tmp/deleted.jsonl']) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + vi.advanceTimersByTime(100) + expect(children[1]!.sent).not.toContainEqual({ type: 'shutdown' }) + children[1]!.emit('message', { type: 'invalidated', generation: 1 }) + await invalidation + vi.advanceTimersByTime(100) + + expect(children[1]!.sent).toContainEqual({ type: 'shutdown' }) + client.dispose() + }) + + it('faults the child on malformed output without affecting later processes', async () => { + const { child, client } = setup() + const request = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(child) + await Promise.resolve() + + child.emit('message', { nope: true }) + + await expect(request).rejects.toThrow('malformed') + expect(child.killed).toBe(true) + client.dispose() + }) + + it('retires an idle child gracefully, then kills it after the shutdown bound', async () => { + vi.useFakeTimers() + const { child, client } = setup(100) + const request = client.request({ type: 'request', operation: 'titles', requests: [] }) + readyAiVaultServiceChild(child) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await request + + vi.advanceTimersByTime(100) + expect(child.sent).toContainEqual({ type: 'shutdown' }) + vi.advanceTimersByTime(2_000) + expect(child.killed).toBe(true) + client.dispose() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts new file mode 100644 index 00000000000..cfcee4c2240 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -0,0 +1,325 @@ +import type { ChildProcess } from 'node:child_process' +import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' +import { + AI_VAULT_SERVICE_IDLE_TIMEOUT_MS, + AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS, + AI_VAULT_SERVICE_MAX_CALLS, + AI_VAULT_SERVICE_READY_TIMEOUT_MS, + AI_VAULT_SERVICE_SCAN_TIMEOUT_MS, + AiVaultServiceIdleRetirement, + AiVaultServiceInvalidations, + armAiVaultServiceCancellationTimeout, + clearAiVaultServiceCall, + createAiVaultServiceReadyWaiter, + rejectAiVaultServiceCall, + requeueAiVaultServiceStart, + retireAiVaultServiceChild, + type AiVaultServiceClientOptions, + type AiVaultServicePendingCall, + type AiVaultServiceReadyWaiter +} from './session-scanner-service-client-state' +import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy' +import { + AI_VAULT_SERVICE_PROTOCOL_VERSION, + aiVaultServiceLane, + isAiVaultServiceChildMessage, + type AiVaultServiceChildMessage, + type AiVaultServiceInit, + type AiVaultServiceRequest, + type AiVaultServiceRequestBody, + type AiVaultServiceResultValue +} from './session-scanner-service-protocol' + +export class AiVaultScannerServiceClient { + private child: ChildProcess | null = null + private readyWaiter: AiVaultServiceReadyWaiter | null = null + private readonly active = new Map() + private readonly queue: AiVaultServicePendingCall[] = [] + private readonly invalidations = new AiVaultServiceInvalidations() + private nextId = 1 + private readonly idleRetirement = new AiVaultServiceIdleRetirement() + private readonly restartPolicy = new AiVaultServiceRestartPolicy() + private disposed = false + + constructor(private readonly options: AiVaultServiceClientOptions) {} + + request(body: AiVaultServiceRequestBody, signal?: AbortSignal): Promise { + if (this.disposed) { + return Promise.reject(new Error('AI Vault service client was disposed.')) + } + if (signal?.aborted) { + return Promise.reject(createAiVaultScanCancelledError()) + } + if (this.queue.length + this.active.size >= AI_VAULT_SERVICE_MAX_CALLS) { + return Promise.reject(new Error('AI Vault service queue is full.')) + } + const request = { ...body, id: this.nextId++ } as AiVaultServiceRequest + return new Promise((resolve, reject) => { + const call: AiVaultServicePendingCall = { + request, + lane: aiVaultServiceLane(request.operation), + signal, + resolve: resolve as (value: unknown) => void, + reject, + timer: null, + onAbort: null, + cancelled: false, + sent: false, + startRetried: false + } + if (signal) { + call.onAbort = () => this.cancel(call) + signal.addEventListener('abort', call.onAbort, { once: true }) + } + this.queue.push(call) + this.idleRetirement.clear() + this.pump() + }) + } + + async invalidate(paths: string[]): Promise { + if (paths.length === 0 || this.disposed) { + return + } + this.idleRetirement.clear() + const child = await this.ensureChild() + return this.invalidations.open( + AI_VAULT_SERVICE_READY_TIMEOUT_MS, + (generation) => this.onInvalidationDeadline(generation), + (generation) => child.send({ type: 'invalidate', generation, paths }) + ) + } + + /** + * The deadline is a startup-sized budget, but a child mid-scan can be slow to + * turn the channel around. Fork IPC ordering already guarantees the child + * applies the invalidation before any request sent after it, so a busy child + * owes nothing here — only an idle one that misses the deadline is wedged. + */ + private onInvalidationDeadline(generation: number): void { + if (this.active.size > 0) { + this.invalidations.settle(generation) + return + } + this.onFault(new Error('AI Vault service cache invalidation timed out.')) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + this.restartPolicy.dispose() + this.idleRetirement.clear() + const error = new Error('AI Vault service client was disposed.') + for (const call of [...this.active.values(), ...this.queue]) { + rejectAiVaultServiceCall(call, error) + } + this.active.clear() + this.queue.length = 0 + this.invalidations.rejectAll(error) + this.retireChild() + } + + private pump(): void { + if (this.restartPolicy.restartScheduled) { + return + } + for (const lane of ['cache', 'interactive'] as const) { + if (this.active.has(lane)) { + continue + } + const index = this.queue.findIndex((call) => call.lane === lane) + if (index < 0) { + continue + } + const call = this.queue.splice(index, 1)[0]! + this.active.set(lane, call) + void this.ensureChild().then( + (child) => this.sendCall(child, call), + (error: Error) => { + if (this.active.get(lane) !== call) { + return + } + this.active.delete(lane) + this.retryStartOrReject(call, error) + this.pump() + } + ) + } + this.scheduleIdleIfNeeded() + } + + private sendCall(child: ChildProcess, call: AiVaultServicePendingCall): void { + if (call.cancelled || this.active.get(call.lane) !== call) { + return + } + const timeoutMs = + call.request.operation === 'scan' + ? AI_VAULT_SERVICE_SCAN_TIMEOUT_MS + : AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS + call.timer = setTimeout(() => { + this.onFault(new Error(`AI Vault service timed out after ${timeoutMs}ms.`)) + }, timeoutMs) + call.timer.unref?.() + call.sent = true + child.send(call.request) + } + + private retryStartOrReject(call: AiVaultServicePendingCall, error: Error): void { + if ( + this.disposed || + !this.restartPolicy.restartScheduled || + !requeueAiVaultServiceStart(call, this.queue) + ) { + rejectAiVaultServiceCall(call, error) + } + } + + private ensureChild(): Promise { + if (this.child && !this.readyWaiter) { + return Promise.resolve(this.child) + } + if (this.readyWaiter) { + return this.readyWaiter.promise + } + const startError = this.restartPolicy.startError() + if (startError) { + return Promise.reject(startError) + } + let child: ChildProcess + try { + child = this.options.processFactory() + } catch (error) { + this.restartPolicy.recordFault(() => this.pump()) + return Promise.reject(error instanceof Error ? error : new Error(String(error))) + } + this.child = child + const waiter = createAiVaultServiceReadyWaiter(AI_VAULT_SERVICE_READY_TIMEOUT_MS, () => + this.onFault(new Error('AI Vault service did not become ready.')) + ) + this.readyWaiter = waiter + child.on('message', (message) => this.onMessage(message)) + child.on('error', (error) => this.onFault(error)) + child.on('disconnect', () => this.onFault(new Error('AI Vault service disconnected.'))) + child.on('exit', (code) => this.onFault(new Error(`AI Vault service exited (${code}).`))) + child.stderr?.on('data', (chunk: Buffer) => this.options.onStderr?.(String(chunk))) + child.send({ + type: 'init', + protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, + ...this.options.init + } satisfies AiVaultServiceInit) + return waiter.promise + } + + private onMessage(raw: unknown): void { + if (!isAiVaultServiceChildMessage(raw)) { + this.onFault(new Error('AI Vault service sent a malformed message.')) + return + } + const message = raw as AiVaultServiceChildMessage + if (message.type === 'ready') { + const waiter = this.readyWaiter + if (!waiter || !this.child) { + return + } + clearTimeout(waiter.timer) + this.readyWaiter = null + waiter.resolve(this.child) + return + } + if (message.type === 'invalidated') { + if (this.invalidations.settle(message.generation)) { + this.scheduleIdleIfNeeded() + } + return + } + const call = [...this.active.values()].find((entry) => entry.request.id === message.id) + if (!call) { + return + } + this.active.delete(call.lane) + clearAiVaultServiceCall(call) + if (!call.cancelled) { + if (message.type === 'error') { + call.reject(new Error(message.message)) + } else { + call.resolve((message as { value: AiVaultServiceResultValue['value'] }).value) + } + } + this.pump() + } + + private cancel(call: AiVaultServicePendingCall): void { + if (call.cancelled) { + return + } + call.cancelled = true + call.reject(createAiVaultScanCancelledError()) + const queuedIndex = this.queue.indexOf(call) + if (queuedIndex >= 0) { + this.queue.splice(queuedIndex, 1) + clearAiVaultServiceCall(call) + this.pump() + return + } + if (this.active.get(call.lane) === call) { + // Why: a call cancelled before it reached the child gets no acknowledgement, + // so waiting on one would kill a healthy service and stall the lane. + if (!call.sent) { + this.active.delete(call.lane) + clearAiVaultServiceCall(call) + this.pump() + return + } + this.child?.send({ type: 'cancel', id: call.request.id }) + armAiVaultServiceCancellationTimeout(call, () => + this.onFault(new Error('AI Vault service did not cancel within 2000ms.')) + ) + } + } + + private onFault(error: Error): void { + const child = this.child + if (!child) { + return + } + this.child = null + child.removeAllListeners() + child.kill() + if (this.readyWaiter) { + clearTimeout(this.readyWaiter.timer) + this.readyWaiter.reject(error) + this.readyWaiter = null + } + // Recorded before the pending calls are settled so retryStartOrReject can see + // whether a respawn is actually coming. + this.restartPolicy.recordFault(() => this.pump()) + const active = [...this.active.values()] + this.active.clear() + for (const call of active) { + this.retryStartOrReject(call, error) + } + this.invalidations.rejectAll(error) + } + + private scheduleIdleIfNeeded(): void { + this.idleRetirement.schedule( + this.active.size > 0 || this.queue.length > 0 || this.invalidations.size > 0 || !this.child, + this.options.idleTimeoutMs ?? AI_VAULT_SERVICE_IDLE_TIMEOUT_MS, + () => this.retireChild() + ) + } + + private retireChild(): void { + this.idleRetirement.clear() + const child = this.child + this.child = null + if (!child) { + return + } + retireAiVaultServiceChild(child) + } +} + +export type { AiVaultServiceProcessFactory } from './session-scanner-service-client-state' diff --git a/src/main/ai-vault/session-scanner-service-entry-path.test.ts b/src/main/ai-vault/session-scanner-service-entry-path.test.ts new file mode 100644 index 00000000000..7723a4f8fe7 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry-path.test.ts @@ -0,0 +1,58 @@ +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + resolveAiVaultServiceEntryPath, + resolveAiVaultServiceEntryPathWithoutApp +} from './session-scanner-service-entry-path' + +describe('resolveAiVaultServiceEntryPath', () => { + it('uses the adjacent electron-vite output when present', () => { + const outMain = join(process.cwd(), 'out', 'main') + const adjacent = join(outMain, 'session-scanner-service-entry.js') + + expect(resolveAiVaultServiceEntryPath(outMain, false, (path) => path === adjacent)).toBe( + adjacent + ) + }) + + it('uses the nested output from a project root', () => { + expect(resolveAiVaultServiceEntryPath(process.cwd(), false, () => false)).toBe( + join(process.cwd(), 'out', 'main', 'session-scanner-service-entry.js') + ) + }) + + it('uses app.asar.unpacked for packaged Electron', () => { + const appPath = join('C:', 'Orca', 'resources', 'app.asar') + + expect(resolveAiVaultServiceEntryPath(appPath, true)).toBe( + join( + 'C:', + 'Orca', + 'resources', + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + ) + }) + + it('uses resourcesPath from packaged Electron-as-Node runtimes', () => { + const resourcesPath = join('Applications', 'Orca.app', 'Contents', 'Resources') + const entry = join( + resourcesPath, + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + + expect( + resolveAiVaultServiceEntryPathWithoutApp( + '/unrelated/cwd', + resourcesPath, + (path) => path === entry + ) + ).toBe(entry) + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-entry-path.ts b/src/main/ai-vault/session-scanner-service-entry-path.ts new file mode 100644 index 00000000000..5f334aa7c44 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry-path.ts @@ -0,0 +1,52 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +type ElectronAppPath = { getAppPath(): string; isPackaged: boolean } + +function loadElectronApp(): ElectronAppPath | null { + try { + return require('electron').app ?? null + } catch { + return null + } +} + +export function resolveAiVaultServiceEntryPath( + appPath: string, + isPackaged: boolean, + pathExists: (candidate: string) => boolean = existsSync +): string { + const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const adjacentEntry = join(basePath, 'session-scanner-service-entry.js') + if (!isPackaged && pathExists(adjacentEntry)) { + return adjacentEntry + } + return join(basePath, 'out', 'main', 'session-scanner-service-entry.js') +} + +export function resolveAiVaultServiceEntryPathWithoutApp( + cwd: string, + resourcesPath: string | undefined, + pathExists: (candidate: string) => boolean = existsSync +): string { + if (resourcesPath) { + const packagedEntry = join( + resourcesPath, + 'app.asar.unpacked', + 'out', + 'main', + 'session-scanner-service-entry.js' + ) + if (pathExists(packagedEntry)) { + return packagedEntry + } + } + return resolveAiVaultServiceEntryPath(cwd, false, pathExists) +} + +export function getAiVaultServiceEntryPath(): string { + const app = loadElectronApp() + return app + ? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged) + : resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath) +} diff --git a/src/main/ai-vault/session-scanner-service-entry.test.ts b/src/main/ai-vault/session-scanner-service-entry.test.ts new file mode 100644 index 00000000000..70dae0d764b --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry.test.ts @@ -0,0 +1,94 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol' + +const invalidateSessionParseCacheEntry = vi.hoisted(() => vi.fn()) +const scanAiVaultSessions = vi.hoisted(() => vi.fn()) + +// Only the invalidation hook is replaced; the title reader shares this module. +vi.mock('./session-scanner-parse-cache', async (importOriginal) => ({ + ...(await importOriginal()), + invalidateSessionParseCacheEntry +})) +vi.mock('./session-scanner', () => ({ scanAiVaultSessions })) +vi.mock('./session-parse-cache-persistence', () => ({ + flushSessionParseCachePersist: vi.fn(() => Promise.resolve()), + initSessionParseCachePersistence: vi.fn() +})) +vi.mock('./session-subagent-reader', () => ({ + listLocalAiVaultSubagentSessions: vi.fn(() => Promise.resolve({ sessions: [], issues: [] })) +})) + +const sent: { type: string; id?: number }[] = [] + +function emit(message: unknown): void { + process.emit('message', message as never, undefined as never) +} + +async function runScan(id: number): Promise { + emit({ type: 'request', id, operation: 'scan', options: {} }) + await vi.waitFor(() => expect(sent.some((message) => message.id === id)).toBe(true)) +} + +function invalidationsFor(path: string): number { + return invalidateSessionParseCacheEntry.mock.calls.filter((call) => call[0] === path).length +} + +describe('AI Vault service entry cache invalidation', () => { + beforeAll(async () => { + process.send = ((message: { type: string; id?: number }) => { + sent.push(message) + return true + }) as typeof process.send + await import('./session-scanner-service-entry') + emit({ type: 'init', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION }) + }) + + beforeEach(() => { + sent.length = 0 + invalidateSessionParseCacheEntry.mockClear() + scanAiVaultSessions.mockResolvedValue({ sessions: [], issues: [], scannedAt: '2026-08-10' }) + }) + + it('re-applies an invalidation once and then stops re-evicting the path', async () => { + emit({ type: 'invalidate', generation: 1, paths: ['/transcripts/a.jsonl'] }) + expect(invalidationsFor('/transcripts/a.jsonl')).toBe(1) + + // The request that overlapped the invalidation still gets the re-apply, so a + // read that started before it cannot leave pre-edit content cached. + await runScan(1) + expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2) + + // Every later request must not keep paying for a consumed invalidation. + await runScan(2) + expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2) + }) + + it('keeps re-applying while another request is still executing', async () => { + let releaseSlowScan: (() => void) | undefined + scanAiVaultSessions.mockImplementationOnce( + () => + new Promise((resolve) => { + releaseSlowScan = () => + resolve({ sessions: [], issues: [], scannedAt: '2026-08-10' } as never) + }) + ) + // 'subagents' runs on the interactive lane, so it overlaps the cache-lane + // scan; 'titles' would not, it shares the cache lane with scans. + emit({ type: 'request', id: 10, operation: 'scan', options: {} }) + await vi.waitFor(() => expect(releaseSlowScan).toBeDefined()) + + emit({ type: 'invalidate', generation: 2, paths: ['/transcripts/b.jsonl'] }) + emit({ type: 'request', id: 11, operation: 'subagents', request: {} }) + await vi.waitFor(() => expect(sent.some((message) => message.id === 11)).toBe(true)) + + // The scan is still reading, so the path stays armed rather than draining. + expect(invalidationsFor('/transcripts/b.jsonl')).toBe(2) + + releaseSlowScan?.() + await vi.waitFor(() => expect(sent.some((message) => message.id === 10)).toBe(true)) + expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3) + + await runScan(12) + expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3) + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-entry.ts b/src/main/ai-vault/session-scanner-service-entry.ts new file mode 100644 index 00000000000..74a5ea9c355 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry.ts @@ -0,0 +1,204 @@ +import type { AiVaultSessionTitle } from '../../shared/ai-vault-session-title' +import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read' +import { + flushSessionParseCachePersist, + initSessionParseCachePersistence +} from './session-parse-cache-persistence' +import { scanAiVaultSessions } from './session-scanner' +import { invalidateSessionParseCacheEntry } from './session-scanner-parse-cache' +import { + AI_VAULT_SERVICE_PROTOCOL_VERSION, + aiVaultServiceLane, + cacheServiceTitle, + isAiVaultServiceRequest, + type AiVaultServiceChildMessage, + type AiVaultServiceParentMessage, + type AiVaultServiceRequest, + type AiVaultServiceResultValue +} from './session-scanner-service-protocol' +import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader' +import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths' +import { listLocalAiVaultSubagentSessions } from './session-subagent-reader' + +if (!process.send) { + throw new Error('AI Vault service requires a parent IPC channel.') +} + +const controllers = new Map() +const cancelled = new Set() +const pending = new Set() +const titleIndex = new Map() +const invalidatedPaths = new Set() +let initialized = false +let shuttingDown = false +let cacheLane = Promise.resolve() +let interactiveLane = Promise.resolve() + +function send(message: AiVaultServiceChildMessage): void { + process.send?.(message) +} + +function titleKey(request: { agent: string; sessionId: string }): string { + return `${request.agent}\0${request.sessionId}` +} + +async function executeRequest(request: AiVaultServiceRequest): Promise { + const controller = new AbortController() + controllers.set(request.id, controller) + try { + if (cancelled.delete(request.id)) { + controller.abort() + } + if (request.operation === 'titles') { + const requests = await resolveHostReadableAiVaultTitleRequests( + request.requests, + controller.signal + ) + return { + operation: 'titles', + value: await readAiVaultSessionTitlesFromFiles(requests, { + signal: controller.signal, + cache: { + get: (entry) => titleIndex.get(titleKey(entry)) ?? null, + set: (title) => cacheServiceTitle(titleIndex, title) + } + }) + } + } + if (request.operation === 'subagents') { + return { + operation: 'subagents', + value: await listLocalAiVaultSubagentSessions(request.request) + } + } + if (request.operation === 'firstPrompt') { + return { + operation: 'firstPrompt', + value: await readAiVaultFirstUserPrompt(request.request) + } + } + const startedAt = performance.now() + const result = await scanAiVaultSessions({ ...request.options, signal: controller.signal }) + for (const session of result.sessions) { + if ((session.agent === 'claude' || session.agent === 'codex') && session.title.trim()) { + cacheServiceTitle(titleIndex, { + agent: session.agent, + sessionId: session.sessionId, + title: session.title.trim() + }) + } + } + return { + operation: 'scan', + value: { result, durationMs: performance.now() - startedAt } + } + } finally { + controllers.delete(request.id) + cancelled.delete(request.id) + for (const path of invalidatedPaths) { + invalidateSessionParseCacheEntry(path) + } + // Why: the re-apply only protects reads that overlapped the invalidation. + // Once nothing else is executing it has done its job, and holding the paths + // would re-evict them on every later request for the life of the process. + if (controllers.size === 0) { + invalidatedPaths.clear() + } + } +} + +async function handleRequest(request: AiVaultServiceRequest): Promise { + try { + const value = await executeRequest(request) + send({ type: 'result', id: request.id, ...value }) + } catch (error) { + send({ + type: 'error', + id: request.id, + message: error instanceof Error ? error.message : String(error), + retryable: true + }) + } finally { + pending.delete(request.id) + } +} + +function queueRequest(request: AiVaultServiceRequest): void { + if (pending.size >= 16) { + send({ + type: 'error', + id: request.id, + message: 'AI Vault service queue is full.', + retryable: true + }) + return + } + pending.add(request.id) + if (aiVaultServiceLane(request.operation) === 'interactive') { + interactiveLane = interactiveLane.then(() => handleRequest(request)) + return + } + cacheLane = cacheLane.then(() => handleRequest(request)) +} + +async function shutdown(): Promise { + if (shuttingDown) { + return + } + shuttingDown = true + for (const controller of controllers.values()) { + controller.abort() + } + await Promise.allSettled([cacheLane, interactiveLane]) + await flushSessionParseCachePersist() + process.disconnect?.() +} + +process.on('message', (raw: AiVaultServiceParentMessage) => { + if (raw?.type === 'init') { + if (initialized || raw.protocol !== AI_VAULT_SERVICE_PROTOCOL_VERSION) { + void shutdown() + return + } + initialized = true + if (raw.sessionParseCache) { + initSessionParseCachePersistence(raw.sessionParseCache) + } + send({ type: 'ready', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, pid: process.pid }) + return + } + if (!initialized || shuttingDown) { + return + } + if (raw?.type === 'cancel') { + cancelled.add(raw.id) + controllers.get(raw.id)?.abort() + return + } + if (raw?.type === 'invalidate') { + for (const path of raw.paths) { + invalidatedPaths.delete(path) + invalidatedPaths.add(path) + invalidateSessionParseCacheEntry(path) + } + while (invalidatedPaths.size > 4_096) { + const oldest = invalidatedPaths.values().next().value + if (oldest === undefined) { + break + } + invalidatedPaths.delete(oldest) + } + titleIndex.clear() + send({ type: 'invalidated', generation: raw.generation }) + return + } + if (raw?.type === 'shutdown') { + void shutdown() + return + } + if (isAiVaultServiceRequest(raw)) { + queueRequest(raw) + } +}) + +process.on('disconnect', () => void shutdown()) diff --git a/src/main/ai-vault/session-scanner-service-env.test.ts b/src/main/ai-vault/session-scanner-service-env.test.ts new file mode 100644 index 00000000000..5d06c0508e8 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-env.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { buildAiVaultServiceEnv, buildRelayAiVaultServiceEnv } from './session-scanner-service-env' + +describe('buildAiVaultServiceEnv', () => { + it('drops Node flag injection variables so the forked heap cap and loader stand', () => { + const env = buildAiVaultServiceEnv( + { + NODE_OPTIONS: '--max-old-space-size=8192 --require=/tmp/evil.js', + NODE_REPL_EXTERNAL_MODULE: '/tmp/evil.js', + NODE_PATH: '/tmp/evil-modules', + PATH: '/usr/bin' + }, + 'linux' + ) + + expect(env.NODE_OPTIONS).toBeUndefined() + expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined() + expect(env.NODE_PATH).toBeUndefined() + expect(env.PATH).toBe('/usr/bin') + }) + + it('drops an unrecognised variable rather than carrying a shell-exported secret', () => { + const env = buildAiVaultServiceEnv( + { AWS_SECRET_ACCESS_KEY: 'shhh', HOME: '/home/dev' }, + 'linux' + ) + + expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined() + expect(env.HOME).toBe('/home/dev') + }) + + it('keeps the agent-home variables the scanner discovers sessions through', () => { + const env = buildAiVaultServiceEnv( + { + CODEX_HOME: '/home/dev/.codex', + COPILOT_HOME: '/home/dev/.copilot', + DEVIN_HOME: '/home/dev/.devin', + GROK_HOME: '/home/dev/.grok', + KIMI_CODE_HOME: '/home/dev/.kimi-code', + OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions', + OPENCLAW_STATE_DIR: '/home/dev/.openclaw', + PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions', + PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent', + PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions', + PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions' + }, + 'linux' + ) + + expect(env).toEqual({ + CODEX_HOME: '/home/dev/.codex', + COPILOT_HOME: '/home/dev/.copilot', + DEVIN_HOME: '/home/dev/.devin', + GROK_HOME: '/home/dev/.grok', + KIMI_CODE_HOME: '/home/dev/.kimi-code', + OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions', + OPENCLAW_STATE_DIR: '/home/dev/.openclaw', + PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions', + PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent', + PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions', + PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions', + ELECTRON_RUN_AS_NODE: '1' + }) + }) + + it('keeps the OpenCode data-directory and database overrides', () => { + const env = buildAiVaultServiceEnv( + { XDG_DATA_HOME: '/home/dev/data', OPENCODE_DB: 'opencode-alt.db' }, + 'linux' + ) + + expect(env.XDG_DATA_HOME).toBe('/home/dev/data') + expect(env.OPENCODE_DB).toBe('opencode-alt.db') + }) + + it('runs the forked Electron binary as plain Node', () => { + expect(buildAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBe('1') + }) + + it('ignores a POSIX variable that only matches an allowed name by case', () => { + expect(buildAiVaultServiceEnv({ codex_home: '/tmp/spoof' }, 'linux')).toEqual({ + ELECTRON_RUN_AS_NODE: '1' + }) + }) + + it('resolves a lowercased Windows variable the OS would still honour', () => { + const env = buildAiVaultServiceEnv({ codex_home: 'C:\\codex', Path: 'C:\\bin' }, 'win32') + + expect(env.CODEX_HOME).toBe('C:\\codex') + expect(env.PATH).toBe('C:\\bin') + }) + + it('spells SystemRoot the way Windows Node expects', () => { + expect(buildAiVaultServiceEnv({ SystemRoot: 'C:\\Windows' }, 'win32').SystemRoot).toBe( + 'C:\\Windows' + ) + }) + + it('does not mutate the caller environment', () => { + const baseEnv = { NODE_OPTIONS: '--inspect', HOME: '/home/dev' } + buildAiVaultServiceEnv(baseEnv, 'linux') + + expect(baseEnv.NODE_OPTIONS).toBe('--inspect') + }) +}) + +describe('buildRelayAiVaultServiceEnv', () => { + it('drops Node flag injection variables', () => { + const env = buildRelayAiVaultServiceEnv( + { NODE_OPTIONS: '--max-old-space-size=8192', NODE_PATH: '/tmp/evil', HOME: '/home/ada' }, + 'linux' + ) + + expect(env.NODE_OPTIONS).toBeUndefined() + expect(env.NODE_PATH).toBeUndefined() + expect(env.HOME).toBe('/home/ada') + }) + + // The sidecar takes remoteHome and hostPlatform from its init message, so an + // agent-home override on the remote host is not part of how it finds roots. + it('withholds the agent-home variables the desktop child needs', () => { + const env = buildRelayAiVaultServiceEnv( + { CODEX_HOME: '/remote/.codex', PATH: '/usr/bin' }, + 'linux' + ) + + expect(env.CODEX_HOME).toBeUndefined() + expect(env.PATH).toBe('/usr/bin') + }) + + it('stays plain Node rather than an Electron child', () => { + expect(buildRelayAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBeUndefined() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-env.ts b/src/main/ai-vault/session-scanner-service-env.ts new file mode 100644 index 00000000000..1bfb75344ca --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-env.ts @@ -0,0 +1,98 @@ +/** + * Environments for the AI Vault service children. + * + * Deliberately allowlists, never `...process.env` (the plugin worker takes the + * same stance): the children are forked with a heap cap and no loader, and an + * ambient NODE_OPTIONS would raise the cap or `--require` code straight into + * them. Shell-exported secrets have no business in a transcript reader either. + */ + +// What Node and libuv need to start and resolve a home, temp dir and locale. +const RUNTIME_ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'USERPROFILE', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ', + 'TMPDIR', + 'TEMP', + 'TMP', + // Why: Windows Node/libuv need these to resolve DLLs and the machine root. + 'SYSTEMROOT', + 'SYSTEMDRIVE', + 'WINDIR', + 'COMSPEC', + 'PATHEXT', + 'PROCESSOR_ARCHITECTURE', + 'NUMBER_OF_PROCESSORS' +] as const + +// Why: the desktop child resolves agent roots from its own environment, so +// dropping one hides every session of a user who relocated that agent's home. +const AGENT_ROOT_ENV_ALLOWLIST = [ + 'CODEX_HOME', + 'COPILOT_HOME', + 'DEVIN_HOME', + 'GROK_HOME', + 'KIMI_CODE_HOME', + 'OMP_CODING_AGENT_DIR', + 'OPENCLAW_STATE_DIR', + 'OPENCODE_DB', + 'PI_CODING_AGENT_DIR', + 'PRIME_AGENT_CODING_AGENT_DIR', + 'PRIME_AGENT_CODING_AGENT_SESSION_DIR', + 'PRIME_AGENT_SESSION_DIR', + // Why: OpenCode has no home variable — its store hangs off the XDG data dir, + // so this one is an agent root here rather than generic runtime state. + 'XDG_DATA_HOME' +] as const + +function pickAllowedEnv( + keys: readonly string[], + baseEnv: NodeJS.ProcessEnv, + platform: NodeJS.Platform +): NodeJS.ProcessEnv { + const windowsLookup = new Map() + if (platform === 'win32') { + // Why: Windows resolves env names case-insensitively, so a lowercased + // `codex_home` still reaches the child; folding on POSIX instead would + // promote an attacker-set `path` over the real one. + for (const [key, value] of Object.entries(baseEnv)) { + if (typeof value === 'string') { + windowsLookup.set(key.toUpperCase(), value) + } + } + } + const env: NodeJS.ProcessEnv = {} + for (const key of keys) { + const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key] + if (value !== undefined) { + env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value + } + } + return env +} + +/** Desktop: forked from the Electron binary, so it also needs run-as-node. */ +export function buildAiVaultServiceEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + const env = pickAllowedEnv( + [...RUNTIME_ENV_ALLOWLIST, ...AGENT_ROOT_ENV_ALLOWLIST], + baseEnv, + platform + ) + env.ELECTRON_RUN_AS_NODE = '1' + return env +} + +/** Relay: the sidecar takes every root from its init message, not the environment. */ +export function buildRelayAiVaultServiceEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): NodeJS.ProcessEnv { + return pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform) +} diff --git a/src/main/ai-vault/session-scanner-service-priority.ts b/src/main/ai-vault/session-scanner-service-priority.ts new file mode 100644 index 00000000000..e0a22b340e5 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-priority.ts @@ -0,0 +1,13 @@ +import { constants, setPriority } from 'node:os' + +export function lowerAiVaultServicePriority(pid: number | undefined): boolean { + if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) { + return false + } + try { + setPriority(pid, constants.priority.PRIORITY_BELOW_NORMAL) + return true + } catch { + return false + } +} diff --git a/src/main/ai-vault/session-scanner-service-protocol.ts b/src/main/ai-vault/session-scanner-service-protocol.ts new file mode 100644 index 00000000000..f2842751eb1 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-protocol.ts @@ -0,0 +1,118 @@ +import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import type { + AiVaultSessionTitle, + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import type { ReadAiVaultFirstUserPromptArgs } from './session-first-user-prompt-read' +import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence' +import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' + +export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1 + +export type AiVaultServiceLane = 'cache' | 'interactive' +export type AiVaultServiceOperation = 'scan' | 'titles' | 'subagents' | 'firstPrompt' + +export type AiVaultServiceSubagentRequest = { + agent: 'claude' | 'omp' + parentFilePath: string +} + +export type AiVaultServiceInit = { + type: 'init' + protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION + sessionParseCache: SessionParseCachePersistenceOptions | null +} + +export type AiVaultServiceRequestBody = + | { type: 'request'; operation: 'scan'; options: AiVaultWorkerScanOptions } + | { + type: 'request' + operation: 'titles' + requests: AiVaultSessionTitleRequest[] + } + | { + type: 'request' + operation: 'subagents' + request: AiVaultServiceSubagentRequest + } + | { + type: 'request' + operation: 'firstPrompt' + request: ReadAiVaultFirstUserPromptArgs + } + +export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number } + +export type AiVaultServiceParentMessage = + | AiVaultServiceInit + | AiVaultServiceRequest + | { type: 'cancel'; id: number } + | { type: 'invalidate'; generation: number; paths: string[] } + | { type: 'shutdown' } + +export type AiVaultServiceResultValue = + | { operation: 'scan'; value: { result: AiVaultListResult; durationMs: number } } + | { operation: 'titles'; value: AiVaultSessionTitlesResult } + | { operation: 'subagents'; value: AiVaultSubagentListResult } + | { operation: 'firstPrompt'; value: { prompt: string | null } } + +export type AiVaultServiceChildMessage = + | { + type: 'ready' + protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION + pid: number + } + | ({ type: 'result'; id: number } & AiVaultServiceResultValue) + | { type: 'error'; id: number; message: string; retryable: boolean } + | { type: 'invalidated'; generation: number } + +export function aiVaultServiceLane(operation: AiVaultServiceOperation): AiVaultServiceLane { + return operation === 'subagents' || operation === 'firstPrompt' ? 'interactive' : 'cache' +} + +export function isAiVaultServiceRequest(value: unknown): value is AiVaultServiceRequest { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + return ( + message.type === 'request' && + Number.isSafeInteger(message.id) && + (message.operation === 'scan' || + message.operation === 'titles' || + message.operation === 'subagents' || + message.operation === 'firstPrompt') + ) +} + +export function isAiVaultServiceChildMessage(value: unknown): value is AiVaultServiceChildMessage { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + if (message.type === 'ready') { + return message.protocol === AI_VAULT_SERVICE_PROTOCOL_VERSION && Number.isInteger(message.pid) + } + if (message.type === 'invalidated') { + return Number.isSafeInteger(message.generation) + } + return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id) +} + +export function cacheServiceTitle( + titleIndex: Map, + title: AiVaultSessionTitle, + maxEntries = 4_096 +): void { + const key = `${title.agent}\0${title.sessionId}` + titleIndex.delete(key) + titleIndex.set(key, title) + while (titleIndex.size > maxEntries) { + const oldest = titleIndex.keys().next().value + if (oldest === undefined) { + break + } + titleIndex.delete(oldest) + } +} diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.test.ts b/src/main/ai-vault/session-scanner-service-restart-policy.test.ts new file mode 100644 index 00000000000..98dfadf91e3 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-restart-policy.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy' + +afterEach(() => vi.useRealTimers()) + +describe('AiVaultServiceRestartPolicy', () => { + it('keeps one pending restart when faults overlap', () => { + vi.useFakeTimers() + const policy = new AiVaultServiceRestartPolicy() + const restart = vi.fn() + + policy.recordFault(restart) + policy.recordFault(restart) + vi.advanceTimersByTime(10_000) + + expect(restart).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending restart on dispose after overlapping faults', () => { + vi.useFakeTimers() + const policy = new AiVaultServiceRestartPolicy() + const restart = vi.fn() + + policy.recordFault(restart) + policy.recordFault(restart) + policy.dispose() + vi.advanceTimersByTime(10_000) + + expect(restart).not.toHaveBeenCalled() + expect(policy.restartScheduled).toBe(false) + }) + + it('opens the circuit after three faults inside the window', () => { + let now = 0 + const policy = new AiVaultServiceRestartPolicy(() => now) + + expect(policy.startError()).toBeNull() + for (let index = 0; index < 3; index += 1) { + policy.recordFault(() => undefined) + now += 1_000 + } + + expect(policy.startError()?.message).toContain('circuit is open') + now += 60_000 + expect(policy.startError()).toBeNull() + policy.dispose() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.ts b/src/main/ai-vault/session-scanner-service-restart-policy.ts new file mode 100644 index 00000000000..605e6c9731b --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-restart-policy.ts @@ -0,0 +1,49 @@ +const AI_VAULT_SERVICE_FAULT_WINDOW_MS = 60_000 +const AI_VAULT_SERVICE_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const + +export class AiVaultServiceRestartPolicy { + private faults: number[] = [] + private circuitUntil = 0 + private timer: NodeJS.Timeout | null = null + + constructor(private readonly now: () => number = Date.now) {} + + get restartScheduled(): boolean { + return this.timer !== null + } + + startError(): Error | null { + return this.now() < this.circuitUntil + ? new Error('AI Vault service restart circuit is open.') + : null + } + + recordFault(restart: () => void): void { + const now = this.now() + this.faults = [ + ...this.faults.filter((time) => now - time < AI_VAULT_SERVICE_FAULT_WINDOW_MS), + now + ] + if (this.faults.length >= 3) { + this.circuitUntil = now + AI_VAULT_SERVICE_FAULT_WINDOW_MS + } + const delay = AI_VAULT_SERVICE_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)] + // Why: a second fault before the pending restart fires would otherwise strand + // the old timer, leaving a restart that dispose() can no longer cancel. + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = setTimeout(() => { + this.timer = null + restart() + }, delay) + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} diff --git a/src/main/ai-vault/session-scanner-service-spawn.test.ts b/src/main/ai-vault/session-scanner-service-spawn.test.ts new file mode 100644 index 00000000000..5c64a13ecb4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-spawn.test.ts @@ -0,0 +1,47 @@ +import type { ChildProcess } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const forkMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ existsSync: () => true })) + +const { spawnAiVaultServiceProcess } = await import('./session-scanner-service-spawn') + +function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } { + return forkMock.mock.calls.at(-1)?.[2] ?? {} +} + +describe('spawnAiVaultServiceProcess', () => { + beforeEach(() => { + forkMock.mockReset() + forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess) + }) + + it('keeps NODE_OPTIONS out of the child so the heap cap and loader stand', () => { + vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js') + spawnAiVaultServiceProcess() + const options = forkOptions() + + // Asserted first: omitting `env` entirely inherits everything, and would + // leave the NODE_OPTIONS assertion below passing for the wrong reason. + expect(options.env).toBeDefined() + expect(options.env?.NODE_OPTIONS).toBeUndefined() + expect(options.execArgv).toEqual(['--max-old-space-size=384']) + vi.unstubAllEnvs() + }) + + it('still passes through a relocated agent home', () => { + vi.stubEnv('CODEX_HOME', '/home/dev/elsewhere/.codex') + spawnAiVaultServiceProcess() + + expect(forkOptions().env?.CODEX_HOME).toBe('/home/dev/elsewhere/.codex') + vi.unstubAllEnvs() + }) + + it('runs the forked Electron binary as plain Node', () => { + spawnAiVaultServiceProcess() + + expect(forkOptions().env?.ELECTRON_RUN_AS_NODE).toBe('1') + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts new file mode 100644 index 00000000000..9fd20cfe57e --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -0,0 +1,91 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../../shared/ai-vault-session-title' +import { withSpan } from '../observability/tracer' +import type { + ReadAiVaultFirstUserPromptArgs, + ReadAiVaultFirstUserPromptResult +} from './session-first-user-prompt-read' +import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence' +import { buildAiVaultServiceEnv } from './session-scanner-service-env' +import { AiVaultScannerServiceClient } from './session-scanner-service-client' +import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path' +import { lowerAiVaultServicePriority } from './session-scanner-service-priority' +import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' +import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' + +export function spawnAiVaultServiceProcess(): ChildProcess { + const entryPath = getAiVaultServiceEntryPath() + if (!existsSync(entryPath)) { + throw new Error(`AI Vault service entry not found: ${entryPath}`) + } + const child = fork(entryPath, [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + execArgv: ['--max-old-space-size=384'], + env: buildAiVaultServiceEnv(), + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + lowerAiVaultServicePriority(child.pid) + child.unref() + return child +} + +let sharedClient: AiVaultScannerServiceClient | null = null + +function getSharedClient(): AiVaultScannerServiceClient { + sharedClient ??= new AiVaultScannerServiceClient({ + processFactory: spawnAiVaultServiceProcess, + init: { sessionParseCache: getSessionParseCachePersistenceOptions() }, + onStderr: (text) => console.error('[ai-vault-service]', text.trimEnd()) + }) + return sharedClient +} + +export function scanAiVaultSessionsInService( + options: AiVaultWorkerScanOptions, + signal?: AbortSignal +): Promise { + return withSpan('aiVault.scan.service', async (span) => { + const value = await getSharedClient().request<{ + result: AiVaultListResult + durationMs: number + }>({ type: 'request', operation: 'scan', options }, signal) + span.setAttribute('serviceDurationMs', value.durationMs) + span.setAttribute('sessions', value.result.sessions.length) + return value.result + }) +} + +export function resolveAiVaultSessionTitlesInService( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal +): Promise { + return getSharedClient().request({ type: 'request', operation: 'titles', requests }, signal) +} + +export function listAiVaultSubagentSessionsInService( + request: AiVaultServiceSubagentRequest, + signal?: AbortSignal +): Promise { + return getSharedClient().request({ type: 'request', operation: 'subagents', request }, signal) +} + +export function readAiVaultFirstUserPromptInService( + request: ReadAiVaultFirstUserPromptArgs, + signal?: AbortSignal +): Promise { + return getSharedClient().request({ type: 'request', operation: 'firstPrompt', request }, signal) +} + +export function invalidateAiVaultServiceCache(paths: string[]): Promise { + return sharedClient?.invalidate(paths) ?? Promise.resolve() +} + +export function resetAiVaultScannerServiceForTests(): void { + sharedClient?.dispose() + sharedClient = null +} diff --git a/src/main/ai-vault/session-scanner-service-test-child.ts b/src/main/ai-vault/session-scanner-service-test-child.ts new file mode 100644 index 00000000000..3fa1a84ec02 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-test-child.ts @@ -0,0 +1,52 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' + +export class AiVaultServiceTestChild extends EventEmitter { + readonly sent: unknown[] = [] + readonly stderr = new EventEmitter() + readonly pid: number + killed = false + unrefed = false + + constructor(pid = 12_345) { + super() + this.pid = pid + } + + send(message: unknown, callback?: (error: Error | null) => void): boolean { + this.sent.push(message) + callback?.(null) + return true + } + + kill(): boolean { + this.killed = true + return true + } + + unref(): this { + this.unrefed = true + return this + } + + asChildProcess(): ChildProcess { + return this as unknown as ChildProcess + } +} + +export function readyAiVaultServiceChild(child: AiVaultServiceTestChild): void { + child.emit('message', { type: 'ready', protocol: 1, pid: child.pid }) +} + +export function aiVaultServiceRequestId(child: AiVaultServiceTestChild, operation: string): number { + const request = child.sent.find( + (message) => + typeof message === 'object' && + message !== null && + (message as { operation?: string }).operation === operation + ) as { id?: number } | undefined + if (request?.id === undefined) { + throw new Error(`No ${operation} request was sent.`) + } + return request.id +} diff --git a/src/main/ai-vault/session-subagent-reader.ts b/src/main/ai-vault/session-subagent-reader.ts new file mode 100644 index 00000000000..5921419c54a --- /dev/null +++ b/src/main/ai-vault/session-subagent-reader.ts @@ -0,0 +1,12 @@ +import type { AiVaultSubagentListResult } from '../../shared/ai-vault-types' +import { listClaudeSubagentSessions } from './session-scanner-claude-subagents' +import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing' +import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol' + +export function listLocalAiVaultSubagentSessions( + request: AiVaultServiceSubagentRequest +): Promise { + return request.agent === 'claude' + ? listClaudeSubagentSessions({ parentFilePath: request.parentFilePath }) + : listOmpSubagentSessions({ parentFilePath: request.parentFilePath }) +} diff --git a/src/main/ai-vault/session-title-resolver.ts b/src/main/ai-vault/session-title-resolver.ts index db1881f5ee3..80518000e09 100644 --- a/src/main/ai-vault/session-title-resolver.ts +++ b/src/main/ai-vault/session-title-resolver.ts @@ -5,7 +5,7 @@ import { type AiVaultSessionTitleRequest, type AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' -import { resolveAiVaultSessionTitlesInWorker } from './session-scanner-worker-spawn' +import { resolveAiVaultSessionTitlesInBackground } from './session-scanner-background' const TRANSCRIPT_PATH_MAX_LENGTH = 32_768 @@ -42,5 +42,5 @@ export async function resolveLocalAiVaultSessionTitles( deduped.set(key, normalized) } } - return resolveAiVaultSessionTitlesInWorker([...deduped.values()], signal) + return resolveAiVaultSessionTitlesInBackground([...deduped.values()], signal) } diff --git a/src/main/antigravity/hook-service.ts b/src/main/antigravity/hook-service.ts index e83160a3549..535eb3a3682 100644 --- a/src/main/antigravity/hook-service.ts +++ b/src/main/antigravity/hook-service.ts @@ -25,6 +25,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -296,6 +297,18 @@ function removeInstalledConfig(config: HooksConfig): void { } export class AntigravityHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + if (process.platform === 'win32') { + for (const event of ANTIGRAVITY_EVENTS) { + await refreshManagedScriptIfPresent( + getWindowsWrapperScriptPath(event), + getWindowsWrapperScript(event.eventName) + ) + } + } + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/browser/browser-text-insertion.ts b/src/main/browser/browser-text-insertion.ts index 4d89b3a06fa..6c04fd52ae5 100644 --- a/src/main/browser/browser-text-insertion.ts +++ b/src/main/browser/browser-text-insertion.ts @@ -1,22 +1,10 @@ import { measureClipboardTextByteLength } from '../../shared/clipboard-text' import { yieldToEventLoop } from '../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../shared/utf8-byte-limits' import type { CdpCommandSender } from './snapshot-engine' export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024 -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function splitBrowserTextInsertionChunks( text: string, maxChunkBytes = BROWSER_TEXT_INSERT_CHUNK_BYTES @@ -38,24 +26,11 @@ export function* iterateBrowserTextInsertionChunks( return } - let currentStart = 0 - let currentBytes = 0 - let index = 0 - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextIndex = index + codeUnitLength - const characterBytes = getUtf8ByteLengthForCodePoint(codePoint) - if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) { - yield text.slice(currentStart, index) - currentStart = index - currentBytes = 0 - } - currentBytes += characterBytes - index = nextIndex - } - if (currentStart < text.length) { - yield text.slice(currentStart) + let startIndex = 0 + while (startIndex < text.length) { + const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax) + yield text.slice(startIndex, endIndex) + startIndex = endIndex } } @@ -68,8 +43,7 @@ export async function insertTextThroughCdp( let chunk = chunks.next() while (!chunk.done) { await sender('Input.insertText', { text: chunk.value }) - // Why: browser automation text can be paste-sized; yielding keeps the main - // process responsive between bounded CDP payloads. + // Keep paste-sized insertion from monopolizing the main process. chunk = chunks.next() if (options?.yieldBetweenChunks !== false && !chunk.done) { await yieldToEventLoop() diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 751ee0c0dae..3609399ac33 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,20 +1,12 @@ -import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeUsagePersistedState } from './types' import type * as Scanner from './scanner' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -23,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', async (importOriginal) => ({ ...(await importOriginal()), scanClaudeUsageFiles: vi.fn() @@ -54,12 +23,15 @@ vi.mock('./scanner', async (importOriginal) => ({ import { ClaudeUsageStore, initClaudeUsagePath } from './store' import { scanClaudeUsageFiles } from './scanner' -function createStoreWithState(state: Partial): ClaudeUsageStore { - const store = new ClaudeUsageStore({ +function createBackingStore(): ConstructorParameters[0] { + return { getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + } +} + +function createStoreWithState(state: Partial): ClaudeUsageStore { + const store = new ClaudeUsageStore(createBackingStore()) ;(store as unknown as { state: ClaudeUsagePersistedState }).state = { schemaVersion: 1, @@ -86,11 +58,6 @@ describe('ClaudeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-claude-usage-store-')) getPathMock.mockReturnValue(tempUserData) initClaudeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanClaudeUsageFiles).mockReset() vi.mocked(scanClaudeUsageFiles).mockResolvedValue({ processedFiles: [], @@ -106,6 +73,17 @@ describe('ClaudeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) + it('defaults a null legacy opt-in while invalidating the cache', () => { + writeFileSync( + join(tempUserData, 'orca-claude-usage.json'), + JSON.stringify({ schemaVersion: 4, scanState: { enabled: null } }) + ) + + const store = new ClaudeUsageStore(createBackingStore()) + + expect(store.getScanState().enabled).toBe(false) + }) + it('reports no data for Orca scope when only non-Orca usage exists', async () => { const store = createStoreWithState({ sessions: [ @@ -617,64 +595,7 @@ describe('ClaudeUsageStore', () => { expect(refreshMock).toHaveBeenCalledWith(false) }) - it('persists setEnabled via async durable write without leaving tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: false, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - - await store.setEnabled(true) - - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - const persisted = JSON.parse( - readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8') - ) - expect(persisted.scanState.enabled).toBe(true) - // Pretty-print preserved for human inspection of the analytics cache. - expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: ClaudeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('persists a successful refresh with one full-cache write', async () => { + it('adapts Claude scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -687,25 +608,7 @@ describe('ClaudeUsageStore', () => { await store.refresh(true) - // Why exactly one: scan start used to rewrite the whole 20 MB cache before any result changed. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).scanState - ).toMatchObject({ - lastScanStartedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-09T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-claude-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) + expect(scanClaudeUsageFiles).toHaveBeenCalledWith([], []) + expect(readFileSync(join(tempUserData, 'orca-claude-usage.json'), 'utf-8')).toContain('\n') }) }) diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index 180f7660b6b..66085504089 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store is the single main-process owner for Claude usage persistence, scan gating, and query semantics. Keeping those policy decisions together avoids split-brain range/scope logic across multiple files. */ +/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' -import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { ClaudeUsageBreakdownKind, ClaudeUsageBreakdownRow, ClaudeUsageDailyPoint, ClaudeUsageRange, - ClaudeUsageScanState, ClaudeUsageScope, ClaudeUsageSessionRow, ClaudeUsageSnapshot, @@ -16,16 +13,15 @@ import type { } from '../../shared/claude-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { ClaudeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' // Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older // caches either lack ownership or used narrower keys and can under/over-count // after fork reclaim (#8006). const SCHEMA_VERSION = 5 -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 // Why: capture the path after configureDevUserDataPath() but before app.setName() @@ -130,6 +126,21 @@ function getDefaultState(): ClaudeUsagePersistedState { } } +function normalizePersistedState(state: ClaudeUsagePersistedState): ClaudeUsagePersistedState { + if (state.schemaVersion === SCHEMA_VERSION) { + return state + } + // Scanner changes invalidate totals, but preserving enabled keeps existing tracking on. + const defaults = getDefaultState() + return { + ...defaults, + scanState: { + ...defaults.scanState, + enabled: state.scanState.enabled ?? defaults.scanState.enabled + } + } +} + export function initClaudeUsagePath(): void { _claudeUsageFile = join(app.getPath('userData'), 'orca-claude-usage.json') } @@ -288,120 +299,22 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: ClaudeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class ClaudeUsageStore { - private state: ClaudeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 20 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[claude-usage]', getClaudeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): ClaudeUsagePersistedState { - try { - const usageFile = getClaudeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as ClaudeUsagePersistedState - if (parsed.schemaVersion !== SCHEMA_VERSION) { - // Why: scanner semantics affect persisted totals, so old Claude caches - // must be rebuilt after parser/source changes instead of reused briefly. - // Preserve scanState.enabled so existing users keep tracking on across - // schema bumps; the next refresh will repopulate the analytics. - const defaults = getDefaultState() - return { - ...defaults, - scanState: { - ...defaults.scanState, - enabled: parsed.scanState?.enabled ?? defaults.scanState.enabled - } - } - } - return { - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - } - } catch (error) { - // Why: Claude usage is a local analytics feature, not primary workspace - // state. A corrupt cache should degrade to a fresh rebuild instead of - // preventing Orca from booting, but we leave the file on disk for debugging. - console.error('[claude-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): ClaudeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyClaudeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class ClaudeUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + ClaudeUsagePersistedState, + 'hasAnyClaudeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[claude-usage]', + resolveCacheFile: getClaudeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyClaudeData', + jsonIndent: 2, + scan: scanClaudeUsageFiles + }) } getSnapshot( @@ -419,63 +332,6 @@ export class ClaudeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - // Why: assign scanPromise before any await so concurrent refresh shares one scan. - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await scanClaudeUsageFiles( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -843,7 +699,7 @@ export class ClaudeUsageStore { } private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -856,12 +712,12 @@ export class ClaudeUsageStore { } private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { // Why: daily aggregates use local calendar days, so session filtering has // to use the same conversion or the sessions table/counts can disagree // with the chart around UTC day boundaries. - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -883,10 +739,4 @@ export class ClaudeUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/claude/hook-service.ts b/src/main/claude/hook-service.ts index ba2dfac1351..eb8891a5bf7 100644 --- a/src/main/claude/hook-service.ts +++ b/src/main/claude/hook-service.ts @@ -13,6 +13,7 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { buildPosixHookPayloadCapture, buildWindowsHookEnvironmentGuardLines, @@ -171,6 +172,18 @@ export class ClaudeHookService { return { agent: this.options.agent, state, configPath, managedHooksPresent, detail } } + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent( + getManagedScriptPath(this.options.settings), + getManagedScript('local', { skipWhenDevinImportsClaude: this.options.agent === 'claude' }) + ) + // Why: no agent gate — the statusline script only ever exists for claude, so presence is the gate. + await refreshManagedScriptIfPresent( + getStatusLineScriptPath(this.options.settings), + getManagedStatusLineScript('local') + ) + } + install(): AgentHookInstallStatus { const configPath = getConfigPath(this.options.settings) const scriptPath = getManagedScriptPath(this.options.settings) diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9c17fd21116..180611c1590 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -489,7 +489,7 @@ describe('CliInstaller', () => { const registryReader = new WindowsUserPathRegistryReader({ platform: 'win32', registryLoader: async () => ({ - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, getRegistryKey: () => ({ Path: { name: 'Path', type: 2, value: registryPath } }) diff --git a/src/main/cli/windows-user-path-registry.test.ts b/src/main/cli/windows-user-path-registry.test.ts index 12beca13852..7f919694626 100644 --- a/src/main/cli/windows-user-path-registry.test.ts +++ b/src/main/cli/windows-user-path-registry.test.ts @@ -7,7 +7,7 @@ import { function registryModule(pathValue?: string, type = 2) { return { - HK: { CU: 0x80000001 }, + HK: { CU: 0x80000001, LM: 0x80000002 }, REG: { SZ: 1, EXPAND_SZ: 2 }, getRegistryKey: vi.fn(() => pathValue === undefined ? {} : { Path: { name: 'Path', type, value: pathValue } } diff --git a/src/main/cli/windows-user-path-registry.ts b/src/main/cli/windows-user-path-registry.ts index d3e493fd87d..90e8ba7863e 100644 --- a/src/main/cli/windows-user-path-registry.ts +++ b/src/main/cli/windows-user-path-registry.ts @@ -1,26 +1,17 @@ -import { createRequire } from 'node:module' +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' export type WindowsUserPathReadResult = | { state: 'success'; value: string | null; expandable: boolean } | { state: 'unknown'; detail: string } -type RegistryValue = { - name?: unknown - type?: unknown - value?: unknown -} - -type WindowsRegistryModule = { - HK: { CU: number } - getRegistryKey: ( - root: number, - path: string - ) => Record | null | undefined -} - type WindowsUserPathRegistryReaderOptions = { platform?: NodeJS.Platform - registryLoader?: () => Promise + registryLoader?: () => Promise now?: () => number cacheTtlMs?: number } @@ -28,19 +19,9 @@ type WindowsUserPathRegistryReaderOptions = { const DEFAULT_CACHE_TTL_MS = 1_000 const USER_ENVIRONMENT_KEY = 'Environment' const USER_PATH_VALUE = 'Path' -const REG_SZ = 1 -const REG_EXPAND_SZ = 2 -const requireFromMain = createRequire(__filename) - -async function loadWindowsRegistryModule(): Promise { - // Why: the optional dependency is not present in non-Windows installs, so - // TypeScript and the main bundle must not resolve it eagerly. - return requireFromMain('windows-native-registry') as WindowsRegistryModule -} - export class WindowsUserPathRegistryReader { private readonly platform: NodeJS.Platform - private readonly registryLoader: () => Promise + private readonly registryLoader: () => Promise private readonly now: () => number private readonly cacheTtlMs: number private cached: { readAt: number; result: WindowsUserPathReadResult } | null = null @@ -49,7 +30,7 @@ export class WindowsUserPathRegistryReader { constructor(options: WindowsUserPathRegistryReaderOptions = {}) { this.platform = options.platform ?? process.platform - this.registryLoader = options.registryLoader ?? loadWindowsRegistryModule + this.registryLoader = options.registryLoader ?? (async () => loadWindowsNativeRegistry()) this.now = options.now ?? Date.now this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS } @@ -123,7 +104,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: null, expandable: false } } if ( - (pathEntry.type !== REG_SZ && pathEntry.type !== REG_EXPAND_SZ) || + (pathEntry.type !== WINDOWS_REG_SZ && pathEntry.type !== WINDOWS_REG_EXPAND_SZ) || typeof pathEntry.value !== 'string' ) { return { @@ -134,7 +115,7 @@ export class WindowsUserPathRegistryReader { return { state: 'success', value: pathEntry.value || null, - expandable: pathEntry.type === REG_EXPAND_SZ + expandable: pathEntry.type === WINDOWS_REG_EXPAND_SZ } } catch { return { diff --git a/src/main/codex-usage/store-snapshot.benchmark.test.ts b/src/main/codex-usage/store-snapshot.benchmark.test.ts index 6ecd39963b9..dbd1482f34c 100644 --- a/src/main/codex-usage/store-snapshot.benchmark.test.ts +++ b/src/main/codex-usage/store-snapshot.benchmark.test.ts @@ -16,8 +16,8 @@ vi.mock('electron', () => ({ function createStoreWithState(state: CodexUsagePersistedState): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = state return store diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 83e5a43fabd..dc5d7b9fe56 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -1,25 +1,12 @@ /* eslint-disable max-lines */ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - CodexUsageDailyAggregate, - CodexUsagePersistedFile, - CodexUsagePersistedState, - CodexUsageSession -} from './types' +import type { CodexUsagePersistedState } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -28,29 +15,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanCodexUsageFiles: vi.fn() })) @@ -58,27 +22,7 @@ vi.mock('./scanner', () => ({ import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store' import { scanCodexUsageFiles } from './scanner' -type ScanResult = { - processedFiles: CodexUsagePersistedFile[] - sessions: CodexUsageSession[] - dailyAggregates: CodexUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void - reject: (reason?: unknown) => void -} { - let resolve!: (value: T) => void - let reject!: (reason?: unknown) => void - const promise = new Promise((promiseResolve, promiseReject) => { - resolve = promiseResolve - reject = promiseReject - }) - return { promise, resolve, reject } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedFiles: [], sessions: [], @@ -89,9 +33,8 @@ function createEmptyScanResult(): ScanResult { function createStoreWithState(state: Partial): CodexUsageStore { const store = new CodexUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: CodexUsagePersistedState }).state = { schemaVersion: 1, @@ -118,11 +61,6 @@ describe('CodexUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-')) getPathMock.mockReturnValue(tempUserData) initCodexUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanCodexUsageFiles).mockReset() vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -134,7 +72,7 @@ describe('CodexUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one compact async disk write', async () => { + it('adapts Codex scans to compact cache persistence', async () => { const store = createStoreWithState({ schemaVersion: 5, scanState: { @@ -147,92 +85,9 @@ describe('CodexUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole 60 MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8') + expect(scanCodexUsageFiles).toHaveBeenCalledWith([], []) expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson))) - expect(persistedJson).not.toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - schemaVersion: 5, - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: CodexUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-codex-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => { diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index 10aad99fbbf..c1784f556a1 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -1,14 +1,11 @@ -/* eslint-disable max-lines -- Why: this store owns Codex analytics persistence, scan policy, and renderer query semantics. Keeping them together prevents the Codex range/scope rules from drifting away from the scanner’s event model. */ +/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { CodexUsageBreakdownKind, CodexUsageBreakdownRow, CodexUsageDailyPoint, CodexUsageRange, - CodexUsageScanState, CodexUsageScope, CodexUsageSessionRow, CodexUsageSnapshot, @@ -16,13 +13,12 @@ import type { } from '../../shared/codex-usage-types' import type { AutomationRunUsage } from '../../shared/automations-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { CodexUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000 let _codexUsageFile: string | null = null @@ -308,31 +304,6 @@ function estimateCostUsd( ) } -function getRangeCutoff(range: CodexUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - type ScopedCodexUsageModelRow = { modelKey: string modelLabel: string @@ -345,78 +316,21 @@ type ScopedCodexUsageModelRow = { totalTokens: number } -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - -export class CodexUsageStore { - private state: CodexUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the 60 MB usage JSON must not block the Electron main thread; the writer serializes writes - // and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[codex-usage]', getCodexUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): CodexUsagePersistedState { - try { - const usageFile = getCodexUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as CodexUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[codex-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Compact: this cache reaches 60 MB, and pretty-printing it costs main-thread time per scan. - return this.writer.write(() => JSON.stringify(this.state)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): CodexUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyCodexData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class CodexUsageStore extends UsageProviderStoreLifecycle< + 'processedFiles', + CodexUsagePersistedState, + 'hasAnyCodexData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[codex-usage]', + resolveCacheFile: getCodexUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedFiles', + dataPresenceKey: 'hasAnyCodexData', + scan: codexUsageProvider.scan + }) } getSnapshot( @@ -434,61 +348,6 @@ export class CodexUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole multi-MB cache before a single - // result changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await codexUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint ? this.state.processedFiles : [] - ) - this.state.processedFiles = result.processedFiles - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise { await this.refresh(false) return this.buildSummary(scope, range) @@ -888,7 +747,7 @@ export class CodexUsageStore { } private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((entry) => { if (cutoff && entry.day < cutoff) { return false @@ -901,9 +760,9 @@ export class CodexUsageStore { } private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day) { return false } @@ -975,10 +834,4 @@ export class CodexUsageStore { Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt ) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - return getWorktreeFingerprint(worktreesByRepo) - } } diff --git a/src/main/codex/hook-service.ts b/src/main/codex/hook-service.ts index 1c4052cefca..3bf087b14d9 100644 --- a/src/main/codex/hook-service.ts +++ b/src/main/codex/hook-service.ts @@ -19,6 +19,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { resolveHooksJsonWritePath } from '../agent-hooks/hook-config-write-path' import { writeFileAtomically } from '../codex-accounts/fs-utils' import { @@ -1021,6 +1022,10 @@ function getWslReconciliationKey(runtimeHomePath: string): string { } export class CodexHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + private readonly wslReconciliationGeneration = new Map() private supersedeWslReconciliation(runtimeHomePath: string | null | undefined): number { diff --git a/src/main/command-code/hook-service.ts b/src/main/command-code/hook-service.ts index 2906ffd2da3..1e1ab9e56af 100644 --- a/src/main/command-code/hook-service.ts +++ b/src/main/command-code/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -88,6 +89,10 @@ function buildInstalledConfig( } export class CommandCodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), buildCommandCodeManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/computer/key-mapping.test.ts b/src/main/computer/key-mapping.test.ts deleted file mode 100644 index 6b599cd483c..00000000000 --- a/src/main/computer/key-mapping.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseKey } from './key-mapping' - -describe('parseKey', () => { - it.each([ - ['a', { key: 'a', modifiers: [] }], - ['7', { key: '7', modifiers: [] }], - [';', { key: ';', modifiers: [] }], - ['+', { key: '+', modifiers: [] }], - ['Return', { key: 'Enter', modifiers: [] }], - ['Escape', { key: 'Escape', modifiers: [] }], - ['BackSpace', { key: 'Backspace', modifiers: [] }], - ['Tab', { key: 'Tab', modifiers: [] }], - ['space', { key: 'Space', modifiers: [] }], - ['Page_Up', { key: 'PageUp', modifiers: [] }], - ['Page_Down', { key: 'PageDown', modifiers: [] }], - ['Home', { key: 'Home', modifiers: [] }], - ['End', { key: 'End', modifiers: [] }], - ['Up', { key: 'ArrowUp', modifiers: [] }], - ['Down', { key: 'ArrowDown', modifiers: [] }], - ['Left', { key: 'ArrowLeft', modifiers: [] }], - ['Right', { key: 'ArrowRight', modifiers: [] }], - ['Delete', { key: 'Delete', modifiers: [] }], - ['Insert', { key: 'Insert', modifiers: [] }], - ['F1', { key: 'F1', modifiers: [] }], - ['F24', { key: 'F24', modifiers: [] }], - ['KP_0', { key: 'Numpad0', modifiers: [] }], - ['KP_9', { key: 'Numpad9', modifiers: [] }], - ['KP_Add', { key: 'NumpadAdd', modifiers: [] }], - ['KP_Subtract', { key: 'NumpadSubtract', modifiers: [] }], - ['KP_Multiply', { key: 'NumpadMultiply', modifiers: [] }], - ['KP_Divide', { key: 'NumpadDivide', modifiers: [] }], - ['KP_Enter', { key: 'NumpadEnter', modifiers: [] }] - ])('maps %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it.each([ - ['ctrl+a', { key: 'a', modifiers: ['Ctrl'] }], - ['control+a', { key: 'a', modifiers: ['Ctrl'] }], - ['ctrl+shift+t', { key: 't', modifiers: ['Ctrl', 'Shift'] }], - ['alt+F4', { key: 'F4', modifiers: ['Alt'] }], - ['cmd+a', { key: 'a', modifiers: ['Meta'] }], - ['command+a', { key: 'a', modifiers: ['Meta'] }], - ['CmdOrCtrl+a', { key: 'a', modifiers: [process.platform === 'darwin' ? 'Meta' : 'Ctrl'] }], - ['ctrl++', { key: '+', modifiers: ['Ctrl'] }], - ['ctrl+shift++', { key: '+', modifiers: ['Ctrl', 'Shift'] }], - ['super+Left', { key: 'ArrowLeft', modifiers: ['Meta'] }], - ['win+Right', { key: 'ArrowRight', modifiers: ['Meta'] }] - ])('maps chord %s', (input, expected) => { - expect(parseKey(input)).toEqual(expected) - }) - - it('rejects unknown modifiers and key names', () => { - expect(() => parseKey('hyper+a')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('NotAKey')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - expect(() => parseKey('F25')).toThrow(expect.objectContaining({ code: 'invalid_argument' })) - }) -}) diff --git a/src/main/computer/key-mapping.ts b/src/main/computer/key-mapping.ts deleted file mode 100644 index 4634f241268..00000000000 --- a/src/main/computer/key-mapping.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { RuntimeClientError } from './runtime-client-error' - -export type KeyChord = { - key: string - modifiers: string[] -} - -const MODIFIER_NAMES: Record = { - ctrl: 'Ctrl', - control: 'Ctrl', - shift: 'Shift', - alt: 'Alt', - meta: 'Meta', - super: 'Meta', - cmd: 'Meta', - command: 'Meta', - win: 'Meta' -} - -const PLATFORM_MODIFIER_NAMES: Record string> = { - cmdorctrl: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl'), - commandorcontrol: () => (process.platform === 'darwin' ? 'Meta' : 'Ctrl') -} - -const KEY_NAMES: Record = { - Return: 'Enter', - Enter: 'Enter', - Escape: 'Escape', - Esc: 'Escape', - BackSpace: 'Backspace', - Backspace: 'Backspace', - Tab: 'Tab', - space: 'Space', - Space: 'Space', - Page_Up: 'PageUp', - Page_Down: 'PageDown', - Home: 'Home', - End: 'End', - Up: 'ArrowUp', - Down: 'ArrowDown', - Left: 'ArrowLeft', - Right: 'ArrowRight', - Delete: 'Delete', - Insert: 'Insert', - KP_Add: 'NumpadAdd', - KP_Subtract: 'NumpadSubtract', - KP_Multiply: 'NumpadMultiply', - KP_Divide: 'NumpadDivide', - KP_Enter: 'NumpadEnter' -} - -export function parseKey(input: string): KeyChord { - const parts = splitKeyChord(input) - if (parts.length === 0) { - throw new RuntimeClientError('invalid_argument', 'key must not be empty') - } - - const keyPart = parts.at(-1)! - const modifiers = parts.slice(0, -1).map(parseModifier) - return { - key: parseBaseKey(keyPart), - modifiers: dedupeModifiers(modifiers) - } -} - -function splitKeyChord(input: string): string[] { - const trimmed = input.trim() - // Why: `+` is both the chord separator and a printable key users can press. - if (trimmed === '+') { - return ['+'] - } - if (trimmed.endsWith('+')) { - return [ - ...trimmed - .slice(0, -1) - .split('+') - .map((part) => part.trim()) - .filter(Boolean), - '+' - ] - } - return input - .split('+') - .map((part) => part.trim()) - .filter(Boolean) -} - -function parseModifier(input: string): string { - const platformModifier = PLATFORM_MODIFIER_NAMES[input.toLowerCase()] - if (platformModifier) { - return platformModifier() - } - const modifier = MODIFIER_NAMES[input.toLowerCase()] - if (!modifier) { - throw new RuntimeClientError('invalid_argument', `unknown modifier '${input}'`) - } - return modifier -} - -function parseBaseKey(input: string): string { - const mapped = KEY_NAMES[input] - if (mapped) { - return mapped - } - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(input)) { - return input - } - const keypadDigit = input.match(/^KP_([0-9])$/) - if (keypadDigit) { - return `Numpad${keypadDigit[1]}` - } - if (isPrintableAscii(input)) { - return input - } - throw new RuntimeClientError('invalid_argument', `unknown key '${input}'`) -} - -function isPrintableAscii(input: string): boolean { - if (input.length !== 1) { - return false - } - const code = input.charCodeAt(0) - return code >= 0x20 && code <= 0x7e -} - -function dedupeModifiers(modifiers: string[]): string[] { - return [...new Set(modifiers)] -} diff --git a/src/main/computer/permissions.test.ts b/src/main/computer/permissions.test.ts deleted file mode 100644 index fd94f389b34..00000000000 --- a/src/main/computer/permissions.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - notificationCtorMock, - notificationIsSupportedMock, - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock, - isTrustedAccessibilityClientMock -} = vi.hoisted(() => { - const notificationOnMock = vi.fn() - const notificationRemoveListenerMock = vi.fn() - const notificationShowMock = vi.fn() - const notificationCtorMock = vi.fn(function () { - return { - on: notificationOnMock, - removeListener: notificationRemoveListenerMock, - show: notificationShowMock - } - }) - return { - notificationCtorMock, - notificationIsSupportedMock: vi.fn(() => true), - notificationOnMock, - notificationRemoveListenerMock, - notificationShowMock, - shellOpenExternalMock: vi.fn(), - isTrustedAccessibilityClientMock: vi.fn(() => false) - } -}) - -vi.mock('electron', () => ({ - Notification: Object.assign(notificationCtorMock, { - isSupported: notificationIsSupportedMock - }), - shell: { - openExternal: shellOpenExternalMock - }, - systemPreferences: { - isTrustedAccessibilityClient: isTrustedAccessibilityClientMock - } -})) - -import { notifyPermissionRequired } from './permissions' - -describe('notifyPermissionRequired', () => { - const originalPlatform = process.platform - - beforeEach(() => { - vi.useFakeTimers() - vi.clearAllTimers() - notificationCtorMock.mockClear() - notificationIsSupportedMock.mockReset() - notificationIsSupportedMock.mockReturnValue(true) - notificationOnMock.mockClear() - notificationRemoveListenerMock.mockClear() - notificationShowMock.mockClear() - shellOpenExternalMock.mockClear() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - afterEach(() => { - vi.useRealTimers() - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }) - }) - - function getNotificationEventHandler(eventName: string): (...args: unknown[]) => void { - const call = notificationOnMock.mock.calls.find((c: unknown[]) => c[0] === eventName) - if (!call) { - throw new Error(`Notification ${eventName} handler not registered`) - } - return call[1] as (...args: unknown[]) => void - } - - it('clears the retained notification fallback timer when the notification closes', () => { - notifyPermissionRequired('Enable accessibility') - - expect(notificationShowMock).toHaveBeenCalledTimes(1) - expect(vi.getTimerCount()).toBe(1) - - const closeHandler = getNotificationEventHandler('close') - closeHandler() - - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('close', closeHandler) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) - }) - - it('opens macOS Accessibility settings on click after releasing notification state', () => { - Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) - notifyPermissionRequired('Enable accessibility') - - getNotificationEventHandler('click')() - - expect(shellOpenExternalMock).toHaveBeenCalledWith( - expect.stringContaining('Privacy_Accessibility') - ) - expect(vi.getTimerCount()).toBe(0) - }) - - it('clears retained notification state when native delivery fails', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - notifyPermissionRequired('Enable accessibility') - expect(vi.getTimerCount()).toBe(1) - - const failedHandler = getNotificationEventHandler('failed') - failedHandler({}, 'Application is not code signed') - - expect(warn).toHaveBeenCalledWith( - expect.stringContaining('Accessibility permission notification failed') - ) - expect(vi.getTimerCount()).toBe(0) - expect(notificationRemoveListenerMock).toHaveBeenCalledWith('failed', failedHandler) - } finally { - warn.mockRestore() - } - }) -}) diff --git a/src/main/computer/permissions.ts b/src/main/computer/permissions.ts deleted file mode 100644 index a98f01c3dbc..00000000000 --- a/src/main/computer/permissions.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Notification, shell } from 'electron' - -const ACCESSIBILITY_SETTINGS_URL = - 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility' - -const activePermissionNotifications = new Set() - -/** Surface a notification through Orca's existing notification system (do not duplicate UI). */ -export function notifyPermissionRequired(instructions: string): void { - if (!Notification.isSupported()) { - return - } - - const notification = new Notification({ - title: 'Accessibility permission required', - body: instructions - }) - activePermissionNotifications.add(notification) - - let releaseTimer: ReturnType | null = null - let released = false - function release(): void { - if (released) { - return - } - released = true - activePermissionNotifications.delete(notification) - notification.removeListener('close', release) - notification.removeListener('click', onClick) - notification.removeListener('failed', onFailed) - if (releaseTimer) { - clearTimeout(releaseTimer) - releaseTimer = null - } - } - function onClick(): void { - release() - if (process.platform === 'darwin') { - void shell.openExternal(ACCESSIBILITY_SETTINGS_URL) - } - } - function onFailed(_event: unknown, error?: string): void { - // Why: Electron 42 reports macOS UNNotification delivery failures here, - // most commonly when a local validation build is not code-signed. - console.warn( - `[computer] Accessibility permission notification failed${error ? `: ${error}` : '.'}` - ) - release() - } - notification.on('close', release) - notification.on('click', onClick) - notification.on('failed', onFailed) - releaseTimer = setTimeout(release, 5 * 60 * 1000) - if (typeof releaseTimer.unref === 'function') { - releaseTimer.unref() - } - notification.show() -} diff --git a/src/main/copilot/hook-service.ts b/src/main/copilot/hook-service.ts index 6a8206d0b59..332731fe352 100644 --- a/src/main/copilot/hook-service.ts +++ b/src/main/copilot/hook-service.ts @@ -17,6 +17,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -183,6 +184,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CopilotHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/cursor/hook-service.ts b/src/main/cursor/hook-service.ts index 7b5f3b90dda..63ff9b36a71 100644 --- a/src/main/cursor/hook-service.ts +++ b/src/main/cursor/hook-service.ts @@ -15,6 +15,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -101,6 +102,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class CursorHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/daemon/binary-frame.test.ts b/src/main/daemon/binary-frame.test.ts deleted file mode 100644 index 81148f18a59..00000000000 --- a/src/main/daemon/binary-frame.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { encodeFrame, createFrameParser, FrameType, FRAME_HEADER_SIZE } from './binary-frame' - -describe('encodeFrame', () => { - it('encodes a data frame with correct header', () => { - const payload = Buffer.from('hello') - const frame = encodeFrame(FrameType.Data, payload) - - expect(frame.length).toBe(FRAME_HEADER_SIZE + payload.length) - expect(frame[0]).toBe(FrameType.Data) - expect(frame.readUInt32BE(1)).toBe(payload.length) - expect(frame.subarray(FRAME_HEADER_SIZE).toString()).toBe('hello') - }) - - it('encodes a resize frame', () => { - const payload = Buffer.from(JSON.stringify({ cols: 120, rows: 40 })) - const frame = encodeFrame(FrameType.Resize, payload) - - expect(frame[0]).toBe(FrameType.Resize) - expect(frame.readUInt32BE(1)).toBe(payload.length) - }) - - it('encodes an exit frame with exit code', () => { - const payload = Buffer.from(JSON.stringify({ code: 0 })) - const frame = encodeFrame(FrameType.Exit, payload) - - expect(frame[0]).toBe(FrameType.Exit) - const decoded = JSON.parse(frame.subarray(FRAME_HEADER_SIZE).toString()) - expect(decoded.code).toBe(0) - }) - - it('encodes empty payload', () => { - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - - expect(frame.length).toBe(FRAME_HEADER_SIZE) - expect(frame[0]).toBe(FrameType.Kill) - expect(frame.readUInt32BE(1)).toBe(0) - }) - - it('throws on payload exceeding max size', () => { - const oversized = Buffer.alloc(1024 * 1024 + 1) - expect(() => encodeFrame(FrameType.Data, oversized)).toThrow() - }) -}) - -describe('createFrameParser', () => { - it('parses a single complete frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('test data') - const frame = encodeFrame(FrameType.Data, payload) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[0][1].toString()).toBe('test data') - }) - - it('parses multiple frames in one chunk', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame1 = encodeFrame(FrameType.Data, Buffer.from('one')) - const frame2 = encodeFrame(FrameType.Data, Buffer.from('two')) - parser.feed(Buffer.concat([frame1, frame2])) - - expect(onFrame).toHaveBeenCalledTimes(2) - expect(onFrame.mock.calls[0][1].toString()).toBe('one') - expect(onFrame.mock.calls[1][1].toString()).toBe('two') - }) - - it('handles frame split across header boundary', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('split test') - const frame = encodeFrame(FrameType.Data, payload) - - // Split in the middle of the header (3 bytes, then the rest) - parser.feed(frame.subarray(0, 3)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(3)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('split test') - }) - - it('handles frame split in the middle of payload', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const payload = Buffer.from('payload split') - const frame = encodeFrame(FrameType.Data, payload) - - // Split after header + 3 bytes of payload - const splitPoint = FRAME_HEADER_SIZE + 3 - parser.feed(frame.subarray(0, splitPoint)) - expect(onFrame).not.toHaveBeenCalled() - - parser.feed(frame.subarray(splitPoint)) - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('payload split') - }) - - it('handles byte-by-byte feeding', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Exit, Buffer.from('{"code":42}')) - - for (let i = 0; i < frame.length; i++) { - parser.feed(frame.subarray(i, i + 1)) - } - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Exit) - expect(JSON.parse(onFrame.mock.calls[0][1].toString())).toEqual({ code: 42 }) - }) - - it('handles zero-length payload frame', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frame = encodeFrame(FrameType.Kill, Buffer.alloc(0)) - parser.feed(frame) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Kill) - expect(onFrame.mock.calls[0][1].length).toBe(0) - }) - - it('parses different frame types correctly', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - const frames = [ - encodeFrame(FrameType.Data, Buffer.from('data')), - encodeFrame(FrameType.Resize, Buffer.from('resize')), - encodeFrame(FrameType.Signal, Buffer.from('signal')) - ] - - parser.feed(Buffer.concat(frames)) - - expect(onFrame).toHaveBeenCalledTimes(3) - expect(onFrame.mock.calls[0][0]).toBe(FrameType.Data) - expect(onFrame.mock.calls[1][0]).toBe(FrameType.Resize) - expect(onFrame.mock.calls[2][0]).toBe(FrameType.Signal) - }) - - it('resets buffer state', () => { - const onFrame = vi.fn() - const parser = createFrameParser(onFrame) - - // Feed partial frame then reset - const frame = encodeFrame(FrameType.Data, Buffer.from('lost')) - parser.feed(frame.subarray(0, 3)) - parser.reset() - - // Feed a new complete frame - const frame2 = encodeFrame(FrameType.Data, Buffer.from('fresh')) - parser.feed(frame2) - - expect(onFrame).toHaveBeenCalledOnce() - expect(onFrame.mock.calls[0][1].toString()).toBe('fresh') - }) -}) diff --git a/src/main/daemon/binary-frame.ts b/src/main/daemon/binary-frame.ts deleted file mode 100644 index e8c4fd6ae90..00000000000 --- a/src/main/daemon/binary-frame.ts +++ /dev/null @@ -1,56 +0,0 @@ -export { FrameType } from './types' -import type { FrameType } from './types' -import { FRAME_HEADER_SIZE, FRAME_MAX_PAYLOAD } from './types' - -export { FRAME_HEADER_SIZE } - -export function encodeFrame(type: FrameType, payload: Buffer): Buffer { - if (payload.length > FRAME_MAX_PAYLOAD) { - throw new Error(`Frame payload ${payload.length} exceeds max ${FRAME_MAX_PAYLOAD}`) - } - - const frame = Buffer.allocUnsafe(FRAME_HEADER_SIZE + payload.length) - frame[0] = type - frame.writeUInt32BE(payload.length, 1) - payload.copy(frame, FRAME_HEADER_SIZE) - return frame -} - -export type FrameParser = { - feed(chunk: Buffer): void - reset(): void -} - -export function createFrameParser( - onFrame: (type: FrameType, payload: Buffer) => void -): FrameParser { - let buffer: Buffer = Buffer.alloc(0) - - function parse(): void { - while (buffer.length >= FRAME_HEADER_SIZE) { - const payloadLength = buffer.readUInt32BE(1) - const totalLength = FRAME_HEADER_SIZE + payloadLength - - if (buffer.length < totalLength) { - break - } - - const type = buffer[0] as FrameType - const payload = buffer.subarray(FRAME_HEADER_SIZE, totalLength) - buffer = buffer.subarray(totalLength) - - onFrame(type, payload) - } - } - - return { - feed(chunk: Buffer): void { - buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]) - parse() - }, - - reset(): void { - buffer = Buffer.alloc(0) - } - } -} diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 7a697e6d96c..cbc58a44274 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -1321,6 +1321,18 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { source: 'headless' }) }) + + it('exposes live state separately from the visible frame', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[?1004h\x1b[?25lframe') + + const snapshot = await adapter.getBufferSnapshot(id) + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('frame') + expect(snapshot?.data).toContain('frame') + }) }) describe('shutdown', () => { @@ -1506,6 +1518,19 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.snapshot).toContain('prompt$') }) + it('publishes the alt-frame payload as explicit strings', async () => { + const sessionId = 'alt-frame-boundary' + await adapter.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('\x1b[?1049h\x1b[HSTATIC-ALT-FRAME') + await new Promise((r) => setTimeout(r, 50)) + + const result = await adapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.snapshotPrefixAnsi).toContain('\x1b[?1049h') + expect(result.snapshotFrameAnsi).toContain('STATIC-ALT-FRAME') + expect(result.snapshot).toBe([result.snapshotPrefixAnsi, result.snapshotFrameAnsi].join('')) + expect(result.snapshotFrameRestoreAnsi).not.toContain('STATIC-ALT-FRAME') + }) + it('returns the preserved sequence from attach-only adoption', async () => { const sessionId = 'attach-sequence-handoff' await adapter.spawn({ cols: 80, rows: 24, sessionId }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index f8bdf30deab..94d66c639a7 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -831,10 +831,9 @@ export class DaemonPtyAdapter implements IPtyProvider { } const isAltScreen = result.snapshot.modes.alternateScreen - const snapshotPayload = - result.snapshot.scrollbackAnsi + - result.snapshot.rehydrateSequences + - result.snapshot.snapshotAnsi + const snapshotPrefix = result.snapshot.scrollbackAnsi + result.snapshot.rehydrateSequences + const snapshotFrame = result.snapshot.snapshotAnsi + const snapshotPayload = snapshotPrefix + snapshotFrame // Why kitty flags ride beside the payload, not inside it: the snapshot reaches renderer xterms where POST_REPLAY_REATTACH_RESET's kitty reset must win (terminal-query-authority.md §kitty). const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { @@ -847,6 +846,14 @@ export class DaemonPtyAdapter implements IPtyProvider { snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + // Why only for an alt frame: normal history remains safe to replay at its capture grid. + ...(isAltScreen && snapshotFrame && result.snapshot.frameRestoreAnsi + ? { + snapshotPrefixAnsi: snapshotPrefix, + snapshotFrameAnsi: snapshotFrame, + snapshotFrameRestoreAnsi: result.snapshot.frameRestoreAnsi + } + : {}), ...(providerSequence ? { providerSequence } : {}), ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } @@ -1216,6 +1223,7 @@ export class DaemonPtyAdapter implements IPtyProvider { } return { data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + frameRestoreAnsi: snapshot.frameRestoreAnsi, scrollbackAnsi: snapshot.scrollbackAnsi, cols: snapshot.cols, rows: snapshot.rows, diff --git a/src/main/daemon/daemon-pty-provider.test.ts b/src/main/daemon/daemon-pty-provider.test.ts deleted file mode 100644 index 50800ffec89..00000000000 --- a/src/main/daemon/daemon-pty-provider.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' -import { DaemonPtyProvider } from './daemon-pty-provider' -import { DaemonServer } from './daemon-server' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'daemon-provider-test-')) -} - -function createMockSubprocess(): SubprocessHandle & { - _simulateData: (data: string) => void - _simulateExit: (code: number) => void -} { - let onDataCb: ((data: string) => void) | null = null - let onExitCb: ((code: number) => void) | null = null - return { - pid: 77777, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(() => setTimeout(() => onExitCb?.(137), 5)), - signal: vi.fn(), - onData(cb) { - onDataCb = cb - }, - onExit(cb) { - onExitCb = cb - }, - dispose: vi.fn(), - _simulateData(data: string) { - onDataCb?.(data) - }, - _simulateExit(code: number) { - onExitCb?.(code) - } - } -} - -async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { - const start = Date.now() - while (!predicate()) { - if (Date.now() - start > timeoutMs) { - throw new Error('waitFor timed out') - } - await new Promise((r) => setTimeout(r, 10)) - } -} - -describe('DaemonPtyProvider', () => { - let dir: string - let socketPath: string - let tokenPath: string - let server: DaemonServer - let provider: DaemonPtyProvider - let lastSubprocess: ReturnType - - beforeEach(async () => { - dir = createTestDir() - socketPath = getDaemonSocketPath(dir) - tokenPath = join(dir, 'test.token') - - server = new DaemonServer({ - socketPath, - tokenPath, - spawnSubprocess: () => { - lastSubprocess = createMockSubprocess() - return lastSubprocess - } - }) - await server.start() - - provider = new DaemonPtyProvider({ socketPath, tokenPath }) - }) - - afterEach(async () => { - await provider?.cleanup() - await server?.shutdown() - rmSync(dir, { recursive: true, force: true }) - }) - - describe('spawn', () => { - it('creates a session in the daemon and returns an id', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - sessionId: 'test-session' - }) - - expect(result.id).toBe('test-session') - }) - - it('spawns with cwd and env', async () => { - const result = await provider.spawn({ - cols: 80, - rows: 24, - cwd: '/home/user', - env: { MY_VAR: 'hello' }, - sessionId: 'session-with-env' - }) - - expect(result.id).toBe('session-with-env') - }) - }) - - describe('write', () => { - it('sends data to the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should not throw - provider.write('s1', 'ls\n') - - // Give the fire-and-forget notify time to arrive - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.write).toHaveBeenCalledWith('ls\n') - }) - }) - - describe('resize', () => { - it('resizes the daemon session', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - provider.resize('s1', 120, 40) - - await new Promise((r) => setTimeout(r, 50)) - expect(lastSubprocess.resize).toHaveBeenCalledWith(120, 40) - }) - }) - - describe('shutdown (kill session)', () => { - it('kills the session in the daemon', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.shutdown('s1', { immediate: false }) - - expect(lastSubprocess.kill).toHaveBeenCalled() - }) - }) - - describe('data events', () => { - it('delivers data events from the daemon to onData listeners', async () => { - const dataPayloads: { id: string; data: string }[] = [] - provider.onData((payload) => dataPayloads.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Simulate PTY output from subprocess - lastSubprocess._simulateData('hello from shell') - - await waitFor(() => dataPayloads.length > 0) - expect(dataPayloads[0]).toEqual({ id: 's1', data: 'hello from shell' }) - }) - }) - - describe('exit events', () => { - it('delivers exit events from the daemon to onExit listeners', async () => { - const exits: { id: string; code: number }[] = [] - provider.onExit((payload) => exits.push(payload)) - - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - lastSubprocess._simulateExit(42) - - await waitFor(() => exits.length > 0) - expect(exits[0]).toEqual({ id: 's1', code: 42, incarnationId: expect.any(String) }) - }) - }) - - describe('cleanup', () => { - it('disconnects from daemon without killing sessions', async () => { - await provider.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - await provider.cleanup() - - // Session should still be alive in the daemon — verify by connecting a new provider - const provider2 = new DaemonPtyProvider({ socketPath, tokenPath }) - const result = await provider2.spawn({ cols: 80, rows: 24, sessionId: 's1' }) - - // Should reattach (not create new) since the session is alive - // The daemon's createOrAttach returns isNew=false for existing sessions - expect(result.id).toBe('s1') - await provider2.cleanup() - }) - }) - - describe('multiple sessions', () => { - it('handles multiple concurrent sessions', async () => { - const r1 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'a' }) - const r2 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'b' }) - const r3 = await provider.spawn({ cols: 80, rows: 24, sessionId: 'c' }) - - expect(r1.id).toBe('a') - expect(r2.id).toBe('b') - expect(r3.id).toBe('c') - }) - }) -}) diff --git a/src/main/daemon/daemon-pty-provider.ts b/src/main/daemon/daemon-pty-provider.ts deleted file mode 100644 index 2c75f9dcfb8..00000000000 --- a/src/main/daemon/daemon-pty-provider.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { DaemonClient } from './client' -import type { DaemonEvent, CreateOrAttachResult } from './types' -import type { PtyIncarnationId } from '../../shared/pty-incarnation' - -export type DaemonPtyProviderOptions = { - socketPath: string - tokenPath: string -} - -export type DaemonSpawnOptions = { - cols: number - rows: number - sessionId: string - cwd?: string - env?: Record - envToDelete?: string[] - command?: string -} - -export type DaemonSpawnResult = { - id: string - incarnationId?: PtyIncarnationId - isNew: boolean - pid: number | null -} - -export class DaemonPtyProvider { - private client: DaemonClient - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] - private exitListeners: ((payload: { - id: string - code: number - incarnationId?: PtyIncarnationId - }) => void)[] = [] - private removeEventListener: (() => void) | null = null - - constructor(opts: DaemonPtyProviderOptions) { - this.client = new DaemonClient({ - socketPath: opts.socketPath, - tokenPath: opts.tokenPath - }) - } - - async spawn(opts: DaemonSpawnOptions): Promise { - await this.client.ensureConnected() - this.setupEventRouting() - - const result = await this.client.request('createOrAttach', { - sessionId: opts.sessionId, - cols: opts.cols, - rows: opts.rows, - cwd: opts.cwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command - }) - - return { - id: opts.sessionId, - ...(result.incarnationId ? { incarnationId: result.incarnationId } : {}), - isNew: result.isNew, - pid: result.pid - } - } - - write(id: string, data: string): void { - this.client.notify('write', { sessionId: id, data }) - } - - resize(id: string, cols: number, rows: number): void { - this.client.notify('resize', { sessionId: id, cols, rows }) - } - - async shutdown( - id: string, - opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } - ): Promise { - // Why: convert the absolute teardown deadline to a relative timeout only here, - // at the RPC leaf; undefined keeps the DaemonClient 30s default. - await this.client.request( - 'kill', - { sessionId: id, immediate: opts.immediate ?? false }, - opts.deadlineMs !== undefined ? Math.max(1, opts.deadlineMs - Date.now()) : undefined - ) - } - - onData(callback: (payload: { id: string; data: string }) => void): () => void { - this.dataListeners.push(callback) - return () => { - const idx = this.dataListeners.indexOf(callback) - if (idx !== -1) { - this.dataListeners.splice(idx, 1) - } - } - } - - onExit( - callback: (payload: { id: string; code: number; incarnationId?: PtyIncarnationId }) => void - ): () => void { - this.exitListeners.push(callback) - return () => { - const idx = this.exitListeners.indexOf(callback) - if (idx !== -1) { - this.exitListeners.splice(idx, 1) - } - } - } - - async cleanup(): Promise { - this.removeEventListener?.() - this.removeEventListener = null - this.client.disconnect() - } - - private setupEventRouting(): void { - if (this.removeEventListener) { - return - } - - this.removeEventListener = this.client.onEvent((raw) => { - const event = raw as DaemonEvent - if (event.type !== 'event') { - return - } - - if (event.event === 'data') { - for (const listener of this.dataListeners) { - listener({ id: event.sessionId, data: event.payload.data }) - } - } else if (event.event === 'exit') { - for (const listener of this.exitListeners) { - listener({ - id: event.sessionId, - code: event.payload.code, - ...(event.payload.incarnationId ? { incarnationId: event.payload.incarnationId } : {}) - }) - } - } - }) - } -} diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 37633f32334..9ee6918113f 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -575,6 +575,55 @@ describe('HeadlessEmulator', () => { }) describe('rehydration sequences', () => { + it('separates complete live state from an alternate-screen frame', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[?1004h\x1b[?25l\x1b[5;10H\x1b7\x1b[31;44;1mframe') + + const snapshot = emulator.getSnapshot() + + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1049h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot.frameRestoreAnsi).toContain('\x1b7') + expect(snapshot.frameRestoreAnsi).toContain('\x1b[31;44;1m') + expect(snapshot.frameRestoreAnsi).not.toContain('frame') + + const restored = new HeadlessEmulator({ cols: 80, rows: 24 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + expect(restored.isAlternateScreen).toBe(true) + await restored.write('X') + expect(restored.getSnapshot().snapshotAnsi).toContain('\x1b[31;44;1mX') + restored.dispose() + }) + + it('restores current and saved cursor rows relative to origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[3;8r\x1b[?6h\x1b[2;7H\x1b7\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[3]?.[6]).toBe('S') + restored.dispose() + }) + + it('restores a saved cursor from before the current origin-mode margins', async () => { + emulator = new HeadlessEmulator({ cols: 20, rows: 10 }) + await emulator.write('\x1b[?1049h\x1b[1;3H\x1b7\x1b[3;8r\x1b[?6h\x1b[4;5H') + + const snapshot = emulator.getSnapshot() + const restored = new HeadlessEmulator({ cols: 20, rows: 10 }) + await restored.write(snapshot.frameRestoreAnsi ?? '') + await restored.write('C\x1b8S') + + expect(restored.getVisibleLines()[5]?.[4]).toBe('C') + expect(restored.getVisibleLines()[0]?.[2]).toBe('S') + restored.dispose() + }) + it('generates rehydration for non-default modes', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b[?2004h') diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 195b97a30f6..12be643d637 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -13,6 +13,7 @@ import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { buildFrameRestoreSnapshotFields } from './terminal-frame-restore-sequences' import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' import { installTerminalViewAttributeResponder, @@ -255,6 +256,7 @@ export class HeadlessEmulator { this.restoredOscLinks ), rehydrateSequences: buildRehydrateSequences(modes), + ...buildFrameRestoreSnapshotFields(this.serializer, this.terminal, modes), cwd: this.oscText.cwd, modes, cols: this.terminal.cols, @@ -266,10 +268,6 @@ export class HeadlessEmulator { ? { pendingEscapeTailAnsi: this.partialEscapeTail } : {}) } - if (this.partialEscapeTail.length > 0) { - // Why a separate field: consumers write their own reset sequences after the body, and any ESC after a dangling partial would abort it. - snapshot.pendingEscapeTailAnsi = this.partialEscapeTail - } return snapshot } diff --git a/src/main/daemon/production-launcher.test.ts b/src/main/daemon/production-launcher.test.ts deleted file mode 100644 index 4404c5c2c99..00000000000 --- a/src/main/daemon/production-launcher.test.ts +++ /dev/null @@ -1,444 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { createProductionLauncher } from './production-launcher' -import { startDaemon, type DaemonHandle } from './daemon-main' -import { DaemonClient } from './client' -import type { SubprocessHandle } from './session' -import { getDaemonSocketPath } from './daemon-spawner' - -const { forkMock } = vi.hoisted(() => ({ - forkMock: vi.fn() -})) - -vi.mock('child_process', async () => { - const actual = await vi.importActual>('child_process') - return { ...actual, fork: forkMock } -}) - -function createTestDir(): string { - return mkdtempSync(join(tmpdir(), 'prod-launcher-test-')) -} - -function createMockSubprocess(): SubprocessHandle { - let onExitCb: ((code: number) => void) | null = null - return { - pid: 44444, - getForegroundProcess: vi.fn(() => null), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), - forceKill: vi.fn(), - signal: vi.fn(), - onData(_cb: (data: string) => void) {}, - onExit(cb: (code: number) => void) { - onExitCb = cb - }, - dispose: vi.fn() - } -} - -describe('createProductionLauncher', () => { - let dir: string - let handles: DaemonHandle[] - - beforeEach(() => { - dir = createTestDir() - handles = [] - }) - - afterEach(async () => { - for (const h of handles) { - await h.shutdown().catch(() => {}) - } - forkMock.mockReset() - rmSync(dir, { recursive: true, force: true }) - }) - - it('returns a launcher function', () => { - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js', - getAppVersion: () => '1.2.3' - }) - expect(typeof launcher).toBe('function') - }) - - it('rejects either ownership argument without its pair before forking', async () => { - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => '/fake/path.js', - getAppVersion: () => '1.2.3' - }) - - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), join(dir, 'daemon.pid')) - ).rejects.toThrow('provided together') - await expect( - launcher(socketPathFor(dir), tokenPathFor(dir), undefined, 'launch-a') - ).rejects.toThrow('provided together') - expect(forkMock).not.toHaveBeenCalled() - }) - - it('can be used with DaemonSpawner (in-process fallback)', async () => { - // Use in-process launcher for testing (same as DaemonSpawner tests) - const launcher = async (socketPath: string, tokenPath: string) => { - const handle = await startDaemon({ - socketPath, - tokenPath, - spawnSubprocess: () => createMockSubprocess() - }) - handles.push(handle) - return { shutdown: () => handle.shutdown() } - } - - const socketPath = getDaemonSocketPath(dir) - const tokenPath = join(dir, 'test.token') - const handle = await launcher(socketPath, tokenPath) - - const client = new DaemonClient({ socketPath, tokenPath }) - await client.ensureConnected() - expect(client.isConnected()).toBe(true) - client.disconnect() - - await handle.shutdown() - handles.pop() - }) - - it('removes startup child listeners after readiness', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(), - disconnect: vi.fn(), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const pidPath = join(dir, 'daemon.pid') - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-a') - handlers.message[0]?.({ - type: 'ready', - startedAtMs: 123_456, - linuxStartTicks: '4242', - bootId: 'boot-a' - }) - const handle = await launch - - expect(handle.shutdown).toEqual(expect.any(Function)) - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - expect(child.disconnect).toHaveBeenCalled() - expect(child.unref).toHaveBeenCalled() - expect(forkMock).toHaveBeenCalledWith( - join(dir, 'daemon-entry.js'), - expect.arrayContaining([ - '--pid-record', - pidPath, - '--launch-nonce', - 'launch-a', - '--entry-path', - join(dir, 'daemon-entry.js'), - '--app-version', - '1.2.3' - ]), - expect.objectContaining({ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] }) - ) - }) - - it('resolves shutdown only after observing child exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - once: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event]?.push(callback) - return child - }), - off: vi.fn((event: string, callback: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== callback) ?? [] - return child - }), - kill: vi.fn((signal: NodeJS.Signals) => { - if (signal === 'SIGTERM') { - queueMicrotask(() => { - child.exitCode = 0 - for (const callback of handlers.exit.slice()) { - callback(0) - } - }) - } - return true - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-shutdown' })) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-shutdown') - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) - const handle = await launch - - await expect(handle.shutdown()).resolves.toBeUndefined() - - expect(child.kill).toHaveBeenCalledWith('SIGTERM') - expect(child.exitCode).toBe(0) - expect(existsSync(pidPath)).toBe(false) - }) - - it('rejects shutdown and releases child handles when SIGKILL never produces exit', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - killed: false, - connected: false, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => true), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready', startedAtMs: 123_456 }) - const handle = await launch - - const shutdown = expect(handle.shutdown()).rejects.toThrow( - 'Daemon did not exit after SIGKILL' - ) - expect(handlers.exit).toHaveLength(1) - - await vi.advanceTimersByTimeAsync(6000) - await shutdown - - expect(child.kill).toHaveBeenNthCalledWith(1, 'SIGTERM') - expect(child.kill).toHaveBeenNthCalledWith(2, 'SIGKILL') - expect(handlers.exit).toHaveLength(0) - expect(child.unref).toHaveBeenCalledTimes(2) - } finally { - vi.useRealTimers() - } - }) - - it('preserves readiness and signaling failures while releasing startup IPC', async () => { - vi.useFakeTimers() - try { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('permission denied'), { code: 'EACCES' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const launch = launcher(socketPathFor(dir), tokenPathFor(dir)) - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(handlers.message).toHaveLength(0) - expect(handlers.error).toHaveLength(0) - expect(handlers.exit).toHaveLength(0) - } finally { - vi.useRealTimers() - } - }) - - it('preserves exact PID ownership when child exit cannot be confirmed', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const signalError = Object.assign(new Error('signal blocked'), { code: 'EPERM' }) - const child = { - pid: 12345, - connected: true, - exitCode: null, - signalCode: null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - once: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - kill: vi.fn(() => { - throw signalError - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-b' })) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-b') - handlers.message[0]?.({ type: 'ready' }) - - const error = await launch.catch((caught: unknown) => caught) - expect(error).toBeInstanceOf(AggregateError) - expect((error as AggregateError).errors).toEqual([ - expect.objectContaining({ message: 'Daemon readiness identity is incomplete' }), - signalError - ]) - expect(child.disconnect).toHaveBeenCalledOnce() - expect(child.unref).toHaveBeenCalledOnce() - expect(existsSync(pidPath)).toBe(true) - }) - - it('removes exact child-side PID ownership after a pre-ready exit', async () => { - const handlers: Record void)[]> = { - message: [], - error: [], - exit: [] - } - const child = { - pid: 12345, - connected: true, - exitCode: null as number | null, - signalCode: null as NodeJS.Signals | null, - on: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event]?.push(cb) - return child - }), - off: vi.fn((event: string, cb: (arg?: unknown) => void) => { - handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] - return child - }), - disconnect: vi.fn(() => { - child.connected = false - }), - unref: vi.fn() - } - forkMock.mockReturnValueOnce(child) - const pidPath = join(dir, 'daemon.pid') - writeFileSync(pidPath, JSON.stringify({ pid: 12345, launchNonce: 'launch-c' })) - const launcher = createProductionLauncher({ - getDaemonEntryPath: () => join(dir, 'daemon-entry.js'), - getAppVersion: () => '1.2.3' - }) - - const launch = launcher(socketPathFor(dir), tokenPathFor(dir), pidPath, 'launch-c') - child.exitCode = 1 - handlers.exit[0]?.(1) - - await expect(launch).rejects.toThrow('Daemon process exited prematurely with code 1') - expect(existsSync(pidPath)).toBe(false) - }) -}) - -function socketPathFor(dir: string): string { - return join(dir, 'test.sock') -} - -function tokenPathFor(dir: string): string { - return join(dir, 'test.token') -} diff --git a/src/main/daemon/production-launcher.ts b/src/main/daemon/production-launcher.ts deleted file mode 100644 index 342ff6e94ba..00000000000 --- a/src/main/daemon/production-launcher.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { fork, type ChildProcess } from 'node:child_process' -import { - unlinkOwnedDaemonPidFile, - type DaemonLauncher, - type DaemonProcessHandle -} from './daemon-spawner' -import { parseDaemonReadyIdentity, type DaemonReadyIdentity } from './daemon-ready-identity' - -const READY_TIMEOUT_MS = 10_000 - -export type ProductionLauncherOptions = { - getDaemonEntryPath: () => string - getAppVersion: () => string -} - -export function createProductionLauncher(opts: ProductionLauncherOptions): DaemonLauncher { - return async ( - socketPath: string, - tokenPath: string, - pidPath?: string, - launchNonce?: string - ): Promise => { - if ((pidPath === undefined) !== (launchNonce === undefined)) { - // Why: partial ownership metadata would launch a v24 daemon that cannot - // prove which PID record it may remove during self-retirement. - throw new Error('Daemon PID path and launch nonce must be provided together') - } - const entryPath = opts.getDaemonEntryPath() - - const appVersion = opts.getAppVersion() - const child = fork( - entryPath, - [ - '--socket', - socketPath, - '--token', - tokenPath, - ...(pidPath && launchNonce - ? [ - '--pid-record', - pidPath, - '--launch-nonce', - launchNonce, - '--entry-path', - entryPath, - '--app-version', - appVersion - ] - : []) - ], - { - // Why: detached daemon output is not consumed; ignored streams cannot - // keep Electron alive after the child and IPC channel are unreferenced. - stdio: ['ignore', 'ignore', 'ignore', 'ipc'], - detached: true, - env: { ...process.env }, - ...(process.platform === 'win32' ? { windowsHide: true } : {}) - } - ) - - try { - await waitForReady(child) - } catch (error) { - return rejectAfterChildCleanup(child, error, pidPath, launchNonce) - } - if (!Number.isSafeInteger(child.pid) || (child.pid as number) <= 0) { - return rejectAfterChildCleanup( - child, - new Error('Daemon readiness identity is incomplete'), - pidPath, - launchNonce - ) - } - - // Unref so the Electron process can exit without waiting for the daemon - child.unref() - child.disconnect() - - return { - shutdown: async () => { - await shutdownChild(child) - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - } - } - } -} - -function waitForReady(child: ChildProcess): Promise { - return new Promise((resolve, reject) => { - let timeout: ReturnType | undefined - let settled = false - function cleanupStartupListeners(): void { - if (timeout) { - clearTimeout(timeout) - } - child.off('message', onMessage) - child.off('error', onError) - child.off('exit', onExit) - } - function fail(error: Error): void { - if (settled) { - return - } - settled = true - cleanupStartupListeners() - reject(error) - } - function onMessage(msg: unknown): void { - if (msg && typeof msg === 'object' && (msg as Record).type === 'ready') { - if (settled) { - return - } - const readyIdentity = parseDaemonReadyIdentity(msg) - if (!readyIdentity) { - fail(new Error('Daemon readiness identity is incomplete')) - return - } - settled = true - // Why: the daemon is detached after readiness, so startup listeners - // must not keep the child process closure alive for the daemon lifetime. - cleanupStartupListeners() - resolve(readyIdentity) - } - } - function onError(err: Error): void { - fail(new Error(`Daemon process error: ${err.message}`)) - } - function onExit(code: number | null): void { - fail(new Error(`Daemon process exited prematurely with code ${code}`)) - } - - timeout = setTimeout(() => { - fail(new Error('Daemon failed to signal readiness within timeout')) - }, READY_TIMEOUT_MS) - - child.on('message', onMessage) - child.on('error', onError) - child.on('exit', onExit) - }) -} - -async function shutdownChild(child: ChildProcess): Promise { - try { - if ( - (child.exitCode !== null && child.exitCode !== undefined) || - (child.signalCode !== null && child.signalCode !== undefined) - ) { - return - } - await new Promise((resolve, reject) => { - let settled = false - let timeout: ReturnType - let forceTimeout: ReturnType | undefined - function finish(error?: unknown): void { - if (settled) { - return - } - settled = true - clearTimeout(timeout) - if (forceTimeout) { - clearTimeout(forceTimeout) - } - child.off('exit', onExit) - if (error) { - reject(error) - } else { - resolve() - } - } - - function onExit(): void { - finish() - } - - timeout = setTimeout(() => { - try { - if (child.kill('SIGKILL') === false) { - finish(new Error('Failed to deliver SIGKILL to daemon')) - return - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - return - } - // Why: signal delivery is not process exit; keep the listener for one - // bounded interval before releasing launcher-owned handles. - if (!settled) { - forceTimeout = setTimeout( - () => finish(new Error('Daemon did not exit after SIGKILL')), - 1000 - ) - } - }, 5000) - - child.once('exit', onExit) - try { - if (child.kill('SIGTERM') === false) { - finish(new Error('Failed to deliver SIGTERM to daemon')) - } - } catch (error) { - finish(isNoSuchProcessError(error) ? undefined : error) - } - }) - } finally { - if (child.connected) { - child.disconnect() - } - child.unref() - } -} - -async function rejectAfterChildCleanup( - child: ChildProcess, - launchError: unknown, - pidPath?: string, - launchNonce?: string -): Promise { - try { - await shutdownChild(child) - } catch (cleanupError) { - throw new AggregateError( - [launchError, cleanupError], - 'Daemon launch and child cleanup both failed' - ) - } - unlinkLaunchedDaemonPidFile(child, pidPath, launchNonce) - throw launchError -} - -function unlinkLaunchedDaemonPidFile( - child: ChildProcess, - pidPath?: string, - launchNonce?: string -): void { - if (pidPath && launchNonce && Number.isSafeInteger(child.pid) && (child.pid as number) > 0) { - unlinkOwnedDaemonPidFile(pidPath, child.pid as number, launchNonce) - } -} - -function isNoSuchProcessError(error: unknown): boolean { - return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH' -} diff --git a/src/main/daemon/terminal-frame-restore-sequences.ts b/src/main/daemon/terminal-frame-restore-sequences.ts new file mode 100644 index 00000000000..6d91771c4d2 --- /dev/null +++ b/src/main/daemon/terminal-frame-restore-sequences.ts @@ -0,0 +1,97 @@ +import type { Terminal } from '@xterm/headless' +import type { SerializeAddon } from '@xterm/addon-serialize' +import { + buildAbsoluteCursorRestoreSequence, + readSavedCursorRegister +} from '../../shared/terminal-serialize-absolute-cursor' +import type { SavedCursorRegister } from '../../shared/terminal-serialize-absolute-cursor' +import type { TerminalModes } from './types' + +export function buildFrameRestoreSnapshotFields( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes +): { frameRestoreAnsi?: string } { + return modes.alternateScreen + ? { + frameRestoreAnsi: buildTerminalFrameRestoreSequences( + serializer, + terminal, + modes, + readSavedCursorRegister(terminal) + ) + } + : {} +} + +/** Restores live terminal state without replaying an alternate-screen frame. */ +function buildTerminalFrameRestoreSequences( + serializer: SerializeAddon, + terminal: Terminal, + modes: TerminalModes, + savedCursor: SavedCursorRegister | null +): string { + const terminalModes = terminal.modes + const seqs: string[] = ['\x1b[0m\x1b[?1049h', serializeCurrentSgrState(serializer, terminal)] + if (terminalModes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + if (terminalModes.applicationKeypadMode) { + seqs.push('\x1b[?66h') + } + if (terminalModes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + if (terminalModes.insertMode) { + seqs.push('\x1b[4h') + } + if (terminalModes.reverseWraparoundMode) { + seqs.push('\x1b[?45h') + } + if (terminalModes.sendFocusMode) { + seqs.push('\x1b[?1004h') + } + if (!terminalModes.wraparoundMode) { + seqs.push('\x1b[?7l') + } + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + if (!terminalModes.showCursor) { + seqs.push('\x1b[?25l') + } + seqs.push( + buildAbsoluteCursorRestoreSequence(terminal, savedCursor, { + restoreModesWithoutCursor: true + }) + ) + return seqs.join('') +} + +function serializeCurrentSgrState(serializer: SerializeAddon, terminal: Terminal): string { + // Unsupported addon behavior under our vendored patch: an out-of-range row emits only its authoritative pen-state diff. + const emptyRow = terminal.buffer.normal.length + return serializer.serialize({ + range: { start: emptyRow, end: emptyRow }, + excludeAltBuffer: true, + excludeModes: true + }) +} diff --git a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts index b54227652dd..f0927fcf379 100644 --- a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts +++ b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts @@ -70,6 +70,24 @@ async function roundTripStyles(source: string): Promise { return replay(addon.serialize()) } +describe('out-of-range SGR state serialization', () => { + it('emits only the live pen state for a row beyond the normal buffer', async () => { + const { terminal, addon } = createTerminal() + await write(terminal, '\x1b[31;44;1m') + + // This deliberately pins unsupported SerializeAddon behavior used by + // terminal-frame-restore-sequences under Orca's vendored addon patch. + const outOfRangeRow = terminal.buffer.normal.length + expect( + addon.serialize({ + range: { start: outOfRangeRow, end: outOfRangeRow }, + excludeAltBuffer: true, + excludeModes: true + }) + ).toBe('\x1b[31;44;1m') + }) +}) + describe('SGR intensity round-trip (BUG B, addon patch)', () => { it('restores bold set immediately after dim is cleared (minimized repro)', async () => { const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB') diff --git a/src/main/daemon/terminal-snapshot.ts b/src/main/daemon/terminal-snapshot.ts index 653d8fc4bd6..6d378a8d107 100644 --- a/src/main/daemon/terminal-snapshot.ts +++ b/src/main/daemon/terminal-snapshot.ts @@ -9,6 +9,8 @@ export type TerminalSnapshot = { scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string + /** Live modes and cursor state that can be restored without the alt frame. */ + frameRestoreAnsi?: string cwd: string | null modes: TerminalModes cols: number diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 865572ea0b1..1f067885467 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -383,23 +383,6 @@ export type DaemonSessionInfo = SessionInfo & { // existing importers keep one types entry point. export * from './daemon-stream-events' -// ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── -// -// 5-byte header: [type:1][length:4 big-endian] -// Followed by `length` bytes of payload. - -export const enum FrameType { - Data = 0x01, - Resize = 0x02, - Exit = 0x03, - Error = 0x04, - Kill = 0x05, - Signal = 0x06 -} - -export const FRAME_HEADER_SIZE = 5 -export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB - // ─── Notify prefix ────────────────────────────────────────────────── // Requests with IDs starting with this prefix are fire-and-forget: // the daemon processes them but does not send a response. diff --git a/src/main/devin/hook-service.ts b/src/main/devin/hook-service.ts index 82b00a71c3c..6cff0d2b824 100644 --- a/src/main/devin/hook-service.ts +++ b/src/main/devin/hook-service.ts @@ -5,6 +5,7 @@ import { writeHooksJson, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeHooksJsonRemote, @@ -79,6 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class DevinHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getDevinManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getDevinConfigPath() const scriptPath = getDevinManagedScriptPath() diff --git a/src/main/droid/hook-service.ts b/src/main/droid/hook-service.ts index 508f24e98c8..fdef56a2401 100644 --- a/src/main/droid/hook-service.ts +++ b/src/main/droid/hook-service.ts @@ -16,6 +16,7 @@ import { type HookDefinition, type HooksConfig } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -159,6 +160,10 @@ function buildInstalledDroidConfig( } export class DroidHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/emulator/android/scrcpy-control-protocol.test.ts b/src/main/emulator/android/scrcpy-control-protocol.test.ts deleted file mode 100644 index 10e0e7a842a..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - SCRCPY_CONTROL_MSG_TYPE, - encodeBackOrScreenOn, - encodeInjectKeycode, - encodeInjectText, - encodeInjectTouchEvent -} from './scrcpy-control-protocol' - -describe('SCRCPY_CONTROL_MSG_TYPE', () => { - it('pins the v2.4 control message type codes', () => { - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE).toBe(0) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT).toBe(1) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT).toBe(2) - expect(SCRCPY_CONTROL_MSG_TYPE.INJECT_SCROLL_EVENT).toBe(3) - expect(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON).toBe(4) - }) -}) - -describe('encodeInjectTouchEvent', () => { - it('encodes a touch-down event with the full 32-byte field layout', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 0x1122334455667788n, - x: 100, - y: 200, - screenWidth: 1080, - screenHeight: 1920 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT) - expect(buf.readUInt8(0)).toBe(2) - expect(buf.readUInt8(1)).toBe(0) // AMOTION_EVENT_ACTION_DOWN - expect(buf.readBigUInt64BE(2)).toBe(0x1122334455667788n) - expect(buf.readInt32BE(10)).toBe(100) - expect(buf.readInt32BE(14)).toBe(200) - expect(buf.readUInt16BE(18)).toBe(1080) - expect(buf.readUInt16BE(20)).toBe(1920) - expect(buf.readUInt16BE(22)).toBe(0xffff) // default pressure 1.0 for down - expect(buf.readUInt32BE(24)).toBe(1) // AMOTION_EVENT_BUTTON_PRIMARY - expect(buf.readUInt32BE(28)).toBe(1) // buttons set while pressed - }) - - it('encodes a move event with action code 2 and full default pressure', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(2) // AMOTION_EVENT_ACTION_MOVE - expect(buf.readUInt16BE(22)).toBe(0xffff) - expect(buf.readUInt32BE(28)).toBe(1) - }) - - it('encodes an up event with zero default pressure and cleared buttons', () => { - const buf = encodeInjectTouchEvent({ - action: 'up', - pointerId: 1n, - x: 5, - y: 6, - screenWidth: 720, - screenHeight: 1280 - }) - - expect(buf.length).toBe(32) - expect(buf.readUInt8(1)).toBe(1) // AMOTION_EVENT_ACTION_UP - expect(buf.readUInt16BE(22)).toBe(0) // default pressure 0 on up - expect(buf.readUInt32BE(24)).toBe(1) // actionButton stays primary - expect(buf.readUInt32BE(28)).toBe(0) // buttons cleared on up - }) - - it('writes negative coordinates as signed int32', () => { - const buf = encodeInjectTouchEvent({ - action: 'move', - pointerId: 1n, - x: -3, - y: -7, - screenWidth: 1, - screenHeight: 1 - }) - - expect(buf.readInt32BE(10)).toBe(-3) - expect(buf.readInt32BE(14)).toBe(-7) - }) - - it('encodes explicit fractional pressure as fixed-point u16', () => { - const buf = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 0.5 - }) - - expect(buf.readUInt16BE(22)).toBe(Math.round(0.5 * 0xffff)) - }) - - it('clamps pressure above 1 and below 0', () => { - const high = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: 2 - }) - expect(high.readUInt16BE(22)).toBe(0xffff) - - const low = encodeInjectTouchEvent({ - action: 'down', - pointerId: 1n, - x: 0, - y: 0, - screenWidth: 1, - screenHeight: 1, - pressure: -1 - }) - expect(low.readUInt16BE(22)).toBe(0) - }) -}) - -describe('encodeInjectKeycode', () => { - it('encodes a keycode-down event (14 bytes) with round-tripped fields', () => { - const buf = encodeInjectKeycode({ - action: 'down', - keycode: 66, // AKEYCODE_ENTER - repeat: 3, - metaState: 0x1000 - }) - - expect(buf.length).toBe(14) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE) - expect(buf.readUInt8(0)).toBe(0) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - expect(buf.readInt32BE(2)).toBe(66) - expect(buf.readInt32BE(6)).toBe(3) - expect(buf.readInt32BE(10)).toBe(0x1000) - }) - - it('encodes a keycode-up event and applies repeat/metaState defaults', () => { - const buf = encodeInjectKeycode({ action: 'up', keycode: 4 }) // AKEYCODE_BACK - - expect(buf.length).toBe(14) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - expect(buf.readInt32BE(2)).toBe(4) - expect(buf.readInt32BE(6)).toBe(0) // default repeat - expect(buf.readInt32BE(10)).toBe(0) // default metaState - }) -}) - -describe('encodeInjectText', () => { - it('encodes the type byte, UTF-8 byte length header, and payload', () => { - const buf = encodeInjectText('hello') - - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT) - expect(buf.readUInt8(0)).toBe(1) - expect(buf.readUInt32BE(1)).toBe(5) - expect(buf.subarray(5).toString('utf8')).toBe('hello') - expect(buf.length).toBe(5 + 5) - }) - - it('uses UTF-8 byte length (not string length) for multi-byte characters', () => { - const text = 'é' // one JS char, two UTF-8 bytes - const buf = encodeInjectText(text) - - expect(text.length).toBe(1) - expect(buf.readUInt32BE(1)).toBe(2) - expect(buf.length).toBe(5 + 2) - expect(buf.subarray(5).toString('utf8')).toBe('é') - }) - - it('encodes an empty string as a 5-byte header with zero length', () => { - const buf = encodeInjectText('') - - expect(buf.length).toBe(5) - expect(buf.readUInt32BE(1)).toBe(0) - }) -}) - -describe('encodeBackOrScreenOn', () => { - it('encodes a 2-byte down message', () => { - const buf = encodeBackOrScreenOn('down') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(0)).toBe(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON) - expect(buf.readUInt8(0)).toBe(4) - expect(buf.readUInt8(1)).toBe(0) // AKEY_EVENT_ACTION_DOWN - }) - - it('encodes a 2-byte up message', () => { - const buf = encodeBackOrScreenOn('up') - - expect(buf.length).toBe(2) - expect(buf.readUInt8(1)).toBe(1) // AKEY_EVENT_ACTION_UP - }) -}) diff --git a/src/main/emulator/android/scrcpy-control-protocol.ts b/src/main/emulator/android/scrcpy-control-protocol.ts deleted file mode 100644 index 4e35148948c..00000000000 --- a/src/main/emulator/android/scrcpy-control-protocol.ts +++ /dev/null @@ -1,91 +0,0 @@ -// scrcpy server control protocol v2.4 -// Byte-exact encoders for the scrcpy control socket. Every multi-byte field is -// big-endian to match the server's DataInputStream reads. - -export const SCRCPY_CONTROL_MSG_TYPE = { - INJECT_KEYCODE: 0, - INJECT_TEXT: 1, - INJECT_TOUCH_EVENT: 2, - INJECT_SCROLL_EVENT: 3, - BACK_OR_SCREEN_ON: 4 -} as const - -export type ScrcpyKeyAction = 'down' | 'up' -export type ScrcpyTouchAction = 'down' | 'up' | 'move' - -// AKEY_EVENT_ACTION_* / AMOTION_EVENT_ACTION_* codes the server forwards to Android. -const KEY_ACTION_CODE: Record = { down: 0, up: 1 } -const TOUCH_ACTION_CODE: Record = { down: 0, up: 1, move: 2 } - -// AMOTION_EVENT_BUTTON_PRIMARY — scrcpy reports the primary button for touches. -const BUTTON_PRIMARY = 1 -// Pressure is a u16 fixed-point value where 0xFFFF represents 1.0. -const PRESSURE_MAX = 0xffff - -function encodePressure(pressure: number): number { - const fixed = Math.round(pressure * PRESSURE_MAX) - if (fixed < 0) { - return 0 - } - if (fixed > PRESSURE_MAX) { - return PRESSURE_MAX - } - return fixed -} - -export function encodeInjectTouchEvent(p: { - action: ScrcpyTouchAction - pointerId: bigint - x: number - y: number - screenWidth: number - screenHeight: number - pressure?: number -}): Buffer { - // A finger that lifts has no pressure; otherwise full pressure unless overridden. - const pressure = p.pressure ?? (p.action === 'up' ? 0 : 1) - const buf = Buffer.alloc(32) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TOUCH_EVENT, 0) - buf.writeUInt8(TOUCH_ACTION_CODE[p.action], 1) - buf.writeBigUInt64BE(p.pointerId, 2) - buf.writeInt32BE(p.x, 10) - buf.writeInt32BE(p.y, 14) - buf.writeUInt16BE(p.screenWidth, 18) - buf.writeUInt16BE(p.screenHeight, 20) - buf.writeUInt16BE(encodePressure(pressure), 22) - buf.writeUInt32BE(BUTTON_PRIMARY, 24) - buf.writeUInt32BE(p.action === 'up' ? 0 : BUTTON_PRIMARY, 28) - return buf -} - -export function encodeInjectKeycode(p: { - action: ScrcpyKeyAction - keycode: number - repeat?: number - metaState?: number -}): Buffer { - const buf = Buffer.alloc(14) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_KEYCODE, 0) - buf.writeUInt8(KEY_ACTION_CODE[p.action], 1) - buf.writeInt32BE(p.keycode, 2) - buf.writeInt32BE(p.repeat ?? 0, 6) - buf.writeInt32BE(p.metaState ?? 0, 10) - return buf -} - -export function encodeInjectText(text: string): Buffer { - // The length prefix is the UTF-8 byte count, which can exceed text.length. - const payload = Buffer.from(text, 'utf8') - const buf = Buffer.alloc(5 + payload.length) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.INJECT_TEXT, 0) - buf.writeUInt32BE(payload.length, 1) - payload.copy(buf, 5) - return buf -} - -export function encodeBackOrScreenOn(action: ScrcpyKeyAction): Buffer { - const buf = Buffer.alloc(2) - buf.writeUInt8(SCRCPY_CONTROL_MSG_TYPE.BACK_OR_SCREEN_ON, 0) - buf.writeUInt8(KEY_ACTION_CODE[action], 1) - return buf -} diff --git a/src/main/emulator/android/scrcpy-stream-session.ts b/src/main/emulator/android/scrcpy-stream-session.ts index b7cec131e2c..0333e9ee5db 100644 --- a/src/main/emulator/android/scrcpy-stream-session.ts +++ b/src/main/emulator/android/scrcpy-stream-session.ts @@ -22,8 +22,7 @@ import { emulatorProbe, emulatorProbeError } from '../emulator-probe' // A live scrcpy session validated against a real emulator. The connection // handshake (dummy byte, 64-byte device name, codec meta) and the server option -// set are coupled to the pinned scrcpy server version; the pure framing -// (scrcpy-video-frame-parser) and control encoding (scrcpy-control-protocol) are +// set are coupled to the pinned scrcpy server version; pure video framing is // unit-tested, while this orchestration is exercised live via probes. const DEVICE_NAME_BYTES = 64 @@ -198,7 +197,6 @@ export class ScrcpyStreamSession { socket.on('error', (error) => emulatorProbeError('scrcpy.control.fail', error, { serial: this.options.serial }) ) - // Drop the ref on reset so sendControl() can't write to a dead socket (half-open session). socket.on('close', () => { if (this.controlSocket === socket) { this.controlSocket = null @@ -253,11 +251,6 @@ export class ScrcpyStreamSession { } } - // Sends an encoded scrcpy control message (see scrcpy-control-protocol). - sendControl(message: Buffer): void { - this.controlSocket?.write(message) - } - private fail(message: string): void { if (this.closed) { return diff --git a/src/main/gemini/hook-service.ts b/src/main/gemini/hook-service.ts index 1615ea336f9..4e44dba7bb6 100644 --- a/src/main/gemini/hook-service.ts +++ b/src/main/gemini/hook-service.ts @@ -16,6 +16,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -97,6 +98,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { } export class GeminiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/git/git-capability-state.test.ts b/src/main/git/git-capability-state.test.ts index 0d499aea163..b6e655efe62 100644 --- a/src/main/git/git-capability-state.test.ts +++ b/src/main/git/git-capability-state.test.ts @@ -1,13 +1,19 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { clearGitCapabilityStateForTests, getLocalGitCapabilityCache, - getSshGitCapabilityCache + getSshGitCapabilityCache, + withLocalGitCapabilityCacheForExecution } from './git-capability-state' +import { + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests +} from './wsl-linked-worktree-git-routing' describe('Git capability execution-host state', () => { beforeEach(() => { clearGitCapabilityStateForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('shares native state while isolating each WSL distro', () => { @@ -34,4 +40,34 @@ describe('Git capability execution-host state', () => { getSshGitCapabilityCache(replacementProvider) ) }) + + it('uses native capability state for a prepared host-routed WSL worktree', async () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + try { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + await expect( + withLocalGitCapabilityCacheForExecution( + { cwd: String.raw`C:\repo\linked`, wslDistro: 'Ubuntu' }, + async (capabilities) => capabilities + ) + ).resolves.toBe(getLocalGitCapabilityCache()) + expect(getLocalGitCapabilityCache()).not.toBe( + getLocalGitCapabilityCache({ wslDistro: 'Ubuntu' }) + ) + } finally { + platform.mockRestore() + } + }) + + it('starts non-candidate capability work without an added async turn', async () => { + let started = false + const result = withLocalGitCapabilityCacheForExecution({ cwd: '/repo' }, async () => { + started = true + return 'done' + }) + + expect(started).toBe(true) + await expect(result).resolves.toBe('done') + }) }) diff --git a/src/main/git/git-capability-state.ts b/src/main/git/git-capability-state.ts index aec7a369a26..7721df722c8 100644 --- a/src/main/git/git-capability-state.ts +++ b/src/main/git/git-capability-state.ts @@ -1,9 +1,14 @@ import { GitCapabilityCache } from '../../shared/git-capability-cache' import { parseWslUncPath } from '../../shared/wsl-paths' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from './wsl-linked-worktree-git-routing' type LocalGitCapabilityTarget = { cwd?: string wslDistro?: string + signal?: AbortSignal } const localCapabilitiesByExecutionHost = new Map() @@ -29,6 +34,28 @@ export function getLocalGitCapabilityCache( return cache } +export function withLocalGitCapabilityCacheForExecution( + target: LocalGitCapabilityTarget, + run: (capabilities: GitCapabilityCache) => Promise +): Promise { + if (!target.cwd || !isWslLinkedWorktreeGitRoutingCandidate(target.cwd, target.wslDistro)) { + try { + return run(getLocalGitCapabilityCache(target)) + } catch (error) { + return Promise.reject(error) + } + } + return prepareWslLinkedWorktreeGitRouting(target.cwd, target.wslDistro, { + signal: target.signal + }).then((usesHostGit) => + run( + getLocalGitCapabilityCache( + usesHostGit ? { cwd: target.cwd } : { cwd: target.cwd, wslDistro: target.wslDistro } + ) + ) + ) +} + export function getSshGitCapabilityCache(provider: object): GitCapabilityCache { let cache = sshCapabilitiesByProvider.get(provider) if (!cache) { diff --git a/src/main/git/runner-wsl-direct-read.test.ts b/src/main/git/runner-wsl-direct-read.test.ts index f90ea76ad9b..cc6e8364d7f 100644 --- a/src/main/git/runner-wsl-direct-read.test.ts +++ b/src/main/git/runner-wsl-direct-read.test.ts @@ -17,12 +17,18 @@ vi.mock('../observability/instrumentation', () => ({ })) vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() })) -import { gitExecFileAsync, gitStreamStdout } from './runner' +import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner' import { getWslGitReadEnvironment, resetWslGitReadEnvironmentForTests, seedWslGitReadEnvironmentForTests } from './wsl-git-read-environment' +import { + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' const DISTRO = 'Ubuntu' const LOGIN_ENVIRONMENT = { @@ -71,10 +77,12 @@ describe('WSL direct Git reads', () => { execFileSyncMock.mockReset() spawnMock.mockReset() resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) afterEach(() => { resetWslGitReadEnvironmentForTests() + resetWslLinkedWorktreeGitRoutingForTests() }) it('coalesces the login-shell environment probe per distro', async () => { @@ -495,4 +503,125 @@ describe('WSL direct Git reads', () => { expect(execFileMock.mock.calls[0]?.[0]).toBe('git') }) }) + + it('keeps gitSpawn cache-only while async linked-worktree discovery is pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const pending = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[0]?.[0]).toBe('wsl.exe') + releaseStat?.() + await expect(pending).resolves.toBe(true) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls[1]?.[0]).toBe('git') + }) + }) + + it('aborts an async Git call while linked-worktree discovery remains pending', async () => { + await withPlatform('win32', async () => { + let releaseStat: (() => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + await delayedStat + return { isDirectory: () => false, isFile: () => true } + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const discovery = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + const controller = new AbortController() + + const command = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + signal: controller.signal + }) + controller.abort() + + await expect(command).rejects.toMatchObject({ name: 'AbortError' }) + expect(execFileMock).not.toHaveBeenCalled() + releaseStat?.() + await expect(discovery).resolves.toBe(true) + }) + }) + + it('keeps gitSpawn cache-only when a linked-worktree route expires', async () => { + await withPlatform('win32', async () => { + let currentTime = 1_000 + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => currentTime) + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => ({ isDirectory: () => false, isFile: () => true })), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + spawnMock.mockReturnValue(createMockChild()) + + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + + expect(spawnMock.mock.calls[0]?.[0]).toBe('git') + expect(spawnMock.mock.calls[1]?.[0]).toBe('wsl.exe') + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + await prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, DISTRO, { + platform: 'win32', + fileSystem + }) + gitSpawn(['ls-files'], { + cwd: String.raw`C:\repo`, + wslDistro: DISTRO, + stdio: ['ignore', 'pipe', 'pipe'] + }) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(spawnMock.mock.calls[2]?.[0]).toBe('git') + } finally { + nowSpy.mockRestore() + } + }) + }) }) diff --git a/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts new file mode 100644 index 00000000000..efc37c192f7 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-timeout.test.ts @@ -0,0 +1,98 @@ +import { EventEmitter } from 'node:events' +import type * as FsPromises from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, statMock, readFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + statMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock, + execFileSync: vi.fn(), + spawn: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => ({ + ...(await importOriginal()), + stat: statMock, + readFile: readFileMock +})) + +import { gitExecFileAsync } from './runner' +import { + resetWslLinkedWorktreeGitRoutingForTests, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS +} from './wsl-linked-worktree-git-routing' + +function createMockChild(): EventEmitter & { pid: number; kill: ReturnType } { + const child = new EventEmitter() as EventEmitter & { + pid: number + kill: ReturnType + } + child.pid = 1234 + child.kill = vi.fn() + return child +} + +async function withWindowsPlatform(run: () => Promise): Promise { + const original = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await run() + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: original }) + } +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + resetWslLinkedWorktreeGitRoutingForTests() +}) + +describe('WSL linked-worktree routing probe timeout', () => { + it('retries discovery on the next production Git call', async () => { + vi.useFakeTimers() + await withWindowsPlatform(async () => { + statMock + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValue({ isDirectory: () => false, isFile: () => true }) + readFileMock.mockResolvedValue('gitdir: C:/main/.git/worktrees/linked\n') + execFileMock.mockImplementation((command, _args, _options, callback) => { + const child = createMockChild() + queueMicrotask(() => { + if (command === 'wsl.exe') { + callback?.( + Object.assign(new Error('not a git repository'), { code: 128 }), + '', + 'fatal: not a git repository' + ) + } else { + callback?.(null, 'host git recovered\n', '') + } + }) + return child + }) + + const first = gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + const firstFailure = expect(first).rejects.toThrow('not a git repository') + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await firstFailure + + await expect( + gitExecFileAsync(['status', '--short'], { + cwd: String.raw`C:\repo`, + wslDistro: 'Ubuntu' + }) + ).resolves.toEqual({ stdout: 'host git recovered\n', stderr: '' }) + + expect(statMock).toHaveBeenCalledTimes(2) + expect(execFileMock.mock.calls.map(([command]) => command)).toEqual(['wsl.exe', 'git']) + }) + }) +}) diff --git a/src/main/git/runner-wsl-linked-gitdir-windows.test.ts b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts new file mode 100644 index 00000000000..1af46a51069 --- /dev/null +++ b/src/main/git/runner-wsl-linked-gitdir-windows.test.ts @@ -0,0 +1,161 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { listFilesWithGit } from '../ipc/filesystem-list-files-git-fallback' +import { searchWithGitGrep } from '../ipc/filesystem-search-git' +import { + gitExecFileAsync, + gitExecFileAsyncBuffer, + gitStreamStdout, + toLinuxPath, + toWindowsWslPath +} from './runner' +import { listWorktrees } from './worktree' +import { resetWslLinkedWorktreeGitRoutingForTests } from './wsl-linked-worktree-git-routing' + +const distro = process.env.ORCA_TEST_WSL_DISTRO?.trim() +const fixtureRoots: string[] = [] +const wslFixtureRoots: string[] = [] + +function hostGit(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }) +} + +function wslExec(args: string[]): string { + return execFileSync('wsl.exe', ['-d', distro!, '--exec', ...args], { encoding: 'utf8' }) +} + +afterEach(async () => { + await Promise.all( + fixtureRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) + for (const path of wslFixtureRoots.splice(0)) { + if (!/^\/tmp\/orca-wsl-native-[A-Za-z0-9]+$/.test(path)) { + throw new Error(`Refusing to remove unexpected WSL fixture path: ${path}`) + } + wslExec(['rm', '-rf', '--', path]) + } +}) + +describe.runIf(process.platform === 'win32' && Boolean(distro))( + 'Windows-authored linked worktree in WSL', + () => { + it('handles the worktree through the production Git runner', async () => { + const fixtureRoot = await mkdtemp(join(process.cwd(), '.wsl-linked-gitdir-')) + fixtureRoots.push(fixtureRoot) + const mainPath = join(fixtureRoot, 'main') + const linkedPath = join(fixtureRoot, 'linked') + + hostGit(['init', mainPath], fixtureRoot) + hostGit(['config', 'user.name', 'Orca Test'], mainPath) + hostGit(['config', 'user.email', 'test@invalid'], mainPath) + await writeFile(join(mainPath, 'tracked.txt'), 'tracked\n') + hostGit(['add', 'tracked.txt'], mainPath) + hostGit(['commit', '-m', 'fixture'], mainPath) + hostGit(['worktree', 'add', '-b', 'linked', linkedPath], mainPath) + await writeFile(join(linkedPath, 'untracked.txt'), 'untracked\n') + + const gitFile = await readFile(join(linkedPath, '.git'), 'utf8') + expect(gitFile).toMatch(/^gitdir: [A-Za-z]:\//) + expect(() => + execFileSync( + 'wsl.exe', + ['-d', distro!, '--exec', 'git', '-C', toLinuxPath(linkedPath), 'status', '--short'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] } + ) + ).toThrow(/not a git repository/i) + + await expect(listFilesWithGit(linkedPath, [], { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining(['tracked.txt', 'untracked.txt']) + ) + resetWslLinkedWorktreeGitRoutingForTests() + await expect( + searchWithGitGrep(linkedPath, { query: 'tracked', rootPath: linkedPath }, 10, { + wslDistro: distro! + }) + ).resolves.toMatchObject({ totalMatches: 2 }) + + let streamedStatus = '' + await expect( + gitStreamStdout(['status', '--short'], { + cwd: linkedPath, + wslDistro: distro!, + onStdout: (chunk) => { + streamedStatus += chunk + } + }) + ).resolves.toEqual({ stoppedEarly: false }) + expect(streamedStatus).toBe('?? untracked.txt\n') + + await gitExecFileAsync(['add', '--', 'untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsync(['status', '--short'], { cwd: linkedPath }) + ).resolves.toMatchObject({ stdout: 'A untracked.txt\n' }) + await expect( + gitExecFileAsyncBuffer(['show', ':untracked.txt'], { + cwd: linkedPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: Buffer.from('untracked\n') }) + + const folderPath = join(linkedPath, 'packages', 'app') + await mkdir(folderPath, { recursive: true }) + await expect( + gitExecFileAsync(['rev-parse', '--show-toplevel'], { + cwd: folderPath, + wslDistro: distro! + }) + ).resolves.toMatchObject({ stdout: `${linkedPath.replace(/\\/g, '/')}\n` }) + + const linkedExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: linkedPath, + wslDistro: distro! + }) + const mainExecPath = await gitExecFileAsync(['--exec-path'], { + cwd: mainPath, + wslDistro: distro! + }) + expect(linkedExecPath.stdout).toMatch(/^[A-Za-z]:\//) + expect(mainExecPath.stdout).toMatch(/(?:^|\n)\/[^\n]+\n$/) + await expect(listWorktrees(linkedPath, { wslDistro: distro! })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: expect.stringMatching(/[\\/]linked$/) }) + ]) + ) + + const nestedPath = join(linkedPath, 'nested') + wslExec(['git', 'init', toLinuxPath(nestedPath)]) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: nestedPath, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + + it('keeps WSL-native repositories on WSL Git', async () => { + const linuxRoot = wslExec(['mktemp', '-d', '/tmp/orca-wsl-native-XXXXXX']).trim() + wslFixtureRoots.push(linuxRoot) + const windowsRoot = toWindowsWslPath(linuxRoot, distro!) + + wslExec(['git', 'init', linuxRoot]) + wslExec(['git', '-C', linuxRoot, 'config', 'user.name', 'Orca Test']) + wslExec(['git', '-C', linuxRoot, 'config', 'user.email', 'test@invalid']) + await writeFile(join(windowsRoot, 'tracked.txt'), 'tracked\n') + wslExec(['git', '-C', linuxRoot, 'add', 'tracked.txt']) + wslExec(['git', '-C', linuxRoot, 'commit', '-m', 'fixture']) + await writeFile(join(windowsRoot, 'untracked.txt'), 'untracked\n') + + await expect( + gitExecFileAsync(['status', '--short'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\?\? untracked\.txt\n$/) }) + await expect( + gitExecFileAsync(['--exec-path'], { cwd: windowsRoot, wslDistro: distro! }) + ).resolves.toMatchObject({ stdout: expect.stringMatching(/(?:^|\n)\/[^\n]+\n$/) }) + }) + } +) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 5371ab520d3..2bca4a4e034 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -48,6 +48,11 @@ import { peekWslGitReadEnvironment, type WslGitReadEnvironment } from './wsl-git-read-environment' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting, + usesHostGitForWslLinkedWorktree +} from './wsl-linked-worktree-git-routing' // Re-exported for existing importers; lightweight consumers should import from './exec-error' to avoid this heavy module. import { extractExecError, parseRetryAfterMs } from './exec-error' export { extractExecError, parseRetryAfterMs } @@ -335,6 +340,10 @@ function resolveGitCommand( options: GitExecOptions, forceLoginShell = false ): ResolvedCommand { + if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) { + // Why: WSL Git resolves a Windows-authored linked-worktree pointer relative to cwd. + return { binary: 'git', args, cwd: options.cwd, wsl: null, wslMode: null } + } if (!forceLoginShell && shouldAttemptWslDirectGit(options)) { const distro = wslDistroForCommand(options.cwd, options.wslDistro) const environment = distro ? peekWslGitReadEnvironment(distro) : undefined @@ -908,13 +917,7 @@ async function buildNetworkSshPolicyEnv(options: GitExecOptions): Promise<{ return { env: promptEnv, mode: 'explicit-env' } } - const resolved = resolveCommand( - 'git', - ['config', '--get', 'core.sshCommand'], - options.cwd, - options.wslDistro, - { useWslLoginShell: Boolean(options.wslDistro) } - ) + const resolved = resolveGitCommand(['config', '--get', 'core.sshCommand'], options, true) let configuredCommand = '' try { const { stdout } = await execFileCapture(resolved.binary, resolved.args, { @@ -964,6 +967,11 @@ export async function gitExecFileAsync( return withGitSpan( { args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } const resolved = resolveGitCommand(args, options) const policy = options.useConfiguredSshCommandForNetwork ? await buildNetworkSshPolicyEnv(options) @@ -1055,9 +1063,10 @@ export async function gitExecFileAsyncBuffer( args: string[], options: { cwd: string; maxBuffer?: number; wslDistro?: string } ): Promise<{ stdout: Buffer }> { - const resolved = resolveCommand('git', args, options.cwd, options.wslDistro, { - useWslLoginShell: Boolean(options.wslDistro) - }) + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro) + } + const resolved = resolveGitCommand(args, options, true) const { stdout } = (await execFileCapture(resolved.binary, resolved.args, { cwd: resolved.cwd, encoding: 'buffer', @@ -1098,6 +1107,11 @@ export async function gitStreamStdout( ): Promise { const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER return withGitSpan({ args, cwd: options.cwd }, async () => { + if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, { + signal: options.signal + }) + } const gitOptions: GitExecOptions = { cwd: options.cwd, ...(options.env ? { env: options.env } : {}), @@ -1294,8 +1308,10 @@ export function gitSpawn( options: SpawnOptions & { cwd: string; wslDistro?: string } ): ChildProcess { const { wslDistro, ...spawnOptions } = options - const resolved = resolveCommand('git', args, options.cwd, wslDistro, { - useWslLoginShell: Boolean(wslDistro) + const resolved = resolveGitCommand(args, { + cwd: options.cwd, + ...(wslDistro ? { wslDistro } : {}), + ...(spawnOptions.env ? { env: spawnOptions.env } : {}) }) const spawnStartedAt = performance.now() const child = spawn(resolved.binary, resolved.args, { diff --git a/src/main/git/status-conflict-overlap.bench.test.ts b/src/main/git/status-conflict-overlap.bench.test.ts new file mode 100644 index 00000000000..5c92a23e10c --- /dev/null +++ b/src/main/git/status-conflict-overlap.bench.test.ts @@ -0,0 +1,250 @@ +import { performance } from 'node:perf_hooks' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFsPromises from 'node:fs/promises' +import type * as GitRunner from './runner' +import type { GitExec } from '../../relay/git-handler-ops' +import type { RelayGitStreamExec } from '../../relay/git-stdout-stream' + +const { gitStreamStdoutMock, readFileMock } = vi.hoisted(() => ({ + gitStreamStdoutMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFile: readFileMock } +}) + +vi.mock('./runner', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, gitStreamStdout: gitStreamStdoutMock } +}) + +import { getStatus } from './status' +import { getStatusOp } from '../../relay/git-handler-status-ops' + +const BENCH_DELAY_MS = 25 +const BENCH_SAMPLES = 31 +const BENCH_WARMUPS = 5 +const describeBench = process.env.ORCA_GIT_STATUS_OVERLAP_BENCH === '1' ? describe : describe.skip + +type Deferred = { + promise: Promise + resolve: (value: T) => void +} + +type BenchmarkResult = { + path: string + medianMs: number + p95Ms: number + minMs: number + maxMs: number +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + return { promise, resolve } +} + +function wait(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +function percentile(samples: number[], fraction: number): number { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.ceil(sorted.length * fraction) - 1]! +} + +describe('git status conflict-read overlap', () => { + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + + beforeEach(() => { + readFileMock.mockReset() + gitStreamStdoutMock.mockReset() + readFileMock.mockResolvedValue('gitdir: /repo/.git/worktrees/feature\n') + gitStreamStdoutMock.mockResolvedValue({ stoppedEarly: false }) + }) + + it('starts native status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockImplementation(async () => { + statusStarted.resolve() + return { stoppedEarly: false } + }) + + const resultPromise = getStatus('/repo') + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('starts relay status before conflict-marker I/O settles', async () => { + const markerRead = deferred() + const statusStarted = deferred() + readFileMock.mockReturnValue(markerRead.promise) + const relayStreamGit: RelayGitStreamExec = async () => { + statusStarted.resolve() + return { stoppedEarly: false } + } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + await statusStarted.promise + expect(readFileMock).toHaveBeenCalledWith(join('/repo', '.git'), 'utf-8') + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('observes an early native status rejection while marker I/O remains pending', async () => { + const markerRead = deferred() + readFileMock.mockReturnValue(markerRead.promise) + gitStreamStdoutMock.mockRejectedValue(new Error('status failed first')) + let settled = false + + const resultPromise = getStatus('/repo').finally(() => { + settled = true + }) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + + markerRead.resolve('gitdir: /repo/.git/worktrees/feature\n') + await expect(resultPromise).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it('keeps a synchronous native status failure fail-soft', async () => { + const statusError = new Error('status threw') + gitStreamStdoutMock.mockImplementation(() => { + throw statusError + }) + + await expect(getStatus('/repo')).resolves.toMatchObject({ + entries: [], + conflictOperation: 'unknown' + }) + }) + + it.each(['throw', 'reject'] as const)('keeps relay %s failures fail-soft', async (mode) => { + const statusError = new Error(`status ${mode}`) + const relayStreamGit = (() => { + if (mode === 'throw') { + throw statusError + } + return Promise.reject(statusError) + }) as RelayGitStreamExec + + await expect( + getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + ).resolves.toMatchObject({ entries: [], conflictOperation: 'unknown' }) + }) + + it('rethrows the original relay status failure after cancellation', async () => { + const controller = new AbortController() + const statusError = new Error('cancelled status') + const relayStreamGit: RelayGitStreamExec = async () => { + controller.abort(statusError) + throw statusError + } + + await expect( + getStatusOp( + relayGit, + relayStreamGit, + { worktreePath: '/repo' }, + { signal: controller.signal } + ) + ).rejects.toBe(statusError) + }) + + it('surfaces detector errors without waiting for a hung status read', async () => { + const statusStarted = deferred() + const relayStreamGit: RelayGitStreamExec = () => { + statusStarted.resolve() + return new Promise(() => {}) + } + const invalidPath = { toString: () => '/repo' } + + const resultPromise = getStatusOp(relayGit, relayStreamGit, { + worktreePath: invalidPath + }) + const rejection = expect(resultPromise).rejects.toThrow(TypeError) + await statusStarted.promise + await rejection + }) + + it('keeps detector errors ahead of concurrent status failures', async () => { + const statusError = new Error('status failed too') + const relayStreamGit = (() => { + throw statusError + }) as RelayGitStreamExec + const invalidPath = { toString: () => '/repo' } + + const result = getStatusOp(relayGit, relayStreamGit, { worktreePath: invalidPath }) + await expect(result).rejects.toBeInstanceOf(TypeError) + }) +}) + +describeBench('git status conflict-read overlap benchmark', () => { + it('measures native and relay orchestration with matched independent latency', async () => { + readFileMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return 'gitdir: /repo/.git/worktrees/feature\n' + }) + gitStreamStdoutMock.mockImplementation(async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + }) + const relayGit: GitExec = async () => ({ stdout: '', stderr: '' }) + const relayStreamGit: RelayGitStreamExec = async () => { + await wait(BENCH_DELAY_MS) + return { stoppedEarly: false } + } + const cases = [ + { name: 'native', run: () => getStatus('/repo') }, + { + name: 'relay', + run: () => getStatusOp(relayGit, relayStreamGit, { worktreePath: '/repo' }) + } + ] + + const results: BenchmarkResult[] = [] + for (const benchmarkCase of cases) { + for (let index = 0; index < BENCH_WARMUPS; index += 1) { + await benchmarkCase.run() + } + const samples: number[] = [] + for (let index = 0; index < BENCH_SAMPLES; index += 1) { + const startedAt = performance.now() + await benchmarkCase.run() + samples.push(performance.now() - startedAt) + } + results.push({ + path: benchmarkCase.name, + medianMs: Number(percentile(samples, 0.5).toFixed(3)), + p95Ms: Number(percentile(samples, 0.95).toFixed(3)), + minMs: Number(Math.min(...samples).toFixed(3)), + maxMs: Number(Math.max(...samples).toFixed(3)) + }) + } + + console.table(results) + expect(results).toHaveLength(2) + }, 10_000) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 07bf124d31c..a51d1784ddf 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -315,22 +315,32 @@ async function runGetStatus( // Why: stream + parse and stop at `limit` so a huge un-ignored folder can't buffer enough to crash the process. const parser = new StatusPorcelainParser() let didHitLimit = false + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const result = await gitStreamStdout(statusArgs, { + cwd: worktreePath, + wslDistro: options.wslDistro, + preferWslDirectGit: true, + // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. + env: gitOptionalLocksDisabledEnv(), + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return result + })() + ]) const conflictOperation = await conflictPromise try { - const { stoppedEarly } = await gitStreamStdout(statusArgs, { - cwd: worktreePath, - wslDistro: options.wslDistro, - preferWslDirectGit: true, - // Why: status polling is read-like; disable optional locks to avoid racing terminal Git on index.lock. - env: gitOptionalLocksDisabledEnv(), - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } - didHitLimit = stoppedEarly + didHitLimit = statusResult.value.stoppedEarly statusSucceeded = true } catch (error) { // Why: an aborted scan must reject, not resolve as an empty result. diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 064eb0887fd..9ee8eb2e188 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -30,7 +30,7 @@ import { isUnsupportedRevParsePathFormatError, isUnsupportedWorktreeListZError } from '../../shared/git-worktree-command-capabilities' -import { getLocalGitCapabilityCache } from './git-capability-state' +import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' import { gitExecFileAsync, translateWslOutputPaths } from './runner' import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' @@ -410,32 +410,32 @@ async function readRepoLocation( resolveBasePath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) try { - return await capabilities.runWithFallback( - 'rev-parse-path-format', - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) + return await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'rev-parse-path-format', + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + if (hasUnsupportedRevParsePathFormatEcho(stdout)) { + // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. + capabilities.rememberUnsupported('rev-parse-path-format') + } + return parseRepoLocation(resolveBasePath, stdout) + }, + async () => { + const { stdout } = await gitExecFileAsync( + ['rev-parse', '--show-toplevel', '--git-common-dir'], + gitExecOptions(repoPath, options) + ) + return parseRepoLocation(resolveBasePath, stdout) + }, + isUnsupportedRevParsePathFormatError ) - if (hasUnsupportedRevParsePathFormatEcho(stdout)) { - // Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers. - capabilities.rememberUnsupported('rev-parse-path-format') - } - return parseRepoLocation(resolveBasePath, stdout) - }, - async () => { - const { stdout } = await gitExecFileAsync( - ['rev-parse', '--show-toplevel', '--git-common-dir'], - gitExecOptions(repoPath, options) - ) - return parseRepoLocation(resolveBasePath, stdout) - }, - isUnsupportedRevParsePathFormatError ) } catch { return undefined @@ -578,41 +578,44 @@ async function readWorktreeList( repoPath: string, options: GitWorktreeExecOptions = {} ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: options.wslDistro - }) const execOptions = { cwd: repoPath, ...options, timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS } - return capabilities.runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await gitExecFileAsync( - ['worktree', 'list', '--porcelain', '-z'], - execOptions + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + capabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain', '-z'], + execOptions + ) + return normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }), + options + ) + }, + async () => { + // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. + const { stdout } = await gitExecFileAsync( + ['worktree', 'list', '--porcelain'], + execOptions + ) + const normalized = await normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout), + options + ) + // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting + // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). + return annotatePrunableByExistence(normalized, repoPath, options) + }, + isUnsupportedWorktreeListZError ) - return normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout, { nulDelimited: true }), - options - ) - }, - async () => { - // Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser. - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], execOptions) - const normalized = await normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout), - options - ) - // Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting - // stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389). - return annotatePrunableByExistence(normalized, repoPath, options) - }, - isUnsupportedWorktreeListZError ) } @@ -1355,14 +1358,12 @@ async function deleteAlreadyMergedBranchAfterSafeDeleteFailure( }) const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName) // Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes. - if ( - !(await branchHasNoUnmergedChangesWithLazyTargetRefresh( - runGit, - branchName, - targetRefs, - getLocalGitCapabilityCache({ cwd: repoPath, wslDistro: options.wslDistro }) - )) - ) { + const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal }, + (capabilities) => + branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities) + ) + if (!hasNoUnmergedChanges) { return false } await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) => diff --git a/src/main/git/wsl-linked-worktree-git-routing.test.ts b/src/main/git/wsl-linked-worktree-git-routing.test.ts new file mode 100644 index 00000000000..9ab07309b1f --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.test.ts @@ -0,0 +1,600 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + parseWindowsLinkedGitdir, + prepareWslLinkedWorktreeGitRouting, + resetWslLinkedWorktreeGitRoutingForTests, + seedWslLinkedWorktreeGitRoutingForTests, + usesHostGitForWslLinkedWorktree, + WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD, + WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL, + WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS, + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS, + WSL_LINKED_WORKTREE_ROUTE_TTL_MS, + type WslLinkedWorktreeRoutingFileSystem +} from './wsl-linked-worktree-git-routing' + +afterEach(() => resetWslLinkedWorktreeGitRoutingForTests()) + +const fileMarker = { isDirectory: () => false, isFile: () => true } +const directoryMarker = { isDirectory: () => true, isFile: () => false } + +function missingMarker(): NodeJS.ErrnoException { + return Object.assign(new Error('missing'), { code: 'ENOENT' }) +} + +describe('parseWindowsLinkedGitdir', () => { + it.each([ + ['gitdir: C:/repo/.git/worktrees/linked\n', 'C:/repo/.git/worktrees/linked'], + [String.raw`gitdir: D:\repo\.git\worktrees\linked`, String.raw`D:\repo\.git\worktrees\linked`] + ])('accepts a Windows drive-qualified gitdir', (content, expected) => { + expect(parseWindowsLinkedGitdir(content)).toBe(expected) + }) + + it.each([ + 'gitdir: ../main/.git/worktrees/linked', + 'gitdir: /home/dev/repo/.git/worktrees/linked', + 'gitdir: C:repo/.git/worktrees/linked', + 'not a gitdir' + ])('rejects the non-Windows-linked marker %s', (content) => { + expect(parseWindowsLinkedGitdir(content)).toBeNull() + }) +}) + +describe('usesHostGitForWslLinkedWorktree', () => { + it('scopes a cached route to the exact primed folder workspace', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked\packages\app`) + + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\packages\app`, 'Ubuntu', 'win32') + ).toBe(true) + expect(usesHostGitForWslLinkedWorktree('C:/repo/linked/packages/app/', 'Ubuntu', 'win32')).toBe( + true + ) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\main`, 'Ubuntu', 'win32')).toBe(false) + }) + + it('does not affect native Windows, WSL-native, or non-Windows execution', () => { + seedWslLinkedWorktreeGitRoutingForTests(String.raw`C:\repo\linked`) + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, undefined, 'win32')).toBe( + false + ) + expect( + usesHostGitForWslLinkedWorktree( + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', + 'Ubuntu', + 'win32' + ) + ).toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked`, 'Ubuntu', 'linux')).toBe( + false + ) + }) +}) + +describe('prepareWslLinkedWorktreeGitRouting', () => { + it('discovers parent markers independently for sibling folder workspaces', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return fileMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('caches an ordinary repository marker without reading it', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => directoryMarker), + readFile: vi.fn(async () => '') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + expect(fileSystem.readFile).not.toHaveBeenCalled() + }) + + it('fails closed without caching a marker read error', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi + .fn() + .mockRejectedValueOnce(Object.assign(new Error('access denied'), { code: 'EACCES' })) + .mockResolvedValueOnce('gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(2) + }) + + it('backs off repeated marker stat errors after one immediate retry', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => { + throw Object.assign(new Error('device unavailable'), { code: 'EIO' }) + }), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + await expect(prepare()).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS - 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + + currentTime += 1 + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(3) + }) + + it('does not inherit a linked-parent route across a nested repository boundary', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\repo\linked\nested\.git` ? directoryMarker : fileMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\linked\nested`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect( + usesHostGitForWslLinkedWorktree(String.raw`C:\repo\linked\nested`, 'Ubuntu', 'win32') + ).toBe(false) + }) + + it('does not conflate case-distinct directories on case-sensitive Windows storage', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => + path === String.raw`C:\Repo\.git` ? fileMarker : directoryMarker + ), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\Repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + }) + + it('revalidates a linked route that becomes an ordinary repository', async () => { + let linked = true + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + linked = false + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('revalidates an ordinary repository that becomes a linked worktree', async () => { + let linked = false + let currentTime = 1_000 + const now = (): number => currentTime + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => (linked ? fileMarker : directoryMarker)), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + linked = true + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS - 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + + currentTime += 1 + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(true) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32', now)).toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('does not let a sibling route supersede an unexpired nested-repository miss', async () => { + let linked = false + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async (path) => { + if (path === String.raw`C:\repo\.git`) { + return linked ? fileMarker : directoryMarker + } + throw missingMarker() + }), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + linked = true + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\other`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo\packages\app`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(6) + }) + + it('does not evict fresh routes while pruning under working-directory churn', async () => { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(async () => fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + for (let index = 0; index <= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD; index += 1) { + await prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + } + + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo-0`, 'Ubuntu', 'win32')).toBe(true) + expect( + usesHostGitForWslLinkedWorktree( + `C:\\repo-${WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD}`, + 'Ubuntu', + 'win32' + ) + ).toBe(true) + }) + + it('coalesces delayed discovery without blocking an event-loop turn', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + let settled = false + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }).finally(() => { + settled = true + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await new Promise((resolve) => setImmediate(resolve)) + + expect(settled).toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) + + it('cancels a waiter without canceling or duplicating shared discovery', async () => { + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const controller = new AbortController() + + const cancelled = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + signal: controller.signal + }) + controller.abort() + + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + releaseStat?.(fileMarker) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('ignores an old probe that settles after reset and a new same-path probe starts', async () => { + let releaseOld: ((marker: typeof fileMarker) => void) | undefined + const oldMarker = new Promise((resolve) => { + releaseOld = resolve + }) + const oldFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => oldMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + const oldRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: oldFileSystem + }) + + resetWslLinkedWorktreeGitRoutingForTests() + let releaseCurrent: ((marker: typeof directoryMarker) => void) | undefined + const currentMarker = new Promise((resolve) => { + releaseCurrent = resolve + }) + const currentFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => currentMarker), + readFile: vi.fn(async () => '') + } + const currentRoute = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + + releaseOld?.(fileMarker) + await expect(oldRoute).resolves.toBe(false) + expect(usesHostGitForWslLinkedWorktree(String.raw`C:\repo`, 'Ubuntu', 'win32')).toBe(false) + const currentJoiner = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem: currentFileSystem + }) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + + releaseCurrent?.(directoryMarker) + await expect(Promise.all([currentRoute, currentJoiner])).resolves.toEqual([false, false]) + expect(currentFileSystem.stat).toHaveBeenCalledTimes(1) + }) + + it('fails closed and retries after a stalled discovery deadline', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockImplementationOnce(() => new Promise(() => {})) + .mockResolvedValueOnce(fileMarker), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + + await expect(Promise.all([first, second])).resolves.toEqual([false, false]) + expect(fileSystem.stat).toHaveBeenCalledTimes(1) + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(true) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes for one working directory after repeated deadlines', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const prepare = (): Promise => + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + + for (let index = 0; index < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD; index += 1) { + const route = prepare() + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(route).resolves.toBe(false) + } + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS) + await expect(prepare()).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD) + } finally { + vi.useRealTimers() + } + }) + + it('caps outstanding probes across working directories', async () => { + vi.useFakeTimers() + try { + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi.fn(() => new Promise(() => {})), + readFile: vi.fn(async () => '') + } + const pending = Array.from( + { length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, + (_, index) => + prepareWslLinkedWorktreeGitRouting(`C:\\repo-${index}`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ) + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\blocked`, 'Ubuntu', { + platform: 'win32', + fileSystem + }) + ).resolves.toBe(false) + expect(fileSystem.stat).toHaveBeenCalledTimes(WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL) + await vi.advanceTimersByTimeAsync(WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + await expect(Promise.all(pending)).resolves.toEqual( + Array.from({ length: WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL }, () => false) + ) + } finally { + vi.useRealTimers() + } + }) + + it('coalesces concurrent revalidation after a cached route expires', async () => { + let currentTime = 1_000 + const now = (): number => currentTime + let releaseStat: ((marker: typeof fileMarker) => void) | undefined + const delayedStat = new Promise((resolve) => { + releaseStat = resolve + }) + const fileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: vi + .fn() + .mockResolvedValueOnce(directoryMarker) + .mockReturnValueOnce(delayedStat), + readFile: vi.fn(async () => 'gitdir: C:/main/.git/worktrees/linked\n') + } + + await expect( + prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + ).resolves.toBe(false) + currentTime += WSL_LINKED_WORKTREE_ROUTE_TTL_MS + + const first = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + const second = prepareWslLinkedWorktreeGitRouting(String.raw`C:\repo`, 'Ubuntu', { + platform: 'win32', + fileSystem, + now + }) + + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + releaseStat?.(fileMarker) + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(fileSystem.stat).toHaveBeenCalledTimes(2) + expect(fileSystem.readFile).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/git/wsl-linked-worktree-git-routing.ts b/src/main/git/wsl-linked-worktree-git-routing.ts new file mode 100644 index 00000000000..440307fd5f0 --- /dev/null +++ b/src/main/git/wsl-linked-worktree-git-routing.ts @@ -0,0 +1,306 @@ +import * as fsPromises from 'node:fs/promises' +import { win32 } from 'node:path' +import type { Stats } from 'node:fs' + +type CachedRoute = { usesHostGit: boolean; expiresAt: number } +type TransientRouteFailure = { count: number; retryAfter: number } + +// Bounds stale routing after delete/recreate while amortizing async parent walks on slow storage. +export const WSL_LINKED_WORKTREE_ROUTE_TTL_MS = 30_000 +export const WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD = 512 +export const WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS = 5_000 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD = 2 +export const WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL = 32 +export const WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS = 1_000 +const WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS = 30_000 + +const routeByCwd = new Map() +const pendingRoutes = new Map>() +const transientFailuresByCwd = new Map() +const activeProbeCountByCwd = new Map() +let activeProbeCount = 0 +let routeProbeGeneration = 0 +let nextRoutePruneAt = 0 + +export function isWslLinkedWorktreeGitRoutingCandidate( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform +): boolean { + return platform === 'win32' && Boolean(wslDistro) && /^[A-Za-z]:[/\\]/.test(cwd) +} + +function normalize(path: string): string { + return win32.resolve(path) +} + +function cachedRoute(cwd: string, now: number): boolean | undefined { + const exact = routeByCwd.get(cwd) + if (!exact) { + return undefined + } + if (exact.expiresAt <= now) { + routeByCwd.delete(cwd) + return undefined + } + return exact.usesHostGit +} + +export function parseWindowsLinkedGitdir(content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + return firstLine.match(/^gitdir:\s*([A-Za-z]:[/\\].*?)\s*$/i)?.[1] ?? null +} + +export type WslLinkedWorktreeRoutingFileSystem = { + stat(path: string): Promise> + readFile(path: string): Promise +} + +export type WslLinkedWorktreeRoutingOptions = { + platform?: NodeJS.Platform + fileSystem?: WslLinkedWorktreeRoutingFileSystem + now?: () => number + signal?: AbortSignal +} + +const defaultFileSystem: WslLinkedWorktreeRoutingFileSystem = { + stat: (path) => fsPromises.stat(path), + readFile: (path) => fsPromises.readFile(path, 'utf8') +} + +async function shouldUseHostGit( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem +): Promise { + let candidate = cwd + const driveRoot = win32.parse(candidate).root + while (true) { + const markerPath = win32.join(candidate, '.git') + try { + const marker = await fileSystem.stat(markerPath) + if (marker.isDirectory()) { + return false + } + if (marker.isFile()) { + return parseWindowsLinkedGitdir(await fileSystem.readFile(markerPath)) !== null + } + return false + } catch (error) { + const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : null + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error + } + } + if (candidate === driveRoot) { + return false + } + candidate = win32.dirname(candidate) + } +} + +function rememberRoute(cwd: string, usesHostGit: boolean, now: number): boolean { + const expiresAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + if ( + routeByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD && + now >= nextRoutePruneAt + ) { + for (const [cachedCwd, route] of routeByCwd) { + if (route.expiresAt <= now) { + routeByCwd.delete(cachedCwd) + } + } + nextRoutePruneAt = now + WSL_LINKED_WORKTREE_ROUTE_TTL_MS + } + routeByCwd.set(cwd, { usesHostGit, expiresAt }) + return usesHostGit +} + +function routingAbortError(): Error { + return Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' }) +} + +function rememberTransientFailure(cwd: string, now: number): void { + const count = (transientFailuresByCwd.get(cwd)?.count ?? 0) + 1 + const retryDelay = + count === 1 + ? 0 + : Math.min( + WSL_LINKED_WORKTREE_ROUTE_RETRY_BASE_MS * 2 ** Math.min(count - 2, 10), + WSL_LINKED_WORKTREE_ROUTE_RETRY_MAX_MS + ) + if ( + !transientFailuresByCwd.has(cwd) && + transientFailuresByCwd.size >= WSL_LINKED_WORKTREE_ROUTE_CACHE_PRUNE_THRESHOLD + ) { + const oldest = transientFailuresByCwd.keys().next().value + if (oldest !== undefined) { + transientFailuresByCwd.delete(oldest) + } + } + transientFailuresByCwd.delete(cwd) + transientFailuresByCwd.set(cwd, { count, retryAfter: now + retryDelay }) +} + +function canStartRouteProbe(cwd: string, now: number): boolean { + const failure = transientFailuresByCwd.get(cwd) + return Boolean( + (!failure || failure.retryAfter <= now) && + (activeProbeCountByCwd.get(cwd) ?? 0) < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_PER_CWD && + activeProbeCount < WSL_LINKED_WORKTREE_ROUTE_MAX_PROBES_TOTAL + ) +} + +function trackRouteProbe(cwd: string): () => void { + const generation = routeProbeGeneration + activeProbeCount += 1 + activeProbeCountByCwd.set(cwd, (activeProbeCountByCwd.get(cwd) ?? 0) + 1) + return () => { + if (generation !== routeProbeGeneration) { + return + } + activeProbeCount -= 1 + const remaining = (activeProbeCountByCwd.get(cwd) ?? 1) - 1 + if (remaining === 0) { + activeProbeCountByCwd.delete(cwd) + } else { + activeProbeCountByCwd.set(cwd, remaining) + } + } +} + +function waitForRouteUnlessAborted( + route: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return route + } + if (signal.aborted) { + return Promise.reject(routingAbortError()) + } + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort) + reject(routingAbortError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + route.then( + (result) => { + signal.removeEventListener('abort', onAbort) + resolve(result) + }, + (error) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +function discoverRoute( + cwd: string, + fileSystem: WslLinkedWorktreeRoutingFileSystem, + now: () => number +): Promise { + return new Promise((resolve) => { + const generation = routeProbeGeneration + let settled = false + const finish = (usesHostGit: boolean, cacheable: boolean): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + if (generation !== routeProbeGeneration) { + resolve(false) + return + } + if (cacheable) { + transientFailuresByCwd.delete(cwd) + resolve(rememberRoute(cwd, usesHostGit, now())) + } else { + rememberTransientFailure(cwd, now()) + resolve(usesHostGit) + } + } + const timer = setTimeout(() => finish(false, false), WSL_LINKED_WORKTREE_ROUTE_PROBE_TIMEOUT_MS) + timer.unref() + const releaseProbe = trackRouteProbe(cwd) + void shouldUseHostGit(cwd, fileSystem).then( + (usesHostGit) => { + releaseProbe() + finish(usesHostGit, true) + }, + () => { + releaseProbe() + finish(false, false) + } + ) + }) +} + +export async function prepareWslLinkedWorktreeGitRouting( + cwd: string, + wslDistro: string | undefined, + options: WslLinkedWorktreeRoutingOptions = {} +): Promise { + const platform = options.platform ?? process.platform + const fileSystem = options.fileSystem ?? defaultFileSystem + const now = options.now ?? Date.now + if (!isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform)) { + return false + } + if (options.signal?.aborted) { + throw routingAbortError() + } + const normalizedCwd = normalize(cwd) + const cached = cachedRoute(normalizedCwd, now()) + if (cached !== undefined) { + return cached + } + const pending = pendingRoutes.get(normalizedCwd) + if (pending) { + return waitForRouteUnlessAborted(pending, options.signal) + } + if (!canStartRouteProbe(normalizedCwd, now())) { + return false + } + const discovery = discoverRoute(normalizedCwd, fileSystem, now) + const route = discovery.finally(() => { + if (pendingRoutes.get(normalizedCwd) === route) { + pendingRoutes.delete(normalizedCwd) + } + }) + pendingRoutes.set(normalizedCwd, route) + return waitForRouteUnlessAborted(route, options.signal) +} + +export function usesHostGitForWslLinkedWorktree( + cwd: string, + wslDistro: string | undefined, + platform: NodeJS.Platform = process.platform, + now: () => number = Date.now +): boolean { + return ( + isWslLinkedWorktreeGitRoutingCandidate(cwd, wslDistro, platform) && + cachedRoute(normalize(cwd), now()) === true + ) +} + +export function resetWslLinkedWorktreeGitRoutingForTests(): void { + routeByCwd.clear() + pendingRoutes.clear() + transientFailuresByCwd.clear() + activeProbeCountByCwd.clear() + activeProbeCount = 0 + routeProbeGeneration += 1 + nextRoutePruneAt = 0 +} + +export function seedWslLinkedWorktreeGitRoutingForTests(root: string): void { + const normalizedRoot = normalize(root) + routeByCwd.set(normalizedRoot, { + usesHostGit: true, + expiresAt: Number.POSITIVE_INFINITY + }) +} diff --git a/src/main/github/conflict-summary.ts b/src/main/github/conflict-summary.ts index 73ec657977c..a8c52c46085 100644 --- a/src/main/github/conflict-summary.ts +++ b/src/main/github/conflict-summary.ts @@ -6,7 +6,7 @@ import { import { gitExecFileAsync } from '../git/runner' import { clearGitCapabilityStateForTests, - getLocalGitCapabilityCache + withLocalGitCapabilityCacheForExecution } from '../git/git-capability-state' import { __resetPRConflictSummaryDerivationCachesForTests, @@ -219,10 +219,6 @@ async function loadConflictingFiles( baseOid: string, localGitOptions: LocalGitExecOptions ): Promise { - const capabilities = getLocalGitCapabilityCache({ - cwd: repoPath, - wslDistro: localGitOptions.wslDistro - }) const modernArgs = [ 'merge-tree', '--write-tree', @@ -244,40 +240,44 @@ async function loadConflictingFiles( baseOid ] - return capabilities.runWithFallback( - 'merge-tree-write-tree', - () => + return withLocalGitCapabilityCacheForExecution( + { cwd: repoPath, wslDistro: localGitOptions.wslDistro }, + (capabilities) => capabilities.runWithFallback( - 'merge-tree-merge-base', + 'merge-tree-write-tree', + () => + capabilities.runWithFallback( + 'merge-tree-merge-base', + async () => { + try { + const result = await gitExecFileAsync(modernArgs, { + cwd: repoPath, + ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) + }) + return parseMergeTreeNameOnlyOutput(result.stdout) + } catch (error) { + if (isUnsupportedMergeTreeWriteTreeError(error)) { + throw error + } + // Why: `git merge-tree --write-tree` exits 1 for conflicts but still + // writes the useful file list; only option rejection reaches fallback. + const stdoutFromError = getGitErrorOutput(error, 'stdout') + if (stdoutFromError) { + return parseMergeTreeNameOnlyOutput(stdoutFromError) + } + throw error + } + }, + () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), + isUnsupportedMergeTreeMergeBaseError + ), async () => { - try { - const result = await gitExecFileAsync(modernArgs, { - cwd: repoPath, - ...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}) - }) - return parseMergeTreeNameOnlyOutput(result.stdout) - } catch (error) { - if (isUnsupportedMergeTreeWriteTreeError(error)) { - throw error - } - // Why: `git merge-tree --write-tree` exits 1 for conflicts but still - // writes the useful file list; only option rejection reaches fallback. - const stdoutFromError = getGitErrorOutput(error, 'stdout') - if (stdoutFromError) { - return parseMergeTreeNameOnlyOutput(stdoutFromError) - } - throw error - } + // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; + // fail closed without respawning the same rejected command every refresh. + throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') }, - () => loadConflictingFilesWithLegacyMergeTree(repoPath, legacyArgs, localGitOptions), - isUnsupportedMergeTreeMergeBaseError - ), - async () => { - // Why: Git before 2.38 cannot derive a reliable real-merge conflict list; - // fail closed without respawning the same rejected command every refresh. - throw new Error('Git merge-tree --write-tree is unavailable on this execution host.') - }, - isUnsupportedMergeTreeWriteTreeError + isUnsupportedMergeTreeWriteTreeError + ) ) } diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 51a6150eab6..1b6e8bba165 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -14,6 +14,7 @@ import { writeManagedScript, type HookDefinition } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readHooksJsonRemote, writeHooksJsonRemote, @@ -182,6 +183,10 @@ function buildInstalledConfig( } export class GrokHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const scriptPath = getManagedScriptPath() diff --git a/src/main/ipc/ai-vault-delete.ts b/src/main/ipc/ai-vault-delete.ts index a221cc28ceb..110786d11d7 100644 --- a/src/main/ipc/ai-vault-delete.ts +++ b/src/main/ipc/ai-vault-delete.ts @@ -5,6 +5,7 @@ import { } from '../ai-vault/cached-session-list' import { deleteAiVaultSessionFile } from '../ai-vault/session-delete' import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-parse-cache' +import { invalidateAiVaultBackgroundCache } from '../ai-vault/session-scanner-background' import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { AiVaultDeleteSessionArgs, @@ -15,6 +16,7 @@ import type { // invalidation is injected rather than reached into from here. type AiVaultDeleteDeps = { invalidateMultiHostListCache: () => void + invalidateBackgroundCache?: (paths: string[]) => Promise } // Binds the delete orchestration to the caller's cache-invalidation seam. @@ -52,6 +54,11 @@ export async function deleteAiVaultSession( // what the renderer echoes back as filePath), so invalidate with that exact // key — resolve() could normalise it away from the stored key and miss. invalidateSessionParseCacheEntry(args?.filePath ?? '') + await (deps.invalidateBackgroundCache ?? invalidateAiVaultBackgroundCache)([ + args?.filePath ?? '' + ]).catch((error) => { + console.warn('[ai-vault] background cache invalidation failed:', error) + }) } return result diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index aaf4bb67cd6..a7ba5835611 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -169,14 +169,22 @@ describe('Agent Session History scan coalescing', () => { expect(result).not.toHaveProperty('cancelled') }) - it('still rejects the handler when a scan fails for a non-cancellation reason', async () => { + it('reports a failed local scan as a host issue rather than rejecting', async () => { mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('transcript root is unreadable')) registerAiVaultHandlers() const list = ipcHandler('aiVault:listSessions') - await expect( - list({ sender: { id: 1 } }, { executionHostScope: 'local', requestToken: 'scan' }) - ).rejects.toThrow('transcript root is unreadable') + // The local leg degrades like the SSH legs above: a rejection reaches the + // renderer as a raw string painted over the list instead of an issue row. + const result = await list( + { sender: { id: 1 } }, + { executionHostScope: 'local', requestToken: 'scan' } + ) + expect(result).toMatchObject({ + sessions: [], + issues: [expect.objectContaining({ message: 'transcript root is unreadable', kind: 'host' })] + }) + expect(result).not.toHaveProperty('cancelled') }) it('re-joins a preempted same-scope caller onto the forced refresh', async () => { diff --git a/src/main/ipc/ai-vault-subagent-list.ts b/src/main/ipc/ai-vault-subagent-list.ts index 49f609577c7..64a579c1550 100644 --- a/src/main/ipc/ai-vault-subagent-list.ts +++ b/src/main/ipc/ai-vault-subagent-list.ts @@ -1,7 +1,6 @@ import { resolve } from 'node:path' import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list' -import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' -import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing' +import { listAiVaultSubagentSessionsInBackground } from '../ai-vault/session-scanner-background' import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' @@ -45,7 +44,5 @@ export async function listAiVaultSubagentSessions( if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) { return { sessions: [], issues: [] } } - return args.agent === 'claude' - ? listClaudeSubagentSessions({ parentFilePath }) - : listOmpSubagentSessions({ parentFilePath }) + return listAiVaultSubagentSessionsInBackground({ agent: args.agent, parentFilePath }) } diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 9af91f92a33..13b2bc51926 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -93,6 +93,7 @@ vi.mock('./ssh', () => ({ const { OMP_SESSIONS_DIR } = await import('../ai-vault/session-scanner-roots') const { _internals, registerAiVaultHandlers } = await import('./ai-vault') +const { deleteAiVaultSession: deleteAiVaultSessionWithDeps } = await import('./ai-vault-delete') const provider = {} as IFilesystemProvider @@ -789,9 +790,14 @@ describe('deleteAiVaultSession', () => { } it('invalidates every AI Vault cache after a real delete', async () => { + const invalidateMultiHostListCache = vi.fn() + const invalidateBackgroundCache = vi.fn().mockResolvedValue(undefined) mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' }) - const result = await _internals.deleteAiVaultSession(args) + const result = await deleteAiVaultSessionWithDeps(args, { + invalidateMultiHostListCache, + invalidateBackgroundCache + }) expect(result).toEqual({ outcome: 'deleted' }) expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith( @@ -802,8 +808,10 @@ describe('deleteAiVaultSession', () => { executionHostId: 'local' }) ) + expect(invalidateMultiHostListCache).toHaveBeenCalledTimes(1) expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1) expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath) + expect(invalidateBackgroundCache).toHaveBeenCalledWith([args.filePath]) }) it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => { diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index c2f5be216c7..8f4b411cb6f 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -24,7 +24,7 @@ import { type AiVaultSubagentListArgs, type AiVaultSubagentListResult } from '../../shared/ai-vault-types' -import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' +import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-handler' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' import { LOCAL_EXECUTION_HOST_ID, @@ -139,7 +139,7 @@ async function scanAiVaultSessionsByHostScope( const depth = requestedAiVaultSessionDepth(args) const scopePaths = args?.scopePaths ?? [] if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { - return scanLocalAiVaultSessions(args, signal) + return scanLocalAiVaultSessionsAsIssue(args, signal) } if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() @@ -149,7 +149,7 @@ async function scanAiVaultSessionsByHostScope( ...(sshHosts.issue ? [sshHosts.issue] : []) ] const scannedResults = await Promise.all([ - scanLocalAiVaultSessionsForAllScope(args, signal), + scanLocalAiVaultSessionsAsIssue(args, signal), ...sshHosts.hostInfos.map((hostInfo) => scanHostLegWithCache({ cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`, @@ -226,8 +226,10 @@ function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targ // Why: the SSH legs already degrade to an issue row so one bad host can't take // the shared Promise.all down; the local leg can throw too (parse-cache load, -// WSL home resolution) and would otherwise discard every host's sessions. -async function scanLocalAiVaultSessionsForAllScope( +// WSL home resolution, scanner service supervision) and would otherwise discard +// every host's sessions under 'all', or replace the list with a raw error string +// under single-host scope. +async function scanLocalAiVaultSessionsAsIssue( args: AiVaultListArgs | undefined, signal: AbortSignal | undefined ): Promise { diff --git a/src/main/ipc/claude-usage.ts b/src/main/ipc/claude-usage.ts deleted file mode 100644 index 3496706e8e3..00000000000 --- a/src/main/ipc/claude-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { ClaudeUsageStore } from '../claude-usage/store' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageRange, - ClaudeUsageScope -} from '../../shared/claude-usage-types' - -export function registerClaudeUsageHandlers(claudeUsage: ClaudeUsageStore): void { - ipcMain.handle('claudeUsage:getScanState', () => claudeUsage.getScanState()) - ipcMain.handle('claudeUsage:setEnabled', (_event, args: { enabled: boolean }) => - claudeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('claudeUsage:refresh', (_event, args?: { force?: boolean }) => - claudeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'claudeUsage:getSnapshot', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'claudeUsage:getSummary', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getDaily', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange }) => - claudeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'claudeUsage:getBreakdown', - ( - _event, - args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; kind: ClaudeUsageBreakdownKind } - ) => claudeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'claudeUsage:getRecentSessions', - (_event, args: { scope: ClaudeUsageScope; range: ClaudeUsageRange; limit?: number }) => - claudeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/codex-usage.ts b/src/main/ipc/codex-usage.ts deleted file mode 100644 index 3db15a92076..00000000000 --- a/src/main/ipc/codex-usage.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ipcMain } from 'electron' -import type { CodexUsageStore } from '../codex-usage/store' -import type { - CodexUsageBreakdownKind, - CodexUsageRange, - CodexUsageScope -} from '../../shared/codex-usage-types' - -export function registerCodexUsageHandlers(codexUsage: CodexUsageStore): void { - ipcMain.handle('codexUsage:getScanState', () => codexUsage.getScanState()) - ipcMain.handle('codexUsage:setEnabled', (_event, args: { enabled: boolean }) => - codexUsage.setEnabled(args.enabled) - ) - ipcMain.handle('codexUsage:refresh', (_event, args?: { force?: boolean }) => - codexUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'codexUsage:getSnapshot', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'codexUsage:getSummary', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getDaily', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange }) => - codexUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'codexUsage:getBreakdown', - ( - _event, - args: { scope: CodexUsageScope; range: CodexUsageRange; kind: CodexUsageBreakdownKind } - ) => codexUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'codexUsage:getRecentSessions', - (_event, args: { scope: CodexUsageScope; range: CodexUsageRange; limit?: number }) => - codexUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts index 0cf672fa30f..69dde7afaf3 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback-real.test.ts @@ -1,17 +1,19 @@ -import { execFile as execFileCallback } from 'node:child_process' +import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { promisify } from 'node:util' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' -const { checkRgAvailableMock } = vi.hoisted(() => ({ - checkRgAvailableMock: vi.fn() +const { wslAwareSpawnMock } = vi.hoisted(() => ({ + wslAwareSpawnMock: vi.fn() })) -vi.mock('./rg-availability', () => ({ - checkRgAvailable: checkRgAvailableMock +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock })) import { listQuickOpenFiles } from './filesystem-list-files' @@ -50,6 +52,16 @@ async function initRepo(repoPath: string): Promise { describe('filesystem-list-files real git fallback', () => { let tempDir: string | null = null + beforeEach(() => { + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { + cwd: options.cwd, + stdio: options.stdio + }) + ) + }) + afterEach(async () => { if (tempDir) { await rm(tempDir, { recursive: true, force: true }) @@ -59,7 +71,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('returns real paths for UTF-8 filenames from the git fallback', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-git-fallback-')) const repoPath = join(tempDir, 'repo') await execFile('git', ['init', '-q', repoPath]) @@ -71,7 +82,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('fills nested git repos from gitlink and untracked embedded-repo entries', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-monorepo-')) const repoPath = join(tempDir, 'parent') const appPath = join(repoPath, 'packages', 'app') @@ -117,7 +127,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('walks a non-git root instead of returning an empty git fallback result', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-non-git-')) await writeRel(tempDir, 'folder/file.ts') @@ -127,7 +136,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('bounds a non-git readdir fallback without treating the limit as an error', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bounded-non-git-')) await writeRel(tempDir, 'a.ts') await writeRel(tempDir, 'b.ts') @@ -139,7 +147,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('rejects abnormal git ls-files failures instead of resolving an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-bad-index-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) @@ -151,7 +158,6 @@ describe('filesystem-list-files real git fallback', () => { }) it('resolves an empty repo as an empty list', async () => { - checkRgAvailableMock.mockResolvedValue(false) tempDir = await mkdtemp(join(tmpdir(), 'orca-quick-open-empty-repo-')) const repoPath = join(tempDir, 'repo') await initRepo(repoPath) diff --git a/src/main/ipc/filesystem-list-files-git-fallback.ts b/src/main/ipc/filesystem-list-files-git-fallback.ts index ff390d13caf..78e03f56065 100644 --- a/src/main/ipc/filesystem-list-files-git-fallback.ts +++ b/src/main/ipc/filesystem-list-files-git-fallback.ts @@ -1,5 +1,9 @@ import type { ChildProcess } from 'node:child_process' import { gitSpawn } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' import { buildGitLsFilesArgsForQuickOpen, shouldExcludeQuickOpenRelPath, @@ -25,6 +29,19 @@ async function isInsideGitWorkTree( localGitOptions: { wslDistro?: string }, signal?: AbortSignal ): Promise { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + try { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro, { signal }) + } catch (error) { + if (signal?.aborted) { + throw fileListingCancellationError(signal) + } + throw error + } + } if (signal?.aborted) { throw fileListingCancellationError(signal) } diff --git a/src/main/ipc/filesystem-list-files-install-rg.test.ts b/src/main/ipc/filesystem-list-files-install-rg.test.ts index 7f4c34f5eda..f0221d8e24e 100644 --- a/src/main/ipc/filesystem-list-files-install-rg.test.ts +++ b/src/main/ipc/filesystem-list-files-install-rg.test.ts @@ -1,16 +1,21 @@ +import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process' +import { EventEmitter } from 'node:events' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' +import type * as GitRunner from '../git/runner' const { listFilesWithGitMock, resolveAuthorizedPathMock, checkRgAvailableMock, - getLocalGitOptionsForRegisteredWorktreeMock + getLocalGitOptionsForRegisteredWorktreeMock, + wslAwareSpawnMock } = vi.hoisted(() => ({ listFilesWithGitMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), - getLocalGitOptionsForRegisteredWorktreeMock: vi.fn() + getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + wslAwareSpawnMock: vi.fn() })) vi.mock('./filesystem-list-files-git-fallback', () => ({ @@ -29,14 +34,39 @@ vi.mock('./local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../git/runner', async (importOriginal) => ({ + ...(await importOriginal()), + wslAwareSpawn: wslAwareSpawnMock +})) + import { listQuickOpenFiles } from './filesystem-list-files' +function createStartedRipgrepProcess(): ChildProcess { + const child = new EventEmitter() as ChildProcess + ;(child as unknown as Record).stdout = new EventEmitter() + ;( + (child as unknown as Record).stdout as EventEmitter & { + setEncoding: () => void + } + ).setEncoding = vi.fn() + ;(child as unknown as Record).stderr = new EventEmitter() + ;(child as unknown as Record).kill = vi.fn() + ;(child as unknown as Record).exitCode = null + ;(child as unknown as Record).signalCode = null + Object.defineProperty(child, 'pid', { value: 1 }) + return child +} + describe('filesystem-list-files ripgrep guidance', () => { beforeEach(() => { vi.clearAllMocks() resolveAuthorizedPathMock.mockImplementation(async (path) => path) - checkRgAvailableMock.mockResolvedValue(false) + checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({}) + wslAwareSpawnMock.mockImplementation( + (_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) => + spawn('orca-definitely-missing-rg', [], { stdio: options.stdio }) + ) }) it('turns only a readdir budget failure into install guidance', async () => { @@ -51,6 +81,31 @@ describe('filesystem-list-files ripgrep guidance', () => { ) }) + it('keeps the WSL preflight and falls back before starting real rg', async () => { + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + await expect(listQuickOpenFiles('C:\\repo', {} as Store)).resolves.toEqual(['src/index.ts']) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const primary = createStartedRipgrepProcess() + const ignored = createStartedRipgrepProcess() + let callIndex = 0 + wslAwareSpawnMock.mockImplementation(() => (++callIndex === 1 ? primary : ignored)) + listFilesWithGitMock.mockResolvedValue(['src/index.ts']) + + const listing = listQuickOpenFiles('/workspace', {} as Store) + await Promise.resolve() + primary.emit('close', 127, null) + + await expect(listing).resolves.toEqual(['src/index.ts']) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + it('keeps cancellation and Git errors unchanged', async () => { const cancellation = new Error('File listing cancelled') listFilesWithGitMock.mockRejectedValueOnce(cancellation) diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 946f8653dd6..7debd516760 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -36,6 +36,7 @@ import { listQuickOpenFiles } from './filesystem-list-files' import { EventEmitter } from 'node:events' import type { Store } from '../persistence' import type { ChildProcess } from 'node:child_process' +import { FileListingCancelledError } from '../../shared/file-listing-cancellation' const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -55,10 +56,24 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } +function createMissingRipgrepProcess(): ChildProcess { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + void Promise.resolve().then(() => child.emit('close', -2, null)) + return child +} + +async function flushMicrotasks(): Promise { + for (let index = 0; index < 12; index++) { + await Promise.resolve() + } +} + function isIgnoredRgPass(args: string[]): boolean { return args.includes('--no-ignore-vcs') } @@ -93,6 +108,8 @@ describe('filesystem-list-files', () => { expect(p1.kill).toHaveBeenCalled() expect(p2.kill).not.toHaveBeenCalled() expect(spawnMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(spawnMock.mock.calls[0]?.[1]).not.toContain('--version') }) it('merges normal files and ignored files and filters correctly', async () => { @@ -108,6 +125,8 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) + await flushMicrotasks() + expect(spawnMock).toHaveBeenCalledTimes(2) // Simulate stdout output for normal files setTimeout(() => { @@ -192,6 +211,24 @@ describe('filesystem-list-files', () => { await expect(promise).resolves.toEqual(['src/index.ts']) }) + it('does not mistake a WSL-routed executable exit 127 for missing rg', async () => { + const p1 = createMockProcess() + const p2 = createMockProcess() + Object.defineProperty(p1, 'pid', { value: 1 }) + Object.defineProperty(p2, 'pid', { value: 2 }) + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + spawnMock.mockImplementation((_cmd, args: string[]) => (isIgnoredRgPass(args) ? p2 : p1)) + + const promise = listQuickOpenFiles('C:\\repo', {} as unknown as Store) + setTimeout(() => { + p1.emit('close', 127, null) + p2.emit('close', 0, null) + }, 0) + + await expect(promise).rejects.toThrow('rg exited with code 127') + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + }) + it('rejects rg failures instead of resolving a false-empty list', async () => { const p1 = createMockProcess() const p2 = createMockProcess() @@ -330,16 +367,90 @@ describe('filesystem-list-files', () => { expect(result).toEqual(['valid.ts']) }) - describe('git ls-files fallback', () => { - it('falls back to git ls-files when rg is not available', async () => { - checkRgAvailableMock.mockResolvedValue(false) + it('lets cancellation win a native-unavailable race before Git starts', async () => { + const first = createMockProcess() + Object.defineProperty(first, 'pid', { value: undefined }) + spawnMock.mockReturnValue(first) + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + const promise = listQuickOpenFiles( + '/mock/root', + {} as unknown as Store, + undefined, + controller.signal + ) + await flushMicrotasks() + controller.abort(cancellation) + first.emit('close', -2, null) + + await expect(promise).rejects.toBe(cancellation) + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(spawnMock.mock.calls.some((call) => call[0] === 'git')).toBe(false) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => first.emit('error', error)).not.toThrow() + expect(first.listenerCount('error')).toBe(0) + }) + + describe('git ls-files fallback', () => { + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const primary = createMockProcess() + const missingIgnored = createMockProcess() + const revParse = createMockProcess() + const gitPrimary = createMockProcess() + const gitIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + let gitPassIndex = 0 + spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return isIgnoredRgPass(args) ? missingIgnored : primary + } + if (args.includes('rev-parse')) { + return revParse + } + if (args.includes('ls-files')) { + return gitPassIndex++ === 0 ? gitPrimary : gitIgnored + } + return createMockProcess() + }) + + const promise = listQuickOpenFiles('/mock/root', {} as unknown as Store) + await flushMicrotasks() + expect(spawnMock.mock.calls.filter((call) => call[0] === 'rg')).toHaveLength(2) + missingIgnored.emit('close', -2, null) + + await vi.waitFor(() => expect(primary.kill).toHaveBeenCalled()) + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + await vi.waitFor(() => + expect( + spawnMock.mock.calls.some((call) => (call[1] as string[]).includes('rev-parse')) + ).toBe(true) + ) + revParse.emit('close', 0, null) + await vi.waitFor(() => + expect( + spawnMock.mock.calls.filter((call) => (call[1] as string[]).includes('ls-files')) + ).toHaveLength(2) + ) + gitPrimary.emit('close', 0, null) + gitIgnored.emit('close', 0, null) + + await expect(promise).resolves.toEqual([]) + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('falls back to git ls-files when rg is not available', async () => { let callIndex = 0 const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -378,9 +489,10 @@ describe('filesystem-list-files', () => { const result = await promise - // Verify rg was never called + // The primary real command doubles as the availability check. const rgCalls = spawnMock.mock.calls.filter((call) => call[0] === 'rg') - expect(rgCalls.length).toBe(0) + expect(rgCalls.length).toBe(1) + expect(rgCalls.every((call) => !(call[1] as string[]).includes('--version'))).toBe(true) // Verify git ls-files was called const gitCalls = spawnMock.mock.calls.filter( @@ -402,13 +514,15 @@ describe('filesystem-list-files', () => { }) it('stops after primary Git files fill the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -439,10 +553,12 @@ describe('filesystem-list-files', () => { }) it('does not let a discarded Git directory placeholder consume the result budget', async () => { - checkRgAvailableMock.mockResolvedValue(false) const revParseProc = createMockProcess() const primary = createMockProcess() spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -469,14 +585,15 @@ describe('filesystem-list-files', () => { }) it('git fallback applies hidden dir blocklist', async () => { - checkRgAvailableMock.mockResolvedValue(false) - const revParseProc = createMockProcess() const gitP1 = createMockProcess() const gitP2 = createMockProcess() let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -518,7 +635,6 @@ describe('filesystem-list-files', () => { }) it('settles and detaches git fallback scans that ignore timeout kills', async () => { - checkRgAvailableMock.mockResolvedValue(false) vi.useFakeTimers() try { @@ -528,6 +644,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -541,13 +660,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\0partial') @@ -567,7 +682,6 @@ describe('filesystem-list-files', () => { }) it('keeps primary results when only the ignored pass times out', async () => { - checkRgAvailableMock.mockResolvedValue(false) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.useFakeTimers() @@ -578,6 +692,9 @@ describe('filesystem-list-files', () => { let callIndex = 0 spawnMock.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'rg') { + return createMissingRipgrepProcess() + } if (cmd === 'git' && args.includes('rev-parse')) { return revParseProc } @@ -591,13 +708,9 @@ describe('filesystem-list-files', () => { const storeMock = {} as unknown as Store const promise = listQuickOpenFiles('/mock/root', storeMock) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() revParseProc.emit('close', 0, null) - await Promise.resolve() - await Promise.resolve() - await Promise.resolve() + await flushMicrotasks() ;(gitP1.stdout as unknown as EventEmitter).emit( 'data', diff --git a/src/main/ipc/filesystem-list-files.ts b/src/main/ipc/filesystem-list-files.ts index 7edb688de72..c1666234cd0 100644 --- a/src/main/ipc/filesystem-list-files.ts +++ b/src/main/ipc/filesystem-list-files.ts @@ -17,6 +17,12 @@ import { import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk' import { buildInstallRgMessage } from '../../shared/quick-open-install-rg' import { listFilesWithGit } from './filesystem-list-files-git-fallback' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../../shared/ripgrep-process-availability' export async function listQuickOpenFiles( rootPath: string, @@ -37,13 +43,9 @@ export async function listQuickOpenFiles( // every worktree instead of just the active one. The shared helper // normalizes, validates, and root-relativizes every input. const excludePathPrefixes = buildExcludePathPrefixes(authorizedRootPath, excludePaths) + const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - // Why: checking rg availability upfront avoids a race condition where - // spawn('rg') emits 'close' before 'error' on some platforms, causing - // the handler to resolve with empty results before the git fallback - // can run. - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const listWithoutRipgrep = async (): Promise => { try { return await listFilesWithGit( authorizedRootPath, @@ -59,6 +61,12 @@ export async function listQuickOpenFiles( throw new Error(await buildInstallRgMessage(err)) } } + if ( + wslDistroForOutput && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { + return listWithoutRipgrep() + } const files = new Set() const children: { @@ -68,8 +76,6 @@ export async function listQuickOpenFiles( }[] = [] // Why: WSL-routed rg can emit Linux-native absolute paths. UNC repos carry // their distro in the path; Windows-path repos carry it in project runtime. - const wslDistroForOutput = parseWslPath(authorizedRootPath)?.distro ?? localGitOptions.wslDistro - const { primary, ignoredPass } = buildRgArgsForQuickOpen({ // Why: rg evaluates root-relative exclude globs against cwd only when the // search target is cwd-relative. With an absolute target, `!packages/app` @@ -86,6 +92,8 @@ export async function listQuickOpenFiles( let buf = '' let done = false let parseablePathCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false const processLine = (rawLine: string): boolean => { const translated = @@ -138,12 +146,27 @@ export async function listQuickOpenFiles( /* drain */ } const handleError = (): void => { + processErrorObserved = true // Why: treat spawn errors like an abnormal exit — discard residual // buffer so a truncated final byte sequence cannot leak as a path. buf = '' + if (isRipgrepUnavailableExit(child, null, null)) { + finish(new RipgrepUnavailableError()) + return + } finish(new Error('rg failed to start')) } const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + buf = '' + finish(new RipgrepUnavailableError()) + return + } if (signal) { // Why: a signal exit means timeout/OOM/external kill. Returning the // already-streamed prefix would recreate the false-empty bug this @@ -179,6 +202,10 @@ export async function listQuickOpenFiles( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) if (err) { reject(err) } else { @@ -197,7 +224,7 @@ export async function listQuickOpenFiles( // Why: on timeout, the buffer is likely truncated mid-path. Discard // it so Quick Open never displays a malformed entry. buf = '' - child.kill() + killSpawnedRipgrepProcess(child) finish(new Error('rg list timed out')) }, 10000) }) @@ -213,7 +240,7 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } @@ -225,24 +252,30 @@ export async function listQuickOpenFiles( } entry.finish() if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } } } - try { + const primaryRun = runRg(primary) if (maxResults === undefined) { - await Promise.all([runRg(primary), runRg(ignoredPass)]) + // Why: a pid-less primary proves launch failure; avoid doubling the failed spawn. + await (children[0]?.child.pid === undefined + ? primaryRun + : Promise.all([primaryRun, runRg(ignoredPass)])) } else { // Why: ignored-file output can be much larger and faster than the primary // pass; let source files claim the bounded autocomplete budget first. - await runRg(primary) + await primaryRun if (files.size < maxResults) { await runRg(ignoredPass) } } } catch (err) { killSurvivors() + if (err instanceof RipgrepUnavailableError) { + return listWithoutRipgrep() + } throw err } return Array.from(files).slice(0, maxResults) diff --git a/src/main/ipc/filesystem-search-git.ts b/src/main/ipc/filesystem-search-git.ts index 065dbd11dad..9b0c91a6c65 100644 --- a/src/main/ipc/filesystem-search-git.ts +++ b/src/main/ipc/filesystem-search-git.ts @@ -8,6 +8,10 @@ import { SEARCH_TIMEOUT_MS } from '../../shared/text-search' import { gitSpawn } from '../git/runner' +import { + isWslLinkedWorktreeGitRoutingCandidate, + prepareWslLinkedWorktreeGitRouting +} from '../git/wsl-linked-worktree-git-routing' /** * Fallback text search using git grep. Used when rg is not available. @@ -16,12 +20,15 @@ import { gitSpawn } from '../git/runner' * is launched from a desktop entry (which inherits a minimal system PATH). * git grep is always available since this is a git-focused app. */ -export function searchWithGitGrep( +export async function searchWithGitGrep( rootPath: string, args: SearchOptions, maxResults: number, localGitOptions: { wslDistro?: string } = {} ): Promise { + if (isWslLinkedWorktreeGitRoutingCandidate(rootPath, localGitOptions.wslDistro)) { + await prepareWslLinkedWorktreeGitRouting(rootPath, localGitOptions.wslDistro) + } return new Promise((resolve) => { const gitArgs = buildGitGrepArgs(args.query, args) const matchRegex = buildSubmatchRegex(args.query, args) diff --git a/src/main/ipc/filesystem-search-rg-timeout.test.ts b/src/main/ipc/filesystem-search-rg-timeout.test.ts index 8d9881b2261..9e0a259d2d3 100644 --- a/src/main/ipc/filesystem-search-rg-timeout.test.ts +++ b/src/main/ipc/filesystem-search-rg-timeout.test.ts @@ -7,6 +7,7 @@ const { resolveAuthorizedPathMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, toWindowsWslPathMock } = vi.hoisted(() => ({ @@ -14,6 +15,7 @@ const { resolveAuthorizedPathMock: vi.fn(), checkRgAvailableMock: vi.fn(), getLocalGitOptionsForRegisteredWorktreeMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), toWindowsWslPathMock: vi.fn((value: string) => value) })) @@ -56,7 +58,7 @@ vi.mock('./filesystem-mutations', () => ({ })) vi.mock('./filesystem-search-git', () => ({ - searchWithGitGrep: vi.fn() + searchWithGitGrep: searchWithGitGrepMock })) vi.mock('./local-worktree-runtime-options', () => ({ @@ -87,6 +89,12 @@ function createMockProcess(): ChildProcess { return p } +async function flushMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('filesystem rg search timeout', () => { beforeEach(() => { handlers.clear() @@ -120,6 +128,7 @@ describe('filesystem rg search timeout', () => { const result = await promise expect(result.truncated).toBe(true) + expect(checkRgAvailableMock).not.toHaveBeenCalled() expect(child.kill).toHaveBeenCalled() expect((child.stdout as unknown as EventEmitter).listenerCount('data')).toBe(0) expect((child.stderr as unknown as EventEmitter).listenerCount('data')).toBe(0) @@ -130,8 +139,77 @@ describe('filesystem rg search timeout', () => { } }) + it.each(['error-first', 'close-first'] as const)( + 'falls back once when a native launch failure is %s', + async (order) => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: undefined }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it('keeps post-spawn errors on the existing empty-result path', async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise<{ files: unknown[] }> + await flushMicrotasks() + child.emit('error', new Error('post-spawn failure')) + + await expect(promise).resolves.toMatchObject({ files: [] }) + expect(searchWithGitGrepMock).not.toHaveBeenCalled() + }) + + it("falls back when a native launcher exits outside ripgrep's contract", async () => { + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: '/repo', query: 'ok' } + ) as Promise + await flushMicrotasks() + child.emit('close', 127, null) + + await expect(promise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + it('routes rg through the registered WSL project runtime for Windows-path worktrees', async () => { const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) wslAwareSpawnMock.mockReturnValue(child) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) registerFilesystemHandlers({} as never) @@ -142,12 +220,13 @@ describe('filesystem rg search timeout', () => { ) as Promise setTimeout(() => { - child.emit('close') + child.emit('close', 127, null) }, 10) await promise expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -158,6 +237,23 @@ describe('filesystem rg search timeout', () => { ) }) + it('keeps the WSL search preflight and falls back before starting real rg', async () => { + const fallback = { files: [], totalMatches: 0, truncated: false } + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + registerFilesystemHandlers({} as never) + + const promise = handlers.get('fs:search')!( + { sender: { id: 7 } }, + { rootPath: 'C:\\repo', query: 'ok' } + ) as Promise + + await expect(promise).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + it('translates WSL rg output for Windows-path project search results', async () => { const child = createMockProcess() wslAwareSpawnMock.mockReturnValue(child) diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 4ca9547b77c..d5fb98086f8 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -112,6 +112,11 @@ import { } from './source-control-ai-linked-issue' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths } from './markdown-documents' import { checkRgAvailable } from './rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { getSshFilesystemProvider, requireSshFilesystemProvider @@ -980,32 +985,35 @@ export function registerFilesystemHandlers( Math.min(args.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) const searchKey = `${event.sender.id}:${rootPath}` + // Why: WSL's bash exit 127 is ambiguous with a real executable returning 127. + const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro - // Why: probe rg upfront; on some platforms spawn emits 'close' before 'error', resolving empty before the git-grep fallback runs. - const rgAvailable = await checkRgAvailable(rootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + if (wslDistroForOutput && !(await checkRgAvailable(rootPath, localGitOptions.wslDistro))) { return searchWithGitGrep(rootPath, args, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const rgArgs = buildRgArgs(args.query, rootPath, args) // Why: kill the prior rg so it stops parsing thousands of matches on the main thread (the large-repo freeze) after the UI moved on. - activeTextSearches.get(searchKey)?.kill() + const previousChild = activeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null let killTimeout: ReturnType - // Why: WSL-routed rg emits Linux paths; UNC repos carry the distro in the path, Windows-path repos in project runtime. - const wslDistroForOutput = parseWslPath(rootPath)?.distro ?? localGitOptions.wslDistro const transformAbsPath = wslDistroForOutput ? (p: string): string => (p.startsWith('/') ? toWindowsWslPath(p, wslDistroForOutput) : p) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1019,13 +1027,22 @@ export function registerFilesystemHandlers( child?.stderr?.off('data', handleStderrData) child?.off('error', handleError) child?.off('close', handleClose) - resolvePromise(finalize(acc)) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(rootPath, args, maxResults, localGitOptions)) const processLine = (line: string): void => { const verdict = ingestRgJsonLine(line, rootPath, acc, maxResults, transformAbsPath) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1049,9 +1066,24 @@ export function registerFilesystemHandlers( // Drain stderr so rg cannot block on a full pipe. } const handleError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } resolveOnce() } - const handleClose = (): void => { + const handleClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !wslDistroForOutput + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -1067,7 +1099,9 @@ export function registerFilesystemHandlers( // Why: timeout kills the child mid-scan; mark truncated so the UI shows incomplete results. killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index d7930066ad7..b7cb9dd5a7b 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -15,6 +15,7 @@ const { notificationCtorMock, notificationIsSupportedMock, getAllWindowsMock, + getTrustedUIRendererWindowMock, shellOpenExternalMock } = vi.hoisted(() => { const removeHandlerMock = vi.fn() @@ -35,6 +36,7 @@ const { }) const notificationIsSupportedMock = vi.fn(() => true) const getAllWindowsMock = vi.fn(() => []) + const getTrustedUIRendererWindowMock = vi.fn() const shellOpenExternalMock = vi.fn() return { removeHandlerMock, @@ -47,6 +49,7 @@ const { notificationCtorMock, notificationIsSupportedMock, getAllWindowsMock, + getTrustedUIRendererWindowMock, shellOpenExternalMock } }) @@ -81,6 +84,10 @@ vi.mock('./notification-authorization-status', () => ({ readNotificationAuthorizationStatus: readAuthorizationStatusMock })) +vi.mock('./ui', () => ({ + getTrustedUIRendererWindow: getTrustedUIRendererWindowMock +})) + // Why: notifications.ts pulls in the tray module (for the minimized attention // dot), which transitively loads app-icon/electron-toolkit; stub it so this // suite stays focused on notification dispatch and avoids that import chain. @@ -121,6 +128,8 @@ describe('registerNotificationHandlers', () => { readAuthorizationStatusMock.mockResolvedValue(null) getAllWindowsMock.mockReset() getAllWindowsMock.mockReturnValue([]) + getTrustedUIRendererWindowMock.mockReset() + getTrustedUIRendererWindowMock.mockReturnValue(null) shellOpenExternalMock.mockClear() setTrayAttentionMock.mockClear() }) @@ -452,20 +461,32 @@ describe('registerNotificationHandlers', () => { } }) - it('focuses the originating terminal pane when a notification with paneKey is clicked', async () => { + it('focuses the originating terminal pane in the main window when a dashboard popout is open', async () => { + const popoutSend = vi.fn() + const popoutFocus = vi.fn() const webContentsSend = vi.fn() const restore = vi.fn() + const show = vi.fn() const focus = vi.fn() - getAllWindowsMock.mockReturnValue([ - { - isDestroyed: () => false, - isFocused: () => false, - isMinimized: () => true, - restore, - focus, - webContents: { send: webContentsSend } - } as never - ]) + const popoutWindow = { + isDestroyed: () => false, + isFocused: () => true, + isMinimized: () => false, + restore: vi.fn(), + focus: popoutFocus, + webContents: { send: popoutSend } + } + const mainWindow = { + isDestroyed: () => false, + isFocused: () => false, + isMinimized: () => true, + restore, + show, + focus, + webContents: { send: webContentsSend } + } + getAllWindowsMock.mockReturnValue([popoutWindow, mainWindow] as never) + getTrustedUIRendererWindowMock.mockReturnValue(mainWindow) registerNotificationHandlers({ getSettings: () => ({ notifications: { @@ -486,8 +507,12 @@ describe('registerNotificationHandlers', () => { getNotificationEventHandler('click')() + expect(getTrustedUIRendererWindowMock).toHaveBeenCalledTimes(1) expect(restore).toHaveBeenCalledTimes(1) + expect(show).toHaveBeenCalledTimes(1) expect(focus).toHaveBeenCalledTimes(1) + expect(popoutFocus).not.toHaveBeenCalled() + expect(popoutSend).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(0) expect(notificationRemoveListenerMock).toHaveBeenCalledWith('click', expect.any(Function)) expect(webContentsSend).toHaveBeenCalledWith('ui:activateWorktree', { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 95e9e3e3a47..7418396a199 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -28,6 +28,7 @@ import { readNotificationAuthorizationStatus } from './notification-authorizatio import { parsePaneKey } from '../../shared/stable-pane-id' import { setTrayAttention } from '../tray/system-tray' import { isMainWindowVisible } from '../window/main-window-visibility' +import { getTrustedUIRendererWindow } from './ui' const NOTIFICATION_COOLDOWN_MS = 5000 const MAX_RECENT_NOTIFICATION_KEYS = 50 @@ -512,8 +513,8 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime const repoId = getRepoIdFromWorktreeId(args.worktreeId) clickHandler = () => { release() - const win = BrowserWindow.getAllWindows().find((w) => !w.isDestroyed()) - if (!win) { + const win = getTrustedUIRendererWindow() + if (!win || win.isDestroyed()) { return } if (process.platform === 'darwin') { @@ -522,6 +523,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime if (win.isMinimized()) { win.restore() } + win.show() win.focus() win.webContents.send('ui:activateWorktree', { repoId, diff --git a/src/main/ipc/opencode-usage.ts b/src/main/ipc/opencode-usage.ts deleted file mode 100644 index 47b3c95dea7..00000000000 --- a/src/main/ipc/opencode-usage.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ipcMain } from 'electron' -import type { OpenCodeUsageStore } from '../opencode-usage/store' -import type { - OpenCodeUsageBreakdownKind, - OpenCodeUsageRange, - OpenCodeUsageScope -} from '../../shared/opencode-usage-types' - -export function registerOpenCodeUsageHandlers(openCodeUsage: OpenCodeUsageStore): void { - ipcMain.handle('openCodeUsage:getScanState', () => openCodeUsage.getScanState()) - ipcMain.handle('openCodeUsage:setEnabled', (_event, args: { enabled: boolean }) => - openCodeUsage.setEnabled(args.enabled) - ) - ipcMain.handle('openCodeUsage:refresh', (_event, args?: { force?: boolean }) => - openCodeUsage.refresh(args?.force ?? false) - ) - ipcMain.handle( - 'openCodeUsage:getSnapshot', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getSnapshot(args.scope, args.range, args.limit) - ) - ipcMain.handle( - 'openCodeUsage:getSummary', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getSummary(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getDaily', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange }) => - openCodeUsage.getDaily(args.scope, args.range) - ) - ipcMain.handle( - 'openCodeUsage:getBreakdown', - ( - _event, - args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - } - ) => openCodeUsage.getBreakdown(args.scope, args.range, args.kind) - ) - ipcMain.handle( - 'openCodeUsage:getRecentSessions', - (_event, args: { scope: OpenCodeUsageScope; range: OpenCodeUsageRange; limit?: number }) => - openCodeUsage.getRecentSessions(args.scope, args.range, args.limit) - ) -} diff --git a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts index 38e135d4558..916ab35ab63 100644 --- a/src/main/ipc/preflight-windows-path-refresh.repro.test.ts +++ b/src/main/ipc/preflight-windows-path-refresh.repro.test.ts @@ -3,45 +3,11 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -const { registryQueryAsyncMock, registryQuerySyncMock } = vi.hoisted(() => ({ - registryQueryAsyncMock: vi.fn(), - registryQuerySyncMock: vi.fn() -})) - -vi.mock('node:child_process', async (importOriginal) => { - const original = await importOriginal>() - const originalExecFile = original.execFile as ( - command: string, - commandArgs: string[], - commandOptions: unknown, - commandCallback: (error: Error | null, stdout: string, stderr: string) => void - ) => unknown - const registryAwareExecFile = ( - file: string, - args: string[], - options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ): unknown => { - if (file.toLowerCase().endsWith('\\reg.exe')) { - return registryQueryAsyncMock(file, args, options, callback) - } - return originalExecFile(file, args, options, callback) - } - const customPromisify = Symbol.for('nodejs.util.promisify.custom') - Object.defineProperty(registryAwareExecFile, customPromisify, { - value: (originalExecFile as unknown as Record)[customPromisify] - }) - return { - ...original, - execFile: registryAwareExecFile, - execFileSync: registryQuerySyncMock - } -}) - import { __resetPersistedWindowsPathCacheForTests, mergePersistedWindowsPathAsync } from '../pty/windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from '../pty/windows-path-registry-reader' import { execLocalPreflightCommand } from './preflight-command-exec' describe.runIf(process.platform === 'win32')('Windows preflight Path refresh reproduction', () => { @@ -50,8 +16,7 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep afterEach(() => { process.env.Path = originalPath - registryQueryAsyncMock.mockReset() - registryQuerySyncMock.mockReset() + __setWindowsPathRegistryLoaderForTests() __resetPersistedWindowsPathCacheForTests() for (const directory of fixtureDirs.splice(0)) { rmSync(directory, { recursive: true, force: true }) @@ -68,22 +33,13 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep ) let persistedUserPath = '' - registryQuerySyncMock.mockImplementation((_file, args: string[]) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - return ` Path REG_SZ ${value}\r\n` - }) - registryQueryAsyncMock.mockImplementation( - ( - _file: string, - args: string[], - _options: unknown, - callback: (error: Error | null, stdout: string, stderr: string) => void - ) => { - const value = String(args[1]).startsWith('HKCU') ? persistedUserPath : '' - callback(null, ` Path REG_SZ ${value}\r\n`, '') - return {} as never - } - ) + const getRegistryKey = vi.fn((root: number) => ({ + Path: { type: 1, value: root === 2 ? persistedUserPath : '' } + })) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) __resetPersistedWindowsPathCacheForTests() await expect(execLocalPreflightCommand(command, ['/?'])).rejects.toMatchObject({ @@ -97,7 +53,6 @@ describe.runIf(process.platform === 'win32')('Windows preflight Path refresh rep await expect(execLocalPreflightCommand(command, ['/?'])).resolves.toMatchObject({ stdout: expect.any(String) }) - expect(registryQuerySyncMock).toHaveBeenCalledTimes(2) - expect(registryQueryAsyncMock).toHaveBeenCalledTimes(2) + expect(getRegistryKey).toHaveBeenCalledTimes(4) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 1ca019f6f0f..24786811086 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -5672,6 +5672,7 @@ export function registerPtyHandlers( args: { id?: unknown; opts?: { scrollbackRows?: unknown } } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 55043e2b526..b9aa768213b 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -6,9 +6,7 @@ const { callRuntimeEnvironmentMock, registerCliHandlersMock, registerPreflightHandlersMock, - registerClaudeUsageHandlersMock, - registerCodexUsageHandlersMock, - registerOpenCodeUsageHandlersMock, + registerUsageProviderHandlersMock, registerGitHubHandlersMock, registerFeedbackHandlersMock, registerStatsHandlersMock, @@ -72,9 +70,7 @@ const { callRuntimeEnvironmentMock: vi.fn(), registerCliHandlersMock: vi.fn(), registerPreflightHandlersMock: vi.fn(), - registerClaudeUsageHandlersMock: vi.fn(), - registerCodexUsageHandlersMock: vi.fn(), - registerOpenCodeUsageHandlersMock: vi.fn(), + registerUsageProviderHandlersMock: vi.fn(), registerGitHubHandlersMock: vi.fn(), registerFeedbackHandlersMock: vi.fn(), registerStatsHandlersMock: vi.fn(), @@ -180,16 +176,8 @@ vi.mock('./preflight', () => ({ registerPreflightHandlers: registerPreflightHandlersMock })) -vi.mock('./claude-usage', () => ({ - registerClaudeUsageHandlers: registerClaudeUsageHandlersMock -})) - -vi.mock('./codex-usage', () => ({ - registerCodexUsageHandlers: registerCodexUsageHandlersMock -})) - -vi.mock('./opencode-usage', () => ({ - registerOpenCodeUsageHandlers: registerOpenCodeUsageHandlersMock +vi.mock('./usage-provider-handlers', () => ({ + registerUsageProviderHandlers: registerUsageProviderHandlersMock })) vi.mock('./github', () => ({ @@ -395,9 +383,7 @@ describe('registerCoreHandlers', () => { callRuntimeEnvironmentMock.mockReset() registerCliHandlersMock.mockReset() registerPreflightHandlersMock.mockReset() - registerClaudeUsageHandlersMock.mockReset() - registerCodexUsageHandlersMock.mockReset() - registerOpenCodeUsageHandlersMock.mockReset() + registerUsageProviderHandlersMock.mockReset() registerGitHubHandlersMock.mockReset() registerFeedbackHandlersMock.mockReset() registerStatsHandlersMock.mockReset() @@ -494,9 +480,11 @@ describe('registerCoreHandlers', () => { result: { sessions: 'bad-shape' } }) - expect(registerClaudeUsageHandlersMock).toHaveBeenCalledWith(claudeUsage) - expect(registerCodexUsageHandlersMock).toHaveBeenCalledWith(codexUsage) - expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) + expect(registerUsageProviderHandlersMock).toHaveBeenCalledWith({ + claudeUsage, + codexUsage, + openCodeUsage + }) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith( codexAccounts, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 607aa08ce3e..f372cb3f772 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -8,9 +8,7 @@ import type { StatsCollector } from '../stats/collector' import { registerFilesystemHandlers } from './filesystem' import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment' import { registerFilesystemWatcherHandlers } from './filesystem-watcher' -import { registerClaudeUsageHandlers } from './claude-usage' -import { registerCodexUsageHandlers } from './codex-usage' -import { registerOpenCodeUsageHandlers } from './opencode-usage' +import { registerUsageProviderHandlers } from './usage-provider-handlers' import { registerGitHubHandlers } from './github' import { registerGitLabHandlers } from './gitlab' import { registerHostedReviewHandlers } from './hosted-review' @@ -143,9 +141,7 @@ export function registerCoreHandlers( registerAppHandlers(store, { onBeforeRelaunch: lifecycleOptions.onBeforeRelaunch }) registerCliHandlers() registerPreflightHandlers() - registerClaudeUsageHandlers(claudeUsage) - registerCodexUsageHandlers(codexUsage) - registerOpenCodeUsageHandlers(openCodeUsage) + registerUsageProviderHandlers({ claudeUsage, codexUsage, openCodeUsage }) registerCodexAccountHandlers(codexAccounts, () => store.getSettings()) registerAgentHookHandlers(runtime, { getPtyIdForPaneKey }) registerCodexConfigSyncHandlers(codexAccounts.runtimeHomeService) diff --git a/src/main/ipc/usage-provider-handlers.test.ts b/src/main/ipc/usage-provider-handlers.test.ts new file mode 100644 index 00000000000..54733b87c26 --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' + +const { handle } = vi.hoisted(() => ({ handle: vi.fn() })) + +vi.mock('electron', () => ({ ipcMain: { handle } })) + +import { registerUsageProviderHandlers } from './usage-provider-handlers' + +describe('usage provider IPC handlers', () => { + it('registers every provider route and forwards query arguments', () => { + const createUsage = () => ({ + getScanState: vi.fn(), + setEnabled: vi.fn(), + refresh: vi.fn(), + getSnapshot: vi.fn(), + getSummary: vi.fn(), + getDaily: vi.fn(), + getBreakdown: vi.fn(), + getRecentSessions: vi.fn() + }) + const claudeUsage = createUsage() + const codexUsage = createUsage() + const openCodeUsage = createUsage() + registerUsageProviderHandlers({ + claudeUsage: claudeUsage as never, + codexUsage: codexUsage as never, + openCodeUsage: openCodeUsage as never + }) + + const prefixes = ['claudeUsage', 'codexUsage', 'openCodeUsage'] + const suffixes = Object.keys(claudeUsage) + expect(handle.mock.calls.map(([channel]) => channel)).toEqual( + prefixes.flatMap((prefix) => suffixes.map((suffix) => `${prefix}:${suffix}`)) + ) + + const call = (prefix: string, suffix: string, args?: unknown): unknown => { + const handler = handle.mock.calls.find( + ([channel]) => channel === `${prefix}:${suffix}` + )?.[1] as ((event: unknown, args?: unknown) => unknown) | undefined + return handler?.({}, args) + } + call('claudeUsage', 'getScanState') + call('codexUsage', 'getScanState') + call('openCodeUsage', 'getScanState') + call('claudeUsage', 'setEnabled', { enabled: true }) + call('claudeUsage', 'refresh') + call('claudeUsage', 'refresh', { force: true }) + call('claudeUsage', 'getSnapshot', { scope: 'orca', range: '30d', limit: 7 }) + call('claudeUsage', 'getSummary', { scope: 'all', range: '7d' }) + call('claudeUsage', 'getDaily', { scope: 'orca', range: '90d' }) + call('claudeUsage', 'getBreakdown', { scope: 'all', range: 'all', kind: 'model' }) + call('claudeUsage', 'getRecentSessions', { scope: 'orca', range: '30d', limit: 4 }) + + expect(claudeUsage.getScanState).toHaveBeenCalledWith() + expect(codexUsage.getScanState).toHaveBeenCalledWith() + expect(openCodeUsage.getScanState).toHaveBeenCalledWith() + expect(claudeUsage.setEnabled).toHaveBeenCalledWith(true) + expect(claudeUsage.refresh.mock.calls).toEqual([[false], [true]]) + expect(claudeUsage.getSnapshot).toHaveBeenCalledWith('orca', '30d', 7) + expect(claudeUsage.getSummary).toHaveBeenCalledWith('all', '7d') + expect(claudeUsage.getDaily).toHaveBeenCalledWith('orca', '90d') + expect(claudeUsage.getBreakdown).toHaveBeenCalledWith('all', 'all', 'model') + expect(claudeUsage.getRecentSessions).toHaveBeenCalledWith('orca', '30d', 4) + }) +}) diff --git a/src/main/ipc/usage-provider-handlers.ts b/src/main/ipc/usage-provider-handlers.ts new file mode 100644 index 00000000000..58496f1c8ad --- /dev/null +++ b/src/main/ipc/usage-provider-handlers.ts @@ -0,0 +1,68 @@ +import { ipcMain } from 'electron' +import type { ClaudeUsageStore } from '../claude-usage/store' +import type { CodexUsageStore } from '../codex-usage/store' +import type { OpenCodeUsageStore } from '../opencode-usage/store' + +type UsageProviderStores = { + claudeUsage: ClaudeUsageStore + codexUsage: CodexUsageStore + openCodeUsage: OpenCodeUsageStore +} + +type UsageProviderChannelPrefix = keyof UsageProviderStores + +type UsageProviderHandlerStore = { + getScanState: () => unknown + setEnabled: (enabled: boolean) => unknown + refresh: (force?: boolean) => unknown + getSnapshot: (scope: Scope, range: Range, limit?: number) => unknown + getSummary: (scope: Scope, range: Range) => unknown + getDaily: (scope: Scope, range: Range) => unknown + getBreakdown: (scope: Scope, range: Range, kind: BreakdownKind) => unknown + getRecentSessions: (scope: Scope, range: Range, limit?: number) => unknown +} + +type UsageRangeArgs = { + scope: Scope + range: Range +} + +function registerProviderHandlers( + prefix: UsageProviderChannelPrefix, + usage: UsageProviderHandlerStore +): void { + ipcMain.handle(`${prefix}:getScanState`, () => usage.getScanState()) + ipcMain.handle(`${prefix}:setEnabled`, (_event, args: { enabled: boolean }) => + usage.setEnabled(args.enabled) + ) + ipcMain.handle(`${prefix}:refresh`, (_event, args?: { force?: boolean }) => + usage.refresh(args?.force ?? false) + ) + ipcMain.handle( + `${prefix}:getSnapshot`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getSnapshot(args.scope, args.range, args.limit) + ) + ipcMain.handle(`${prefix}:getSummary`, (_event, args: UsageRangeArgs) => + usage.getSummary(args.scope, args.range) + ) + ipcMain.handle(`${prefix}:getDaily`, (_event, args: UsageRangeArgs) => + usage.getDaily(args.scope, args.range) + ) + ipcMain.handle( + `${prefix}:getBreakdown`, + (_event, args: UsageRangeArgs & { kind: BreakdownKind }) => + usage.getBreakdown(args.scope, args.range, args.kind) + ) + ipcMain.handle( + `${prefix}:getRecentSessions`, + (_event, args: UsageRangeArgs & { limit?: number }) => + usage.getRecentSessions(args.scope, args.range, args.limit) + ) +} + +export function registerUsageProviderHandlers(stores: UsageProviderStores): void { + registerProviderHandlers('claudeUsage', stores.claudeUsage) + registerProviderHandlers('codexUsage', stores.codexUsage) + registerProviderHandlers('openCodeUsage', stores.openCodeUsage) +} diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 84e9e1fbaae..02f23bc094a 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -603,6 +603,18 @@ describe('registerWorktreeHandlers', () => { expect(orderedTargets).not.toContain(staleId) }) + it('persistSortOrder skips ranks that already represent the requested order', () => { + const firstId = 'repo-1::/workspace/first' + const secondId = 'repo-1::/workspace/second' + store.getWorktreeMeta.mockImplementation((id: string) => ({ + sortOrder: id === firstId ? 200 : 100 + })) + + handlers['worktrees:persistSortOrder'](null, { orderedIds: [firstId, secondId] }) + + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + }) + it('prefetches the local default create base through the runtime refresh cache', async () => { const repo = { id: 'repo-1', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 87bd0ded95c..cc0b4b0452f 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -8,6 +8,7 @@ import { isFolderRepo } from '../../shared/repo-kind' import { readBranchRenameFailureOutputForDisplay } from '../agent-hooks/branch-rename-failure-output' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports' +import { planWorktreeSortOrderUpdates } from '../../shared/worktree-sort-order-update' import { getProjectHostSetupWorktreeMeta } from '../../shared/project-host-setup-projection' import { TaskSourceContextSchema } from '../../shared/task-source-context-schema' import { WorkspaceLinkedItemSchema } from '../../shared/workspace-linked-item-schema' @@ -141,6 +142,7 @@ import { registerSshProviderRequestAbort } from '../ssh/ssh-provider-authority' import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' type CreateWorktreeArgsWithSystemProvenance = CreateWorktreeArgs & { automationProvenance?: AutomationWorkspaceProvenance @@ -521,15 +523,18 @@ type WorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget } -const preservedBranchCleanupByWorktreeId = new Map() +const preservedBranchCleanupByScope = new Map() function rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -541,14 +546,16 @@ function rememberPreservedBranchCleanupTarget( `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - preservedBranchCleanupByWorktreeId.set(worktreeId, { + preservedBranchCleanupByScope.set(preservedBranchCleanupScopeKey({ worktreeId, hostId }), { + worktreeId, + hostId, branchName: result.preservedBranch.branchName, head, ...(pushTarget ? { pushTarget } : {}) }) return } - preservedBranchCleanupByWorktreeId.delete(worktreeId) + preservedBranchCleanupByScope.delete(preservedBranchCleanupScopeKey({ worktreeId, hostId })) } function preserveBranchHeadFallback( @@ -570,9 +577,21 @@ function preserveBranchHeadFallback( function getPreservedBranchCleanupTarget( worktreeId: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: ExecutionHostId ): PreservedBranchCleanupTarget { - const target = preservedBranchCleanupByWorktreeId.get(worktreeId) + const exactTarget = hostId + ? preservedBranchCleanupByScope.get(preservedBranchCleanupScopeKey({ worktreeId, hostId })) + : undefined + const legacyMatches = hostId + ? [] + : [...preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === worktreeId && + target.branchName === branchName && + target.head === expectedHead + ) + const target = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if (!target || target.branchName !== branchName || target.head !== expectedHead) { throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) } @@ -2399,7 +2418,9 @@ export function registerWorktreeHandlers( await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => { removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) }) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: removalHostId }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2508,7 +2529,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2553,7 +2579,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) return {} @@ -2585,7 +2616,12 @@ export function registerWorktreeHandlers( } runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) notifyWorktreesChanged(mainWindow, repoId) return {} } @@ -2633,6 +2669,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2730,6 +2767,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -2883,7 +2921,12 @@ export function registerWorktreeHandlers( ) runtime.clearOptimisticReconcileToken(args.worktreeId) removeWorktreeMetadataAndTransientState(store, args.worktreeId, removalHostId) - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: removalHostId + }) + ) invalidateAuthorizedRootsCache() notifyWorktreesChanged(mainWindow, repoId) removalCompleted = true @@ -2907,6 +2950,7 @@ export function registerWorktreeHandlers( ) rememberPreservedBranchCleanupTarget( args.worktreeId, + removalHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget @@ -3010,7 +3054,17 @@ export function registerWorktreeHandlers( removeWorktreeMetadataAndTransientState(store, args.worktreeId, ownerHost?.id) // Why: cached roots outlive the forgotten workspace, so an ownerless path stays filesystem-authorized until a rebuild. invalidateAuthorizedRootsCache() - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + if (ownerHost?.id) { + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId: args.worktreeId, hostId: ownerHost.id }) + ) + } else { + for (const [key, target] of preservedBranchCleanupByScope) { + if (target.worktreeId === args.worktreeId) { + preservedBranchCleanupByScope.delete(key) + } + } + } notifyWorktreesChanged(mainWindow, repoId) return {} })() @@ -3029,15 +3083,21 @@ export function registerWorktreeHandlers( 'worktrees:forceDeletePreservedBranch', async ( _event, - args: { worktreeId: string; branchName: string; expectedHead: string } + args: { + worktreeId: string + branchName: string + expectedHead: string + hostId?: ExecutionHostId + } ): Promise => { const { repoId } = parseWorktreeId(args.worktreeId) const cleanupTarget = getPreservedBranchCleanupTarget( args.worktreeId, args.branchName, - args.expectedHead + args.expectedHead, + args.hostId ) - const repo = store.getRepo(repoId) + const repo = getRepoForWorktreeRemoval(store, repoId, cleanupTarget.hostId) if (!repo) { throw new Error(`Repo not found: ${repoId}`) } @@ -3080,7 +3140,12 @@ export function registerWorktreeHandlers( ) } - preservedBranchCleanupByWorktreeId.delete(args.worktreeId) + preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: args.worktreeId, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } ) @@ -3142,18 +3207,13 @@ export function registerWorktreeHandlers( if (!Array.isArray(args?.orderedIds) || args.orderedIds.length === 0) { return } - const now = Date.now() - for (let i = 0; i < args.orderedIds.length; i++) { - // Why: a sidebar-order snapshot must only reorder worktrees that already - // exist — it must never create one. Without this guard a stale id the - // renderer still lists (e.g. a removed repo's `${repoId}::${path}`) gets a - // fresh worktreeMeta entry minted here, resurrecting an orphan/duplicate - // workspace on the next launch. setWorktreeMeta has no repo-existence check. - if (!store.getWorktreeMeta(args.orderedIds[i])) { - continue - } - // Descending timestamps: first item gets highest sortOrder so b - a sorts first-wins on cold start. - store.setWorktreeMeta(args.orderedIds[i], { sortOrder: now - i * 1000 }) + const updates = planWorktreeSortOrderUpdates( + args.orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) } }) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 94f5d11ea3e..8036330272a 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -18,6 +18,7 @@ import { wrapPosixHookCommand, writeManagedScript } from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' import { readTextFileRemote, writeManagedScriptRemote, @@ -155,6 +156,10 @@ function buildStatus(present: Set, configPath: string): AgentHookInstall } export class KimiHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + getStatus(): AgentHookInstallStatus { const configPath = getConfigPath() const text = readConfigToml(configPath) diff --git a/src/main/native-chat/transcript-tail-reader-cancellation.test.ts b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts index cf3eaf47e7f..d211805337f 100644 --- a/src/main/native-chat/transcript-tail-reader-cancellation.test.ts +++ b/src/main/native-chat/transcript-tail-reader-cancellation.test.ts @@ -1,5 +1,5 @@ import type * as NodeFsPromisesModule from 'node:fs/promises' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' const fsMocks = vi.hoisted(() => ({ open: vi.fn(), @@ -15,19 +15,33 @@ vi.mock('node:fs/promises', async (importOriginal) => ({ import { readNativeChatTranscriptTail } from './transcript-tail-reader' describe('native chat transcript tail cancellation', () => { - it('rejects after pending tail I/O closes when the request is canceled', async () => { - let finishRead: (() => void) | undefined + beforeEach(() => { + fsMocks.open.mockReset() + fsMocks.stat.mockReset() + }) + + it('closes after a pending tail read without reading another chunk when canceled', async () => { + let finishTailRead: (() => void) | undefined + let readCount = 0 const close = vi.fn(async () => {}) - const read = vi.fn( - (buffer: Buffer) => - new Promise<{ bytesRead: number; buffer: Buffer }>((resolve) => { - finishRead = () => { - buffer[0] = 0x0a - resolve({ bytesRead: 1, buffer }) - } - }) - ) - fsMocks.stat.mockResolvedValue({ size: 1 }) + const read = vi.fn((buffer: Buffer) => { + readCount++ + if (readCount === 1) { + buffer[0] = 0x0a + return Promise.resolve({ bytesRead: 1, buffer }) + } + if (readCount > 2) { + buffer.fill(0x0a) + return Promise.resolve({ bytesRead: buffer.length, buffer }) + } + return new Promise<{ bytesRead: number; buffer: Buffer }>((resolve) => { + finishTailRead = () => { + buffer.fill(0x0a) + resolve({ bytesRead: buffer.length, buffer }) + } + }) + }) + fsMocks.stat.mockResolvedValue({ size: 8 * 64 * 1024 + 1 }) fsMocks.open.mockResolvedValue({ close, read }) const controller = new AbortController() const canceled = new Error('request canceled') @@ -41,12 +55,46 @@ describe('native chat transcript tail cancellation', () => { controller.signal ) const rejection = expect(pending).rejects.toBe(canceled) - await vi.waitFor(() => expect(read).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(read).toHaveBeenCalledTimes(2)) controller.abort(canceled) - finishRead?.() + finishTailRead?.() await rejection + expect(read).toHaveBeenCalledTimes(2) + expect(close).toHaveBeenCalledOnce() + }) + + it('closes a handle opened after cancellation without starting a read', async () => { + let finishOpen: (() => void) | undefined + const close = vi.fn(async () => {}) + const read = vi.fn() + fsMocks.stat.mockResolvedValue({ size: 1 }) + fsMocks.open.mockImplementation( + () => + new Promise((resolve) => { + finishOpen = () => resolve({ close, read }) + }) + ) + const controller = new AbortController() + const canceled = new Error('request canceled while opening') + const pending = readNativeChatTranscriptTail( + { + agent: 'claude', + sessionId: 'session-id', + filePath: 'transcript.jsonl', + limit: 40 + }, + controller.signal + ) + const rejection = expect(pending).rejects.toBe(canceled) + await vi.waitFor(() => expect(fsMocks.open).toHaveBeenCalledOnce()) + + controller.abort(canceled) + finishOpen?.() + + await rejection + expect(read).not.toHaveBeenCalled() expect(close).toHaveBeenCalledOnce() }) }) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index f5cbed8efdd..c7b1527858a 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -46,7 +46,8 @@ export async function readNativeChatTranscriptTailFile( decode: NativeChatLineDecoder, includeTrailingLine = false, endOffset?: number, - decodeLifecycle?: NativeChatTurnLifecycleDecoder | null + decodeLifecycle?: NativeChatTurnLifecycleDecoder | null, + signal?: AbortSignal ): Promise<{ messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle @@ -56,7 +57,9 @@ export async function readNativeChatTranscriptTailFile( malformedRecordCount?: number oversizedRecordCount?: number }> { + signal?.throwIfAborted() const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) + signal?.throwIfAborted() if (end === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } @@ -69,19 +72,25 @@ export async function readNativeChatTranscriptTailFile( let oversizedRecordCount = 0 let ignoreNextMalformedRecord = false try { - const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) + signal?.throwIfAborted() + const consumedTo = includeTrailingLine + ? end + : await findLastCompleteLineEnd(handle, end, signal) if (consumedTo === 0) { return { messages: [], consumedTo: 0, hasMore: false, beforeOffset: 0 } } const newestFirst: { message: NativeChatMessage; offset: number }[] = [] const finalByte = Buffer.allocUnsafe(1) await handle.read(finalByte, 0, 1, consumedTo - 1) + signal?.throwIfAborted() ignoreNextMalformedRecord = finalByte[0] !== 0x0a let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) while (cursor > 0 && newestFirst.length <= limit) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() let segmentEnd = bytesRead for (let index = bytesRead - 1; index >= 0 && newestFirst.length <= limit; index--) { if (buffer[index] !== 0x0a) { @@ -175,18 +184,23 @@ export async function readNativeChatTranscriptTailFile( async function findLastCompleteLineEnd( handle: Awaited>, - end: number + end: number, + signal?: AbortSignal ): Promise { + signal?.throwIfAborted() const lastByte = Buffer.allocUnsafe(1) await handle.read(lastByte, 0, 1, end - 1) + signal?.throwIfAborted() if (lastByte[0] === 0x0a) { return end } let cursor = end while (cursor > 0) { + signal?.throwIfAborted() const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) const buffer = Buffer.allocUnsafe(cursor - start) const { bytesRead } = await handle.read(buffer, 0, buffer.length, start) + signal?.throwIfAborted() const newline = buffer.subarray(0, bytesRead).lastIndexOf(0x0a) if (newline >= 0) { return start + newline + 1 @@ -235,7 +249,8 @@ export async function readNativeChatTranscriptTail( decode, true, args.beforeOffset, - decodeLifecycle + decodeLifecycle, + signal ) signal?.throwIfAborted() return { diff --git a/src/main/opencode-usage/store.test.ts b/src/main/opencode-usage/store.test.ts index 6f3759efa7f..d565327fd18 100644 --- a/src/main/opencode-usage/store.test.ts +++ b/src/main/opencode-usage/store.test.ts @@ -1,24 +1,15 @@ -import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import type * as FsPromises from 'node:fs/promises' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OpenCodeUsageDailyAggregate, - OpenCodeUsagePersistedDatabase, OpenCodeUsagePersistedState, OpenCodeUsageSession } from './types' -const { getPathMock, writeOpens, writeGate } = vi.hoisted(() => ({ - getPathMock: vi.fn(() => '/tmp/orca-test-userdata'), - // Why only mode 'w': the durable write also opens the directory read-only to fsync it, so counting - // every open would hide a regression back to multiple full-cache rewrites per scan. - writeOpens: { value: 0, inFlight: 0, maxConcurrent: 0 }, - writeGate: { - blocked: false, - waiters: [] as (() => void)[] - } +const { getPathMock } = vi.hoisted(() => ({ + getPathMock: vi.fn(() => '/tmp/orca-test-userdata') })) vi.mock('electron', () => ({ @@ -27,29 +18,6 @@ vi.mock('electron', () => ({ } })) -vi.mock('node:fs/promises', async () => { - const actual = await vi.importActual('node:fs/promises') - return { - ...actual, - open: (async (...args: Parameters) => { - if (args[1] !== 'w') { - return actual.open(...args) - } - writeOpens.value += 1 - writeOpens.inFlight += 1 - writeOpens.maxConcurrent = Math.max(writeOpens.maxConcurrent, writeOpens.inFlight) - try { - if (writeGate.blocked) { - await new Promise((resolve) => writeGate.waiters.push(resolve)) - } - return await actual.open(...args) - } finally { - writeOpens.inFlight -= 1 - } - }) as typeof actual.open - } -}) - vi.mock('./scanner', () => ({ scanOpenCodeUsageDatabases: vi.fn() })) @@ -57,24 +25,7 @@ vi.mock('./scanner', () => ({ import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store' import { scanOpenCodeUsageDatabases } from './scanner' -type ScanResult = { - processedDatabases: OpenCodeUsagePersistedDatabase[] - sessions: OpenCodeUsageSession[] - dailyAggregates: OpenCodeUsageDailyAggregate[] -} - -function createDeferred(): { - promise: Promise - resolve: (value: T) => void -} { - let resolve!: (value: T) => void - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve - }) - return { promise, resolve } -} - -function createEmptyScanResult(): ScanResult { +function createEmptyScanResult() { return { processedDatabases: [], sessions: [], @@ -101,9 +52,8 @@ function getDefaultState(): OpenCodeUsagePersistedState { function createStoreWithState(state: Partial): OpenCodeUsageStore { const store = new OpenCodeUsageStore({ getRepos: () => [], - getAllWorktreeMeta: () => ({}), - getWorktreeMeta: () => undefined - } as never) + getAllWorktreeMeta: () => ({}) + }) ;(store as unknown as { state: OpenCodeUsagePersistedState }).state = { ...getDefaultState(), @@ -212,11 +162,6 @@ describe('OpenCodeUsageStore', () => { tempUserData = mkdtempSync(join(tmpdir(), 'orca-opencode-usage-store-')) getPathMock.mockReturnValue(tempUserData) initOpenCodeUsagePath() - writeOpens.value = 0 - writeOpens.inFlight = 0 - writeOpens.maxConcurrent = 0 - writeGate.blocked = false - writeGate.waiters = [] vi.mocked(scanOpenCodeUsageDatabases).mockReset() vi.mocked(scanOpenCodeUsageDatabases).mockResolvedValue(createEmptyScanResult()) vi.useFakeTimers() @@ -228,7 +173,7 @@ describe('OpenCodeUsageStore', () => { rmSync(tempUserData, { recursive: true, force: true }) }) - it('persists a successful refresh with one full-cache write', async () => { + it('adapts OpenCode scans to pretty-printed cache persistence', async () => { const store = createStoreWithState({ scanState: { enabled: true, @@ -240,89 +185,9 @@ describe('OpenCodeUsageStore', () => { await store.refresh(true) - // Why exactly one: a refresh that rewrites the whole multi-MB cache twice is the regression this guards. - expect(writeOpens.value).toBe(1) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) const persistedJson = readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8') + expect(scanOpenCodeUsageDatabases).toHaveBeenCalledWith([], []) expect(persistedJson).toContain('\n') - expect(JSON.parse(persistedJson).scanState).toMatchObject({ - enabled: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - }) - - it('keeps scan start visible in memory while scan-start persistence is skipped', async () => { - const pendingScan = createDeferred() - vi.mocked(scanOpenCodeUsageDatabases).mockReturnValueOnce(pendingScan.promise) - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: 'previous failure' - } - }) - - const refreshPromise = store.refresh(true) - await Promise.resolve() - - expect(store.getScanState()).toMatchObject({ - isScanning: true, - lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(), - lastScanError: null - }) - expect(writeOpens.value).toBe(0) - - pendingScan.resolve(createEmptyScanResult()) - await refreshPromise - - expect(store.getScanState().isScanning).toBe(false) - expect(writeOpens.value).toBe(1) - }) - - it('vetoes a stale concurrent async write so the newer snapshot wins without leaking tmp files', async () => { - const store = createStoreWithState({ - scanState: { - enabled: true, - lastScanStartedAt: null, - lastScanCompletedAt: null, - lastScanError: null - } - }) - const internals = store as unknown as { - writeToDisk: () => Promise - state: OpenCodeUsagePersistedState - } - - writeGate.blocked = true - const first = internals.writeToDisk() - await vi.waitFor(() => expect(writeGate.waiters.length).toBe(1)) - - internals.state.scanState.enabled = false - writeGate.blocked = false - const second = internals.writeToDisk() - writeGate.waiters.splice(0).forEach((resolve) => resolve()) - await Promise.all([first, second]) - - expect( - JSON.parse(readFileSync(join(tempUserData, 'orca-opencode-usage.json'), 'utf-8')).scanState - .enabled - ).toBe(false) - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - // Serialized, so the superseded write can be skipped safely rather than racing the newer one. - expect(writeOpens.maxConcurrent).toBe(1) - }) - - it('sweeps a usage temp file orphaned by a crash between write and rename', async () => { - const orphan = join(tempUserData, 'orca-opencode-usage.json.999.1.abc.tmp') - writeFileSync(orphan, '{}') - - createStoreWithState({}) - await vi.waitFor(() => - expect(readdirSync(tempUserData).filter((f) => f.endsWith('.tmp'))).toHaveLength(0) - ) }) it('reports no data for Orca scope when only non-Orca OpenCode usage exists', async () => { diff --git a/src/main/opencode-usage/store.ts b/src/main/opencode-usage/store.ts index c85fa519ba5..b87357f9b0a 100644 --- a/src/main/opencode-usage/store.ts +++ b/src/main/opencode-usage/store.ts @@ -1,27 +1,23 @@ -/* eslint-disable max-lines -- Why: this store owns OpenCode analytics persistence, scan policy, and renderer query semantics. Keeping range/scope queries next to scan persistence prevents UI totals from drifting from the SQLite projection. */ +/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */ import { app } from 'electron' import { join } from 'node:path' -import { existsSync, readFileSync } from 'node:fs' -import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' import type { OpenCodeUsageBreakdownKind, OpenCodeUsageBreakdownRow, OpenCodeUsageDailyPoint, OpenCodeUsageRange, - OpenCodeUsageScanState, OpenCodeUsageScope, OpenCodeUsageSessionRow, OpenCodeUsageSnapshot, OpenCodeUsageSummary } from '../../shared/opencode-usage-types' import type { Store } from '../persistence' -import { loadKnownUsageWorktreesByRepo, type UsageWorktreeRef } from '../usage-worktree-metadata' import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types' -import { createWorktreeRefs } from '../usage/usage-worktree-refs' import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider' +import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range' +import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle' const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION -const STALE_MS = 5 * 60_000 let _openCodeUsageFile: string | null = null @@ -70,47 +66,6 @@ function getOpenCodeUsageFile(): string { return _openCodeUsageFile } -function getRangeCutoff(range: OpenCodeUsageRange): string | null { - if (range === 'all') { - return null - } - const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 - const now = new Date() - now.setHours(0, 0, 0, 0) - now.setDate(now.getDate() - (days - 1)) - const year = now.getFullYear() - const month = String(now.getMonth() + 1).padStart(2, '0') - const day = String(now.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getLocalDay(timestamp: string): string | null { - const parsed = new Date(timestamp) - if (Number.isNaN(parsed.getTime())) { - return null - } - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - return `${year}-${month}-${day}` -} - -function getWorktreeFingerprint(worktreesByRepo: Map): string { - const rows = [...worktreesByRepo.entries()] - .flatMap(([repoId, worktrees]) => - worktrees.map((worktree) => - JSON.stringify({ - repoId, - worktreeId: worktree.worktreeId, - path: worktree.path, - displayName: worktree.displayName - }) - ) - ) - .sort() - return JSON.stringify(rows) -} - function addCost(left: number | null, right: number | null): number | null { if (left === null && right === null) { return null @@ -148,62 +103,22 @@ function normalizeSessionCost( } } -export class OpenCodeUsageStore { - private state: OpenCodeUsagePersistedState - private readonly store: Store - private scanPromise: Promise | null = null - // Why: the multi-MB usage JSON must not block the Electron main thread; the writer serializes - // writes and vetoes superseded renames. - private readonly writer = new UsageCacheSnapshotWriter('[opencode-usage]', getOpenCodeUsageFile) - - constructor(store: Store) { - this.store = store - this.state = this.load() - } - - private load(): OpenCodeUsagePersistedState { - try { - const usageFile = getOpenCodeUsageFile() - if (!existsSync(usageFile)) { - return getDefaultState() - } - const parsed = JSON.parse(readFileSync(usageFile, 'utf-8')) as OpenCodeUsagePersistedState - return normalizePersistedState({ - ...getDefaultState(), - ...parsed, - scanState: { - ...getDefaultState().scanState, - ...parsed.scanState - } - }) - } catch (error) { - console.error('[opencode-usage] Failed to load persisted state, starting fresh:', error) - return getDefaultState() - } - } - - private writeToDisk(): Promise { - // Pretty-print preserved: humans inspect this analytics cache on disk. - return this.writer.write(() => JSON.stringify(this.state, null, 2)) - } - - /** Await queued cache writes so quit does not drop the final snapshot. */ - flush(): Promise { - return this.writer.flush() - } - - async setEnabled(enabled: boolean): Promise { - this.state.scanState.enabled = enabled - await this.writeToDisk() - return this.getScanState() - } - - getScanState(): OpenCodeUsageScanState { - return { - ...this.state.scanState, - isScanning: this.scanPromise !== null, - hasAnyOpenCodeData: this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 - } +export class OpenCodeUsageStore extends UsageProviderStoreLifecycle< + 'processedDatabases', + OpenCodeUsagePersistedState, + 'hasAnyOpenCodeData' +> { + constructor(store: Pick) { + super(store, { + logTag: '[opencode-usage]', + resolveCacheFile: getOpenCodeUsageFile, + createDefaultState: getDefaultState, + normalizeState: normalizePersistedState, + sourceKey: 'processedDatabases', + dataPresenceKey: 'hasAnyOpenCodeData', + jsonIndent: 2, + scan: openCodeUsageProvider.scan + }) } getSnapshot( @@ -221,63 +136,6 @@ export class OpenCodeUsageStore { } } - async refresh(force = false): Promise { - if (!this.state.scanState.enabled) { - return this.getScanState() - } - const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() - if (!force && this.state.scanState.lastScanCompletedAt) { - const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt - if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { - return this.getScanState() - } - } - await this.runScan() - return this.getScanState() - } - - private async runScan(): Promise { - if (this.scanPromise) { - await this.scanPromise - return - } - - this.state.scanState.lastScanStartedAt = Date.now() - this.state.scanState.lastScanError = null - // Why no write here: persisting scan-start would rewrite the whole cache before a single result - // changed. The completion/failure write below persists the same fields. - - this.scanPromise = (async () => { - try { - const repos = this.store.getRepos() - const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) - const worktreeFingerprint = getWorktreeFingerprint(worktreesByRepo) - const result = await openCodeUsageProvider.scan( - createWorktreeRefs(repos, worktreesByRepo), - this.state.worktreeFingerprint === worktreeFingerprint - ? this.state.processedDatabases - : [] - ) - this.state.processedDatabases = result.processedDatabases - this.state.sessions = result.sessions - this.state.dailyAggregates = result.dailyAggregates - this.state.worktreeFingerprint = worktreeFingerprint - this.state.scanState.lastScanCompletedAt = Date.now() - this.state.scanState.lastScanError = null - // Why swallow: persistence is a cache concern. A disk failure must not turn a good scan into - // a scan error and reject refresh() for every query caller; writeToDisk already logs it. - await this.writeToDisk().catch(() => {}) - } catch (error) { - this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) - await this.writeToDisk().catch(() => {}) - } finally { - this.scanPromise = null - } - })() - - await this.scanPromise - } - async getSummary( scope: OpenCodeUsageScope, range: OpenCodeUsageRange @@ -479,7 +337,7 @@ export class OpenCodeUsageStore { scope: OpenCodeUsageScope, range: OpenCodeUsageRange ): OpenCodeUsageDailyAggregate[] { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.dailyAggregates.filter((row) => { if (scope === 'orca' && !row.worktreeId) { return false @@ -492,13 +350,13 @@ export class OpenCodeUsageStore { } private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) { - const cutoff = getRangeCutoff(range) + const cutoff = getUsageRangeCutoff(range) return this.state.sessions.filter((session) => { if (scope === 'orca' && !session.primaryWorktreeId) { return false } if (cutoff) { - const day = getLocalDay(session.lastTimestamp) + const day = getLocalUsageDay(session.lastTimestamp) if (!day || day < cutoff) { return false } @@ -506,9 +364,4 @@ export class OpenCodeUsageStore { return true }) } - - private async getCurrentWorktreeFingerprint(): Promise { - const repos = this.store.getRepos() - return getWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) - } } diff --git a/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts new file mode 100644 index 00000000000..4c04ab22d01 --- /dev/null +++ b/src/main/persistence-feature-interaction-broadcast.benchmark.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { normalizeFeatureInteractions } from '../shared/feature-interactions' +import type { PersistedState } from '../shared/types' + +vi.mock('electron', () => ({ + app: { getPath: () => tmpdir() }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString('utf8') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: () => ({}) })) + +import { Store } from './persistence' + +const INTERACTIONS = 200 +const tempDirs: string[] = [] + +function createStore(name: string): { dataFile: string; store: Store } { + const dir = mkdtempSync(join(tmpdir(), `orca-ui-broadcast-${name}-`)) + tempDirs.push(dir) + const dataFile = join(dir, 'orca-data.json') + return { dataFile, store: new Store({ dataFile }) } +} + +describe('feature interaction UI broadcast benchmark', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('reduces repeated interaction recording to one material UI broadcast', () => { + const { store: legacyStore } = createStore('legacy') + const { dataFile, store: optimizedStore } = createStore('optimized') + let legacyBroadcasts = 0 + let optimizedBroadcasts = 0 + legacyStore.onUIChanged(() => legacyBroadcasts++) + optimizedStore.onUIChanged(() => optimizedBroadcasts++) + + const legacyStart = performance.now() + for (let index = 0; index < INTERACTIONS; index++) { + const featureInteractions = normalizeFeatureInteractions( + legacyStore.getUI().featureInteractions + ) + const existing = featureInteractions['agent-orchestration'] + legacyStore.updateUI({ + featureInteractions: { + ...featureInteractions, + 'agent-orchestration': { + firstInteractedAt: existing?.firstInteractedAt ?? 1, + interactionCount: (existing?.interactionCount ?? 0) + 1 + } + } + }) + } + const legacyMs = performance.now() - legacyStart + + const optimizedStart = performance.now() + for (let index = 0; index < INTERACTIONS; index++) { + optimizedStore.recordFeatureInteraction('agent-orchestration') + } + const optimizedMs = performance.now() - optimizedStart + + expect(legacyBroadcasts).toBe(INTERACTIONS) + expect(optimizedBroadcasts).toBe(1) + expect( + optimizedStore.getUI().featureInteractions?.['agent-orchestration']?.interactionCount + ).toBe(INTERACTIONS) + optimizedStore.flush() + expect( + new Store({ dataFile }).getUI().featureInteractions?.['agent-orchestration']?.interactionCount + ).toBe(INTERACTIONS) + const persisted = JSON.parse(readFileSync(dataFile, 'utf8')) as PersistedState + expect(persisted.featureInteractionTelemetryBuckets?.['agent-orchestration']).toBe( + 'count_200_499' + ) + + console.log( + `[bench] ${INTERACTIONS} orchestration interactions: ` + + `full-state broadcasts ${legacyBroadcasts} -> ${optimizedBroadcasts}; ` + + `main-thread mutation ${legacyMs.toFixed(2)}ms -> ${optimizedMs.toFixed(2)}ms` + ) + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index c2e36d3ce9d..922a0ced183 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -5778,7 +5778,7 @@ export class Store { } } - // Why: UI view-state is written from both desktop and mobile (ui.set RPC), so notify to keep bi-directional sync (desktop hydrates UI only once). + // Why: renderer-visible UI state is written from desktop and mobile, so notify to keep bi-directional sync. onUIChanged(listener: (ui: PersistedState['ui']) => void): () => void { this.uiChangeListeners.add(listener) return () => { @@ -6189,7 +6189,8 @@ export class Store { (lastEmittedBucket === null || compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0) - this.updateUI({ + this.state.ui = { + ...this.state.ui, featureInteractions: { ...featureInteractions, [id]: { @@ -6197,11 +6198,15 @@ export class Store { interactionCount: nextCount } } - }) + } this.state.featureInteractionTelemetryBuckets = shouldEmit ? { ...telemetryBuckets, [id]: nextBucket } : telemetryBuckets this.scheduleSave() + // Why: live UI only consumes the seen transition; count-only telemetry must not re-hydrate the renderer. + if (!existing) { + this.notifyUIChanged() + } if (shouldEmit) { track('feature_interaction_usage_bucket_reached', { diff --git a/src/main/providers/pty-provider-contract.ts b/src/main/providers/pty-provider-contract.ts index 02b1d9acf1e..398729efe17 100644 --- a/src/main/providers/pty-provider-contract.ts +++ b/src/main/providers/pty-provider-contract.ts @@ -19,6 +19,8 @@ export type { export type PtyProviderBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string /** Authoritative normal buffer captured beside an alternate-screen frame. */ scrollbackAnsi?: string cols: number diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 235672e05e7..66fec74a482 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -32,6 +32,12 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Normal-buffer history and mode preamble before an alternate-screen frame. */ + snapshotPrefixAnsi?: string + /** Visual alternate-screen frame, separate so newer clients can omit it safely. */ + snapshotFrameAnsi?: string + /** Live state to append when omitting `snapshotFrameAnsi`. */ + snapshotFrameRestoreAnsi?: string /** Provider sequence at the attach boundary. `reset` starts a new provider * generation; `continued` resumes the existing absolute domain. */ providerSequence?: { diff --git a/src/main/pty/windows-environment-path-main-loop.test.ts b/src/main/pty/windows-environment-path-main-loop.test.ts new file mode 100644 index 00000000000..fbe185479cc --- /dev/null +++ b/src/main/pty/windows-environment-path-main-loop.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __resetPersistedWindowsPathCacheForTests, + readPersistedWindowsPathSegments +} from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' + +const CREATE_PROCESS_DELAY_MS = 160 +const delayedExecFileSync = vi.hoisted(() => + vi.fn(() => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, CREATE_PROCESS_DELAY_MS) + return ' Path REG_EXPAND_SZ C:\\Delayed' + }) +) + +vi.mock('node:child_process', () => ({ + execFile: vi.fn(), + execFileSync: delayedExecFileSync +})) + +describe('persisted Windows PATH process creation', () => { + afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() + delayedExecFileSync.mockClear() + }) + + async function measureMainLoopGap(run: () => T): Promise<{ + callMs: number + maxGapMs: number + result: T + }> { + let lastTick = performance.now() + let maxGapMs = 0 + const timer = setInterval(() => { + const now = performance.now() + maxGapMs = Math.max(maxGapMs, now - lastTick - 5) + lastTick = now + }, 5) + await new Promise((resolve) => setTimeout(resolve, 20)) + + const startedAt = performance.now() + const result = run() + const callMs = performance.now() - startedAt + await new Promise((resolve) => setTimeout(resolve, 20)) + clearInterval(timer) + return { callMs, maxGapMs, result } + } + + it('proves delayed process creation blocks while native registry reads create no process', async () => { + const legacy = await measureMainLoopGap(() => + readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' }, + execFileSync: delayedExecFileSync as never + }) + ) + + expect(legacy.result).toEqual(['C:\\Delayed', 'C:\\Delayed']) + expect(delayedExecFileSync).toHaveBeenCalledTimes(2) + expect(legacy.callMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS * 2) + expect(legacy.maxGapMs).toBeGreaterThanOrEqual(CREATE_PROCESS_DELAY_MS) + + __resetPersistedWindowsPathCacheForTests() + delayedExecFileSync.mockClear() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: (root) => ({ + Path: { type: 1, value: root === 1 ? 'C:\\Machine' : 'C:\\User' } + }) + })) + const native = readPersistedWindowsPathSegments({ + platform: 'win32', + env: { SystemRoot: 'C:\\Windows' } + }) + + expect(native).toEqual(['C:\\Machine', 'C:\\User']) + expect(delayedExecFileSync).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/pty/windows-environment-path.test.ts b/src/main/pty/windows-environment-path.test.ts index d639ee604ce..e1ace3350ca 100644 --- a/src/main/pty/windows-environment-path.test.ts +++ b/src/main/pty/windows-environment-path.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { defaultExecFileMock, defaultExecFileSyncMock } = vi.hoisted(() => ({ defaultExecFileMock: vi.fn(), @@ -18,8 +18,26 @@ import { readPersistedWindowsPathSegmentsAsync, resolvePathEnvKey } from './windows-environment-path' +import { __setWindowsPathRegistryLoaderForTests } from './windows-path-registry-reader' -type ExecCallback = (error: Error | null, stdout: string, stderr: string) => void +const registryGetKeyMock = vi.fn() + +function registryPath(value: string): Record { + return { Path: { type: 1, value } } +} + +beforeEach(() => { + registryGetKeyMock.mockReset() + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: registryGetKeyMock + })) +}) + +afterEach(() => { + __resetPersistedWindowsPathCacheForTests() + __setWindowsPathRegistryLoaderForTests() +}) describe('readPersistedWindowsPathSegments', () => { it('reads machine and user Path values from the Windows registry', () => { @@ -67,18 +85,17 @@ describe('readPersistedWindowsPathSegments', () => { it('caches production registry reads briefly', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -86,27 +103,26 @@ describe('readPersistedWindowsPathSegments', () => { it('force-refreshes the production registry cache', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User;C:\\Program Files\\GitHub CLI\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User;C:\\Program Files\\GitHub CLI')) __resetPersistedWindowsPathCacheForTests() try { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) expect(readPersistedWindowsPathSegments({ forceRefresh: true })).toEqual([ 'C:\\Machine', 'C:\\User', 'C:\\Program Files\\GitHub CLI' ]) - expect(defaultExecFileSyncMock).toHaveBeenCalledTimes(4) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -114,9 +130,9 @@ describe('readPersistedWindowsPathSegments', () => { it('keeps the last good segments when a forced read hits a blocked registry', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) .mockImplementation(() => { throw new Error('ERROR: Access is denied.') }) @@ -131,7 +147,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual(['C:\\Machine', 'C:\\User']) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -139,10 +154,10 @@ describe('readPersistedWindowsPathSegments', () => { it('still clears the cache when the registry reports an empty Path', () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileSyncMock - .mockReturnValueOnce(' Path REG_SZ C:\\Machine\r\n') - .mockReturnValueOnce(' Path REG_SZ C:\\User\r\n') - .mockReturnValue(' Path REG_SZ \r\n') + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockReturnValue(registryPath('')) __resetPersistedWindowsPathCacheForTests() try { @@ -154,7 +169,6 @@ describe('readPersistedWindowsPathSegments', () => { expect(readPersistedWindowsPathSegments()).toEqual([]) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileSyncMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) @@ -162,13 +176,9 @@ describe('readPersistedWindowsPathSegments', () => { it('deduplicates concurrent forced asynchronous refreshes and merges each environment', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - const callbacks: ExecCallback[] = [] - defaultExecFileMock.mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callbacks.push(callback) - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) __resetPersistedWindowsPathCacheForTests() try { @@ -177,41 +187,25 @@ describe('readPersistedWindowsPathSegments', () => { const first = mergePersistedWindowsPathAsync(firstEnv, { forceRefresh: true }) const second = mergePersistedWindowsPathAsync(secondEnv, { forceRefresh: true }) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) - callbacks[0]?.(null, ' Path REG_SZ C:\\Machine\r\n', '') - callbacks[1]?.(null, ' Path REG_SZ C:\\User\r\n', '') await Promise.all([first, second]) expect(firstEnv.Path).toBe('C:\\First;C:\\Machine;C:\\User') expect(secondEnv.Path).toBe('C:\\Second;C:\\Machine;C:\\User') + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) - it('keeps the last good cache when bounded asynchronous reads time out', async () => { + it('keeps the last good cache when native registry reads fail', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - defaultExecFileMock - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\Machine\r\n', '') - return {} as never - } - ) - .mockImplementationOnce( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(null, ' Path REG_SZ C:\\User\r\n', '') - return {} as never - } - ) - .mockImplementation( - (_file: string, _args: string[], _options: unknown, callback: ExecCallback) => { - callback(Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' }), '', '') - return {} as never - } - ) + registryGetKeyMock + .mockReturnValueOnce(registryPath('C:\\Machine')) + .mockReturnValueOnce(registryPath('C:\\User')) + .mockImplementation(() => { + throw new Error('registry unavailable') + }) __resetPersistedWindowsPathCacheForTests() try { @@ -223,7 +217,7 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(2) + expect(registryGetKeyMock).toHaveBeenCalledTimes(2) await expect(readPersistedWindowsPathSegmentsAsync({ forceRefresh: true })).resolves.toEqual([ 'C:\\Machine', 'C:\\User' @@ -232,11 +226,9 @@ describe('readPersistedWindowsPathSegments', () => { 'C:\\Machine', 'C:\\User' ]) - expect(defaultExecFileMock).toHaveBeenCalledTimes(4) - expect(defaultExecFileMock.mock.calls[2]?.[2]).toMatchObject({ timeout: 5_000 }) + expect(registryGetKeyMock).toHaveBeenCalledTimes(4) } finally { __resetPersistedWindowsPathCacheForTests() - defaultExecFileMock.mockReset() Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) } }) diff --git a/src/main/pty/windows-environment-path.ts b/src/main/pty/windows-environment-path.ts index 36032da9aec..8fad453cab5 100644 --- a/src/main/pty/windows-environment-path.ts +++ b/src/main/pty/windows-environment-path.ts @@ -1,9 +1,10 @@ -import { execFile, execFileSync } from 'node:child_process' +import type { execFile, execFileSync } from 'node:child_process' import { expandWindowsEnvironmentVariables, expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion' import { getRegExePath } from '../win32-utils' +import { readWindowsPathRegistry } from './windows-path-registry-reader' type ExecFile = typeof execFile type ExecFileSync = typeof execFileSync @@ -16,10 +17,7 @@ type ReadWindowsPathOptions = { platform?: NodeJS.Platform } -type RegistryPathRead = { - failed: boolean - segments: string[] -} +type RegistryPathRead = { failed: boolean; segments: string[] } const WINDOWS_PATH_REGISTRY_KEYS = [ ['HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment', 'Path'], @@ -71,6 +69,19 @@ function registryOutputSegments( : [] } +function readNativeRegistryPaths( + env: NodeJS.ProcessEnv, + pathDelimiter: string +): RegistryPathRead[] { + return readWindowsPathRegistry().map((read) => ({ + failed: read.failed, + segments: + read.value === null + ? [] + : splitPathSegments(expandWindowsEnvironmentVariables(read.value, env), pathDelimiter) + })) +} + function keepLastGoodSegments(segments: string[], failedReads: number): string[] { if (failedReads === WINDOWS_PATH_REGISTRY_KEYS.length && persistedWindowsPathCache) { return [...persistedWindowsPathCache.segments] @@ -145,34 +156,39 @@ export function readPersistedWindowsPathSegments(options: ReadWindowsPathOptions return [...persistedWindowsPathCache.segments] } - const run = options.execFileSync ?? execFileSync const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const segments: string[] = [] - let failedReads = 0 - - for (const [key, valueName] of WINDOWS_PATH_REGISTRY_KEYS) { - try { - const output = run(getRegExePath(env), ['query', key, '/v', valueName], { - encoding: 'utf8', - timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, - windowsHide: true + const reads = options.execFileSync + ? WINDOWS_PATH_REGISTRY_KEYS.map(([key, valueName]) => { + try { + const output = options.execFileSync!( + getRegExePath(env), + ['query', key, '/v', valueName], + { + encoding: 'utf8', + timeout: PERSISTED_WINDOWS_PATH_QUERY_TIMEOUT_MS, + windowsHide: true + } + ) + return { + failed: false, + segments: registryOutputSegments(output, valueName, env, pathDelimiter) + } + } catch { + return { failed: true, segments: [] } + } }) - segments.push(...registryOutputSegments(output, valueName, env, pathDelimiter)) - } catch { - // Registry access can fail in stripped test containers or remote-like - // Windows contexts. Existing PATH remains the fallback in those cases. - failedReads += 1 - } - } + : readNativeRegistryPaths(env, pathDelimiter) + const segments = reads.flatMap((read) => read.segments) + const failedReads = reads.filter((read) => read.failed).length if (!useProductionCache) { return segments } - // Why: local PTY spawn is a hot path on Windows, and each uncached read - // runs two synchronous `reg.exe query` subprocesses. A short TTL keeps - // terminal bursts cheap while still picking up newly installed CLIs soon. + // Why: local PTY spawn is a hot path on Windows, and each uncached refresh performs two + // synchronous native registry reads. A short TTL keeps terminal bursts cheap while still + // picking up newly installed CLIs soon. return cachePersistedWindowsPathSegments(segments, failedReads) } @@ -202,14 +218,22 @@ export async function readPersistedWindowsPathSegmentsAsync( return [...(await pendingPersistedWindowsPathRefresh)] } - const run = options.execFile ?? execFile const env = options.env ?? process.env const pathDelimiter = getPathDelimiter(platform) - const executable = getRegExePath(env) - const refresh = Promise.all( - WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => - readRegistryPathAsync(run, executable, registryValue, env, pathDelimiter) - ) + const refresh = ( + options.execFile + ? Promise.all( + WINDOWS_PATH_REGISTRY_KEYS.map((registryValue) => + readRegistryPathAsync( + options.execFile!, + getRegExePath(env), + registryValue, + env, + pathDelimiter + ) + ) + ) + : Promise.resolve(readNativeRegistryPaths(env, pathDelimiter)) ).then((reads) => { const segments = reads.flatMap((read) => read.segments) if (!useProductionCache) { diff --git a/src/main/pty/windows-path-registry-reader.test.ts b/src/main/pty/windows-path-registry-reader.test.ts new file mode 100644 index 00000000000..634cd30e72a --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + __setWindowsPathRegistryLoaderForTests, + readWindowsPathRegistry +} from './windows-path-registry-reader' + +describe('readWindowsPathRegistry', () => { + afterEach(() => __setWindowsPathRegistryLoaderForTests()) + + it('reads machine and user PATH values without creating a process', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ PATH: { type: 2, value: '%SystemRoot%\\System32' } }) + .mockReturnValueOnce({ Path: { type: 1, value: 'C:\\Users\\me\\bin' } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '%SystemRoot%\\System32' }, + { failed: false, value: 'C:\\Users\\me\\bin' } + ]) + expect(getRegistryKey).toHaveBeenNthCalledWith( + 1, + 1, + 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' + ) + expect(getRegistryKey).toHaveBeenNthCalledWith(2, 2, 'Environment') + }) + + it('distinguishes an empty PATH from a failed registry read', () => { + const getRegistryKey = vi + .fn() + .mockReturnValueOnce({ Path: { type: 1, value: '' } }) + .mockReturnValueOnce({ Path: { type: 4, value: 1 } }) + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: false, value: '' }, + { failed: true, value: null } + ]) + }) + + it('treats a missing PATH value as a failed query', () => { + __setWindowsPathRegistryLoaderForTests(() => ({ + HK: { LM: 1, CU: 2 }, + getRegistryKey: vi.fn(() => ({})) + })) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) + + it('fails closed when the optional native module is unavailable', () => { + __setWindowsPathRegistryLoaderForTests(() => { + throw new Error('native module unavailable') + }) + + expect(readWindowsPathRegistry()).toEqual([ + { failed: true, value: null }, + { failed: true, value: null } + ]) + }) +}) diff --git a/src/main/pty/windows-path-registry-reader.ts b/src/main/pty/windows-path-registry-reader.ts new file mode 100644 index 00000000000..6f98c45e390 --- /dev/null +++ b/src/main/pty/windows-path-registry-reader.ts @@ -0,0 +1,67 @@ +import { + loadWindowsNativeRegistry, + WINDOWS_REG_EXPAND_SZ, + WINDOWS_REG_SZ, + type WindowsNativeRegistryModule +} from '../windows-native-registry' + +export type WindowsPathRegistryRead = { + failed: boolean + value: string | null +} + +const MACHINE_ENVIRONMENT_KEY = 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment' +const USER_ENVIRONMENT_KEY = 'Environment' +const PATH_VALUE = 'Path' + +let windowsRegistryLoader = loadWindowsNativeRegistry + +function readRegistryPath( + registry: WindowsNativeRegistryModule, + root: number, + key: string +): WindowsPathRegistryRead { + try { + const values = registry.getRegistryKey(root, key) + if (!values || typeof values !== 'object') { + return { failed: true, value: null } + } + const entry = Object.entries(values).find( + ([name]) => name.toLowerCase() === PATH_VALUE.toLowerCase() + )?.[1] + if (!entry) { + return { failed: true, value: null } + } + if ( + (entry.type !== WINDOWS_REG_SZ && entry.type !== WINDOWS_REG_EXPAND_SZ) || + typeof entry.value !== 'string' + ) { + return { failed: true, value: null } + } + return { failed: false, value: entry.value } + } catch { + return { failed: true, value: null } + } +} + +export function readWindowsPathRegistry(): WindowsPathRegistryRead[] { + let registry: WindowsNativeRegistryModule + try { + registry = windowsRegistryLoader() + } catch { + return [ + { failed: true, value: null }, + { failed: true, value: null } + ] + } + return [ + readRegistryPath(registry, registry.HK.LM, MACHINE_ENVIRONMENT_KEY), + readRegistryPath(registry, registry.HK.CU, USER_ENVIRONMENT_KEY) + ] +} + +export function __setWindowsPathRegistryLoaderForTests( + loader?: () => WindowsNativeRegistryModule +): void { + windowsRegistryLoader = loader ?? loadWindowsNativeRegistry +} diff --git a/src/main/runtime/folder-workspace-pty-identity.test.ts b/src/main/runtime/folder-workspace-pty-identity.test.ts index 71c18b9412a..d986fa54938 100644 --- a/src/main/runtime/folder-workspace-pty-identity.test.ts +++ b/src/main/runtime/folder-workspace-pty-identity.test.ts @@ -134,6 +134,126 @@ describe('folder workspaces sharing one directory', () => { expect([...(inventory?.livePtyIds ?? [])]).toEqual([PTY_B]) }) + it('keeps the first resolved worktree when normalized identities collide', async () => { + const firstId = `${ROOT_ID}/duplicate/` + const equivalentId = `${ROOT_ID}/duplicate` + const laterId = `${ROOT_ID}/later` + const internals = createRuntimeInternals({ + sessions: [ + { id: 'later-owner-pty', worktreeId: laterId, cwd: FOLDER_PATH, title: 'shell' }, + { id: 'duplicate-owner-pty', worktreeId: equivalentId, cwd: FOLDER_PATH, title: 'shell' } + ] + }) + const resolvedWorktrees = [firstId, equivalentId, laterId].map((id) => + internals.buildResolvedWorktreeFromId(id) + ) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('duplicate-owner-pty')?.worktreeId).toBe(firstId) + }) + + it('stops indexing after a sparse owner match', async () => { + const ownerId = `${ROOT_ID}/first` + const internals = createRuntimeInternals({ + sessions: [{ id: 'sparse-owner-pty', worktreeId: ownerId, cwd: FOLDER_PATH, title: 'shell' }] + }) + let identityReads = 0 + const resolvedWorktrees = [ownerId, `${ROOT_ID}/unused-a`, `${ROOT_ID}/unused-b`].map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.get('sparse-owner-pty')?.worktreeId).toBe(ownerId) + expect(identityReads).toBe(2) + }) + + it('keeps parsed and raw identity domains distinct', async () => { + const parsedId = `${ROOT_ID}/collision` + const rawId = `${REPO_ID}\0${FOLDER_PATH}/collision` + const internals = createRuntimeInternals({ + sessions: [{ id: 'raw-owner-pty', worktreeId: rawId, cwd: FOLDER_PATH, title: 'shell' }] + }) + const parsedWorktree = internals.buildResolvedWorktreeFromId(parsedId) + if (!parsedWorktree || typeof parsedWorktree !== 'object') { + throw new Error(`Failed to resolve ${parsedId}`) + } + const rawWorktree = { ...parsedWorktree, id: rawId } + + await internals.refreshPtyWorktreeRecordsWithControllerInventory([parsedWorktree, rawWorktree]) + + expect(internals.ptysById.get('raw-owner-pty')?.worktreeId).toBe(rawId) + }) + + it('bounds provider and persisted worktree resolution to linear identity reads', async () => { + const worktreeCount = 128 + const worktreeIds = Array.from( + { length: worktreeCount }, + (_, index) => `${ROOT_ID}/worktree-${index}` + ) + const persistedSession: WorkspaceSessionState = { + ...getDefaultWorkspaceSession(), + tabsByWorktree: Object.fromEntries( + worktreeIds.map((worktreeId, index) => [ + worktreeId, + [ + { + id: `indexed-tab-${index}`, + ptyId: `indexed-pty-${index}`, + worktreeId, + title: 'shell' + } + ] + ]) + ) as never + } + const internals = createRuntimeInternals({ + session: persistedSession, + sessions: worktreeIds.map((worktreeId, index) => ({ + id: `indexed-pty-${index}`, + worktreeId, + cwd: `${FOLDER_PATH}/worktree-${index}`, + title: 'shell' + })) + }) + let identityReads = 0 + const resolvedWorktrees = worktreeIds.map((id) => { + const worktree = internals.buildResolvedWorktreeFromId(id) + if (!worktree || typeof worktree !== 'object') { + throw new Error(`Failed to resolve ${id}`) + } + return Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + identityReads += 1 + return id + } + }) + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory(resolvedWorktrees) + + expect(internals.ptysById.size).toBe(worktreeCount) + expect(internals.ptysById.get('indexed-pty-0')?.worktreeId).toBe(worktreeIds[0]) + expect(internals.ptysById.get(`indexed-pty-${worktreeCount - 1}`)?.worktreeId).toBe( + worktreeIds[worktreeCount - 1] + ) + expect(identityReads).toBe(worktreeCount * 2) + }) + it('reports live PTYs per workspace instance and for the folder root separately', () => { const internals = createRuntimeInternals() internals.recordPtyWorktree(PTY_A, WORKSPACE_A, { connected: true }) diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts new file mode 100644 index 00000000000..582837826ac --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMobileSessionTabsAgentStatusHeartbeat, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS +} from './mobile-session-tabs-agent-status-heartbeat' + +describe('mobile session-tabs agent-status heartbeat', () => { + const worktreesByPtyId = new Map() + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + worktreesByPtyId.clear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('refreshes continuously active PTYs through one globally capped FIFO', () => { + const emitted: { worktreeId: string; at: number }[] = [] + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push({ worktreeId, at: Date.now() }) + ) + const ptyIds = Array.from({ length: 24 }, (_, index) => `pty-${index}`) + for (const ptyId of ptyIds) { + worktreesByPtyId.set(ptyId, [ptyId.replace('pty', 'worktree')]) + heartbeat.observeSemanticTitle(ptyId) + } + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - 1) + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.runOnlyPendingTimers() + expect(emitted).toEqual([]) + + vi.advanceTimersByTime(1) + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * (ptyIds.length - 1)) + + expect(emitted.map(({ worktreeId }) => worktreeId)).toEqual( + ptyIds.map((ptyId) => ptyId.replace('pty', 'worktree')) + ) + for (let index = 1; index < emitted.length; index += 1) { + expect(emitted[index]!.at - emitted[index - 1]!.at).toBeGreaterThanOrEqual( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + } + for (const ptyId of ptyIds) { + heartbeat.scheduleDecorativeHeartbeat(ptyId) + } + vi.runOnlyPendingTimers() + expect(emitted).toHaveLength(ptyIds.length) + heartbeat.dispose() + expect(vi.getTimerCount()).toBe(0) + }) + + it('lets semantic transitions bypass and cancel queued heartbeats', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + heartbeat.observeSemanticTitle('pty-2') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + heartbeat.removePty('pty-1') + heartbeat.removePty('pty-2') + expect(vi.getTimerCount()).toBe(0) + }) + + it('deduplicates sibling PTYs because one snapshot refreshes their worktree', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-1']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + heartbeat.removePty('pty-1') + vi.runAllTimers() + + expect(emitted).toEqual(['worktree-1']) + }) + + it('does not slip spacing-delayed worktrees to the next stale boundary', () => { + const emitted: { worktreeId: string; at: number }[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push({ worktreeId, at: Date.now() }) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS) + expect(emitted).toEqual([ + { worktreeId: 'worktree-1', at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS }, + { + worktreeId: 'worktree-2', + at: + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + } + ]) + + vi.advanceTimersByTime( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted.at(-1)).toEqual({ + worktreeId: 'worktree-1', + at: SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + }) + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - 1) + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted).toHaveLength(3) + + vi.advanceTimersByTime(1) + heartbeat.scheduleDecorativeHeartbeat('pty-2') + vi.runOnlyPendingTimers() + expect(emitted.at(-1)).toEqual({ + worktreeId: 'worktree-2', + at: + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS * 2 + + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + }) + }) + + it('drops removed worktrees and pending work during teardown', () => { + const emitted: string[] = [] + worktreesByPtyId.set('pty-1', ['worktree-1']) + worktreesByPtyId.set('pty-2', ['worktree-2']) + const heartbeat = createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => worktreesByPtyId.get(ptyId) ?? [], + (worktreeId) => emitted.push(worktreeId) + ) + heartbeat.observeSemanticTitle('pty-1') + heartbeat.observeSemanticTitle('pty-2') + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + expect(vi.getTimerCount()).toBe(1) + heartbeat.removePty('pty-1') + vi.runAllTimers() + expect(emitted).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + + heartbeat.scheduleDecorativeHeartbeat('pty-2') + expect(vi.getTimerCount()).toBe(1) + heartbeat.removeWorktree('worktree-2') + expect(vi.getTimerCount()).toBe(0) + + heartbeat.scheduleDecorativeHeartbeat('pty-1') + expect(vi.getTimerCount()).toBe(1) + heartbeat.cancelPending() + expect(vi.getTimerCount()).toBe(0) + heartbeat.scheduleDecorativeHeartbeat('pty-1') + heartbeat.dispose() + vi.runAllTimers() + expect(emitted).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts new file mode 100644 index 00000000000..6c80cf4a35d --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-agent-status-heartbeat.ts @@ -0,0 +1,133 @@ +import { AGENT_STATUS_STALE_AFTER_MS } from '../../shared/agent-status-types' + +// Refresh halfway through the stale lease so a bounded global queue has ample drain time. +export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS = AGENT_STATUS_STALE_AFTER_MS / 2 +export const SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS = 50 + +export type MobileSessionTabsAgentStatusHeartbeat = { + observeSemanticTitle: (ptyId: string) => void + scheduleDecorativeHeartbeat: (ptyId: string) => void + removePty: (ptyId: string) => void + removeWorktree: (worktreeId: string) => void + cancelPending: () => void + dispose: () => void +} + +export function createMobileSessionTabsAgentStatusHeartbeat( + resolveWorktreeIds: (ptyId: string) => Iterable, + emit: (worktreeId: string) => void +): MobileSessionTabsAgentStatusHeartbeat { + const lastEligibilityCheckAtByPtyId = new Map() + const lastRefreshAtByWorktreeId = new Map() + const pendingPtyIdsByWorktreeId = new Map>() + let lastGlobalHeartbeatAt: number | null = null + let timer: ReturnType | null = null + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer) + timer = null + } + } + + const arm = (): void => { + if (timer !== null || pendingPtyIdsByWorktreeId.size === 0) { + return + } + const now = Date.now() + const delay = + lastGlobalHeartbeatAt === null + ? 0 + : Math.max( + 0, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS - (now - lastGlobalHeartbeatAt) + ) + timer = setTimeout(() => { + timer = null + const worktreeId = pendingPtyIdsByWorktreeId.keys().next().value + if (typeof worktreeId !== 'string') { + return + } + const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) + pendingPtyIdsByWorktreeId.delete(worktreeId) + const emittedAt = Date.now() + lastRefreshAtByWorktreeId.set(worktreeId, emittedAt) + for (const ptyId of pendingPtyIds ?? []) { + lastEligibilityCheckAtByPtyId.set(ptyId, emittedAt) + } + lastGlobalHeartbeatAt = emittedAt + emit(worktreeId) + arm() + }, delay) + if (typeof timer.unref === 'function') { + timer.unref() + } + } + + return { + observeSemanticTitle(ptyId: string): void { + const observedAt = Date.now() + lastEligibilityCheckAtByPtyId.set(ptyId, observedAt) + for (const worktreeId of resolveWorktreeIds(ptyId)) { + lastRefreshAtByWorktreeId.set(worktreeId, observedAt) + pendingPtyIdsByWorktreeId.delete(worktreeId) + } + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + scheduleDecorativeHeartbeat(ptyId: string): void { + const now = Date.now() + const lastEligibilityCheckAt = lastEligibilityCheckAtByPtyId.get(ptyId) + if ( + lastEligibilityCheckAt !== undefined && + now - lastEligibilityCheckAt < SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + return + } + lastEligibilityCheckAtByPtyId.set(ptyId, now) + for (const worktreeId of resolveWorktreeIds(ptyId)) { + const lastRefreshAt = lastRefreshAtByWorktreeId.get(worktreeId) + if ( + lastRefreshAt === undefined || + now - lastRefreshAt >= SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS + ) { + const pendingPtyIds = pendingPtyIdsByWorktreeId.get(worktreeId) ?? new Set() + pendingPtyIds.add(ptyId) + pendingPtyIdsByWorktreeId.set(worktreeId, pendingPtyIds) + } + } + arm() + }, + removePty(ptyId: string): void { + lastEligibilityCheckAtByPtyId.delete(ptyId) + for (const [worktreeId, pendingPtyIds] of pendingPtyIdsByWorktreeId) { + pendingPtyIds.delete(ptyId) + if (pendingPtyIds.size === 0) { + pendingPtyIdsByWorktreeId.delete(worktreeId) + } + } + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + removeWorktree(worktreeId: string): void { + lastRefreshAtByWorktreeId.delete(worktreeId) + pendingPtyIdsByWorktreeId.delete(worktreeId) + if (pendingPtyIdsByWorktreeId.size === 0) { + clearTimer() + } + }, + cancelPending(): void { + clearTimer() + pendingPtyIdsByWorktreeId.clear() + }, + dispose(): void { + clearTimer() + pendingPtyIdsByWorktreeId.clear() + lastEligibilityCheckAtByPtyId.clear() + lastRefreshAtByWorktreeId.clear() + lastGlobalHeartbeatAt = null + } + } +} diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e5c8a18479e..ce96eddb5e0 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -22,6 +22,7 @@ const { closeWatcherInWatcherProcessMock, checkRgAvailableMock, getLocalGitOptionsForRegisteredWorktreeMock, + searchWithGitGrepMock, wslAwareSpawnMock, watchMock } = vi.hoisted(() => ({ @@ -35,6 +36,7 @@ const { statMock: vi.fn(), watchInWatcherProcessMock: vi.fn(), closeWatcherInWatcherProcessMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), wslAwareSpawnMock: vi.fn(), watchMock: vi.fn() })) @@ -91,6 +93,10 @@ vi.mock('../ipc/local-worktree-runtime-options', () => ({ getLocalGitOptionsForRegisteredWorktree: getLocalGitOptionsForRegisteredWorktreeMock })) +vi.mock('../ipc/filesystem-search-git', () => ({ + searchWithGitGrep: searchWithGitGrepMock +})) + vi.mock('../providers/ssh-filesystem-dispatch', () => ({ getSshFilesystemProvider: vi.fn(), onSshFilesystemProviderRegistered: () => () => undefined, @@ -202,6 +208,12 @@ function createRuntimeSearchChild(): MockRuntimeSearchChild { return child } +async function flushRuntimeSearchMicrotasks(): Promise { + for (let index = 0; index < 8; index++) { + await Promise.resolve() + } +} + describe('RuntimeFileCommands', () => { const originalPlatform = process.platform @@ -217,6 +229,7 @@ describe('RuntimeFileCommands', () => { closeWatcherInWatcherProcessMock.mockReset() watchMock.mockReset() checkRgAvailableMock.mockReset() + searchWithGitGrepMock.mockReset() vi.mocked(getSshFilesystemProvider).mockReset() resetSshConnectionGenerations() getLocalGitOptionsForRegisteredWorktreeMock.mockReset() @@ -754,6 +767,68 @@ describe('RuntimeFileCommands', () => { expect(child.stderr.listenerCount('data')).toBe(0) expect(child.listenerCount('error')).toBe(0) expect(child.listenerCount('close')).toBe(0) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + }) + + it.each(['error-first', 'close-first'] as const)( + 'falls back once when runtime rg native launch failure is %s', + async (order) => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: undefined }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => child.emit('error', error)).not.toThrow() + child.emit('close', -2, null) + } else { + child.emit('close', -2, null) + expect(() => child.emit('error', error)).not.toThrow() + } + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + expect(checkRgAvailableMock).not.toHaveBeenCalled() + expect(child.listenerCount('error')).toBe(0) + expect(child.listenerCount('close')).toBe(0) + } + ) + + it("falls back when a runtime native launcher exits outside ripgrep's contract", async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) + resolveAuthorizedPathMock.mockResolvedValue('/repo') + wslAwareSpawnMock.mockReturnValue(child) + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithGitGrepMock.mockResolvedValue(fallback) + + const resultPromise = commands.searchRuntimeFiles('id:wt-1', { + query: 'needle', + maxResults: 10 + }) + await flushRuntimeSearchMicrotasks() + child.emit('close', 127, null) + + await expect(resultPromise).resolves.toBe(fallback) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) }) it('routes runtime rg searches through the registered WSL project runtime', async () => { @@ -767,6 +842,7 @@ describe('RuntimeFileCommands', () => { })) const { commands, store } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) const child = createRuntimeSearchChild() + Object.defineProperty(child, 'pid', { value: 1 }) resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') checkRgAvailableMock.mockResolvedValue(true) getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) @@ -779,7 +855,7 @@ describe('RuntimeFileCommands', () => { await Promise.resolve() await Promise.resolve() await Promise.resolve() - child.emit('close') + child.emit('close', 127, null) await expect(resultPromise).resolves.toMatchObject({ files: [] }) expect(getLocalGitOptionsForRegisteredWorktreeMock).toHaveBeenCalledWith( @@ -788,6 +864,7 @@ describe('RuntimeFileCommands', () => { 'C:\\repo' ) expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(searchWithGitGrepMock).not.toHaveBeenCalled() expect(wslAwareSpawnMock).toHaveBeenCalledWith( 'rg', expect.any(Array), @@ -798,6 +875,25 @@ describe('RuntimeFileCommands', () => { ) }) + it('keeps the runtime WSL preflight and falls back before starting real rg', async () => { + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { id: 'wt-1', repoId: 'repo-1', path: 'C:\\repo' }, + connectionId: null + })) + const { commands } = createRuntimeFileCommands({ resolveRuntimeFileTarget }) + const fallback = { files: [], totalMatches: 0, truncated: false } + resolveAuthorizedPathMock.mockResolvedValue('C:\\repo') + getLocalGitOptionsForRegisteredWorktreeMock.mockReturnValue({ wslDistro: 'Ubuntu' }) + checkRgAvailableMock.mockResolvedValue(false) + searchWithGitGrepMock.mockResolvedValue(fallback) + + await expect( + commands.searchRuntimeFiles('id:wt-1', { query: 'needle', maxResults: 10 }) + ).resolves.toBe(fallback) + expect(checkRgAvailableMock).toHaveBeenCalledWith('C:\\repo', 'Ubuntu') + expect(wslAwareSpawnMock).not.toHaveBeenCalled() + }) + describe('resolveTerminalPath', () => { let tempDirs: string[] = [] diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 68000f1b297..f85bff8abde 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -58,6 +58,11 @@ import { listQuickOpenFiles } from '../ipc/filesystem-list-files' import { searchWithGitGrep } from '../ipc/filesystem-search-git' import { getLocalGitOptionsForRegisteredWorktree } from '../ipc/local-worktree-runtime-options' import { checkRgAvailable } from '../ipc/rg-availability' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess +} from '../../shared/ripgrep-process-availability' import { listMarkdownDocuments, markdownDocumentsFromRelativePaths @@ -1920,26 +1925,33 @@ export class RuntimeFileCommands { 1, Math.min(options.maxResults ?? DEFAULT_SEARCH_MAX_RESULTS, DEFAULT_SEARCH_MAX_RESULTS) ) - const rgAvailable = await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro) - if (!rgAvailable) { + const wslInfo = parseWslPath(authorizedRootPath) + if ( + (wslInfo || localGitOptions.wslDistro) && + !(await checkRgAvailable(authorizedRootPath, localGitOptions.wslDistro)) + ) { return searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions) } - return new Promise((resolvePromise) => { + return new Promise((resolvePromise) => { const searchKey = `${this.host.getRuntimeId()}:${authorizedRootPath}` const rgArgs = buildRgArgs(options.query, authorizedRootPath, options) - this.activeRuntimeTextSearches.get(searchKey)?.kill() + const previousChild = this.activeRuntimeTextSearches.get(searchKey) + if (previousChild) { + killSpawnedRipgrepProcess(previousChild) + } const acc = createAccumulator() let stdoutBuffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false let child: ChildProcess | null = null - const wslInfo = parseWslPath(authorizedRootPath) const transformAbsPath = wslInfo ? (p: string): string => toWindowsWslPath(p, wslInfo.distro) : undefined - const resolveOnce = (): void => { + const finish = (result: SearchResult | PromiseLike): void => { if (resolved) { return } @@ -1948,8 +1960,11 @@ export class RuntimeFileCommands { this.activeRuntimeTextSearches.delete(searchKey) } cleanupListeners() - resolvePromise(finalize(acc)) + resolvePromise(result) } + const resolveOnce = (): void => finish(finalize(acc)) + const resolveWithoutRipgrep = (): void => + finish(searchWithGitGrep(authorizedRootPath, options, maxResults, localGitOptions)) let killTimeout: ReturnType | null = null const cleanupListeners = (): void => { @@ -1961,6 +1976,12 @@ export class RuntimeFileCommands { child?.stderr?.off('data', onStderrData) child?.off('error', onError) child?.off('close', onClose) + if (child) { + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + } } const processLine = (line: string): void => { @@ -1971,8 +1992,8 @@ export class RuntimeFileCommands { maxResults, transformAbsPath ) - if (verdict === 'stop') { - child?.kill() + if (verdict === 'stop' && child) { + killSpawnedRipgrepProcess(child) } } @@ -1996,8 +2017,25 @@ export class RuntimeFileCommands { const onStderrData = (): void => { // Drain stderr so rg cannot block on a full pipe. } - const onError = (): void => resolveOnce() - const onClose = (): void => { + const onError = (): void => { + processErrorObserved = true + if (child && isRipgrepUnavailableExit(child, null, null)) { + resolveWithoutRipgrep() + return + } + resolveOnce() + } + const onClose = (code: number | null, signal: NodeJS.Signals | null): void => { + if ( + child && + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: !(wslInfo || localGitOptions.wslDistro) + }) + ) { + unavailableExitObserved = true + resolveWithoutRipgrep() + return + } if (stdoutBuffer) { processLine(stdoutBuffer) } @@ -2011,7 +2049,9 @@ export class RuntimeFileCommands { killTimeout = setTimeout(() => { acc.truncated = true - child?.kill() + if (child) { + killSpawnedRipgrepProcess(child) + } resolveOnce() }, SEARCH_TIMEOUT_MS) }) diff --git a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts index 47715ea7c5d..7981366cadb 100644 --- a/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts +++ b/src/main/runtime/orca-runtime-hook-agent-status-projection.test.ts @@ -230,6 +230,65 @@ describe('headless hook agent-status projection (#11761)', () => { ) }) + it('does not carry a shell-obscured question into the next working interval', async () => { + const rows = [hookRow()] + const runtime = await createRuntimeWithHookRows(rows) + const questionAt = rows[0]!.receivedAt + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(questionAt + 1) + try { + const initial = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const initialTab = initial.tabs[0] + if (initialTab?.type !== 'terminal' || !initialTab.terminal) { + throw new Error('expected a live terminal handle') + } + await runtime.renameTerminal(initialTab.terminal, 'bash') + observePaneTitle(runtime, 'bash') + let result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + let tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).toEqual( + expect.objectContaining({ state: 'waiting', interactivePrompt: ASK_PROMPT }) + ) + + dateNow.mockReturnValue(questionAt + 2) + observePaneTitle(runtime, '⠋ Claude') + result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).toEqual( + expect.objectContaining({ state: 'working', prompt: '' }) + ) + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + } finally { + dateNow.mockRestore() + } + }) + + it('does not carry a hook question across a provider generation reset', async () => { + const runtime = await createRuntimeWithHookRows([hookRow()]) + const internals = runtime as unknown as { + ptysById: Map + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void + } + internals.ptysById.get(PTY_ID)!.title = 'bash' + internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + }) + + it('does not carry a hook question across an identity-only owner title', async () => { + const runtime = await createRuntimeWithHookRows([hookRow()]) + const internals = runtime as unknown as { + ptysById: Map + } + internals.ptysById.get(PTY_ID)!.title = 'bash' + observePaneTitle(runtime, 'Cursor Agent') + + const result = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`) + const tab = result.tabs[0] + expect(tab?.type === 'terminal' && tab.agentStatus).not.toHaveProperty('interactivePrompt') + }) + // The title path is refreshed live; an older hook `done` must not erase it. it('keeps the title-derived working state when the hook row predates the title', async () => { const now = Date.now() diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index bef36832e11..6545486a3a2 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -11067,6 +11067,19 @@ describe('OrcaRuntimeService', () => { expect(snapshot?.cwd).toBe('/home/me/repo/src') }) + it('projects frame-independent live state through main buffer snapshots', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.registerPty('pty-frame-state', TEST_WORKTREE_ID) + runtime.onPtyData('pty-frame-state', '\x1b[?1049h\x1b[?1004h\x1b[?25lSTATIC-FRAME', 123) + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-frame-state') + + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?1004h') + expect(snapshot?.frameRestoreAnsi).toContain('\x1b[?25l') + expect(snapshot?.frameRestoreAnsi).not.toContain('STATIC-FRAME') + expect(snapshot?.data).toContain('STATIC-FRAME') + }) + it('keeps Windows SSH OSC7 cwd as a drive path when the desktop runtime is POSIX', () => { setPlatform('darwin') const runtime = new OrcaRuntimeService(store) @@ -19103,6 +19116,71 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('terminal_orphan_competing_owner') }) + it('preserves concurrent workspace-session changes when async orphan persistence fails', async () => { + const session = { + ...getDefaultWorkspaceSession(), + activeRepoId: TEST_REPO_ID, + activeWorktreeId: TEST_WORKTREE_ID, + tabsByWorktree: { [TEST_WORKTREE_ID]: [] } + } + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + const durableWrite = deferred() + const durableWriteStarted = deferred() + const runtime = new OrcaRuntimeService({ + ...runtimeStore, + flushPendingOrThrowAsync: vi.fn(() => { + durableWriteStarted.resolve() + return durableWrite.promise + }) + } as never) + runtime.setPtyController({ + write: vi.fn(() => true), + kill: vi.fn(() => true), + getForegroundProcess: async () => null, + listProcesses: async () => [ + { + id: 'pty-async-rollback', + incarnationId: 'inc-async-rollback', + terminalHandle: 'term_async_rollback', + title: 'Async rollback', + cwd: TEST_WORKTREE_PATH, + worktreeId: TEST_WORKTREE_ID, + wslDistro: null + } + ] + }) + const before = await runtime.listTerminals(`id:${TEST_WORKTREE_ID}`) + + const adoption = runtime.adoptTerminalOrphans({ + worktree: `id:${TEST_WORKTREE_ID}`, + expectedTopologyRevision: before.topologyRevisions?.[TEST_WORKTREE_ID] ?? 0, + claims: [ + { + terminal: 'term_async_rollback', + ptyId: 'pty-async-rollback', + incarnationId: 'inc-async-rollback', + tabId: 'tab-async-rollback', + leafId: HEADLESS_LEAF_ID + } + ] + }) + await durableWriteStarted.promise + setSession({ + ...getSession(), + activeTabIdByWorktree: { + ...getSession().activeTabIdByWorktree, + 'concurrent-worktree': 'concurrent-tab' + } + }) + durableWrite.reject(new Error('disk unavailable')) + + await expect(adoption).rejects.toThrow('disk unavailable') + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['tab-async-rollback']).toBeUndefined() + expect(getSession().activeTabIdByWorktree?.['concurrent-worktree']).toBe('concurrent-tab') + expect(getSession().terminalTopologyRevisionByRepoId?.[TEST_REPO_ID] ?? 0).toBe(0) + }) + function publishLegacyWorkerReveal( runtime: OrcaRuntimeService, identity: { worktreeId: string; tabId: string; leafId: string; ptyId: string }, @@ -19869,17 +19947,23 @@ describe('OrcaRuntimeService', () => { } } } - const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + const durableWrite = deferred() + const durableWriteStarted = deferred() let flushCount = 0 - const flushOrThrow = vi.fn(() => { + const flushPendingOrThrowAsync = vi.fn(() => { flushCount += 1 - if (flushCount === 1 || flushCount === 3) { - throw new Error('disk unavailable') + if (flushCount === 1) { + return Promise.resolve() } + durableWriteStarted.resolve() + return durableWrite.promise }) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, { - canRecoverPersistentLocalPtys: () => true - }) + const runtime = new OrcaRuntimeService( + { ...runtimeStore, flushPendingOrThrowAsync } as never, + undefined, + { canRecoverPersistentLocalPtys: () => true } + ) runtime.setOrchestrationDb({ listLegacyWorkerTerminalRecoveryRows: () => [ { @@ -19928,18 +20012,35 @@ describe('OrcaRuntimeService', () => { } as never) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect( - runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) - ).resolves.toMatchObject({ + const recovery = runtime.reconcileLegacyWorkerTerminals({ materializeRenderer: true }) + await durableWriteStarted.promise + const concurrentPaneKey = `concurrent:${HEADLESS_SECOND_LEAF_ID}` + setSession({ + ...getSession(), + sleepingAgentSessionsByPaneKey: { + ...getSession().sleepingAgentSessionsByPaneKey, + [concurrentPaneKey]: { + ...session.sleepingAgentSessionsByPaneKey![workerPaneKey]!, + paneKey: concurrentPaneKey, + tabId: 'concurrent-tab' + } + } + }) + durableWrite.reject(new Error('disk unavailable')) + + await expect(recovery).resolves.toMatchObject({ adoptedDispatchIds: [], exitedDispatchIds: [], deferredDispatchIds: ['dispatch-persistence-failure'] }) - expect(flushOrThrow).toHaveBeenCalledTimes(3) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2) expect(revealTerminalSession).toHaveBeenCalledOnce() expect( getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey?.[concurrentPaneKey]?.tabId).toBe( + 'concurrent-tab' + ) expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled() warn.mockRestore() }) @@ -19966,8 +20067,17 @@ describe('OrcaRuntimeService', () => { } } const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) + const durableWrite = deferred() + const durableWriteStarted = deferred() + const flushPendingOrThrowAsync = vi.fn(() => { + durableWriteStarted.resolve() + return durableWrite.promise + }) + const flushOrThrow = vi.fn(() => { + throw new Error('synchronous persistence must not run') + }) const runtime = new OrcaRuntimeService( - { ...runtimeStore, flushOrThrow: vi.fn() } as never, + { ...runtimeStore, flushOrThrow, flushPendingOrThrowAsync } as never, undefined, { canRecoverPersistentLocalPtys: () => true } ) @@ -19999,12 +20109,24 @@ describe('OrcaRuntimeService', () => { const resolveLegacyWorkerTerminalRecovery = vi.fn() runtime.setNotifier({ resolveLegacyWorkerTerminalRecovery } as never) - await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ + const recovery = runtime.reconcileLegacyWorkerTerminals() + await durableWriteStarted.promise + + expect(resolveLegacyWorkerTerminalRecovery).not.toHaveBeenCalled() + expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched') + durableWrite.resolve() + + await expect(recovery).resolves.toMatchObject({ adoptedDispatchIds: [], exitedDispatchIds: [started.dispatch.id], deferredDispatchIds: [] }) + expect(flushPendingOrThrowAsync).toHaveBeenCalledOnce() + expect(flushPendingOrThrowAsync).toHaveBeenCalledWith({ + drainToStableGeneration: false + }) + expect(flushOrThrow).not.toHaveBeenCalled() expect(db.getDispatchContextById(started.dispatch.id)).toMatchObject({ status: 'failed', failure_count: 1 @@ -20023,7 +20145,7 @@ describe('OrcaRuntimeService', () => { } }) - it('retries missing-worker recovery when clearing its persisted resume fence fails', async () => { + it('waits for durability before retry settles a resolution already present in memory', async () => { const workerPaneKey = `legacy-missing-retry:${HEADLESS_LEAF_ID}` const incarnationId = '34343434-3434-4434-8434-343434343434' const session: WorkspaceSessionState = { @@ -20044,17 +20166,26 @@ describe('OrcaRuntimeService', () => { } } } - const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(session) - const flushOrThrow = vi - .fn() - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => { - throw new Error('disk unavailable') - }) - .mockImplementation(() => undefined) - const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never, undefined, { - canRecoverPersistentLocalPtys: () => true + const { runtimeStore, getSession, setSession } = makeRuntimeStoreWithWorkspaceSession(session) + const firstDurableWrite = deferred() + const firstDurableWriteStarted = deferred() + const retryDurableWrite = deferred() + const retryDurableWriteStarted = deferred() + let flushCount = 0 + const flushPendingOrThrowAsync = vi.fn(() => { + flushCount += 1 + if (flushCount === 1) { + firstDurableWriteStarted.resolve() + return firstDurableWrite.promise + } + retryDurableWriteStarted.resolve() + return retryDurableWrite.promise }) + const runtime = new OrcaRuntimeService( + { ...runtimeStore, flushPendingOrThrowAsync } as never, + undefined, + { canRecoverPersistentLocalPtys: () => true } + ) const db = new OrchestrationDb(':memory:') try { const task = db.createTask({ spec: 'retry missing worker recovery' }) @@ -20084,20 +20215,30 @@ describe('OrcaRuntimeService', () => { runtime.setNotifier({ resolveLegacyWorkerTerminalRecovery } as never) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ + const firstRecovery = runtime.reconcileLegacyWorkerTerminals() + await firstDurableWriteStarted.promise + setSession({ ...getSession(), sleepingAgentSessionsByPaneKey: undefined }) + firstDurableWrite.reject(new Error('disk unavailable')) + + await expect(firstRecovery).resolves.toMatchObject({ exitedDispatchIds: [], deferredDispatchIds: [started.dispatch.id] }) expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched') expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('ready') - expect( - getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]?.automaticResumeBlockedBy - ).toBe('legacy-orchestration-worker') + expect(getSession().sleepingAgentSessionsByPaneKey).toBeUndefined() - await expect(runtime.reconcileLegacyWorkerTerminals()).resolves.toMatchObject({ + const retry = runtime.reconcileLegacyWorkerTerminals() + await retryDurableWriteStarted.promise + expect(db.getDispatchContextById(started.dispatch.id)?.status).toBe('dispatched') + expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('ready') + retryDurableWrite.resolve() + + await expect(retry).resolves.toMatchObject({ exitedDispatchIds: [started.dispatch.id], deferredDispatchIds: [] }) + expect(flushPendingOrThrowAsync).toHaveBeenCalledTimes(2) expect(db.getWorkerDispatch(started.dispatch.id)?.state).toBe('abandoned') expect(getSession().sleepingAgentSessionsByPaneKey?.[workerPaneKey]).toBeUndefined() expect(resolveLegacyWorkerTerminalRecovery).toHaveBeenCalledWith( @@ -24573,7 +24714,7 @@ describe('OrcaRuntimeService', () => { unsubscribe() }) - it('keeps same-status Pi-compatible title changes queued behind the foreground owner probe', async () => { + it('keeps decorative Pi frames queued behind the foreground owner probe', async () => { const foregroundProcess = deferred() const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) const getForegroundProcess = vi.fn(() => foregroundProcess.promise) @@ -24592,9 +24733,9 @@ describe('OrcaRuntimeService', () => { const events: RuntimeMobileSessionTabsResult[] = [] const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot)) - runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi ready\x07', 123) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠋ Pi\x07', 123) await new Promise((resolve) => setImmediate(resolve)) - runtime.onPtyData('pty-typed-omp', '\x1b]0;Pi idle\x07', 124) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠙ Pi\x07', 124) await new Promise((resolve) => setImmediate(resolve)) expect(getForegroundProcess).toHaveBeenCalledTimes(1) @@ -24609,18 +24750,22 @@ describe('OrcaRuntimeService', () => { tabs: [ expect.objectContaining({ type: 'terminal', - title: 'OMP ready', + title: '⠋ OMP', agentStatus: expect.objectContaining({ - state: 'done', + state: 'working', agentType: 'omp', terminalHandle: terminal.handle, - terminalTitle: 'OMP ready' + terminalTitle: '⠋ OMP' }) }) ] }) ]) + runtime.onPtyData('pty-typed-omp', '\x1b]0;⠹ Pi\x07', 125) + await new Promise((resolve) => setImmediate(resolve)) + expect(events).toHaveLength(1) + unsubscribe() }) @@ -39344,6 +39489,35 @@ describe('OrcaRuntimeService', () => { expect(reposChanged).not.toHaveBeenCalled() }) + it('persists changed worktree order once and emits targeted invalidations', () => { + const firstId = `${TEST_REPO_ID}::/tmp/first` + const secondId = `${TEST_REPO_ID}::/tmp/second` + const metaById: Record> = { + [firstId]: { sortOrder: 200 }, + [secondId]: { sortOrder: 100 } + } + const setWorktreeMeta = vi.fn((id: string, updates: Partial) => { + metaById[id] = { ...metaById[id], ...updates } + }) + const runtime = new OrcaRuntimeService({ + ...store, + getWorktreeMeta: (id: string) => metaById[id], + setWorktreeMeta + } as never) + const events: { type: string; repoId?: string }[] = [] + const unsubscribe = runtime.onClientEvent((event) => events.push(event)) + + expect(runtime.persistManagedWorktreeSortOrder([firstId, secondId])).toEqual({ updated: 0 }) + expect(setWorktreeMeta).not.toHaveBeenCalled() + expect(events).toEqual([]) + + expect(runtime.persistManagedWorktreeSortOrder([secondId, firstId])).toEqual({ updated: 2 }) + unsubscribe() + + expect(setWorktreeMeta).toHaveBeenCalledTimes(2) + expect(events).toEqual([{ type: 'worktreesChanged', repoId: TEST_REPO_ID }]) + }) + it('worktree scan cache: folder metadata invalidation preserves raw scans', async () => { vi.mocked(listWorktrees).mockClear() const runtime = createRuntime() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 44644d96ed9..3b55206a686 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -10,6 +10,7 @@ import { normalizeTerminalTitle } from '../../shared/agent-detection' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' +import { planWorktreeSortOrderUpdates } from '../../shared/worktree-sort-order-update' import { isArtifactSharingEnabled } from '../../shared/artifact-sharing-gate' import { sortDirEntries } from '../../shared/file-name-sort' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' @@ -27,10 +28,10 @@ import type { } from './remote-terminal-source-range-consumer' import { createTerminalTitleTracker, - stripBrailleSpinnerGlyphs, type TerminalTitleFactMeta, type TerminalTitleTracker } from '../../shared/terminal-output-side-effects' +import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' import type { TerminalSideEffectBatch, @@ -111,6 +112,7 @@ import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' import { reconcileRequestedWorkerTerminalReleases } from './orchestration/worker-terminal-release-reconciliation' +import { rollbackWorkspaceSessionAfterFailedAsyncWrite } from './workspace-session-failed-write-rollback' import { OrchestrationError } from './orchestration/orchestration-error' import { planLegacyWorkerTerminalRecovery, @@ -248,6 +250,7 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { preservedBranchCleanupScopeKey } from '../../shared/preserved-branch-cleanup' import { getRegisteredSshState } from '../ipc/ssh' import type { AgentProviderSessionMetadata, @@ -981,6 +984,10 @@ import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' +import { + createMobileSessionTabsAgentStatusHeartbeat, + type MobileSessionTabsAgentStatusHeartbeat +} from './mobile-session-tabs-agent-status-heartbeat' import { TerminalFocusNavigationCoalescer } from './terminal-focus-navigation-coalescer' import { appendRecentPtyPathCandidates, @@ -1116,6 +1123,7 @@ type RuntimeStore = { getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] setWorkspaceSession?: Store['setWorkspaceSession'] flushOrThrow?: Store['flushOrThrow'] + flushPendingOrThrowAsync?: Store['flushPendingOrThrowAsync'] persistPtyBinding?: Store['persistPtyBinding'] getSshRemotePtyLeases?: Store['getSshRemotePtyLeases'] getUI?: Store['getUI'] @@ -1300,6 +1308,9 @@ type RuntimePtyWorktreeRecord = { lastAgentStatus: AgentStatus | null /** False until a live OSC frame sets the status; restore seeds never set it. */ lastAgentStatusObservedLive: boolean + lastAgentStatusStartedAtEpochMs: number | null + // A later semantic title interval cannot inherit rich fields from an earlier task. + lastAgentStatusRichInvalidatedAtEpochMs: number | null lastOscTitle: string | null lastOscTitleAt: number | null // Why a second stamp: `lastOscTitleAt` is a title-observation sequence number, @@ -1509,12 +1520,8 @@ type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker // Why: onPtyData batches the mobile session-tab touch to once per chunk; // the stale-working-title timer fires between chunks and must touch - // immediately. These flags route the tracker callback to the right mode. + // immediately. This flag routes the tracker callback to the right mode. applyingChunk: boolean - // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the - // synthetic path gates mobile snapshot fan-out on a non-decorative title - // change (spinner glyph + status comparison key kept below). - applyingSyntheticFrame: boolean lastMobileTitleGateKey: string | null chunkTouchedSessionTabs: boolean // Why: facts observed while applying a chunk are batched into one @@ -1589,6 +1596,8 @@ type ProviderBufferAcquisition = { type RuntimeTerminalBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -1978,6 +1987,11 @@ export type LegacyWorkerTerminalRecoveryResult = { deferredDispatchIds: string[] } +type LegacyWorkerTerminalRecoveryResolution = { + candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] + resolution: 'adopted' | 'exited' +} + export type OrchestrationCompatibilityCallerAuthority = Readonly<{ hostScope: OrchestrationCompatibilityTerminalAuthority['hostScope'] paneKey: string @@ -2108,6 +2122,8 @@ type RuntimeWorktreeRemovalInFlight = { } type PreservedBranchCleanupTarget = { + worktreeId: string + hostId?: ExecutionHostId branchName: string head: string pushTarget?: GitPushTarget @@ -2746,6 +2762,11 @@ export class OrcaRuntimeService { createMobileSessionTabsNotifyCoalescer((worktreeId) => this.notifyMobileSessionTabsChangedNow(worktreeId) ) + private readonly mobileSessionTabsAgentStatusHeartbeat: MobileSessionTabsAgentStatusHeartbeat = + createMobileSessionTabsAgentStatusHeartbeat( + (ptyId) => this.getMobileSessionWorktreeIdsForPty(ptyId), + (worktreeId) => this.touchMobileSessionTabsForWorktree(worktreeId) + ) // Why: concurrent host terminal.focus storms (CLI switch fan-out / bulk open) // each await a full host reveal; only one terminal can be focused, so latest-wins // single-flight bounds host work. Does not replace cheaper activation or @@ -3166,7 +3187,7 @@ export class OrcaRuntimeService { private canonicalFetchKeyCache = new Map() private optimisticReconcileTokens = new Map() private removeManagedWorktreeInFlight = new Map() - private preservedBranchCleanupByWorktreeId = new Map() + private preservedBranchCleanupByScope = new Map() private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly getSshProviderFn: ((connectionId: string) => IPtyProvider | undefined) | null private readonly onPtyStopped: ((ptyId: string) => void) | null @@ -3803,7 +3824,11 @@ export class OrcaRuntimeService { prepareLegacyWorkerTerminalRecovery(): LegacyWorkerTerminalRecoveryPlan { const plan = this.getLegacyWorkerTerminalRecoveryPlan() const store = this.store - if (!store?.getWorkspaceSession || !store.setWorkspaceSession || !store.flushOrThrow) { + if ( + !store?.getWorkspaceSession || + !store.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) + ) { return plan } const sessions = new Map< @@ -3858,13 +3883,25 @@ export class OrcaRuntimeService { for (const [hostId, state] of changed) { store.setWorkspaceSession(state.next, hostId) } - store.flushOrThrow() } catch (error) { - console.warn('[orchestration] failed to persist legacy worker resume fence', error) + console.warn('[orchestration] failed to stage legacy worker resume fence', error) } return plan } + private async flushWorkspaceSessionOrThrowAsync(): Promise { + const store = this.store + if (store?.flushPendingOrThrowAsync) { + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + return + } + if (store?.flushOrThrow) { + store.flushOrThrow() + return + } + throw new Error('workspace_session_persistence_unavailable') + } + async reconcileLegacyWorkerTerminals( options: { connectionId?: string; materializeRenderer?: boolean } = {} ): Promise { @@ -3964,33 +4001,71 @@ export class OrcaRuntimeService { ) } - private persistLegacyWorkerTerminalRecoveryResolution( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number], - resolution: 'adopted' | 'exited' - ): boolean { + private async persistLegacyWorkerTerminalRecoveryBatch( + resolutions: readonly LegacyWorkerTerminalRecoveryResolution[] + ): Promise> { const store = this.store - const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) - if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) { - return false + if ( + !store?.getWorkspaceSession || + !store.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) + ) { + return new Set() } - const record = session.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] - if (!record || !runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { - return true - } - const next = structuredClone(session) - delete next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + const originalSessions = new Map() + const stagedSessions = new Map() + const stagedDispatchIds = new Set() try { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, next) - store.flushOrThrow() - return true + for (const { candidate, resolution } of resolutions) { + const hostId = this.tryGetWorkspaceSessionHostIdForWorktree(candidate.worktreeId) + const session = hostId ? store.getWorkspaceSession(hostId) : null + if (!hostId || !session) { + continue + } + originalSessions.set(hostId, originalSessions.get(hostId) ?? session) + let next = + resolution === 'exited' + ? retireTerminalSurfaceFromPersistence(session, { + worktreeId: candidate.worktreeId, + parentTabId: candidate.tabId, + leafId: candidate.leafId, + ptyId: candidate.ptyId, + incarnationId: candidate.incarnationId + }) + : session + const record = next.sleepingAgentSessionsByPaneKey?.[candidate.paneKey] + if (record && runtimeWorktreeIdsEqual(record.worktreeId, candidate.worktreeId)) { + const sleepingAgentSessionsByPaneKey = { ...next.sleepingAgentSessionsByPaneKey } + delete sleepingAgentSessionsByPaneKey[candidate.paneKey] + next = { ...next, sleepingAgentSessionsByPaneKey } + } + if (next !== session) { + store.setWorkspaceSession(next, hostId) + } + stagedSessions.set(hostId, store.getWorkspaceSession(hostId)) + stagedDispatchIds.add(candidate.dispatchId) + } + if (stagedDispatchIds.size > 0) { + await this.flushWorkspaceSessionOrThrowAsync() + } + return stagedDispatchIds } catch (error) { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, session) - console.warn('[orchestration] failed to persist legacy worker recovery resolution', { - dispatchId: candidate.dispatchId, - resolution, + for (const [hostId, original] of originalSessions) { + const staged = stagedSessions.get(hostId) + const current = store.getWorkspaceSession(hostId) + if (!staged || !current) { + continue + } + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(original, staged, current) + if (rolledBack !== current) { + store.setWorkspaceSession(rolledBack, hostId) + } + } + console.warn('[orchestration] failed to persist legacy worker recovery batch', { + dispatchIds: [...stagedDispatchIds], error }) - return false + return new Set() } } @@ -4015,48 +4090,9 @@ export class OrcaRuntimeService { } } - private resolveExitedLegacyWorkerTerminal( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): boolean { - if ( - !this.rollbackLegacyWorkerTerminalSurface(candidate) || - !this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'exited') || - !this.reconcileMissingLegacyWorkerTerminal(candidate) - ) { - return false - } - this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited') - return true - } - private rollbackLegacyWorkerTerminalSurface( candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): boolean { - const store = this.store - const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) - if (store?.setWorkspaceSession && store.flushOrThrow && session) { - const retired = retireTerminalSurfaceFromPersistence(session, { - worktreeId: candidate.worktreeId, - parentTabId: candidate.tabId, - leafId: candidate.leafId, - ptyId: candidate.ptyId, - incarnationId: candidate.incarnationId - }) - if (retired !== session) { - try { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, retired) - store.flushOrThrow() - } catch (error) { - this.setWorkspaceSessionForWorktree(candidate.worktreeId, session) - console.warn('[orchestration] failed to persist legacy worker surface rollback', { - dispatchId: candidate.dispatchId, - error - }) - return false - } - } - } - + ): void { const snapshot = this.mobileSessionTabsByWorktree.get(candidate.worktreeId) if (snapshot) { const retired = retireTerminalSurfacesFromSnapshot({ @@ -4104,13 +4140,6 @@ export class OrcaRuntimeService { 'rolled_back', candidate.ptyId ) - return true - } - - private resolveReplacedLegacyWorkerTerminal( - candidate: LegacyWorkerTerminalRecoveryPlan['candidates'][number] - ): boolean { - return this.resolveExitedLegacyWorkerTerminal(candidate) } private updateLegacyWorkerTerminalRecoveryRetry( @@ -4190,6 +4219,7 @@ export class OrcaRuntimeService { const adoptedDispatchIds: string[] = [] const exitedDispatchIds: string[] = [] const deferredDispatchIds = new Set(plan.ambiguousDispatchIds) + const pendingResolutions: LegacyWorkerTerminalRecoveryResolution[] = [] const recoveryCandidatesByProvider = new Map< string, { @@ -4256,11 +4286,7 @@ export class OrcaRuntimeService { } for (const { candidate, workspace } of provider.entries) { if (!inventory.livePtyIds.has(candidate.ptyId)) { - if (this.resolveExitedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const controllerIdentity = inventory.terminalIdentityByPtyId.get(candidate.ptyId) @@ -4272,11 +4298,7 @@ export class OrcaRuntimeService { controllerIdentity.handle !== candidate.terminalHandle || controllerIdentity.incarnationId !== candidate.incarnationId ) { - if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const preAdoptionInventory = await this.refreshPtyWorktreeRecordsWithControllerInventory( @@ -4290,11 +4312,7 @@ export class OrcaRuntimeService { continue } if (!preAdoptionInventory.livePtyIds.has(candidate.ptyId)) { - if (this.resolveExitedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const preAdoptionIdentity = preAdoptionInventory.terminalIdentityByPtyId.get( @@ -4308,11 +4326,7 @@ export class OrcaRuntimeService { preAdoptionIdentity.handle !== candidate.terminalHandle || preAdoptionIdentity.incarnationId !== candidate.incarnationId ) { - if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const session = this.getWorkspaceSessionForWorktree(candidate.worktreeId) @@ -4443,11 +4457,7 @@ export class OrcaRuntimeService { if (!finalInventory.livePtyIds.has(candidate.ptyId)) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) this.onPtyExit(candidate.ptyId, 0, candidate.incarnationId) - if (this.resolveExitedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } const finalIdentity = finalInventory.terminalIdentityByPtyId.get(candidate.ptyId) @@ -4461,21 +4471,32 @@ export class OrcaRuntimeService { finalIdentity.incarnationId !== candidate.incarnationId ) { this.legacyWorkerTerminalReceiptEpochByPane.delete(candidate.paneKey) - if (this.resolveReplacedLegacyWorkerTerminal(candidate)) { - exitedDispatchIds.push(candidate.dispatchId) - } else { - deferredDispatchIds.add(candidate.dispatchId) - } - continue - } - if (!this.persistLegacyWorkerTerminalRecoveryResolution(candidate, 'adopted')) { - deferredDispatchIds.add(candidate.dispatchId) + pendingResolutions.push({ candidate, resolution: 'exited' }) continue } + pendingResolutions.push({ candidate, resolution: 'adopted' }) + } + } + const persistedDispatchIds = + await this.persistLegacyWorkerTerminalRecoveryBatch(pendingResolutions) + for (const { candidate, resolution } of pendingResolutions) { + if (!persistedDispatchIds.has(candidate.dispatchId)) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + if (resolution === 'adopted') { this.legacyWorkerRecoveredPtys.add(candidate.ptyId) this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'adopted') adoptedDispatchIds.push(candidate.dispatchId) + continue } + this.rollbackLegacyWorkerTerminalSurface(candidate) + if (!this.reconcileMissingLegacyWorkerTerminal(candidate)) { + deferredDispatchIds.add(candidate.dispatchId) + continue + } + this.notifier?.resolveLegacyWorkerTerminalRecovery?.(candidate.paneKey, 'exited') + exitedDispatchIds.push(candidate.dispatchId) } const result = { blockedPaneCount: plan.blockedPanes.length, @@ -6534,6 +6555,21 @@ export class OrcaRuntimeService { } } + private getMobileSessionWorktreeIdsForPty(ptyId: string): string[] { + const worktreeIds: string[] = [] + for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { + const hasPtyBackedTab = snapshot.tabs.some( + (tab) => + tab.type === 'terminal' && + (tab.ptyId === ptyId || tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] === ptyId) + ) + if (hasPtyBackedTab) { + worktreeIds.push(worktreeId) + } + } + return worktreeIds + } + /** Bump the snapshot version and emit, coalesced unless `immediate`. * Why the bump: clients gate mirrored snapshots on a strictly increasing * `snapshotVersion`, so a re-emit at the same version is silently dropped. */ @@ -9081,6 +9117,9 @@ export class OrcaRuntimeService { // subscriber closing mid-window still receives the latest settled state. this.mobileSessionTabsNotifyCoalescer.flushAll() this.mobileSessionTabListeners.delete(subscription) + if (this.mobileSessionTabListeners.size === 0) { + this.mobileSessionTabsAgentStatusHeartbeat.cancelPending() + } } } @@ -9867,13 +9906,11 @@ export class OrcaRuntimeService { ingestSyntheticTitleFrame(ptyId: string, data: string): void { const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) entry.applyingChunk = true - entry.applyingSyntheticFrame = true entry.chunkTouchedSessionTabs = false try { entry.tracker.applySyntheticTitleFrame(data) } finally { entry.applyingChunk = false - entry.applyingSyntheticFrame = false this.flushPendingTerminalSideEffectFacts(ptyId, entry) } if (entry.chunkTouchedSessionTabs) { @@ -10111,13 +10148,13 @@ export class OrcaRuntimeService { return { ...snapshot, lastTitle: tracked } } - /** Decorative comparison key: spinner frame glyphs stripped, derived agent - * status kept so a working→idle flip with an otherwise-equal label still - * counts as a change. */ + /** Decorative comparison key: only recognized agent titles fold leading spinner frames. */ private makeDecorativeTitleGateKey(rawTitle: string, normalizedTitle: string): string { - return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( - normalizedTitle - )}` + // Stable Pi/Gemini/Grok display normalization also defines their semantic gate. + const normalizedSignature = + rawTitle === normalizedTitle ? null : getDecorativeAgentTitleSignature(normalizedTitle) + const signature = normalizedSignature ?? getDecorativeAgentTitleSignature(rawTitle) + return signature === null ? `literal\u0000${normalizedTitle}` : `agent\u0000${signature}` } private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { @@ -10148,28 +10185,42 @@ export class OrcaRuntimeService { ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) }) const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) - // Why: an identity-only cursor title records nothing, so on a fresh pane `changed` is - // false — but the tracker title is that pane's only Cursor identity and still has to - // fan out to the sidebar and mobile (#10258). - if (!changed && !this.isLiveCursorNativeTitle(rawTitle, meta)) { - return - } + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey } + const tracksReplicatedStatus = + live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 + const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null + if ( + tracksReplicatedStatus && + decorativeOnly && + !this.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId) && + (titleStatus === 'working' || titleStatus === 'permission') + ) { + // Normalized Pi/Gemini/Grok frames still renew the replicated status lease. + this.mobileSessionTabsAgentStatusHeartbeat.scheduleDecorativeHeartbeat(ptyId) + } + // Why: an identity-only cursor title records nothing, but the tracker + // title is that pane's only Cursor identity and must still fan out (#10258). + if (!changed && !identityOnlyTitle) { + return + } if (live?.applyingChunk) { // Why: synthetic spinner ticks change only the braille glyph // ~12.5x/sec; fanning out full mobile session snapshots per frame // is pure churn. Raw lastOscTitle updates above stay cheap. - if (!(live.applyingSyntheticFrame && decorativeOnly)) { + if (!decorativeOnly) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) live.chunkTouchedSessionTabs = true } } else { // Stale-working-title timer path — fires between chunks, so the // per-chunk batching in onPtyData cannot pick it up. + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) this.touchMobileSessionSnapshotsForPty(ptyId) } }, @@ -10213,7 +10264,6 @@ export class OrcaRuntimeService { const entry: RuntimePtyTitleTrackerEntry = { tracker, applyingChunk: false, - applyingSyntheticFrame: false, lastMobileTitleGateKey: null, chunkTouchedSessionTabs: false, pendingFacts: [], @@ -10255,11 +10305,22 @@ export class OrcaRuntimeService { const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() + const observedAtEpochMs = identityOnlyTitle ? null : Date.now() pty.lastOscTitle = recordedTitle pty.lastOscTitleAt = identityOnlyTitle ? null : observedAt - pty.lastOscTitleEpochMs = identityOnlyTitle ? null : Date.now() + pty.lastOscTitleEpochMs = observedAtEpochMs pty.lastAgentStatus = agentStatus pty.lastAgentStatusObservedLive = true + if (prevStatus !== agentStatus) { + pty.lastAgentStatusStartedAtEpochMs = observedAtEpochMs + } + if ( + identityOnlyTitle || + terminalTitleBlocksExplicitAgentStatus(recordedTitle) || + (prevStatus !== null && agentStatus !== null && prevStatus !== agentStatus) + ) { + pty.lastAgentStatusRichInvalidatedAtEpochMs = observedAtEpochMs ?? Date.now() + } if (identityOnlyTitle) { pty.managementTitle = null pty.managementTitleAt = null @@ -10339,6 +10400,8 @@ export class OrcaRuntimeService { private disposePtyTitleTracker(ptyId: string): void { this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() this.ptyTitleTrackersByPtyId.delete(ptyId) + this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + this.mobileSessionTabsAgentStatusHeartbeat.removePty(ptyId) for (const titleGateKeys of this.terminalSideEffectTitleGateKeysByClientEventListener.values()) { titleGateKeys.delete(ptyId) } @@ -10360,6 +10423,8 @@ export class OrcaRuntimeService { // Why: the prior process's live frames say nothing about the replacement, // so the seed a same-id restore applies must not inherit its authority. pty.lastAgentStatusObservedLive = false + pty.lastAgentStatusStartedAtEpochMs = null + pty.lastAgentStatusRichInvalidatedAtEpochMs = Date.now() pty.managementTitle = null pty.managementTitleAt = null } @@ -11020,6 +11085,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -11038,6 +11104,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11488,6 +11555,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -11538,6 +11606,7 @@ export class OrcaRuntimeService { opts: { scrollbackRows?: number } = {} ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -11958,6 +12027,7 @@ export class OrcaRuntimeService { return data.length > 0 || opts.includeEmpty === true ? this.preferTrackedLastTitle(ptyId, { data, + frameRestoreAnsi: snapshot.frameRestoreAnsi, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), @@ -15447,7 +15517,11 @@ export class OrcaRuntimeService { const { livePtyIds, terminalIdentityByPtyId } = inventory const store = this.store const session = this.getWorkspaceSessionForWorktree(workspace.id) - if (!store?.setWorkspaceSession || !store.flushOrThrow || !session) { + if ( + !store?.setWorkspaceSession || + (!store.flushPendingOrThrowAsync && !store.flushOrThrow) || + !session + ) { throw new Error('workspace_session_unavailable') } const sessionWorktreeId = resolveTerminalSessionWorktreeId(session, workspace.id) @@ -15867,11 +15941,19 @@ export class OrcaRuntimeService { [workspace.id]: activeGroup.id } const persisted = advanceTerminalTopologyRevision(next, workspace.id) + let staged: WorkspaceSessionState | null = null try { this.setWorkspaceSessionForWorktree(workspace.id, persisted) - store.flushOrThrow() + staged = this.getWorkspaceSessionForWorktree(workspace.id) + await this.flushWorkspaceSessionOrThrowAsync() } catch (error) { - this.setWorkspaceSessionForWorktree(workspace.id, session) + const current = this.getWorkspaceSessionForWorktree(workspace.id) + if (staged && current) { + const rolledBack = rollbackWorkspaceSessionAfterFailedAsyncWrite(session, staged, current) + if (rolledBack !== current) { + this.setWorkspaceSessionForWorktree(workspace.id, rolledBack) + } + } throw error } for (const { claim, pty, paneKey } of validated) { @@ -16916,6 +16998,9 @@ export class OrcaRuntimeService { return } this.ptyDelayedForegroundSnapshotTitleObservations.delete(ptyId) + if (this.mobileSessionTabListeners.size > 0) { + this.mobileSessionTabsAgentStatusHeartbeat.observeSemanticTitle(ptyId) + } if (!foregroundAgentChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -23569,21 +23654,29 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } - const now = Date.now() - let updated = 0 - for (let i = 0; i < orderedIds.length; i++) { - // Why: a sort-order snapshot must only reorder existing worktrees, never - // mint new meta — a stale id would otherwise resurrect an orphan workspace - // (setWorktreeMeta has no repo-existence check). - if (!this.store.getWorktreeMeta(orderedIds[i])) { - continue - } - this.store.setWorktreeMeta(orderedIds[i], { sortOrder: now - i * 1000 }) - updated++ + const store = this.store + const updates = planWorktreeSortOrderUpdates( + orderedIds, + (worktreeId) => store.getWorktreeMeta(worktreeId), + Date.now() + ) + for (const update of updates) { + store.setWorktreeMeta(update.worktreeId, { sortOrder: update.sortOrder }) + } + if (updates.length === 0) { + return { updated: 0 } } this.invalidateResolvedWorktreeCache() - this.notifyReposChanged() - return { updated } + const changedRepoIds = new Set( + updates.flatMap((update) => { + const parsed = splitWorktreeId(update.worktreeId) + return parsed ? [parsed.repoId] : [] + }) + ) + for (const repoId of changedRepoIds) { + this.notifyWorktreesChanged(repoId) + } + return { updated: updates.length } } async resolveManagedPrBase(args: { @@ -23978,6 +24071,7 @@ export class OrcaRuntimeService { store.removeWorktreeMeta(worktreeId) } this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) advertisedUrlWatcher.forgetWorktree(worktreeId) deleteWorktreeHistoryDir(worktreeId) @@ -23998,6 +24092,7 @@ export class OrcaRuntimeService { private rememberPreservedBranchCleanupTarget( worktreeId: string, + hostId: ExecutionHostId | undefined, result: RemoveWorktreeResult | undefined, fallbackHead: string | undefined, pushTarget: GitPushTarget | undefined @@ -24009,14 +24104,21 @@ export class OrcaRuntimeService { `Cannot safely offer force-delete for preserved branch "${result.preservedBranch.branchName}" without its saved commit.` ) } - this.preservedBranchCleanupByWorktreeId.set(worktreeId, { - branchName: result.preservedBranch.branchName, - head, - ...(pushTarget ? { pushTarget } : {}) - }) + this.preservedBranchCleanupByScope.set( + preservedBranchCleanupScopeKey({ worktreeId, hostId }), + { + worktreeId, + ...(hostId ? { hostId } : {}), + branchName: result.preservedBranch.branchName, + head, + ...(pushTarget ? { pushTarget } : {}) + } + ) return } - this.preservedBranchCleanupByWorktreeId.delete(worktreeId) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ worktreeId, hostId }) + ) } private preserveBranchHeadFallback( @@ -24038,15 +24140,29 @@ export class OrcaRuntimeService { async forceDeletePreservedBranch( worktreeSelector: string, branchName: string, - expectedHead: string + expectedHead: string, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const removalTarget = parseExactWorktreeIdSelector(worktreeSelector) - const cleanupTarget = removalTarget - ? this.preservedBranchCleanupByWorktreeId.get(removalTarget.id) + const normalizedHostId = parseExecutionHostId(hostId)?.id + const exactTarget = removalTarget + ? this.preservedBranchCleanupByScope.get( + preservedBranchCleanupScopeKey({ worktreeId: removalTarget.id, hostId: normalizedHostId }) + ) : undefined + const legacyMatches = + removalTarget && !hostId + ? [...this.preservedBranchCleanupByScope.values()].filter( + (target) => + target.worktreeId === removalTarget.id && + target.branchName === branchName && + target.head === expectedHead + ) + : [] + const cleanupTarget = exactTarget ?? (legacyMatches.length === 1 ? legacyMatches[0] : undefined) if ( !removalTarget || !cleanupTarget || @@ -24100,7 +24216,12 @@ export class OrcaRuntimeService { ) } - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete( + preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupTarget.hostId + }) + ) return { deleted: true } } @@ -24110,13 +24231,19 @@ export class OrcaRuntimeService { runHooks = false, // Why (#11960): only an explicit Force Delete waives PTY-stop proof; `force` // alone is already set by the ordinary delete confirmation. - allowUnverifiedPtyStop = false + allowUnverifiedPtyStop = false, + hostId?: string ): Promise { if (!this.store) { throw new Error('runtime_unavailable') } const store = this.store + const cleanupHostId = parseExecutionHostId(hostId)?.id const removalTarget = await this.resolveWorktreeRemovalTarget(worktreeSelector) + const cleanupScopeKey = preservedBranchCleanupScopeKey({ + worktreeId: removalTarget.id, + hostId: cleanupHostId + }) const optionsKey = getRuntimeWorktreeRemovalOptionsKey(force, runHooks, allowUnverifiedPtyStop) const inFlightRemoval = this.removeManagedWorktreeInFlight.get(removalTarget.id) if (inFlightRemoval) { @@ -24180,7 +24307,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24223,7 +24350,7 @@ export class OrcaRuntimeService { }) } this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) return {} @@ -24328,7 +24455,7 @@ export class OrcaRuntimeService { } this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24377,7 +24504,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24413,7 +24540,7 @@ export class OrcaRuntimeService { )) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24460,6 +24587,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24505,6 +24633,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, registeredWorktree.head, removedPushTarget @@ -24667,7 +24796,7 @@ export class OrcaRuntimeService { ) this.clearOptimisticReconcileToken(removalTarget.id) this.removeWorktreeMetadataAndHistory(store, removalTarget.id) - this.preservedBranchCleanupByWorktreeId.delete(removalTarget.id) + this.preservedBranchCleanupByScope.delete(cleanupScopeKey) this.invalidateResolvedWorktreeCache() this.invalidateWorktreeScanCacheForRepo(removalTarget.repoId) invalidateAuthorizedRootsCache() @@ -24694,6 +24823,7 @@ export class OrcaRuntimeService { ) this.rememberPreservedBranchCleanupTarget( removalTarget.id, + cleanupHostId, removalResult, refreshedRegisteredWorktree.head, removedPushTarget @@ -29015,6 +29145,8 @@ export class OrcaRuntimeService { lastExitCode: null, lastAgentStatus: null, lastAgentStatusObservedLive: false, + lastAgentStatusStartedAtEpochMs: null, + lastAgentStatusRichInvalidatedAtEpochMs: null, lastOscTitle: null, lastOscTitleAt: null, lastOscTitleEpochMs: null, @@ -29218,6 +29350,7 @@ export class OrcaRuntimeService { for (const ptyId of ambiguousControllerPtyIds) { controllerIdentityByPtyId.delete(ptyId) } + const findResolvedWorktree = createIncrementalResolvedWorktreeLookup(resolvedWorktrees) const persistedIndexesByHostId = new Map< ExecutionHostId, { @@ -29249,14 +29382,12 @@ export class OrcaRuntimeService { ) const controllerIdentity = controllerIdentityByPtyId.get(session.id) const persistedWorktreeId = persistedIndexes.worktreeIdByPtyId.get(session.id) - const providerWorktree = resolvedWorktrees.find( - (worktree) => session.worktreeId && runtimeWorktreeIdsEqual(worktree.id, session.worktreeId) - ) + const providerWorktree = session.worktreeId + ? findResolvedWorktree(session.worktreeId) + : undefined const inferredWorktreeId = inferWorktreeIdFromPtyId(session.id) const persistedWorktree = persistedWorktreeId - ? resolvedWorktrees.find((worktree) => - runtimeWorktreeIdsEqual(worktree.id, persistedWorktreeId) - ) + ? findResolvedWorktree(persistedWorktreeId) : undefined const hasMigrationEvidence = Boolean(session.worktreeId) && @@ -29766,6 +29897,7 @@ export class OrcaRuntimeService { nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + this.mobileSessionTabsAgentStatusHeartbeat.removeWorktree(worktreeId) this.acceptedRendererMobileSnapshotByWorktree.delete(worktreeId) // Why: drop any pending coalesced notify so a stale snapshot can't land after the removed frame. this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) @@ -30302,16 +30434,21 @@ export class OrcaRuntimeService { (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) ? hookAgentStatus.providerSession : tab.agentStatus?.providerSession - const normalizedTabAgentStatus = tab.agentStatus - ? normalizeCompatibleAgentStatusEntryForOwner( - { - ...tab.agentStatus, - ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) - }, - ownerAgent - ) - : null - // Why: keep rich hook status on a live prompt/tool (authoritative even under a non-agent title), else interactivePrompt is lost. + const statusPty = liveLeafPty ?? mobileStatusPty + const normalizedTabAgentStatus = this.renewMobileAgentStatusFromPtyTitle( + tab.agentStatus + ? normalizeCompatibleAgentStatusEntryForOwner( + { + ...tab.agentStatus, + ...(hookProviderSession ? { providerSession: hookProviderSession } : {}) + }, + ownerAgent + ) + : null, + statusPty, + { preserveQuestionUnderShellTitle: true } + ) + // Why: keep rich status on a live prompt/tool, or interactivePrompt is lost under a non-agent title. const hasLiveAgentSignal = normalizedTabAgentStatus?.interactivePrompt != null || normalizedTabAgentStatus?.toolName != null @@ -30331,8 +30468,10 @@ export class OrcaRuntimeService { agentStatus: { state: 'done' as const, prompt: '', - updatedAt: normalizedTabAgentStatus.updatedAt, - stateStartedAt: normalizedTabAgentStatus.stateStartedAt, + updatedAt: statusPty?.lastOscTitleEpochMs ?? normalizedTabAgentStatus.updatedAt, + stateStartedAt: + statusPty?.lastAgentStatusStartedAtEpochMs ?? + normalizedTabAgentStatus.stateStartedAt, paneKey: normalizedTabAgentStatus.paneKey, stateHistory: [], agentType: normalizedTabAgentStatus.agentType, @@ -30425,6 +30564,91 @@ export class OrcaRuntimeService { } } + private renewMobileAgentStatusFromPtyTitle( + status: AgentStatusEntry | null, + pty: RuntimePtyWorktreeRecord | null, + options: { preserveQuestionUnderShellTitle?: boolean } = {} + ): AgentStatusEntry | null { + if (!status || !pty) { + return status + } + if ( + options.preserveQuestionUnderShellTitle && + status.interactivePrompt != null && + terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const richStatusCanOwnTitleInterval = + pty.lastAgentStatusRichInvalidatedAtEpochMs === null || + status.updatedAt > pty.lastAgentStatusRichInvalidatedAtEpochMs + const titleEvidenceAt = pty.lastOscTitleEpochMs + if (titleEvidenceAt === null) { + return richStatusCanOwnTitleInterval ? status : null + } + const buildTitleOnlyStatus = ( + state: AgentStatusEntry['state'], + updatedAt: number, + stateStartedAt: number + ): AgentStatusEntry => ({ + state, + prompt: '', + updatedAt, + stateStartedAt, + paneKey: status.paneKey, + stateHistory: [], + ...(status.agentType ? { agentType: status.agentType } : {}), + ...(status.terminalHandle ? { terminalHandle: status.terminalHandle } : {}), + ...(status.worktreeId ? { worktreeId: status.worktreeId } : {}), + ...(status.tabId ? { tabId: status.tabId } : {}), + ...(status.terminalTitle ? { terminalTitle: status.terminalTitle } : {}), + ...(status.providerSession ? { providerSession: status.providerSession } : {}) + }) + const titleConfirmsState = + (pty.lastAgentStatus === 'working' && status.state === 'working') || + (pty.lastAgentStatus === 'permission' && + (status.state === 'blocked' || status.state === 'waiting')) + if (!titleConfirmsState) { + if (richStatusCanOwnTitleInterval && status.updatedAt >= titleEvidenceAt) { + return status + } + if ( + pty.lastAgentStatus === null && + !terminalTitleBlocksExplicitAgentStatus(pty.lastOscTitle) + ) { + return status + } + const titleState = + pty.lastAgentStatus === 'working' + ? 'working' + : pty.lastAgentStatus === 'permission' + ? 'blocked' + : 'done' + return buildTitleOnlyStatus( + titleState, + titleEvidenceAt, + pty.lastAgentStatusStartedAtEpochMs ?? titleEvidenceAt + ) + } + const richStatusIsFresh = Date.now() - status.updatedAt <= AGENT_STATUS_STALE_AFTER_MS + const richStatusOwnsCurrentState = richStatusIsFresh && richStatusCanOwnTitleInterval + // Fresh explicit evidence from this title interval owns acknowledgement identity. + const stateStartedAt = richStatusOwnsCurrentState + ? status.stateStartedAt + : (pty.lastAgentStatusStartedAtEpochMs ?? status.stateStartedAt) + if (richStatusOwnsCurrentState) { + pty.lastAgentStatusStartedAtEpochMs = stateStartedAt + } + const updatedAt = Math.max(status.updatedAt, titleEvidenceAt) + if (!richStatusOwnsCurrentState) { + return buildTitleOnlyStatus(status.state, updatedAt, stateStartedAt) + } + if (updatedAt === status.updatedAt && stateStartedAt === status.stateStartedAt) { + return status + } + return { ...status, updatedAt, stateStartedAt } + } + /** Mobile-friendly status entry for a PTY, aligning agentType and titles with the active owner. */ private buildPtyMobileAgentStatus( pty: RuntimePtyWorktreeRecord | null, @@ -30499,24 +30723,29 @@ export class OrcaRuntimeService { // Why: OSC 9999 hook payload carries real state/prompt/agent; without preferring it, hook-only transitions never surfaced (#7970). const liveRow = retained ?? this.resolveHookLiveAgentRow(hookRow.live, pty, nonAgentTitle) if (liveRow) { - return { - agentStatus: normalizeCompatibleAgentStatusEntryForOwner( - { - ...liveRow.payload, - paneKey, - updatedAt: liveRow.updatedAt, - stateStartedAt: liveRow.stateStartedAt, - stateHistory: [], - ...(terminalHandle ? { terminalHandle } : {}), - ...((pty?.worktreeId ?? liveRow.worktreeId) - ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } - : {}), - tabId: tab.parentTabId, - terminalTitle, - ...providerSession - }, - ownerAgent - ) + const liveStatus = normalizeCompatibleAgentStatusEntryForOwner( + { + ...liveRow.payload, + paneKey, + updatedAt: liveRow.updatedAt, + stateStartedAt: liveRow.stateStartedAt, + stateHistory: [], + ...(terminalHandle ? { terminalHandle } : {}), + ...((pty?.worktreeId ?? liveRow.worktreeId) + ? { worktreeId: pty?.worktreeId ?? liveRow.worktreeId } + : {}), + tabId: tab.parentTabId, + terminalTitle, + ...providerSession + }, + ownerAgent + ) + // A live question outranks only the shell title that currently obscures it. + const renewedStatus = this.renewMobileAgentStatusFromPtyTitle(liveStatus, pty, { + preserveQuestionUnderShellTitle: true + }) + if (renewedStatus) { + return { agentStatus: renewedStatus } } } // Last resort: the pane's hook evidence is identity only (resume rows, stale @@ -30537,7 +30766,7 @@ export class OrcaRuntimeService { : 'done', prompt: '', updatedAt: evidenceAt, - stateStartedAt: evidenceAt, + stateStartedAt: pty?.lastAgentStatusStartedAtEpochMs ?? evidenceAt, paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), @@ -36680,6 +36909,42 @@ function runtimeWorktreeIdentityKey(worktreeId: string): string { : worktreeId } +function runtimeWorktreeLookupKey(worktreeId: string): string { + const parsed = splitWorktreeId(worktreeId) + return JSON.stringify( + parsed + ? ['parsed', parsed.repoId, normalizeRuntimePathForComparison(parsed.worktreePath)] + : ['raw', worktreeId] + ) +} + +function createIncrementalResolvedWorktreeLookup( + resolvedWorktrees: ResolvedWorktree[] +): (worktreeId: string) => ResolvedWorktree | undefined { + const worktreeByIdentity = new Map() + let indexedCount = 0 + return (worktreeId) => { + const lookupKey = runtimeWorktreeLookupKey(worktreeId) + const indexed = worktreeByIdentity.get(lookupKey) + if (indexed) { + return indexed + } + while (indexedCount < resolvedWorktrees.length) { + const worktree = resolvedWorktrees[indexedCount] + indexedCount += 1 + const key = runtimeWorktreeLookupKey(worktree.id) + // Why: preserve Array.find's first match when normalized identities collide. + if (!worktreeByIdentity.has(key)) { + worktreeByIdentity.set(key, worktree) + } + if (key === lookupKey) { + return worktreeByIdentity.get(key) + } + } + return undefined + } +} + function resolveTerminalSessionWorktreeId( session: WorkspaceSessionState, targetWorktreeId: string diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 7049d59469d..259664c5544 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -57,6 +57,10 @@ import { releaseContextOnlyDispatch, type ContextOnlyDispatchReleaseResult } from './context-only-dispatch-release' +import { + ensureMutationReceiptCapacity, + migrateMutationReceiptCapacity +} from './mutation-receipt-capacity' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -261,9 +265,6 @@ export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION -const MUTATION_RECEIPT_MAX_ROWS = 10_000 -const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 - export type RunListPage = { runs: RunRow[] nextCursor: string | null @@ -281,8 +282,8 @@ type RunListCursor = { id: string } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup. -const SCHEMA_VERSION = 25 +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity. +const SCHEMA_VERSION = 26 function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { if (dbPath === ':memory:' || process.platform === 'win32') { @@ -983,6 +984,9 @@ export class OrchestrationDb { WHERE assignee_handle IS NOT NULL AND status IN ('pending', 'dispatched'); `) } + if (current < 26) { + migrateMutationReceiptCapacity(this.db) + } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) @@ -1398,44 +1402,6 @@ export class OrchestrationDb { // ── Durable mutation receipts ── - private ensureMutationReceiptCapacity(): void { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE state = 'completed' - AND updated_at < datetime('now', ?)` - ) - .run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) - - const row = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - const completedToRemove = row.count - MUTATION_RECEIPT_MAX_ROWS + 1 - if (completedToRemove > 0) { - this.db - .prepare( - `DELETE FROM mutation_receipts - WHERE rowid IN ( - SELECT rowid FROM mutation_receipts - WHERE state = 'completed' - ORDER BY updated_at ASC, rowid ASC - LIMIT ? - )` - ) - .run(completedToRemove) - } - - const retained = this.db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() as { - count: number - } - if (retained.count >= MUTATION_RECEIPT_MAX_ROWS) { - throw new OrchestrationError( - 'mutation_ledger_full', - 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' - ) - } - } - beginMutationReceipt(params: { callerFingerprint: string requestId: string @@ -1458,7 +1424,7 @@ export class OrchestrationDb { this.db.exec('COMMIT') return { disposition: existing.state, row: existing } } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4009,7 +3975,7 @@ export class OrchestrationDb { `Mutation ${receipt.requestId} already has a durable acceptance record.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( @@ -4642,7 +4608,7 @@ export class OrchestrationDb { `Remote attachment request ${params.mutationReceipt.requestId} already exists.` ) } - this.ensureMutationReceiptCapacity() + ensureMutationReceiptCapacity(this.db) this.db .prepare( `INSERT INTO mutation_receipts ( diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts new file mode 100644 index 00000000000..6907d73ba81 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { OrchestrationDb } from './db' +import { MUTATION_RECEIPT_MAX_ROWS } from './mutation-receipt-capacity' + +function sqliteFor(db: OrchestrationDb): Database.Database { + return (db as unknown as { db: Database.Database }).db +} + +function insertReceipts( + sqlite: Database.Database, + count: number, + state: 'pending' | 'completed' +): void { + sqlite + .prepare( + `WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ? + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), ? + FROM receipt_numbers` + ) + .run(count, state) +} + +function beginReceipt(db: OrchestrationDb, requestId: string): void { + db.beginMutationReceipt({ + callerFingerprint: 'new-caller', + requestId, + method: 'orchestration.send', + payloadHash: `hash-${requestId}` + }) +} + +describe('mutation receipt capacity schema', () => { + let db: OrchestrationDb | undefined + let secondDb: OrchestrationDb | undefined + let tempDir: string | undefined + + afterEach(() => { + secondDb?.close() + db?.close() + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('uses the completed receipt index for age and capacity pruning', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, 10, 'completed') + + const agePlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ) + .all('-30 days') as { detail: string }[] + const capacityPlan = sqlite + .prepare( + `EXPLAIN QUERY PLAN + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ?` + ) + .all(64) as { detail: string }[] + const details = [...agePlan, ...capacityPlan].map((row) => row.detail).join('\n') + + expect(details).toContain('idx_mutation_receipts_completed_updated') + expect(details).not.toContain('USE TEMP B-TREE') + expect(details).not.toMatch(/SCAN mutation_receipts(?:\n|$)/) + }) + + it('migrates a populated v25 database and tracks writes from older connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-migration-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + insertReceipts(sqliteFor(db), 20, 'completed') + db.close() + db = undefined + + const oldDb = new Database(dbPath) + oldDb.exec(` + DROP TRIGGER mutation_receipts_count_insert; + DROP TRIGGER mutation_receipts_count_delete; + DROP TABLE mutation_receipt_ledger; + DROP INDEX idx_mutation_receipts_completed_updated; + `) + oldDb.pragma('user_version = 25') + oldDb.close() + + db = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + + const olderConnection = new Database(dbPath) + olderConnection.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('old-client', 'inserted', 'orchestration.send', 'hash', 'pending'); + DELETE FROM mutation_receipts + WHERE caller_fingerprint = 'old-client' AND request_id = 'inserted'; + `) + olderConnection.close() + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: 20 + }) + }) + + it('amortizes capacity pruning while retaining the newest replay records', () => { + db = new OrchestrationDb(':memory:') + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS, 'completed') + + beginReceipt(db, 'first') + const afterFirst = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + beginReceipt(db, 'second') + const afterSecond = sqlite + .prepare('SELECT receipt_count FROM mutation_receipt_ledger') + .get() as { receipt_count: number } + + expect(afterFirst.receipt_count).toBe(MUTATION_RECEIPT_MAX_ROWS - 63) + expect(afterSecond.receipt_count).toBe(afterFirst.receipt_count + 1) + expect(db.getMutationReceipt('caller', 'request_00064')).toBeUndefined() + expect(db.getMutationReceipt('caller', 'request_00065')).toMatchObject({ state: 'completed' }) + expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) + }) + + it('keeps the row limit exact across independent database connections', () => { + tempDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-concurrency-')) + const dbPath = join(tempDir, 'orchestration.db') + db = new OrchestrationDb(dbPath) + secondDb = new OrchestrationDb(dbPath) + const sqlite = sqliteFor(db) + insertReceipts(sqlite, MUTATION_RECEIPT_MAX_ROWS - 1, 'pending') + sqlite.exec(` + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('caller', 'completed-slot', 'orchestration.send', 'hash', 'completed') + `) + + beginReceipt(db, 'first-connection') + expect(() => beginReceipt(secondDb!, 'second-connection')).toThrowError( + expect.objectContaining({ code: 'mutation_ledger_full' }) + ) + db.discardPendingMutationReceipt('new-caller', 'first-connection') + beginReceipt(secondDb, 'second-connection') + + expect(sqlite.prepare('SELECT receipt_count FROM mutation_receipt_ledger').get()).toEqual({ + receipt_count: MUTATION_RECEIPT_MAX_ROWS + }) + }) +}) diff --git a/src/main/runtime/orchestration/mutation-receipt-capacity.ts b/src/main/runtime/orchestration/mutation-receipt-capacity.ts new file mode 100644 index 00000000000..2ba735adad0 --- /dev/null +++ b/src/main/runtime/orchestration/mutation-receipt-capacity.ts @@ -0,0 +1,79 @@ +import type Database from '../../sqlite/sync-database' +import { OrchestrationError } from './orchestration-error' + +export const MUTATION_RECEIPT_MAX_ROWS = 10_000 +const MUTATION_RECEIPT_MAX_AGE_DAYS = 30 +const MUTATION_RECEIPT_PRUNE_BATCH_SIZE = 64 + +export function migrateMutationReceiptCapacity(db: Database.Database): void { + // Why: database triggers keep the count exact for concurrent connections and older binaries. + db.exec(` + CREATE INDEX IF NOT EXISTS idx_mutation_receipts_completed_updated + ON mutation_receipts(updated_at) WHERE state = 'completed'; + + CREATE TABLE IF NOT EXISTS mutation_receipt_ledger ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + receipt_count INTEGER NOT NULL CHECK(receipt_count >= 0) + ); + + INSERT INTO mutation_receipt_ledger (singleton, receipt_count) + SELECT 1, COUNT(*) FROM mutation_receipts + -- SQLite needs a WHERE to disambiguate SELECT UPSERT syntax. + WHERE true + ON CONFLICT(singleton) DO UPDATE SET receipt_count = excluded.receipt_count; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_insert + AFTER INSERT ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count + 1 + WHERE singleton = 1; + END; + + CREATE TRIGGER IF NOT EXISTS mutation_receipts_count_delete + AFTER DELETE ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger + SET receipt_count = receipt_count - 1 + WHERE singleton = 1; + END; + `) +} + +function receiptCount(db: Database.Database): number { + const row = db + .prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1') + .get() as { receipt_count: number } | undefined + if (!row) { + throw new Error('Mutation receipt ledger metadata is missing.') + } + return row.receipt_count +} + +export function ensureMutationReceiptCapacity(db: Database.Database): void { + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' + AND updated_at < datetime('now', ?)` + ).run(`-${MUTATION_RECEIPT_MAX_AGE_DAYS} days`) + + const count = receiptCount(db) + if (count >= MUTATION_RECEIPT_MAX_ROWS) { + db.prepare( + `DELETE FROM mutation_receipts + WHERE rowid IN ( + SELECT rowid FROM mutation_receipts + WHERE state = 'completed' + ORDER BY updated_at ASC, rowid ASC + LIMIT ? + )` + ).run(count - MUTATION_RECEIPT_MAX_ROWS + MUTATION_RECEIPT_PRUNE_BATCH_SIZE) + } + + if (receiptCount(db) >= MUTATION_RECEIPT_MAX_ROWS) { + throw new OrchestrationError( + 'mutation_ledger_full', + 'The durable mutation ledger is full of unresolved operations. Resolve or inspect them before starting another mutation.' + ) + } +} diff --git a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts index 8cb0547ee27..0b9c5ad7097 100644 --- a/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts +++ b/src/main/runtime/orchestration/orchestration-db-retention-pagination.test.ts @@ -74,7 +74,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'request_10000')).toMatchObject({ state: 'completed' }) expect(db.getMutationReceipt('caller', 'new')).toMatchObject({ state: 'pending' }) @@ -117,7 +117,7 @@ describe('OrchestrationDb bounded mutation receipts', () => { const count = sqliteFor(db) .prepare('SELECT COUNT(*) AS count FROM mutation_receipts') .get() as { count: number } - expect(count.count).toBe(MUTATION_RECEIPT_MAX_ROWS) + expect(count.count).toBeLessThanOrEqual(MUTATION_RECEIPT_MAX_ROWS) expect(db.getMutationReceipt('caller', 'request_00001')).toBeUndefined() expect(db.getMutationReceipt('caller', 'remote_pruned')).toMatchObject({ state: 'pending' }) expect(db.getRemoteDispatchAttachment('ctx_remote_pruned')).toBeDefined() @@ -240,7 +240,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ assignee_handle: 'term_worker' }) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: null, @@ -277,7 +277,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getDispatchContextById(dispatch.id)).toBeDefined() }) @@ -309,7 +309,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db = new OrchestrationDb(dbPath) const sqlite = sqliteFor(db) - expect(sqlite.pragma('user_version', { simple: true })).toBe(25) + expect(sqlite.pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)).toMatchObject({ created_by_pane_key: 'tab_creator:leaf_creator', created_by_process_incarnation: 'pty_creator:incarnation-a', @@ -328,7 +328,7 @@ describe('OrchestrationDb dispatch assignee index migration', () => { db.close() db = new OrchestrationDb(dbPath) - expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(25) + expect(sqliteFor(db).pragma('user_version', { simple: true })).toBe(26) expect(db.getTask(task.id)?.created_by_process_incarnation).toBe('pty_creator:incarnation-a') }) }) diff --git a/src/main/runtime/rpc/methods/worktree-schemas.ts b/src/main/runtime/rpc/methods/worktree-schemas.ts index 57b8a9202a5..a3e562e8516 100644 --- a/src/main/runtime/rpc/methods/worktree-schemas.ts +++ b/src/main/runtime/rpc/methods/worktree-schemas.ts @@ -275,6 +275,7 @@ export const WorktreeSet = WorktreeSelector.extend({ }) export const WorktreeRemove = WorktreeSelector.extend({ + hostId: OptionalString, force: OptionalBoolean, // Why (#11960): the CLI's --force is an unambiguous force affordance, but the // desktop sets `force` for an ordinary confirmed delete too, so the PTY-stop @@ -284,6 +285,7 @@ export const WorktreeRemove = WorktreeSelector.extend({ }) export const WorktreeForceDeleteBranch = WorktreeSelector.extend({ + hostId: OptionalString, branchName: z .unknown() .transform((v) => (typeof v === 'string' ? v : '')) diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts index 758e6b83891..acc2dab0537 100644 --- a/src/main/runtime/rpc/methods/worktree.ts +++ b/src/main/runtime/rpc/methods/worktree.ts @@ -271,12 +271,15 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.rm', params: WorktreeRemove, handler: async (params, { runtime }) => { - const result = await runtime.removeManagedWorktree( + const removalArgs = [ params.worktree, params.force === true, params.runHooks === true, params.allowUnverifiedPtyStop === true - ) + ] as const + const result = params.hostId + ? await runtime.removeManagedWorktree(...removalArgs, params.hostId) + : await runtime.removeManagedWorktree(...removalArgs) return { removed: true, ...result } } }), @@ -284,6 +287,17 @@ export const WORKTREE_METHODS: RpcMethod[] = [ name: 'worktree.forceDeleteBranch', params: WorktreeForceDeleteBranch, handler: async (params, { runtime }) => - runtime.forceDeletePreservedBranch(params.worktree, params.branchName, params.expectedHead) + params.hostId + ? runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead, + params.hostId + ) + : runtime.forceDeletePreservedBranch( + params.worktree, + params.branchName, + params.expectedHead + ) }) ] diff --git a/src/main/runtime/workspace-session-failed-write-rollback.ts b/src/main/runtime/workspace-session-failed-write-rollback.ts new file mode 100644 index 00000000000..9460c27b6ed --- /dev/null +++ b/src/main/runtime/workspace-session-failed-write-rollback.ts @@ -0,0 +1,62 @@ +import { isDeepStrictEqual } from 'node:util' +import type { WorkspaceSessionState } from '../../shared/types' + +const MISSING = Symbol('missing') +type RollbackValue = unknown | typeof MISSING + +function isRecord(value: RollbackValue): value is Record { + return ( + value !== MISSING && + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ) +} + +function rollbackValue( + original: RollbackValue, + staged: RollbackValue, + current: RollbackValue +): RollbackValue { + if (isDeepStrictEqual(original, staged)) { + return current + } + if (isDeepStrictEqual(current, staged)) { + return original + } + if (!isRecord(original) || !isRecord(staged) || !isRecord(current)) { + return current + } + let changed = false + const next: Record = { ...current } + for (const key of new Set([ + ...Object.keys(original), + ...Object.keys(staged), + ...Object.keys(current) + ])) { + const value = rollbackValue( + Object.hasOwn(original, key) ? original[key] : MISSING, + Object.hasOwn(staged, key) ? staged[key] : MISSING, + Object.hasOwn(current, key) ? current[key] : MISSING + ) + if (value === MISSING) { + if (Object.hasOwn(next, key)) { + delete next[key] + changed = true + } + } else if (!Object.hasOwn(current, key) || !isDeepStrictEqual(current[key], value)) { + next[key] = value + changed = true + } + } + return changed ? next : current +} + +export function rollbackWorkspaceSessionAfterFailedAsyncWrite( + original: WorkspaceSessionState, + staged: WorkspaceSessionState, + current: WorkspaceSessionState +): WorkspaceSessionState { + return rollbackValue(original, staged, current) as WorkspaceSessionState +} diff --git a/src/main/ssh/ssh-port-scanner.test.ts b/src/main/ssh/ssh-port-scanner.test.ts index 09be7c1f686..f903d71acf0 100644 --- a/src/main/ssh/ssh-port-scanner.test.ts +++ b/src/main/ssh/ssh-port-scanner.test.ts @@ -235,6 +235,40 @@ describe('PortScanner', () => { expect(scanner.getDetectedPorts('t1')).toEqual([]) }) + it('aborts in-flight requests before stopping or replacing a target scan', async () => { + const harness = createVisibilityHarness(true) + const signals: AbortSignal[] = [] + const request = vi.fn( + (_method: string, _params?: Record, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + reject(new Error('missing request signal')) + return + } + signals.push(signal) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + }) + ) + const mux = { request } as unknown as SshChannelMultiplexer + const scanner = new PortScanner(harness.visibility) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(1) + expect(signals[0].aborted).toBe(false) + + scanner.startScanning('t1', mux, vi.fn()) + expect(signals).toHaveLength(2) + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + + scanner.stopScanning('t1') + expect(signals[1].aborted).toBe(true) + await vi.advanceTimersByTimeAsync(10 * BASE) + expect(request).toHaveBeenCalledTimes(2) + expect(harness.listenerCount()).toBe(0) + }) + it('keeps targets independent: stopping one host leaves the other scanning', async () => { const harness = createVisibilityHarness(true) let nextA = 3000 diff --git a/src/main/ssh/ssh-port-scanner.ts b/src/main/ssh/ssh-port-scanner.ts index 6404d035f22..2dbabff597a 100644 --- a/src/main/ssh/ssh-port-scanner.ts +++ b/src/main/ssh/ssh-port-scanner.ts @@ -19,6 +19,7 @@ export type PortScannerWindowVisibility = { type ScanHandle = { timer: ReturnType | null + requestAbortController: AbortController | null intervalMs: number // Why: while the window is hidden the scan chain is parked outright — no // timer wakeups, no remote requests — and the visibility listener resumes @@ -48,6 +49,7 @@ export class PortScanner { const handle: ScanHandle = { timer: null, + requestAbortController: null, intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS, parkedWhileHidden: false, unsubscribeVisibility: () => {}, @@ -65,8 +67,12 @@ export class PortScanner { return } polling = true + const requestAbortController = new AbortController() + handle.requestAbortController = requestAbortController try { - const result = (await mux.request('ports.detect')) as { + const result = (await mux.request('ports.detect', undefined, { + signal: requestAbortController.signal + })) as { ports: DetectedPort[] platform: string } @@ -94,6 +100,9 @@ export class PortScanner { } catch { // Relay disconnected or request timed out — retry on next interval } finally { + if (handle.requestAbortController === requestAbortController) { + handle.requestAbortController = null + } polling = false } } @@ -142,6 +151,8 @@ export class PortScanner { if (handle.timer) { clearTimeout(handle.timer) } + handle.requestAbortController?.abort() + handle.requestAbortController = null handle.unsubscribeVisibility() this.handles.delete(targetId) } diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 23d57757b5b..482b0e8b113 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -134,6 +134,7 @@ describe('isRelayAlreadyInstalled', () => { const cmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(cmd).toContain('relay.js') expect(cmd).toContain('relay-watcher.js') + expect(cmd).toContain('relay-ai-vault-service.js') expect(cmd).toContain('managed-hook-runtime.js') expect(cmd).toContain('.install-complete') }) diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 13a1e2e246a..18738a930c6 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -117,6 +117,7 @@ describe('ssh remote command builders', () => { const probe = probeRelayInstalledCommand(posix, '/home/me/relay') expect(probe).toContain('test -d') expect(probe).toContain('managed-hook-runtime.js') + expect(probe).toContain('relay-ai-vault-service.js') }) it('uses encoded PowerShell for Windows deploy commands', () => { @@ -127,6 +128,7 @@ describe('ssh remote command builders', () => { const probe = probeRelayInstalledCommand(windows, 'C:/Users/me/relay') expect(probe).toContain('-EncodedCommand') expect(decodePowerShellCommand(probe)).toContain('managed-hook-runtime.js') + expect(decodePowerShellCommand(probe)).toContain('relay-ai-vault-service.js') }) it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => { diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 7cec0d027c2..603b6356a1f 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -91,6 +91,7 @@ export function probeRelayInstalledCommand( ): string { const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js') const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js') + const relayAiVaultServiceJs = joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js') const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js') const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete') if (!isWindowsRemoteHost(host)) { @@ -98,6 +99,7 @@ export function probeRelayInstalledCommand( `test -d ${shellEscape(remoteRelayDir)} ` + `&& test -f ${shellEscape(relayJs)} ` + `&& test -f ${shellEscape(relayWatcherJs)} ` + + `&& test -f ${shellEscape(relayAiVaultServiceJs)} ` + `&& test -f ${shellEscape(managedHookRuntimeJs)} ` + `&& test -f ${shellEscape(installComplete)} ` + `&& echo OK || echo MISSING` @@ -108,9 +110,10 @@ export function probeRelayInstalledCommand( `$dir = ${powerShellLiteral(remoteRelayDir)}`, `$relay = ${powerShellLiteral(relayJs)}`, `$watcher = ${powerShellLiteral(relayWatcherJs)}`, + `$aiVaultService = ${powerShellLiteral(relayAiVaultServiceJs)}`, `$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`, `$complete = ${powerShellLiteral(installComplete)}`, - "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" + "if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $aiVaultService -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }" ].join('; ') ) } diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index be5ed622927..6199dfeef52 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -55,6 +55,7 @@ exec /bin/sh -c "$cmd" function writeFakeRelay(dir: string): void { writeFileSync(join(dir, 'relay-watcher.js'), '') + writeFileSync(join(dir, 'relay-ai-vault-service.js'), '') writeFileSync(join(dir, 'managed-hook-runtime.js'), '') writeFileSync( join(dir, 'relay.js'), diff --git a/src/main/usage/usage-calendar-range.test.ts b/src/main/usage/usage-calendar-range.test.ts new file mode 100644 index 00000000000..73645c2ccc1 --- /dev/null +++ b/src/main/usage/usage-calendar-range.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getLocalUsageDay, getUsageRangeCutoff } from './usage-calendar-range' + +describe('usage calendar ranges', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 3, 10, 12)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it.each([ + ['7d', '2026-04-04'], + ['30d', '2026-03-12'], + ['90d', '2026-01-11'], + ['all', null] + ] as const)('uses an inclusive local-calendar cutoff for %s', (range, expected) => { + expect(getUsageRangeCutoff(range)).toBe(expected) + }) + + it('maps timestamps to local calendar days and rejects invalid values', () => { + const localTimestamp = new Date(2026, 3, 4, 23, 30).toISOString() + + expect(getLocalUsageDay(localTimestamp)).toBe('2026-04-04') + expect(getLocalUsageDay('not-a-date')).toBeNull() + }) +}) diff --git a/src/main/usage/usage-calendar-range.ts b/src/main/usage/usage-calendar-range.ts new file mode 100644 index 00000000000..255ef245be5 --- /dev/null +++ b/src/main/usage/usage-calendar-range.ts @@ -0,0 +1,24 @@ +type UsageCalendarRange = '7d' | '30d' | '90d' | 'all' + +function formatLocalDay(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function getUsageRangeCutoff(range: UsageCalendarRange): string | null { + if (range === 'all') { + return null + } + const days = range === '7d' ? 7 : range === '30d' ? 30 : 90 + const cutoff = new Date() + cutoff.setHours(0, 0, 0, 0) + cutoff.setDate(cutoff.getDate() - (days - 1)) + return formatLocalDay(cutoff) +} + +export function getLocalUsageDay(timestamp: string): string | null { + const parsed = new Date(timestamp) + return Number.isNaN(parsed.getTime()) ? null : formatLocalDay(parsed) +} diff --git a/src/main/usage/usage-provider-store-lifecycle.test.ts b/src/main/usage/usage-provider-store-lifecycle.test.ts new file mode 100644 index 00000000000..02a5571363d --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.test.ts @@ -0,0 +1,293 @@ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import type * as FsPromises from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { UsageProviderStoreLifecycle } from './usage-provider-store-lifecycle' + +const { writeProbe } = vi.hoisted(() => ({ + writeProbe: { + opens: 0, + renames: 0, + blocked: false, + waiters: [] as (() => void)[] + } +})) + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises') + return { + ...actual, + open: (async (...args: Parameters) => { + if (args[1] === 'w') { + writeProbe.opens += 1 + if (writeProbe.blocked) { + await new Promise((resolve) => writeProbe.waiters.push(resolve)) + } + } + return actual.open(...args) + }) as typeof actual.open, + rename: ((...args: Parameters) => { + writeProbe.renames += 1 + return actual.rename(...args) + }) as typeof actual.rename + } +}) + +type TestSource = { id: string } +type TestSession = { id: string } +type TestDailyAggregate = { day: string } +type TestScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} +type TestState = { + schemaVersion: number + worktreeFingerprint: string | null + processedSources: TestSource[] + sessions: TestSession[] + dailyAggregates: TestDailyAggregate[] + scanState: TestScanState +} +type TestScanResult = Pick +type TestScan = ( + worktrees: UsageScanWorktreeRef[], + previous: TestSource[] +) => Promise + +const NOW = Date.parse('2026-04-10T16:00:00.000Z') +const EMPTY_WORKTREE_FINGERPRINT = '[]' + +function makeState( + overrides: Partial> & { scanState?: Partial } = {} +): TestState { + const { scanState, ...stateOverrides } = overrides + return { + schemaVersion: 1, + worktreeFingerprint: null, + processedSources: [], + sessions: [], + dailyAggregates: [], + ...stateOverrides, + scanState: { + enabled: false, + lastScanStartedAt: null, + lastScanCompletedAt: null, + lastScanError: null, + ...scanState + } + } +} + +function emptyScanResult(): TestScanResult { + return { processedSources: [], sessions: [], dailyAggregates: [] } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +class TestUsageStore extends UsageProviderStoreLifecycle< + 'processedSources', + TestState, + 'hasAnyTestData' +> { + constructor(cacheFile: string, scan: TestScan) { + super( + { + getRepos: () => [], + getAllWorktreeMeta: () => ({}) + }, + { + logTag: '[test-usage]', + resolveCacheFile: () => cacheFile, + createDefaultState: makeState, + normalizeState: (state) => state, + sourceKey: 'processedSources', + dataPresenceKey: 'hasAnyTestData', + scan + } + ) + } + + replaceState(state: TestState): void { + this.state = state + } + + getState(): TestState { + return this.state + } +} + +describe('UsageProviderStoreLifecycle', () => { + let tempDirectory: string + let stores: TestUsageStore[] + let scan: ReturnType> + + function createStore( + cacheFile = join(tempDirectory, `usage-${stores.length}.json`) + ): TestUsageStore { + const store = new TestUsageStore(cacheFile, scan) + stores.push(store) + return store + } + + beforeEach(() => { + tempDirectory = mkdtempSync(join(tmpdir(), 'orca-usage-lifecycle-')) + stores = [] + writeProbe.opens = 0 + writeProbe.renames = 0 + writeProbe.blocked = false + writeProbe.waiters = [] + scan = vi.fn().mockResolvedValue(emptyScanResult()) + vi.spyOn(Date, 'now').mockReturnValue(NOW) + }) + + afterEach(async () => { + await Promise.all(stores.map((store) => store.flush())) + rmSync(tempDirectory, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('skips disabled and fresh matching states', async () => { + const store = createStore() + + await store.refresh() + expect(scan).not.toHaveBeenCalled() + + store.replaceState( + makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + await store.refresh() + + expect(scan).not.toHaveBeenCalled() + }) + + it('invalidates prior sources on fingerprint changes and reuses them for forced scans', async () => { + const store = createStore() + const refreshedSources = [{ id: 'refreshed' }] + scan.mockResolvedValueOnce({ + processedSources: refreshedSources, + sessions: [], + dailyAggregates: [] + }) + store.replaceState( + makeState({ + worktreeFingerprint: 'outdated', + processedSources: [{ id: 'stale' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1 } + }) + ) + + await store.refresh() + expect(scan).toHaveBeenLastCalledWith([], []) + + scan.mockClear() + await store.refresh(true) + + expect(scan).toHaveBeenCalledWith([], refreshedSources) + }) + + it('shares one in-flight scan and exposes its live state', async () => { + const pendingScan = createDeferred() + scan.mockReturnValueOnce(pendingScan.promise) + const store = createStore() + store.replaceState( + makeState({ scanState: { enabled: true, lastScanError: 'previous failure' } }) + ) + + const firstRefresh = store.refresh(true) + const secondRefresh = store.refresh(true) + await vi.waitFor(() => expect(scan).toHaveBeenCalledTimes(1)) + + expect(store.getScanState()).toMatchObject({ + isScanning: true, + lastScanStartedAt: NOW, + lastScanError: null + }) + expect(writeProbe.opens).toBe(0) + + pendingScan.resolve({ + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [] + }) + await Promise.all([firstRefresh, secondRefresh]) + + expect(store.getScanState()).toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW, + hasAnyTestData: true + }) + expect(writeProbe.opens).toBe(1) + expect(store.getState().processedSources).toEqual([{ id: 'source' }]) + }) + + it('vetoes a superseded generation before rename', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + + writeProbe.blocked = true + const first = store.setEnabled(true) + await vi.waitFor(() => expect(writeProbe.waiters).toHaveLength(1)) + const second = store.setEnabled(false) + writeProbe.blocked = false + writeProbe.waiters.splice(0).forEach((resolve) => resolve()) + await Promise.all([first, second]) + + expect(writeProbe.opens).toBe(2) + expect(writeProbe.renames).toBe(1) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.enabled).toBe(false) + expect(readdirSync(tempDirectory).filter((name) => name.endsWith('.tmp'))).toHaveLength(0) + }) + + it('sweeps a temp file orphaned before rename', async () => { + const cacheFile = join(tempDirectory, 'orphaned-usage.json') + const orphan = `${cacheFile}.${process.pid + 1}.1.test.tmp` + writeFileSync(orphan, '{}') + + createStore(cacheFile) + + await vi.waitFor(() => expect(existsSync(orphan)).toBe(false)) + }) + + it('retains the last successful projection when a scan fails', async () => { + const cacheFile = join(tempDirectory, 'usage-0.json') + const store = createStore() + const previousState = makeState({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: [{ id: 'source' }], + sessions: [{ id: 'session' }], + dailyAggregates: [{ day: '2026-04-09' }], + scanState: { enabled: true, lastScanCompletedAt: NOW - 1_000 } + }) + store.replaceState(previousState) + scan.mockRejectedValueOnce(new Error('scan exploded')) + + await expect(store.refresh(true)).resolves.toMatchObject({ + isScanning: false, + lastScanCompletedAt: NOW - 1_000, + lastScanError: 'scan exploded' + }) + + expect(store.getState()).toMatchObject({ + worktreeFingerprint: EMPTY_WORKTREE_FINGERPRINT, + processedSources: previousState.processedSources, + sessions: previousState.sessions, + dailyAggregates: previousState.dailyAggregates + }) + expect(JSON.parse(readFileSync(cacheFile, 'utf-8')).scanState.lastScanError).toBe( + 'scan exploded' + ) + }) +}) diff --git a/src/main/usage/usage-provider-store-lifecycle.ts b/src/main/usage/usage-provider-store-lifecycle.ts new file mode 100644 index 00000000000..850d9e61bda --- /dev/null +++ b/src/main/usage/usage-provider-store-lifecycle.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import type { Store } from '../persistence' +import { UsageCacheSnapshotWriter } from '../usage-cache-snapshot-writer' +import { loadKnownUsageWorktreesByRepo } from '../usage-worktree-metadata' +import type { UsageScanWorktreeRef } from './usage-provider-contract' +import { createWorktreeRefs, getUsageWorktreeFingerprint } from './usage-worktree-refs' + +const STALE_MS = 5 * 60_000 + +type UsageProviderScanState = { + enabled: boolean + lastScanStartedAt: number | null + lastScanCompletedAt: number | null + lastScanError: string | null +} + +type UsageProviderStoreState = { + worktreeFingerprint: string | null + sessions: unknown[] + dailyAggregates: unknown[] + scanState: UsageProviderScanState +} & Record + +type UsageProviderScanProjection< + SourceKey extends string, + State extends UsageProviderStoreState +> = Pick + +type UsageProviderStoreLifecycleConfig< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> = { + logTag: string + resolveCacheFile: () => string + createDefaultState: () => State + normalizeState: (state: State) => State + sourceKey: SourceKey + dataPresenceKey: DataPresenceKey + jsonIndent?: number + scan: ( + worktrees: UsageScanWorktreeRef[], + previous: State[SourceKey] + ) => Promise> +} + +type PublicUsageProviderScanState = UsageProviderScanState & { + isScanning: boolean +} & Record + +export abstract class UsageProviderStoreLifecycle< + SourceKey extends string, + State extends UsageProviderStoreState, + DataPresenceKey extends string +> { + protected state: State + private scanPromise: Promise | null = null + private readonly writer: UsageCacheSnapshotWriter + + constructor( + private readonly store: Pick, + private readonly config: UsageProviderStoreLifecycleConfig + ) { + this.writer = new UsageCacheSnapshotWriter(config.logTag, config.resolveCacheFile) + this.state = this.load() + } + + getScanState(): PublicUsageProviderScanState { + return { + ...this.state.scanState, + isScanning: this.scanPromise !== null, + [this.config.dataPresenceKey]: + this.state.sessions.length > 0 || this.state.dailyAggregates.length > 0 + } as PublicUsageProviderScanState + } + + /** Await queued cache writes so quit does not drop the final snapshot. */ + flush(): Promise { + return this.writer.flush() + } + + async setEnabled(enabled: boolean): Promise> { + this.state.scanState.enabled = enabled + await this.writeToDisk() + return this.getScanState() + } + + async refresh(force = false): Promise> { + if (!this.state.scanState.enabled) { + return this.getScanState() + } + const currentWorktreeFingerprint = await this.getCurrentWorktreeFingerprint() + if (!force && this.state.scanState.lastScanCompletedAt) { + const ageMs = Date.now() - this.state.scanState.lastScanCompletedAt + if (ageMs < STALE_MS && this.state.worktreeFingerprint === currentWorktreeFingerprint) { + return this.getScanState() + } + } + await this.runScan() + return this.getScanState() + } + + protected writeToDisk(): Promise { + return this.writer.write(() => JSON.stringify(this.state, null, this.config.jsonIndent)) + } + + private load(): State { + const defaults = this.config.createDefaultState() + try { + const cacheFile = this.config.resolveCacheFile() + if (!existsSync(cacheFile)) { + return defaults + } + const parsed = JSON.parse(readFileSync(cacheFile, 'utf-8')) as State + return this.config.normalizeState({ + ...defaults, + ...parsed, + scanState: { ...defaults.scanState, ...parsed.scanState } + }) + } catch (error) { + console.error(`${this.config.logTag} Failed to load persisted state, starting fresh:`, error) + return defaults + } + } + + private async runScan(): Promise { + if (this.scanPromise) { + await this.scanPromise + return + } + + this.state.scanState.lastScanStartedAt = Date.now() + this.state.scanState.lastScanError = null + + // Assign before yielding so concurrent refreshes share one scan. + this.scanPromise = (async () => { + try { + const repos = this.store.getRepos() + const worktreesByRepo = loadKnownUsageWorktreesByRepo(this.store, repos) + const worktreeFingerprint = getUsageWorktreeFingerprint(worktreesByRepo) + const result = await this.config.scan( + createWorktreeRefs(repos, worktreesByRepo), + this.state.worktreeFingerprint === worktreeFingerprint + ? this.state[this.config.sourceKey] + : this.config.createDefaultState()[this.config.sourceKey] + ) + this.state[this.config.sourceKey] = result[this.config.sourceKey] + this.state.sessions = result.sessions + this.state.dailyAggregates = result.dailyAggregates + this.state.worktreeFingerprint = worktreeFingerprint + this.state.scanState.lastScanCompletedAt = Date.now() + this.state.scanState.lastScanError = null + // Persistence failures do not turn a successful source scan into a scan failure. + await this.writeToDisk().catch(() => {}) + } catch (error) { + this.state.scanState.lastScanError = error instanceof Error ? error.message : String(error) + await this.writeToDisk().catch(() => {}) + } finally { + this.scanPromise = null + } + })() + + await this.scanPromise + } + + private async getCurrentWorktreeFingerprint(): Promise { + const repos = this.store.getRepos() + return getUsageWorktreeFingerprint(loadKnownUsageWorktreesByRepo(this.store, repos)) + } +} diff --git a/src/main/usage/usage-worktree-refs.test.ts b/src/main/usage/usage-worktree-refs.test.ts new file mode 100644 index 00000000000..56efe7e4fb8 --- /dev/null +++ b/src/main/usage/usage-worktree-refs.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' +import { getUsageWorktreeFingerprint } from './usage-worktree-refs' + +function worktree(worktreeId: string, path: string, displayName: string): UsageWorktreeRef { + return { worktreeId, path, displayName } +} + +describe('getUsageWorktreeFingerprint', () => { + it('is stable across repo and worktree ordering but changes with identity metadata', () => { + const first = new Map([ + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two'), + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One') + ] + ], + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]] + ]) + const reordered = new Map([ + ['repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'A')]], + [ + 'repo-b', + [ + worktree('repo-b::/repo/b-one', '/repo/b-one', 'B One'), + worktree('repo-b::/repo/b-two', '/repo/b-two', 'B Two') + ] + ] + ]) + const renamed = new Map(reordered) + renamed.set('repo-a', [worktree('repo-a::/repo/a', '/repo/a', 'Renamed A')]) + + expect(getUsageWorktreeFingerprint(first)).toBe(getUsageWorktreeFingerprint(reordered)) + expect(getUsageWorktreeFingerprint(renamed)).not.toBe(getUsageWorktreeFingerprint(first)) + }) +}) diff --git a/src/main/usage/usage-worktree-refs.ts b/src/main/usage/usage-worktree-refs.ts index 0b4347a90a5..f76ed7c0a58 100644 --- a/src/main/usage/usage-worktree-refs.ts +++ b/src/main/usage/usage-worktree-refs.ts @@ -1,6 +1,25 @@ import type { Repo } from '../../shared/types' +import type { UsageWorktreeRef } from '../usage-worktree-metadata' import type { UsageScanWorktreeRef } from './usage-provider-contract' +export function getUsageWorktreeFingerprint( + worktreesByRepo: Map +): string { + const rows = [...worktreesByRepo.entries()] + .flatMap(([repoId, worktrees]) => + worktrees.map((worktree) => + JSON.stringify({ + repoId, + worktreeId: worktree.worktreeId, + path: worktree.path, + displayName: worktree.displayName + }) + ) + ) + .sort() + return JSON.stringify(rows) +} + export function createWorktreeRefs( repos: Repo[], worktreesByRepo: Map diff --git a/src/main/windows-native-registry.ts b/src/main/windows-native-registry.ts new file mode 100644 index 00000000000..e3a2d77889d --- /dev/null +++ b/src/main/windows-native-registry.ts @@ -0,0 +1,25 @@ +import { createRequire } from 'node:module' + +export type WindowsNativeRegistryValue = { + name?: unknown + type?: unknown + value?: unknown +} + +export type WindowsNativeRegistryModule = { + HK: { CU: number; LM: number } + getRegistryKey: ( + root: number, + path: string + ) => Record | null | undefined +} + +export const WINDOWS_REG_SZ = 1 +export const WINDOWS_REG_EXPAND_SZ = 2 + +const requireFromMain = createRequire(__filename) + +export function loadWindowsNativeRegistry(): WindowsNativeRegistryModule { + // Why: non-Windows installs omit this optional dependency, so never resolve it at module load. + return requireFromMain('windows-native-registry') as WindowsNativeRegistryModule +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1cae8cd2359..ea5ba93a99c 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -443,17 +443,7 @@ import type { ComputerUsePermissionSetupResult, ComputerUsePermissionStatusResult } from '../shared/computer-use-permissions-types' -import type { - ClaudeUsageBreakdownKind, - ClaudeUsageBreakdownRow, - ClaudeUsageDailyPoint, - ClaudeUsageRange, - ClaudeUsageScanState, - ClaudeUsageScope, - ClaudeUsageSessionRow, - ClaudeUsageSnapshot, - ClaudeUsageSummary -} from '../shared/claude-usage-types' +import type { ClaudeUsageBreakdownKind, ClaudeUsageSnapshot } from '../shared/claude-usage-types' import type { CodexRateLimitResetResult, GrokAccountStatus, @@ -479,27 +469,10 @@ import type { WorkspacePortScanResult } from '../shared/workspace-ports' import type { GhAuthDiagnostic } from '../shared/github-auth-types' -import type { - CodexUsageBreakdownKind, - CodexUsageBreakdownRow, - CodexUsageDailyPoint, - CodexUsageRange, - CodexUsageScanState, - CodexUsageScope, - CodexUsageSessionRow, - CodexUsageSnapshot, - CodexUsageSummary -} from '../shared/codex-usage-types' +import type { CodexUsageBreakdownKind, CodexUsageSnapshot } from '../shared/codex-usage-types' import type { OpenCodeUsageBreakdownKind, - OpenCodeUsageBreakdownRow, - OpenCodeUsageDailyPoint, - OpenCodeUsageRange, - OpenCodeUsageScanState, - OpenCodeUsageScope, - OpenCodeUsageSessionRow, - OpenCodeUsageSnapshot, - OpenCodeUsageSummary + OpenCodeUsageSnapshot } from '../shared/opencode-usage-types' import type { AiVaultDeleteSessionArgs, @@ -836,92 +809,39 @@ export type MemoryApi = { getSnapshot: () => Promise } -export type ClaudeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getDaily: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - }) => Promise - getBreakdown: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - kind: ClaudeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: ClaudeUsageScope - range: ClaudeUsageRange - limit?: number - }) => Promise +type UsageProviderSnapshot = { + scanState: unknown + summary: { scope: string; range: string } + daily: unknown[] + modelBreakdown: unknown[] + recentSessions: unknown[] } -export type CodexUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getDaily: (args: { - scope: CodexUsageScope - range: CodexUsageRange - }) => Promise - getBreakdown: (args: { - scope: CodexUsageScope - range: CodexUsageRange - kind: CodexUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: CodexUsageScope - range: CodexUsageRange - limit?: number - }) => Promise +type UsageQueryArgs = Pick< + Snapshot['summary'], + 'scope' | 'range' +> + +type UsageProviderApi = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: (args: UsageQueryArgs & { limit?: number }) => Promise + getSummary: (args: UsageQueryArgs) => Promise + getDaily: (args: UsageQueryArgs) => Promise + getBreakdown: ( + args: UsageQueryArgs & { kind: BreakdownKind } + ) => Promise + getRecentSessions: ( + args: UsageQueryArgs & { limit?: number } + ) => Promise } -export type OpenCodeUsageApi = { - getScanState: () => Promise - setEnabled: (args: { enabled: boolean }) => Promise - refresh: (args?: { force?: boolean }) => Promise - getSnapshot: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise - getSummary: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getDaily: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - }) => Promise - getBreakdown: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - kind: OpenCodeUsageBreakdownKind - }) => Promise - getRecentSessions: (args: { - scope: OpenCodeUsageScope - range: OpenCodeUsageRange - limit?: number - }) => Promise -} +export type ClaudeUsageApi = UsageProviderApi + +export type CodexUsageApi = UsageProviderApi + +export type OpenCodeUsageApi = UsageProviderApi export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise @@ -1482,6 +1402,7 @@ export type PreloadApi = { worktreeId: string branchName: string expectedHead: string + hostId?: ExecutionHostId }) => Promise updateMeta: (args: { worktreeId: string; updates: Partial }) => Promise listLineage: () => Promise<{ @@ -1566,6 +1487,9 @@ export type PreloadApi = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1632,6 +1556,7 @@ export type PreloadApi = { opts?: { scrollbackRows?: number } ) => Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null diff --git a/src/preload/index.ts b/src/preload/index.ts index 179ce5ecc2a..e8152ea1184 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -226,6 +226,7 @@ import type { } from './api-types' import type { AgentKind, LaunchSource, RequestKind } from '../shared/telemetry-events' import { createBrowserFindSubscriptions } from './browser-find-subscriptions' +import { createUsageProviderApi } from './usage-provider-api' import type { AppStarSource } from '../shared/gh-star-source' import type { ExecutionHostId } from '../shared/execution-host' import type { @@ -963,6 +964,9 @@ const api = { snapshot?: string snapshotCols?: number snapshotRows?: number + snapshotPrefixAnsi?: string + snapshotFrameAnsi?: string + snapshotFrameRestoreAnsi?: string isReattach?: boolean isAlternateScreen?: boolean replay?: string @@ -1073,6 +1077,7 @@ const api = { opts?: { scrollbackRows?: number } ): Promise<{ data: string + frameRestoreAnsi?: string cols: number rows: number cwd?: string | null @@ -4213,59 +4218,9 @@ const api = { getSnapshot: (): Promise => ipcRenderer.invoke('memory:getSnapshot') }, - claudeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('claudeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('claudeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('claudeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('claudeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('claudeUsage:getRecentSessions', args) - }, - - codexUsage: { - getScanState: (): Promise => ipcRenderer.invoke('codexUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('codexUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('codexUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('codexUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('codexUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('codexUsage:getRecentSessions', args) - }, - - openCodeUsage: { - getScanState: (): Promise => ipcRenderer.invoke('openCodeUsage:getScanState'), - setEnabled: (args: { enabled: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:setEnabled', args), - refresh: (args?: { force?: boolean }): Promise => - ipcRenderer.invoke('openCodeUsage:refresh', args), - getSnapshot: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getSnapshot', args), - getSummary: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getSummary', args), - getDaily: (args: { scope: string; range: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getDaily', args), - getBreakdown: (args: { scope: string; range: string; kind: string }): Promise => - ipcRenderer.invoke('openCodeUsage:getBreakdown', args), - getRecentSessions: (args: { scope: string; range: string; limit?: number }): Promise => - ipcRenderer.invoke('openCodeUsage:getRecentSessions', args) - }, + claudeUsage: createUsageProviderApi(ipcRenderer, 'claudeUsage'), + codexUsage: createUsageProviderApi(ipcRenderer, 'codexUsage'), + openCodeUsage: createUsageProviderApi(ipcRenderer, 'openCodeUsage'), aiVault: { listSessions: (args?: AiVaultListArgs): Promise => diff --git a/src/preload/usage-provider-api.test.ts b/src/preload/usage-provider-api.test.ts new file mode 100644 index 00000000000..0ce594cbb9f --- /dev/null +++ b/src/preload/usage-provider-api.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest' +import { createUsageProviderApi } from './usage-provider-api' + +describe('usage provider preload API', () => { + it('keeps every provider on its existing IPC channels', async () => { + const invoke = vi.fn().mockResolvedValue(undefined) + const query = { scope: 'orca' as const, range: '30d' as const } + + for (const prefix of ['claudeUsage', 'codexUsage', 'openCodeUsage'] as const) { + const api = createUsageProviderApi({ invoke } as never, prefix) + await api.getScanState() + await api.setEnabled({ enabled: true }) + await api.refresh() + await api.getSnapshot({ ...query, limit: 7 }) + await api.getSummary(query) + await api.getDaily(query) + await api.getBreakdown({ ...query, kind: 'project' }) + await api.getRecentSessions({ ...query, limit: 4 }) + + expect(invoke.mock.calls).toEqual([ + [`${prefix}:getScanState`], + [`${prefix}:setEnabled`, { enabled: true }], + [`${prefix}:refresh`, undefined], + [`${prefix}:getSnapshot`, { ...query, limit: 7 }], + [`${prefix}:getSummary`, query], + [`${prefix}:getDaily`, query], + [`${prefix}:getBreakdown`, { ...query, kind: 'project' }], + [`${prefix}:getRecentSessions`, { ...query, limit: 4 }] + ]) + invoke.mockClear() + } + }) +}) diff --git a/src/preload/usage-provider-api.ts b/src/preload/usage-provider-api.ts new file mode 100644 index 00000000000..b80d72850c0 --- /dev/null +++ b/src/preload/usage-provider-api.ts @@ -0,0 +1,29 @@ +import type { IpcRenderer } from 'electron' +import type { PreloadApi } from './api-types' + +type UsageProviderApiKey = 'claudeUsage' | 'codexUsage' | 'openCodeUsage' +type UsageProviderApi = PreloadApi[UsageProviderApiKey] +type UsageRangeArgs = { scope: string; range: string } + +export function createUsageProviderApi( + ipc: Pick, + prefix: Key +): PreloadApi[Key] +export function createUsageProviderApi( + ipc: Pick, + prefix: UsageProviderApiKey +): UsageProviderApi { + return { + getScanState: () => ipc.invoke(`${prefix}:getScanState`), + setEnabled: (args: { enabled: boolean }) => ipc.invoke(`${prefix}:setEnabled`, args), + refresh: (args?: { force?: boolean }) => ipc.invoke(`${prefix}:refresh`, args), + getSnapshot: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getSnapshot`, args), + getSummary: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getSummary`, args), + getDaily: (args: UsageRangeArgs) => ipc.invoke(`${prefix}:getDaily`, args), + getBreakdown: (args: UsageRangeArgs & { kind: string }) => + ipc.invoke(`${prefix}:getBreakdown`, args), + getRecentSessions: (args: UsageRangeArgs & { limit?: number }) => + ipc.invoke(`${prefix}:getRecentSessions`, args) + } +} diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts index efc9a207a73..eb90958d0a2 100644 --- a/src/relay/ai-vault-handler.test.ts +++ b/src/relay/ai-vault-handler.test.ts @@ -8,8 +8,13 @@ import { SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD } from '../shared/ssh-ai-vault-relay' import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' +import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' import type { RelayDispatcher, RequestContext } from './dispatcher' import { AiVaultHandler } from './ai-vault-handler' +import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' +import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' type RequestHandler = (params: Record, context: RequestContext) => Promise @@ -49,7 +54,7 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome, hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions + service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'), scanRemoteSessions) }) await expect( @@ -99,7 +104,8 @@ describe('AiVaultHandler', () => { const dispatcher = createMockDispatcher() new AiVaultHandler(dispatcher.value, { remoteHome, - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64')) }) const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { @@ -123,7 +129,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + ) }) const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) @@ -156,7 +166,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + ) }) await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { @@ -183,7 +197,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions: scanRemoteSessions as never + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions as never + ) }) const firstController = new AbortController() const first = dispatcher.call( @@ -219,7 +237,11 @@ describe('AiVaultHandler', () => { new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', hostPlatform: getRemoteHostPlatform('linux-x64'), - scanRemoteSessions: scanRemoteSessions as never + service: createTestService( + '/home/ada', + getRemoteHostPlatform('linux-x64'), + scanRemoteSessions as never + ) }) const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) await vi.waitFor(() => expect(signals).toHaveLength(1)) @@ -254,11 +276,31 @@ describe('AiVaultHandler', () => { } }) + it('soft-disables the method instead of aborting relay startup when the service is missing', () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const dispatcher = createMockDispatcher() + + expect( + () => + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + ).not.toThrow() + + expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/) + } finally { + stderr.mockRestore() + } + }) + it('stops a relay-local scan when the owning request is cancelled', async () => { const dispatcher = createMockDispatcher() new AiVaultHandler(dispatcher.value, { remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: createTestService('/home/ada', getRemoteHostPlatform('linux-x64')) }) const controller = new AbortController() controller.abort() @@ -267,6 +309,70 @@ describe('AiVaultHandler', () => { dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal) ).rejects.toMatchObject({ name: 'AbortError' }) }) + + it('returns a host issue when the sidecar is unavailable', async () => { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: { + listSessions: () => Promise.reject(new Error('sidecar crashed')), + resolveSessionTitles: () => Promise.resolve({ titles: [] }) + } + }) + + await expect(dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).resolves.toMatchObject({ + sessions: [], + issues: [ + expect.objectContaining({ kind: 'host', message: expect.stringContaining('sidecar') }) + ] + }) + }) + + it('returns no titles instead of an RPC error when the sidecar is unavailable', async () => { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: { + listSessions: () => Promise.resolve(emptyResult()), + resolveSessionTitles: () => Promise.reject(new Error('sidecar crashed')) + } + }) + + await expect( + dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { + requests: [ + { agent: 'codex', sessionId: 'ssh-session', transcriptPath: '/home/ada/s.jsonl' } + ] + }) + ).resolves.toEqual({ titles: [] }) + } finally { + stderr.mockRestore() + } + }) + + it('propagates title cancellation instead of degrading it', async () => { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + service: { + listSessions: () => Promise.resolve(emptyResult()), + resolveSessionTitles: () => { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return Promise.reject(error) + } + } + }) + + await expect( + dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { requests: [] }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) }) async function makeTemporaryHome(): Promise { @@ -279,6 +385,28 @@ function emptyResult(): AiVaultListResult { return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' } } +function createTestService( + remoteHome: string, + hostPlatform: RemoteHostPlatform, + scan: typeof scanRemoteAiVaultSessions = scanRemoteAiVaultSessions +): RelayAiVaultServiceApi { + return { + listSessions: (params, signal) => + scan({ + provider: createRelayAiVaultFilesystemProvider(), + executionHostId: 'local', + remoteHome, + hostPlatform, + limit: params.limit, + unlimited: params.unlimited, + scopePaths: params.scopePaths, + signal + }), + resolveSessionTitles: (requests, signal) => + readAiVaultSessionTitlesFromFiles(requests, { signal }) + } +} + function createMockDispatcher(): { value: RelayDispatcher call: (method: string, params: Record, signal?: AbortSignal) => Promise diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts index a5f26fff7eb..d3e83448ce8 100644 --- a/src/relay/ai-vault-handler.ts +++ b/src/relay/ai-vault-handler.ts @@ -1,4 +1,3 @@ -import { lstat, readdir } from 'node:fs/promises' import { homedir } from 'node:os' import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' @@ -14,34 +13,25 @@ import { SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH, type SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' -import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' -import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' -import { readRelayFileContent } from './fs-handler-file-read' import { relayLogLine } from './relay-diagnostic-log' import type { RelayDispatcher } from './dispatcher' import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator' -import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' - -type ScanRemoteSessions = typeof scanRemoteAiVaultSessions +import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state' type AiVaultHandlerOptions = { remoteHome?: string hostPlatform?: RemoteHostPlatform - scanRemoteSessions?: ScanRemoteSessions + service?: RelayAiVaultServiceApi } export class AiVaultHandler { private readonly remoteHome: string - private readonly scanRemoteSessions: ScanRemoteSessions - private readonly provider: RemoteSessionFilesystemProvider private readonly scanCoordinator = new AiVaultScanCoordinator() constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) { this.remoteHome = options.remoteHome ?? homedir() - this.scanRemoteSessions = options.scanRemoteSessions ?? scanRemoteAiVaultSessions - this.provider = createRelayAiVaultFilesystemProvider() const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform() // Why: an OS/arch this build has no path flavor for must not abort relay // startup — leaving the method unregistered soft-disables the feature and @@ -52,48 +42,78 @@ export class AiVaultHandler { ) return } + // Why: same reasoning as an unsupported platform. Throwing here would take + // relay startup — and every PTY on the host — down over a Vault wiring bug. + const service = options.service + if (!service) { + relayLogLine('[relay] Agent Session History disabled: service unavailable') + return + } dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) => - this.listSessions(hostPlatform, params, context.signal) + this.listSessions(service, params, context.signal) ) dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) => - this.resolveSessionTitles(params, context.signal) + this.resolveSessionTitles(service, params, context.signal) ) } - private resolveSessionTitles( + private async resolveSessionTitles( + service: RelayAiVaultServiceApi, rawParams: Record, signal?: AbortSignal ): Promise { - return readAiVaultSessionTitlesFromFiles(normalizeTitleRequests(rawParams.requests), { signal }) + try { + return await service.resolveSessionTitles(normalizeTitleRequests(rawParams.requests), signal) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + // Why: titles are decoration. Degrade like an unresolvable title instead of + // failing the RPC, which would surface a raw error on every list row. + relayLogLine( + `[relay-ai-vault-service] title resolution unavailable: ${error instanceof Error ? error.message : String(error)}` + ) + return { titles: [] } + } } private async listSessions( - hostPlatform: RemoteHostPlatform, + service: RelayAiVaultServiceApi, rawParams: Record, signal?: AbortSignal ): Promise { const params = normalizeSshAiVaultRelayListParams(rawParams) - const result = await this.scanCoordinator.run({ - key: JSON.stringify({ - limit: params.limit, - unlimited: params.unlimited, - scopePaths: params.scopePaths, - scopePathsTruncated: params.scopePathsTruncated - }), - force: params.force, - signal, - start: (scanSignal) => - this.scanRemoteSessions({ - provider: this.provider, - executionHostId: LOCAL_EXECUTION_HOST_ID, - remoteHome: this.remoteHome, - hostPlatform, + let result: AiVaultListResult + try { + result = await this.scanCoordinator.run({ + key: JSON.stringify({ limit: params.limit, unlimited: params.unlimited, scopePaths: params.scopePaths, - signal: scanSignal - }) - }) + scopePathsTruncated: params.scopePathsTruncated + }), + force: params.force, + signal, + start: (scanSignal) => service.listSessions(params, scanSignal) + }) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } + result = { + sessions: [], + scannedAt: new Date().toISOString(), + issues: [ + { + executionHostId: LOCAL_EXECUTION_HOST_ID, + agent: 'codex', + kind: 'host', + path: this.remoteHome, + message: `Agent Session History service unavailable: ${error instanceof Error ? error.message : String(error)}` + } + ] + } + } if (!params.scopePathsTruncated) { return result } @@ -176,29 +196,3 @@ function currentRelayHostPlatform(): RemoteHostPlatform | null { const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null } - -function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider { - return { - async readDir(dirPath) { - const entries = await readdir(dirPath, { withFileTypes: true }) - return entries.map((entry) => ({ - name: entry.name, - isDirectory: entry.isDirectory(), - isSymlink: entry.isSymbolicLink() - })) - }, - readFile: readRelayFileContent, - async stat(filePath) { - const stats = await lstat(filePath) - return { - size: stats.size, - type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', - mtime: stats.mtimeMs, - mtimeMs: stats.mtimeMs, - dev: stats.dev, - ino: stats.ino, - nlink: stats.nlink - } - } - } -} diff --git a/src/relay/ai-vault-service-client-state.ts b/src/relay/ai-vault-service-client-state.ts new file mode 100644 index 00000000000..de623d19b07 --- /dev/null +++ b/src/relay/ai-vault-service-client-state.ts @@ -0,0 +1,176 @@ +import type { ChildProcess } from 'node:child_process' +import type { AiVaultListResult } from '../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../shared/ai-vault-session-title' +import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { + relayAiVaultServiceLane, + type RelayAiVaultServiceLane, + type RelayAiVaultServiceRequest +} from './ai-vault-service-protocol' + +export const RELAY_AI_VAULT_READY_TIMEOUT_MS = 5_000 +export const RELAY_AI_VAULT_SCAN_TIMEOUT_MS = 130_000 +export const RELAY_AI_VAULT_TITLE_TIMEOUT_MS = 15_000 +export const RELAY_AI_VAULT_MAX_CALLS = 16 +export const RELAY_AI_VAULT_IDLE_TIMEOUT_MS = 10 * 60_000 + +export function relayAiVaultAbortError(): Error { + const error = new Error('The operation was aborted.') + error.name = 'AbortError' + return error +} + +export function relayAiVaultError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +export function armRelayAiVaultCancellationTimeout( + call: RelayAiVaultServiceCall, + onExpired: () => void +): void { + call.timer = setTimeout(onExpired, 2_000) + call.timer.unref?.() +} + +export function createRelayAiVaultServiceCall(args: { + request: RelayAiVaultServiceRequest + signal?: AbortSignal + resolve: RelayAiVaultServiceCall['resolve'] + reject: RelayAiVaultServiceCall['reject'] +}): RelayAiVaultServiceCall { + return { + request: args.request, + lane: relayAiVaultServiceLane(args.request.operation), + signal: args.signal, + forceStart: args.request.operation === 'list' && args.request.params.force === true, + resolve: args.resolve, + reject: args.reject, + timer: null, + onAbort: null, + settled: false, + sent: false, + startRetried: false + } +} + +/** + * A cold start that faults before the request reached the sidecar self-heals on + * the scheduled respawn. Requeue once; the caller settles when this returns false. + */ +export function requeueRelayAiVaultServiceStart( + call: RelayAiVaultServiceCall, + queue: RelayAiVaultServiceCall[] +): boolean { + if (call.sent || call.settled || call.startRetried) { + return false + } + call.startRetried = true + queue.unshift(call) + return true +} + +export function settleRelayAiVaultServiceCall( + call: RelayAiVaultServiceCall, + value: Error | AiVaultListResult | AiVaultSessionTitlesResult +): void { + // A cancelled call is settled before its cancel watchdog is armed, so the + // timer has to be cleared even when the reject/resolve is already done. + if (call.timer) { + clearTimeout(call.timer) + call.timer = null + } + if (call.settled) { + return + } + call.settled = true + if (call.signal && call.onAbort) { + call.signal.removeEventListener('abort', call.onAbort) + } + if (value instanceof Error) { + call.reject(value) + } else { + call.resolve(value) + } +} + +export type RelayAiVaultServiceCall = { + request: RelayAiVaultServiceRequest + lane: RelayAiVaultServiceLane + signal?: AbortSignal + forceStart: boolean + resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void + reject: (error: Error) => void + timer: NodeJS.Timeout | null + onAbort: (() => void) | null + settled: boolean + /** Whether the sidecar received the request; an unsent call gets no reply. */ + sent: boolean + startRetried: boolean +} + +export type RelayAiVaultServiceApi = { + listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise + resolveSessionTitles( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal + ): Promise +} + +export type RelayAiVaultServiceClientOptions = { + processFactory: () => ChildProcess + init: { + remoteHome: string + hostPlatform: RemoteHostPlatform + } + now?: () => number + idleTimeoutMs?: number +} + +export class RelayAiVaultIdleRetirement { + private timer: NodeJS.Timeout | null = null + + clear(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } + + schedule(busy: boolean, timeoutMs: number, retire: () => void): void { + if (busy || this.timer) { + return + } + this.timer = setTimeout(() => { + this.timer = null + retire() + }, timeoutMs) + this.timer.unref?.() + } +} + +export function retireRelayAiVaultServiceChild(child: ChildProcess): void { + const timer = setTimeout(() => child.kill(), 2_000) + timer.unref?.() + child.once('exit', () => clearTimeout(timer)) + child.send({ type: 'shutdown' }, () => undefined) +} + +/** Orderly shutdown that never holds relay teardown past the kill deadline. */ +export function shutdownRelayAiVaultServiceChild(child: ChildProcess): Promise { + child.send({ type: 'shutdown' }, () => undefined) + return new Promise((resolve) => { + const timer = setTimeout(() => { + child.kill() + resolve() + }, 2_000) + timer.unref?.() + child.once('exit', () => { + clearTimeout(timer) + resolve() + }) + }) +} diff --git a/src/relay/ai-vault-service-client.test.ts b/src/relay/ai-vault-service-client.test.ts new file mode 100644 index 00000000000..10eaeb1a660 --- /dev/null +++ b/src/relay/ai-vault-service-client.test.ts @@ -0,0 +1,299 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { + AiVaultServiceTestChild, + readyAiVaultServiceChild +} from '../main/ai-vault/session-scanner-service-test-child' +import { RelayAiVaultServiceClient } from './ai-vault-service-client' +import { RELAY_AI_VAULT_READY_TIMEOUT_MS } from './ai-vault-service-client-state' +import { relayAiVaultServiceEntryPath } from './ai-vault-service-spawn' + +function createClient( + children: AiVaultServiceTestChild[], + idleTimeoutMs?: number +): RelayAiVaultServiceClient { + return new RelayAiVaultServiceClient({ + init: { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }, + processFactory: () => { + const child = new AiVaultServiceTestChild(20_000 + children.length) + children.push(child) + return child.asChildProcess() + }, + idleTimeoutMs + }) +} + +function relayRequests(child: AiVaultServiceTestChild, operation: string): { id: number }[] { + return child.sent.filter( + (message) => (message as { operation?: string }).operation === operation + ) as { id: number }[] +} + +function relayRequestCount(child: AiVaultServiceTestChild, operation: string): number { + return relayRequests(child, operation).length +} + +function relayRequestId(child: AiVaultServiceTestChild, operation: string): number { + const request = relayRequests(child, operation).at(-1) + if (!request) { + throw new Error(`No ${operation} request was sent.`) + } + return request.id +} + +afterEach(() => vi.useRealTimers()) + +describe('RelayAiVaultServiceClient', () => { + it('holds list and title calls behind the ready handshake', async () => { + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const list = client.listSessions({ limit: 20 }) + const titles = client.resolveSessionTitles([]) + const child = children[0]! + + expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })]) + readyAiVaultServiceChild(child) + await Promise.resolve() + const listRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'list' + ) as { id: number } + child.emit('message', { + type: 'result', + id: listRequest.id, + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + const titleRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'titles' + ) as { id: number } + child.emit('message', { + type: 'result', + id: titleRequest.id, + operation: 'titles', + value: { titles: [] } + }) + await expect(titles).resolves.toEqual({ titles: [] }) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('resolves titles while a scan still occupies the cache lane', async () => { + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const list = client.listSessions({}) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + + const titles = client.resolveSessionTitles([ + { agent: 'claude', sessionId: 'session-1', transcriptPath: '/home/ada/session-1.jsonl' } + ]) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: relayRequestId(child, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + + await expect(titles).resolves.toEqual({ titles: [] }) + child.emit('message', { + type: 'result', + id: relayRequestId(child, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('does not start queued cache work until cancelled work acknowledges', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const controller = new AbortController() + const first = client.listSessions({}, controller.signal) + const second = client.listSessions({ limit: 5 }) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + const firstRequest = relayRequestId(child, 'list') + + controller.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(relayRequestCount(child, 'list')).toBe(1) + child.emit('message', { + type: 'error', + id: firstRequest, + message: 'aborted' + }) + await Promise.resolve() + expect(relayRequestCount(child, 'list')).toBe(2) + void second.catch(() => undefined) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('drops a call cancelled before the sidecar received it', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const controller = new AbortController() + const cancelled = client.listSessions({}, controller.signal) + const child = children[0]! + + controller.abort() + await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' }) + expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' })) + + readyAiVaultServiceChild(child) + const next = client.listSessions({ limit: 5 }) + await Promise.resolve() + child.emit('message', { + type: 'result', + id: relayRequestId(child, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(next).resolves.toMatchObject({ sessions: [] }) + vi.advanceTimersByTime(2_000) + + expect(child.killed).toBe(false) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('retries a cold-start crash once without faulting the replacement sidecar', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const list = client.listSessions({}) + + children[0]!.emit('exit', 1) + await Promise.resolve() + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + vi.advanceTimersByTime(RELAY_AI_VAULT_READY_TIMEOUT_MS) + + expect(children[1]!.killed).toBe(false) + children[1]!.emit('message', { + type: 'result', + id: relayRequestId(children[1]!, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(list).resolves.toMatchObject({ sessions: [] }) + const disposing = client.dispose() + children[1]!.emit('exit', 0) + await disposing + }) + + it('keeps the sidecar alive once a cancelled call acknowledges', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const controller = new AbortController() + const first = client.listSessions({}, controller.signal) + const second = client.resolveSessionTitles([]) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + const firstRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'list' + ) as { id: number } + + controller.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + child.emit('message', { type: 'error', id: firstRequest.id, message: 'aborted' }) + await Promise.resolve() + vi.advanceTimersByTime(2_000) + + expect(child.killed).toBe(false) + const titleRequest = child.sent.find( + (message) => (message as { operation?: string }).operation === 'titles' + ) as { id: number } + child.emit('message', { + type: 'result', + id: titleRequest.id, + operation: 'titles', + value: { titles: [] } + }) + await expect(second).resolves.toEqual({ titles: [] }) + const disposing = client.dispose() + child.emit('exit', 0) + await disposing + }) + + it('restarts queued work after a sidecar crash with bounded backoff', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children) + const first = client.listSessions({}) + const second = client.listSessions({ limit: 5 }) + readyAiVaultServiceChild(children[0]!) + await Promise.resolve() + + children[0]!.emit('exit', 1) + await expect(first).rejects.toThrow('exited') + expect(children).toHaveLength(1) + vi.advanceTimersByTime(250) + expect(children).toHaveLength(2) + readyAiVaultServiceChild(children[1]!) + await Promise.resolve() + children[1]!.emit('message', { + type: 'result', + id: relayRequestId(children[1]!, 'list'), + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await expect(second).resolves.toMatchObject({ sessions: [] }) + const disposing = client.dispose() + children[1]!.emit('exit', 0) + await disposing + }) + + it('retires the sidecar after the idle bound', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = createClient(children, 100) + const list = client.listSessions({}) + const child = children[0]! + readyAiVaultServiceChild(child) + await Promise.resolve() + const request = child.sent.find( + (message) => (message as { operation?: string }).operation === 'list' + ) as { id: number } + child.emit('message', { + type: 'result', + id: request.id, + operation: 'list', + value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' } + }) + await list + + vi.advanceTimersByTime(99) + expect(child.sent).not.toContainEqual({ type: 'shutdown' }) + vi.advanceTimersByTime(1) + expect(child.sent).toContainEqual({ type: 'shutdown' }) + vi.advanceTimersByTime(2_000) + expect(child.killed).toBe(true) + await client.dispose() + }) + + it('resolves the sidecar beside each bundled relay', () => { + expect(relayAiVaultServiceEntryPath('/opt/orca/relay')).toBe( + '/opt/orca/relay/relay-ai-vault-service.js' + ) + }) +}) diff --git a/src/relay/ai-vault-service-client.ts b/src/relay/ai-vault-service-client.ts new file mode 100644 index 00000000000..87c0d6e4c84 --- /dev/null +++ b/src/relay/ai-vault-service-client.ts @@ -0,0 +1,314 @@ +import type { ChildProcess } from 'node:child_process' +import type { AiVaultListResult } from '../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../shared/ai-vault-session-title' +import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' +import { + RELAY_AI_VAULT_MAX_CALLS, + RELAY_AI_VAULT_IDLE_TIMEOUT_MS, + RELAY_AI_VAULT_READY_TIMEOUT_MS, + RELAY_AI_VAULT_SCAN_TIMEOUT_MS, + RELAY_AI_VAULT_TITLE_TIMEOUT_MS, + armRelayAiVaultCancellationTimeout, + createRelayAiVaultServiceCall, + relayAiVaultAbortError, + relayAiVaultError, + RelayAiVaultIdleRetirement, + requeueRelayAiVaultServiceStart, + retireRelayAiVaultServiceChild, + settleRelayAiVaultServiceCall, + shutdownRelayAiVaultServiceChild, + type RelayAiVaultServiceApi, + type RelayAiVaultServiceCall, + type RelayAiVaultServiceClientOptions +} from './ai-vault-service-client-state' +import { + RELAY_AI_VAULT_SERVICE_PROTOCOL, + isRelayAiVaultServiceChildMessage, + type RelayAiVaultServiceChildMessage, + type RelayAiVaultServiceInit, + type RelayAiVaultServiceLane, + type RelayAiVaultServiceRequest +} from './ai-vault-service-protocol' +import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy' +import { relayLogLine } from './relay-diagnostic-log' + +export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi { + private child: ChildProcess | null = null + private ready: Promise | null = null + private readyReject: ((error: Error) => void) | null = null + private readyTimer: NodeJS.Timeout | null = null + private readonly active = new Map() + private readonly queue: RelayAiVaultServiceCall[] = [] + private nextId = 1 + private readonly restartPolicy: RelayAiVaultRestartPolicy + private readonly idleRetirement = new RelayAiVaultIdleRetirement() + private disposed = false + + constructor(private readonly options: RelayAiVaultServiceClientOptions) { + this.restartPolicy = new RelayAiVaultRestartPolicy(options.now) + } + + listSessions( + params: SshAiVaultRelayListParams, + signal?: AbortSignal + ): Promise { + return this.request({ type: 'request', id: this.nextId++, operation: 'list', params }, signal) + } + + resolveSessionTitles( + requests: AiVaultSessionTitleRequest[], + signal?: AbortSignal + ): Promise { + return this.request( + { type: 'request', id: this.nextId++, operation: 'titles', requests }, + signal + ) + } + + async dispose(): Promise { + this.disposed = true + this.idleRetirement.clear() + this.clearReadyTimer() + this.restartPolicy.dispose() + const error = new Error('Relay AI Vault service was disposed.') + for (const call of [...this.active.values(), ...this.queue.splice(0)]) { + settleRelayAiVaultServiceCall(call, error) + } + this.active.clear() + const child = this.detachChild() + if (child) { + await shutdownRelayAiVaultServiceChild(child) + } + } + + private request( + request: RelayAiVaultServiceRequest, + signal?: AbortSignal + ): Promise { + if (this.disposed) { + return Promise.reject(new Error('Relay AI Vault service was disposed.')) + } + if (signal?.aborted) { + return Promise.reject(relayAiVaultAbortError()) + } + if (this.queue.length + this.active.size >= RELAY_AI_VAULT_MAX_CALLS) { + return Promise.reject(new Error('Relay AI Vault service queue is full.')) + } + return new Promise((resolve, reject) => { + const call = createRelayAiVaultServiceCall({ + request, + signal, + resolve: resolve as RelayAiVaultServiceCall['resolve'], + reject + }) + if (signal) { + call.onAbort = () => this.cancel(call) + signal.addEventListener('abort', call.onAbort, { once: true }) + } + this.queue.push(call) + this.idleRetirement.clear() + this.pump() + }) + } + + private pump(): void { + if (this.disposed || this.restartPolicy.restartScheduled) { + return + } + for (const lane of ['cache', 'interactive'] as const) { + if (this.active.has(lane)) { + continue + } + const index = this.queue.findIndex((call) => call.lane === lane) + if (index < 0) { + continue + } + const call = this.queue.splice(index, 1)[0]! + this.active.set(lane, call) + void this.ensureChild(call.forceStart).then( + (child) => this.sendCall(child, call), + (error: Error) => { + if (this.active.get(lane) !== call) { + return + } + this.active.delete(lane) + this.retryStartOrSettle(call, error) + this.pump() + } + ) + } + this.scheduleIdleIfNeeded() + } + + private sendCall(child: ChildProcess, call: RelayAiVaultServiceCall): void { + if (this.active.get(call.lane) !== call || call.settled) { + return + } + const timeout = + call.request.operation === 'list' + ? RELAY_AI_VAULT_SCAN_TIMEOUT_MS + : RELAY_AI_VAULT_TITLE_TIMEOUT_MS + call.timer = setTimeout( + () => this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`)), + timeout + ) + call.timer.unref?.() + call.sent = true + child.send(call.request) + } + + private retryStartOrSettle(call: RelayAiVaultServiceCall, error: Error): void { + if (this.disposed || !requeueRelayAiVaultServiceStart(call, this.queue)) { + settleRelayAiVaultServiceCall(call, error) + } + } + + private ensureChild(forceStart: boolean): Promise { + if (this.child && !this.ready) { + return Promise.resolve(this.child) + } + if (this.ready) { + return this.ready + } + const startError = this.restartPolicy.startError(forceStart) + if (startError) { + return Promise.reject(startError) + } + let child: ChildProcess + try { + child = this.options.processFactory() + } catch (error) { + return Promise.reject(relayAiVaultError(error)) + } + this.child = child + this.ready = new Promise((resolve, reject) => { + this.readyReject = reject + // Why: held on the instance so a crash before ready cannot leave the deadline + // armed, where it would later fault the healthy replacement sidecar. + this.readyTimer = setTimeout( + () => this.onFault(new Error('Relay AI Vault service did not become ready.')), + RELAY_AI_VAULT_READY_TIMEOUT_MS + ) + this.readyTimer.unref?.() + child.on('message', (message) => { + if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') { + this.clearReadyTimer() + this.ready = null + this.readyReject = null + resolve(child) + return + } + this.onMessage(message) + }) + }) + child.on('error', (error) => this.onFault(error)) + child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.'))) + child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`))) + child.stderr?.on('data', (chunk: Buffer) => + relayLogLine(`[relay-ai-vault-service] ${String(chunk).trimEnd()}`) + ) + child.send({ + type: 'init', + protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, + ...this.options.init + } satisfies RelayAiVaultServiceInit) + return this.ready + } + + private onMessage(raw: unknown): void { + if (!isRelayAiVaultServiceChildMessage(raw)) { + this.onFault(new Error('Relay AI Vault service sent a malformed message.')) + return + } + const message = raw as RelayAiVaultServiceChildMessage + if (message.type === 'ready') { + return + } + const call = [...this.active.values()].find((entry) => entry.request.id === message.id) + if (!call) { + return + } + this.active.delete(call.lane) + settleRelayAiVaultServiceCall( + call, + message.type === 'error' ? new Error(message.message) : message.value + ) + this.pump() + } + + private cancel(call: RelayAiVaultServiceCall): void { + const index = this.queue.indexOf(call) + if (index >= 0) { + this.queue.splice(index, 1) + settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) + this.pump() + return + } + if (this.active.get(call.lane) === call) { + // Why: a call cancelled before it reached the sidecar gets no acknowledgement, + // so waiting on one would kill a healthy sidecar and stall the lane. + if (!call.sent) { + this.active.delete(call.lane) + settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) + this.pump() + return + } + this.child?.send({ type: 'cancel', id: call.request.id }) + settleRelayAiVaultServiceCall(call, relayAiVaultAbortError()) + armRelayAiVaultCancellationTimeout(call, () => + this.onFault(new Error('Relay AI Vault service did not cancel within 2000ms.')) + ) + } + } + + private onFault(error: Error): void { + if (!this.child) { + return + } + this.idleRetirement.clear() + this.detachChild()?.kill() + this.clearReadyTimer() + this.readyReject?.(error) + this.readyReject = null + this.ready = null + const active = [...this.active.values()] + this.active.clear() + for (const call of active) { + this.retryStartOrSettle(call, error) + } + this.restartPolicy.recordFault() + if (this.queue.length > 0 && !this.disposed) { + this.restartPolicy.scheduleRestart(() => this.pump()) + } + } + + private clearReadyTimer(): void { + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + } + + private detachChild(): ChildProcess | null { + const child = this.child + this.child = null + child?.removeAllListeners() + return child + } + + private scheduleIdleIfNeeded(): void { + this.idleRetirement.schedule( + this.active.size > 0 || this.queue.length > 0 || !this.child, + this.options.idleTimeoutMs ?? RELAY_AI_VAULT_IDLE_TIMEOUT_MS, + () => { + const child = this.detachChild() + if (child) { + retireRelayAiVaultServiceChild(child) + } + } + ) + } +} diff --git a/src/relay/ai-vault-service-entry.ts b/src/relay/ai-vault-service-entry.ts new file mode 100644 index 00000000000..9bd3ea35786 --- /dev/null +++ b/src/relay/ai-vault-service-entry.ts @@ -0,0 +1,122 @@ +import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' +import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader' +import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem' +import { + RELAY_AI_VAULT_SERVICE_PROTOCOL, + isRelayAiVaultServiceRequest, + relayAiVaultServiceLane, + type RelayAiVaultServiceChildMessage, + type RelayAiVaultServiceInit, + type RelayAiVaultServiceParentMessage, + type RelayAiVaultServiceRequest +} from './ai-vault-service-protocol' + +if (!process.send) { + throw new Error('Relay AI Vault service requires a parent IPC channel.') +} + +const controllers = new Map() +const cancelled = new Set() +const pending = new Set() +const provider = createRelayAiVaultFilesystemProvider() +let init: RelayAiVaultServiceInit | null = null +let cacheLane = Promise.resolve() +let interactiveLane = Promise.resolve() +let shuttingDown = false + +function send(message: RelayAiVaultServiceChildMessage): void { + process.send?.(message) +} + +async function execute(request: RelayAiVaultServiceRequest): Promise { + const controller = new AbortController() + controllers.set(request.id, controller) + if (cancelled.delete(request.id)) { + controller.abort() + } + try { + if (!init) { + throw new Error('Relay AI Vault service is not initialized.') + } + if (request.operation === 'titles') { + const value = await readAiVaultSessionTitlesFromFiles(request.requests, { + signal: controller.signal + }) + send({ type: 'result', id: request.id, operation: 'titles', value }) + return + } + const value = await scanRemoteAiVaultSessions({ + provider, + executionHostId: LOCAL_EXECUTION_HOST_ID, + remoteHome: init.remoteHome, + hostPlatform: init.hostPlatform, + limit: request.params.limit, + unlimited: request.params.unlimited, + scopePaths: request.params.scopePaths, + signal: controller.signal + }) + send({ type: 'result', id: request.id, operation: 'list', value }) + } catch (error) { + send({ + type: 'error', + id: request.id, + message: error instanceof Error ? error.message : String(error) + }) + } finally { + controllers.delete(request.id) + cancelled.delete(request.id) + pending.delete(request.id) + } +} + +async function shutdown(): Promise { + if (shuttingDown) { + return + } + shuttingDown = true + for (const controller of controllers.values()) { + controller.abort() + } + await Promise.allSettled([cacheLane, interactiveLane]) + process.disconnect?.() +} + +process.on('message', (raw: RelayAiVaultServiceParentMessage) => { + if (raw?.type === 'init') { + if (init || raw.protocol !== RELAY_AI_VAULT_SERVICE_PROTOCOL) { + void shutdown() + return + } + init = raw + send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid }) + return + } + if (!init || shuttingDown) { + return + } + if (raw?.type === 'cancel') { + cancelled.add(raw.id) + controllers.get(raw.id)?.abort() + return + } + if (raw?.type === 'shutdown') { + void shutdown() + return + } + if (!isRelayAiVaultServiceRequest(raw)) { + return + } + if (pending.size >= 16) { + send({ type: 'error', id: raw.id, message: 'Relay AI Vault service queue is full.' }) + return + } + pending.add(raw.id) + if (relayAiVaultServiceLane(raw.operation) === 'interactive') { + interactiveLane = interactiveLane.then(() => execute(raw)) + return + } + cacheLane = cacheLane.then(() => execute(raw)) +}) + +process.on('disconnect', () => void shutdown()) diff --git a/src/relay/ai-vault-service-factory.ts b/src/relay/ai-vault-service-factory.ts new file mode 100644 index 00000000000..c6a20640662 --- /dev/null +++ b/src/relay/ai-vault-service-factory.ts @@ -0,0 +1,13 @@ +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { RelayAiVaultServiceClient } from './ai-vault-service-client' +import { spawnRelayAiVaultService } from './ai-vault-service-spawn' + +export function createRelayAiVaultService( + remoteHome: string, + hostPlatform: RemoteHostPlatform +): RelayAiVaultServiceClient { + return new RelayAiVaultServiceClient({ + init: { remoteHome, hostPlatform }, + processFactory: spawnRelayAiVaultService + }) +} diff --git a/src/relay/ai-vault-service-filesystem.ts b/src/relay/ai-vault-service-filesystem.ts new file mode 100644 index 00000000000..9315b2e4b2f --- /dev/null +++ b/src/relay/ai-vault-service-filesystem.ts @@ -0,0 +1,29 @@ +import { lstat, readdir } from 'node:fs/promises' +import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' +import { readRelayFileContent } from './fs-handler-file-read' + +export function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider { + return { + async readDir(dirPath) { + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + }, + readFile: readRelayFileContent, + async stat(filePath) { + const stats = await lstat(filePath) + return { + size: stats.size, + type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', + mtime: stats.mtimeMs, + mtimeMs: stats.mtimeMs, + dev: stats.dev, + ino: stats.ino, + nlink: stats.nlink + } + } + } +} diff --git a/src/relay/ai-vault-service-protocol.ts b/src/relay/ai-vault-service-protocol.ts new file mode 100644 index 00000000000..dbbef94f2f9 --- /dev/null +++ b/src/relay/ai-vault-service-protocol.ts @@ -0,0 +1,86 @@ +import type { AiVaultListResult } from '../shared/ai-vault-types' +import type { + AiVaultSessionTitleRequest, + AiVaultSessionTitlesResult +} from '../shared/ai-vault-session-title' +import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay' +import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' + +export const RELAY_AI_VAULT_SERVICE_PROTOCOL = 1 + +export type RelayAiVaultServiceInit = { + type: 'init' + protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL + remoteHome: string + hostPlatform: RemoteHostPlatform +} + +export type RelayAiVaultServiceRequest = + | { + type: 'request' + id: number + operation: 'list' + params: SshAiVaultRelayListParams + } + | { + type: 'request' + id: number + operation: 'titles' + requests: AiVaultSessionTitleRequest[] + } + +export type RelayAiVaultServiceLane = 'cache' | 'interactive' +export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation'] + +/** Title reads must not queue behind a full scan; they back interactive UI. */ +export function relayAiVaultServiceLane( + operation: RelayAiVaultServiceOperation +): RelayAiVaultServiceLane { + return operation === 'titles' ? 'interactive' : 'cache' +} + +export type RelayAiVaultServiceParentMessage = + | RelayAiVaultServiceInit + | RelayAiVaultServiceRequest + | { type: 'cancel'; id: number } + | { type: 'shutdown' } + +export type RelayAiVaultServiceChildMessage = + | { + type: 'ready' + protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL + pid: number + } + | { type: 'result'; id: number; operation: 'list'; value: AiVaultListResult } + | { + type: 'result' + id: number + operation: 'titles' + value: AiVaultSessionTitlesResult + } + | { type: 'error'; id: number; message: string } + +export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVaultServiceRequest { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + return ( + message.type === 'request' && + Number.isSafeInteger(message.id) && + (message.operation === 'list' || message.operation === 'titles') + ) +} + +export function isRelayAiVaultServiceChildMessage( + value: unknown +): value is RelayAiVaultServiceChildMessage { + if (!value || typeof value !== 'object') { + return false + } + const message = value as Record + if (message.type === 'ready') { + return message.protocol === RELAY_AI_VAULT_SERVICE_PROTOCOL && Number.isSafeInteger(message.pid) + } + return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id) +} diff --git a/src/relay/ai-vault-service-restart-policy.test.ts b/src/relay/ai-vault-service-restart-policy.test.ts new file mode 100644 index 00000000000..8b9608be216 --- /dev/null +++ b/src/relay/ai-vault-service-restart-policy.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy' + +afterEach(() => vi.useRealTimers()) + +describe('RelayAiVaultRestartPolicy', () => { + it('opens the circuit after three faults inside the window and closes it after', () => { + let now = 0 + const policy = new RelayAiVaultRestartPolicy(() => now) + + expect(policy.startError(false)).toBeNull() + for (let index = 0; index < 3; index += 1) { + policy.recordFault() + now += 1_000 + } + + expect(policy.startError(false)?.message).toContain('circuit is open') + now += 60_000 + expect(policy.startError(false)).toBeNull() + }) + + it('keeps the circuit closed when the faults age out of the window', () => { + let now = 0 + const policy = new RelayAiVaultRestartPolicy(() => now) + + for (let index = 0; index < 3; index += 1) { + policy.recordFault() + now += 30_000 + } + + expect(policy.startError(false)).toBeNull() + }) + + it('lets a forced refresh reopen an open circuit', () => { + let now = 0 + const policy = new RelayAiVaultRestartPolicy(() => now) + + for (let index = 0; index < 3; index += 1) { + policy.recordFault() + } + expect(policy.startError(false)?.message).toContain('circuit is open') + + expect(policy.startError(true)).toBeNull() + // The forced start clears the circuit outright, so the next background + // start is no longer refused either. + expect(policy.startError(false)).toBeNull() + }) + + it('backs off further with each fault and keeps one pending restart', () => { + vi.useFakeTimers() + const policy = new RelayAiVaultRestartPolicy() + const restart = vi.fn() + + policy.recordFault() + policy.scheduleRestart(restart) + vi.advanceTimersByTime(250) + expect(restart).toHaveBeenCalledTimes(1) + + policy.recordFault() + policy.scheduleRestart(restart) + policy.recordFault() + policy.scheduleRestart(restart) + vi.advanceTimersByTime(250) + expect(restart).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(5_000) + expect(restart).toHaveBeenCalledTimes(2) + }) + + it('cancels the pending restart on dispose', () => { + vi.useFakeTimers() + const policy = new RelayAiVaultRestartPolicy() + const restart = vi.fn() + + policy.recordFault() + policy.scheduleRestart(restart) + policy.dispose() + vi.advanceTimersByTime(10_000) + + expect(restart).not.toHaveBeenCalled() + expect(policy.restartScheduled).toBe(false) + }) +}) diff --git a/src/relay/ai-vault-service-restart-policy.ts b/src/relay/ai-vault-service-restart-policy.ts new file mode 100644 index 00000000000..e764ea66f6e --- /dev/null +++ b/src/relay/ai-vault-service-restart-policy.ts @@ -0,0 +1,55 @@ +const RELAY_AI_VAULT_FAULT_WINDOW_MS = 60_000 +const RELAY_AI_VAULT_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const + +export class RelayAiVaultRestartPolicy { + private faults: number[] = [] + private circuitUntil = 0 + private timer: NodeJS.Timeout | null = null + + constructor(private readonly now: () => number = Date.now) {} + + get restartScheduled(): boolean { + return this.timer !== null + } + + /** A forced refresh is a deliberate user action, so it reopens the circuit. */ + startError(forceStart: boolean): Error | null { + if (forceStart) { + this.circuitUntil = 0 + return null + } + return this.now() < this.circuitUntil + ? new Error('Relay AI Vault service restart circuit is open.') + : null + } + + recordFault(): void { + const now = this.now() + this.faults = [ + ...this.faults.filter((time) => now - time < RELAY_AI_VAULT_FAULT_WINDOW_MS), + now + ] + if (this.faults.length >= 3) { + this.circuitUntil = now + RELAY_AI_VAULT_FAULT_WINDOW_MS + } + } + + scheduleRestart(restart: () => void): void { + if (this.timer) { + clearTimeout(this.timer) + } + const delay = RELAY_AI_VAULT_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)] + this.timer = setTimeout(() => { + this.timer = null + restart() + }, delay) + this.timer.unref?.() + } + + dispose(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} diff --git a/src/relay/ai-vault-service-spawn.test.ts b/src/relay/ai-vault-service-spawn.test.ts new file mode 100644 index 00000000000..596c25afc2e --- /dev/null +++ b/src/relay/ai-vault-service-spawn.test.ts @@ -0,0 +1,42 @@ +import type { ChildProcess } from 'node:child_process' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const forkMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ fork: forkMock })) + +const { spawnRelayAiVaultService } = await import('./ai-vault-service-spawn') + +function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } { + return forkMock.mock.calls.at(-1)?.[2] ?? {} +} + +describe('spawnRelayAiVaultService', () => { + beforeEach(() => { + forkMock.mockReset() + forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess) + }) + + it('keeps NODE_OPTIONS out of the sidecar so the heap cap and loader stand', () => { + vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js') + spawnRelayAiVaultService() + const options = forkOptions() + + // Asserted first: omitting `env` entirely inherits everything, and would + // leave the NODE_OPTIONS assertion below passing for the wrong reason. + expect(options.env).toBeDefined() + expect(options.env?.NODE_OPTIONS).toBeUndefined() + expect(options.execArgv).toEqual(['--max-old-space-size=384']) + vi.unstubAllEnvs() + }) + + it('does not hand the sidecar the rest of the remote login environment', () => { + vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'shhh') + spawnRelayAiVaultService() + const options = forkOptions() + + expect(options.env).toBeDefined() + expect(options.env?.AWS_SECRET_ACCESS_KEY).toBeUndefined() + vi.unstubAllEnvs() + }) +}) diff --git a/src/relay/ai-vault-service-spawn.ts b/src/relay/ai-vault-service-spawn.ts new file mode 100644 index 00000000000..90c5dbae67a --- /dev/null +++ b/src/relay/ai-vault-service-spawn.ts @@ -0,0 +1,20 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { join } from 'node:path' +import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env' +import { lowerAiVaultServicePriority } from '../main/ai-vault/session-scanner-service-priority' + +export function relayAiVaultServiceEntryPath(baseDir = __dirname): string { + return join(baseDir, 'relay-ai-vault-service.js') +} + +export function spawnRelayAiVaultService(): ChildProcess { + const child = fork(relayAiVaultServiceEntryPath(), [], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + execArgv: ['--max-old-space-size=384'], + env: buildRelayAiVaultServiceEnv(), + ...(process.platform === 'win32' ? { windowsHide: true } : {}) + }) + lowerAiVaultServicePriority(child.pid) + child.unref() + return child +} diff --git a/src/relay/fs-handler-list-files-cancel.test.ts b/src/relay/fs-handler-list-files-cancel.test.ts index 02cd8da6afc..baf6101600c 100644 --- a/src/relay/fs-handler-list-files-cancel.test.ts +++ b/src/relay/fs-handler-list-files-cancel.test.ts @@ -31,6 +31,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } diff --git a/src/relay/fs-handler-list-files-ignored.test.ts b/src/relay/fs-handler-list-files-ignored.test.ts index 1ca5c2f5327..b819d3ac85c 100644 --- a/src/relay/fs-handler-list-files-ignored.test.ts +++ b/src/relay/fs-handler-list-files-ignored.test.ts @@ -16,6 +16,11 @@ import { tmpdir } from 'node:os' import { listFilesWithGit } from './fs-handler-git-fallback' import { listFilesWithRg } from './fs-handler-list-files' import { searchWithRg } from './fs-handler-utils' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { + ListFilesScanCoordinator, + LIST_FILES_SUPERSEDED_MESSAGE +} from './fs-list-files-scan-coordinator' const tempDirs: string[] = [] const SHA1 = '0123456789abcdef0123456789abcdef01234567' @@ -36,6 +41,7 @@ function createMockProcess(): ChildProcess { ;(p as unknown as Record).kill = vi.fn() ;(p as unknown as Record).exitCode = null ;(p as unknown as Record).signalCode = null + Object.defineProperty(p, 'pid', { configurable: true, value: 1 }) return p } @@ -72,6 +78,7 @@ describe('relay quick open ignored file listing', () => { }) const promise = listFilesWithRg('/remote/root', ['packages/other']) + expect(spawnMock).toHaveBeenCalledTimes(2) setTimeout(() => { ;(primaryProc.stdout as unknown as EventEmitter).emit('data', 'src/index.ts\n') @@ -115,6 +122,82 @@ describe('relay quick open ignored file listing', () => { expect(callIndex).toBe(1) }) + it.each(['error-first', 'close-first'] as const)( + 'tags a %s pre-spawn listing failure without starting the ignored pass', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValue(missing) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + + const promise = listFilesWithRg(root) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + } else { + missing.emit('close', -2, null) + } + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + if (order === 'error-first') { + missing.emit('close', -2, null) + } else { + expect(() => missing.emit('error', error)).not.toThrow() + } + expect(spawnMock).toHaveBeenCalledTimes(1) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('kills only the admitted pass when ignored rg fails before spawn', async () => { + const root = await makeTempRoot() + const primary = createMockProcess() + const missingIgnored = createMockProcess() + Object.defineProperty(missingIgnored, 'pid', { value: undefined }) + spawnMock.mockImplementation((_cmd: string, args: string[]) => + args.includes('--no-ignore-vcs') ? missingIgnored : primary + ) + + const promise = listFilesWithRg(root) + expect(spawnMock).toHaveBeenCalledTimes(2) + missingIgnored.emit('close', -2, null) + + await expect(promise).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(primary.kill).toHaveBeenCalled() + expect(missingIgnored.kill).not.toHaveBeenCalled() + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => missingIgnored.emit('error', error)).not.toThrow() + expect(missingIgnored.listenerCount('error')).toBe(0) + }) + + it('does not signal failed-spawn passes when a same-client scan is superseded', async () => { + const root = await makeTempRoot() + const firstChild = createMockProcess() + Object.defineProperty(firstChild, 'pid', { value: undefined }) + spawnMock.mockReturnValue(firstChild) + const coordinator = new ListFilesScanCoordinator() + const first = coordinator.run({ + clientId: 1, + key: 'first', + start: (signal) => listFilesWithRg(root, [], { signal }) + }) + const firstOutcome = first.catch((error: unknown) => error) + + const second = coordinator.run({ + clientId: 1, + key: 'second', + start: async () => ['second.ts'] + }) + + await expect(firstOutcome).resolves.toMatchObject({ message: LIST_FILES_SUPERSEDED_MESSAGE }) + await expect(second).resolves.toEqual(['second.ts']) + expect(firstChild.kill).not.toHaveBeenCalled() + expect(spawnMock).toHaveBeenCalledTimes(1) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + expect(() => firstChild.emit('error', error)).not.toThrow() + }) + it('git fallback ignored pass includes ignored non-env files', async () => { const root = await makeTempRoot() await writeRel(root, 'dist/generated.js') @@ -430,4 +513,101 @@ describe('relay quick open ignored file listing', () => { vi.useRealTimers() } }) + + it.each(['error-first', 'close-first'] as const)( + 'tags only a %s pre-spawn rg search failure as unavailable', + async (order) => { + const root = await makeTempRoot() + const missing = createMockProcess() + Object.defineProperty(missing, 'pid', { value: undefined }) + spawnMock.mockReturnValueOnce(missing) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + if (order === 'error-first') { + expect(() => missing.emit('error', error)).not.toThrow() + missing.emit('close', -2, null) + } else { + missing.emit('close', -2, null) + expect(() => missing.emit('error', error)).not.toThrow() + } + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(missing.listenerCount('error')).toBe(0) + expect(missing.listenerCount('close')).toBe(0) + } + ) + + it('tags unsupported native launcher exits as unavailable', async () => { + const root = await makeTempRoot() + const child = createMockProcess() + Object.defineProperty(child, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(child) + const unavailable = searchWithRg(root, 'ok', { maxResults: 100 }) + + child.emit('close', 127, null) + + await expect(unavailable).rejects.toBeInstanceOf(RipgrepUnavailableError) + }) + + it('keeps missing-root launch errors on their prior non-fallback paths', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const listFirst = createMockProcess() + const listProbe = createMockProcess() + Object.defineProperty(listFirst, 'pid', { value: undefined }) + Object.defineProperty(listProbe, 'pid', { value: 1 }) + let callIndex = 0 + spawnMock.mockImplementation(() => [listFirst, listProbe][callIndex++]) + const listError = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + listFirst.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + expect(spawnMock.mock.calls[1]).toEqual(['rg', ['--version'], { stdio: 'ignore' }]) + listProbe.emit('close', 0, null) + await expect(listing).rejects.toBe(listError) + + spawnMock.mockReset() + const searchChild = createMockProcess() + const searchProbe = createMockProcess() + Object.defineProperty(searchChild, 'pid', { value: undefined }) + Object.defineProperty(searchProbe, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(searchChild).mockReturnValueOnce(searchProbe) + const search = searchWithRg(missingRoot, 'ok', { maxResults: 100 }) + searchChild.emit('error', listError) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + searchProbe.emit('close', 0, null) + await expect(search).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) + + it('keeps missing-rg precedence when the root also disappeared', async () => { + const missingRoot = await makeTempRoot() + await rm(missingRoot, { recursive: true, force: true }) + const first = createMockProcess() + const probe = createMockProcess() + for (const child of [first, probe]) { + Object.defineProperty(child, 'pid', { value: undefined }) + } + let callIndex = 0 + spawnMock.mockImplementation(() => [first, probe][callIndex++]) + const error = Object.assign(new Error('spawn rg ENOENT'), { code: 'ENOENT' }) + const listing = listFilesWithRg(missingRoot) + first.emit('error', error) + + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2)) + probe.emit('close', -2, null) + + await expect(listing).rejects.toBeInstanceOf(RipgrepUnavailableError) + expect(() => probe.emit('error', error)).not.toThrow() + }) + + it('keeps post-spawn rg search errors on the existing empty-result path', async () => { + const started = createMockProcess() + Object.defineProperty(started, 'pid', { value: 1 }) + spawnMock.mockReturnValueOnce(started) + const ordinaryFailure = searchWithRg('/remote/root', 'ok', { maxResults: 100 }) + started.emit('error', new Error('post-spawn failure')) + await expect(ordinaryFailure).resolves.toMatchObject({ files: [], totalMatches: 0 }) + }) }) diff --git a/src/relay/fs-handler-list-files.ts b/src/relay/fs-handler-list-files.ts index c63a352786e..3ec7b99026d 100644 --- a/src/relay/fs-handler-list-files.ts +++ b/src/relay/fs-handler-list-files.ts @@ -1,7 +1,5 @@ /** * Ripgrep-based file listing for Quick Open. - * Extracted from fs-handler-utils.ts to keep it under 300 lines (oxlint max-lines). - * * Why a full rewrite vs. the older execFile+maxBuffer version: on a home-dir * worktree over SSH, rg descended into every dotfile cache, hit the timeout, * and silently resolved with a partial list — Quick Open then showed "No @@ -23,6 +21,13 @@ import { shouldExcludeQuickOpenRelPath, shouldIncludeQuickOpenPath } from '../shared/quick-open-filter' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' export const LIST_FILES_TIMEOUT_MS = 25_000 @@ -78,6 +83,9 @@ export function listFilesWithRg( let passBuf = '' let passDone = false let passFileCount = 0 + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // --no-messages: permission-denied noise on the remote (e.g. .ssh, // root-owned mounts) would otherwise flood stderr. // cwd: rootPath — root-relative exclude globs like `!packages/app/**` @@ -98,6 +106,10 @@ export function listFilesWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) } const rejectPass = (error: Error): void => { if (passDone) { @@ -116,6 +128,16 @@ export function listFilesWithRg( cleanup() passResolve() } + const rejectLaunchFailure = (error: Error): void => { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then( + (unavailable) => { + rejectPass(unavailable ? new RipgrepUnavailableError() : error) + } + ) + } children.push({ child, isDone: () => passDone, @@ -125,7 +147,7 @@ export function listFilesWithRg( timer = setTimeout(() => { // Discard residual buffer on abnormal exit — a truncated byte // sequence could look like a valid path. - child.kill() + killSpawnedRipgrepProcess(child) rejectPass(new Error('rg list timed out')) }, LIST_FILES_TIMEOUT_MS) @@ -149,12 +171,28 @@ export function listFilesWithRg( /* drain to prevent backpressure stalls */ } function handleError(err: Error): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + passBuf = '' + rejectLaunchFailure(err) + return + } rejectPass(err) } function handleClose(code: number | null, signal: NodeJS.Signals | null): void { if (passDone) { return } + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + passBuf = '' + rejectLaunchFailure(new Error(`rg exited with code ${code}`)) + return + } // Why signal != null is a failure: the only way spawn gets a signal // is if the process was killed (timeout, OOM, external SIGKILL). // Trusting its stdout could surface a truncated list as a success. @@ -199,7 +237,7 @@ export function listFilesWithRg( continue } if (entry.child.exitCode === null && entry.child.signalCode === null) { - entry.child.kill() + killSpawnedRipgrepProcess(entry.child) } entry.reject(new Error(reason)) } @@ -228,12 +266,15 @@ export function listFilesWithRg( } signal?.addEventListener('abort', onAbort, { once: true }) + const primaryPass = runPass(primary) const passes = maxResults === undefined - ? Promise.all([runPass(primary), runPass(ignoredPass)]) + ? children[0]?.child.pid === undefined + ? primaryPass + : Promise.all([primaryPass, runPass(ignoredPass)]) : // Why: deterministic primary-first budgeting prevents a large ignored // tree from starving ordinary source paths on a remote host. - runPass(primary).then(() => + primaryPass.then(() => files.size < maxResults ? runPass(ignoredPass) : Promise.resolve() ) diff --git a/src/relay/fs-handler-rg-availability.test.ts b/src/relay/fs-handler-rg-availability.test.ts deleted file mode 100644 index 8b14a3d61af..00000000000 --- a/src/relay/fs-handler-rg-availability.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { EventEmitter } from 'node:events' -import { describe, expect, it, vi } from 'vitest' - -const { execFileMock, spawnMock } = vi.hoisted(() => ({ - execFileMock: vi.fn(), - spawnMock: vi.fn() -})) - -vi.mock('child_process', () => ({ - execFile: execFileMock, - spawn: spawnMock -})) - -import { checkRgAvailable } from './fs-handler-utils' - -class FakeChildProcess extends EventEmitter { - kill = vi.fn() -} - -describe('relay rg availability', () => { - it('removes listeners after a successful probe', async () => { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - child.emit('close', 0) - - await expect(result).resolves.toBe(true) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - }) - - it('settles and detaches when a wedged probe ignores timeout kill', async () => { - vi.useFakeTimers() - try { - const child = new FakeChildProcess() - execFileMock.mockReturnValueOnce(child) - - const result = checkRgAvailable() - await vi.advanceTimersByTimeAsync(5000) - - await expect(result).resolves.toBe(false) - expect(child.kill).toHaveBeenCalledTimes(1) - expect(child.listenerCount('error')).toBe(0) - expect(child.listenerCount('close')).toBe(0) - } finally { - vi.useRealTimers() - } - }) -}) diff --git a/src/relay/fs-handler-ripgrep-fallback.test.ts b/src/relay/fs-handler-ripgrep-fallback.test.ts new file mode 100644 index 00000000000..93d89fd9008 --- /dev/null +++ b/src/relay/fs-handler-ripgrep-fallback.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ChildProcessModule from 'node:child_process' +import type * as FsHandlerGitFallback from './fs-handler-git-fallback' +import type * as FsHandlerUtils from './fs-handler-utils' + +const { + execFileMock, + listFilesWithGitMock, + listFilesWithRgMock, + searchWithGitGrepMock, + searchWithRgMock +} = vi.hoisted(() => ({ + execFileMock: vi.fn(), + listFilesWithGitMock: vi.fn(), + listFilesWithRgMock: vi.fn(), + searchWithGitGrepMock: vi.fn(), + searchWithRgMock: vi.fn() +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock +})) + +vi.mock('./fs-handler-utils', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithRg: listFilesWithRgMock, + searchWithRg: searchWithRgMock +})) + +vi.mock('./fs-handler-git-fallback', async (importOriginal) => ({ + ...(await importOriginal()), + listFilesWithGit: listFilesWithGitMock, + searchWithGitGrep: searchWithGitGrepMock +})) + +import { FileListingCancelledError } from '../shared/file-listing-cancellation' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' +import { RelayContext } from './context' +import { FsHandler } from './fs-handler' + +type FsHandlerInternals = { + search(params: Record): Promise + runListFilesScan( + rootPath: string, + excludePathPrefixes: string[], + signal: AbortSignal, + maxResults?: number + ): Promise +} + +function createHandler(): FsHandlerInternals { + const dispatcher = { + onRequest: vi.fn(), + onNotification: vi.fn(), + onClientDetached: vi.fn(() => () => undefined) + } + const watcherPool = { + dispose: vi.fn(), + forgetRoot: vi.fn(), + subscribe: vi.fn() + } + return new FsHandler(dispatcher as never, new RelayContext(), watcherPool as never) as never +} + +describe('relay direct ripgrep admission', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back only for a tagged search launch failure', async () => { + const handler = createHandler() + const fallback = { files: [], totalMatches: 0, truncated: false } + searchWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + searchWithGitGrepMock.mockResolvedValueOnce(fallback) + + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).resolves.toBe(fallback) + expect(searchWithRgMock).toHaveBeenCalledTimes(1) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + + const ordinaryFailure = new Error('rg failed after spawn') + searchWithRgMock.mockRejectedValueOnce(ordinaryFailure) + await expect(handler.search({ rootPath: '/repo', query: 'needle' })).rejects.toBe( + ordinaryFailure + ) + expect(searchWithGitGrepMock).toHaveBeenCalledTimes(1) + }) + + it('falls back only for a tagged listing launch failure', async () => { + const handler = createHandler() + const controller = new AbortController() + listFilesWithRgMock.mockRejectedValueOnce(new RipgrepUnavailableError()) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(null) + return undefined + }) + listFilesWithGitMock.mockResolvedValueOnce(['src/index.ts']) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).resolves.toEqual([ + 'src/index.ts' + ]) + expect(listFilesWithRgMock).toHaveBeenCalledTimes(1) + expect(listFilesWithGitMock).toHaveBeenCalledTimes(1) + }) + + it('lets cancellation win an unavailable-listing race before Git starts', async () => { + const handler = createHandler() + const controller = new AbortController() + const cancellation = new FileListingCancelledError('superseded') + listFilesWithRgMock.mockImplementationOnce(async () => { + controller.abort(cancellation) + throw new RipgrepUnavailableError() + }) + + await expect(handler.runListFilesScan('/repo', [], controller.signal)).rejects.toBe( + cancellation + ) + expect(execFileMock).not.toHaveBeenCalled() + expect(listFilesWithGitMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/relay/fs-handler-utils.ts b/src/relay/fs-handler-utils.ts index 3fcf50a6eb2..83726c686bf 100644 --- a/src/relay/fs-handler-utils.ts +++ b/src/relay/fs-handler-utils.ts @@ -5,7 +5,7 @@ * These functions depend only on their arguments (plus `rg` being on PATH), * so they are straightforward to test independently. */ -import { spawn, execFile } from 'node:child_process' +import { spawn } from 'node:child_process' import { open } from 'node:fs/promises' import { buildRgArgs, @@ -16,6 +16,13 @@ import { } from '../shared/text-search' import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions' import type { SearchResult as SharedSearchResult } from '../shared/types' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableAfterLaunchFailure, + isRipgrepUnavailableExit, + killSpawnedRipgrepProcess, + RipgrepUnavailableError +} from '../shared/ripgrep-process-availability' // ─── Constants ─────────────────────────────────────────────────────── @@ -89,11 +96,14 @@ export function searchWithRg( query: string, opts: SearchOptions ): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const rgArgs = buildRgArgs(query, rootPath, opts) const acc = createAccumulator() let buffer = '' let resolved = false + let processErrorObserved = false + let unavailableExitObserved = false + let launchFailureCheck: Promise | null = null // Why: spawn can throw synchronously on invalid options (e.g. bad cwd), // which would leak out of the `new Promise` executor and leave the @@ -124,13 +134,47 @@ export function searchWithRg( child.stderr!.off('data', handleStderrData) child.off('error', handleError) child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) resolve(finalize(acc)) } + function rejectUnavailable(): void { + if (resolved) { + return + } + resolved = true + clearTimeout(killTimeout) + child.stdout!.off('data', handleStdoutData) + child.stderr!.off('data', handleStderrData) + child.off('error', handleError) + child.off('close', handleClose) + absorbPendingRipgrepSpawnError(child, { + errorObserved: processErrorObserved, + unavailableExitObserved + }) + reject(new RipgrepUnavailableError()) + } + + function settleLaunchFailure(): void { + if (launchFailureCheck) { + return + } + launchFailureCheck = isRipgrepUnavailableAfterLaunchFailure(rootPath).then((unavailable) => { + if (unavailable) { + rejectUnavailable() + } else { + resolveOnce() + } + }) + } + function processLine(line: string): void { const verdict = ingestRgJsonLine(line, rootPath, acc, opts.maxResults) if (verdict === 'stop') { - child.kill() + killSpawnedRipgrepProcess(child) } } @@ -148,10 +192,24 @@ export function searchWithRg( } function handleError(): void { + processErrorObserved = true + if (isRipgrepUnavailableExit(child, null, null)) { + settleLaunchFailure() + return + } resolveOnce() } - function handleClose(): void { + function handleClose(code: number | null, signal: NodeJS.Signals | null): void { + if ( + isRipgrepUnavailableExit(child, code, signal, { + classifyNativeLauncherExit: true + }) + ) { + unavailableExitObserved = true + settleLaunchFailure() + return + } if (buffer) { processLine(buffer) } @@ -166,57 +224,11 @@ export function searchWithRg( killTimeout = setTimeout(() => { acc.truncated = true - child.kill() + killSpawnedRipgrepProcess(child) resolveOnce() }, SEARCH_TIMEOUT_MS) }) } -// ─── rg availability check ────────────────────────────────────────── - -// Why no cache: `rg --version` is a sub-10ms local spawn, and caching the -// result caused a footgun — a negative cache persisted across rg installs -// (forcing a relay restart), while a positive cache could mask an rg that -// was uninstalled or broken mid-session. The `settled` flag below closes -// the original race between 'error' and 'close' that the cache was added -// to paper over, so re-checking per call is both simpler and safer. -const RG_AVAILABILITY_TIMEOUT_MS = 5000 - -export function checkRgAvailable(): Promise { - return new Promise((resolve) => { - let settled = false - const child = execFile('rg', ['--version']) - let timeout: ReturnType | null = null - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - child.off('error', onError) - child.off('close', onClose) - } - const settle = (available: boolean, options?: { kill?: boolean }): void => { - if (settled) { - return - } - settled = true - cleanup() - if (options?.kill) { - child.kill() - } - resolve(available) - } - const onError = (): void => settle(false) - const onClose = (code: number | null): void => settle(code === 0) - - child.once('error', onError) - child.once('close', onClose) - timeout = setTimeout(() => settle(false, { kill: true }), RG_AVAILABILITY_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) -} - // Moved to fs-handler-list-files.ts to keep this file under 300 lines (oxlint) export { listFilesWithRg, LIST_FILES_TIMEOUT_MS } from './fs-handler-list-files' diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index d07e2bf397f..b7dd2dfa6b5 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -10,12 +10,7 @@ import type { RelayContext } from './context' // Why: RelayContext is accepted in the constructor for protocol back-compat // (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops. import { expandTilde } from './context' -import { - DEFAULT_MAX_RESULTS, - searchWithRg, - listFilesWithRg, - checkRgAvailable -} from './fs-handler-utils' +import { DEFAULT_MAX_RESULTS, searchWithRg, listFilesWithRg } from './fs-handler-utils' import { listFilesWithGit, searchWithGitGrep } from './fs-handler-git-fallback' import { listFilesWithReaddir } from './fs-handler-readdir-fallback' import { ListFilesScanCoordinator } from './fs-list-files-scan-coordinator' @@ -35,6 +30,7 @@ import { RelayStreamRegistry } from './fs-stream-registry' import { scanWorkspaceSpaceDirectory } from './workspace-space-scan' import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' +import { RipgrepUnavailableError } from '../shared/ripgrep-process-availability' import { RelayFilesystemWatchRegistry } from './relay-filesystem-watch-registry' import type { RelayWatcherProcessPool } from './relay-watcher-process-pool' @@ -328,26 +324,22 @@ export class FsHandler { DEFAULT_MAX_RESULTS ) - const rgAvailable = await checkRgAvailable() - if (!rgAvailable) { - return searchWithGitGrep(rootPath, query, { - caseSensitive, - wholeWord, - useRegex, - includePattern, - excludePattern, - maxResults - }) - } - - return searchWithRg(rootPath, query, { + const options = { caseSensitive, wholeWord, useRegex, includePattern, excludePattern, maxResults - }) + } + try { + return await searchWithRg(rootPath, query, options) + } catch (error) { + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } + return searchWithGitGrep(rootPath, query, options) + } } private listFiles(params: Record, context?: RequestContext): Promise { @@ -380,10 +372,14 @@ export class FsHandler { signal: AbortSignal, maxResults?: number ): Promise { - const rgAvailable = await checkRgAvailable() throwIfFileListingCancelled(signal) - if (rgAvailable) { - return listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + try { + return await listFilesWithRg(rootPath, excludePathPrefixes, { signal, maxResults }) + } catch (error) { + throwIfFileListingCancelled(signal) + if (!(error instanceof RipgrepUnavailableError)) { + throw error + } } // Why: git ls-files only works inside git repos. Use rev-parse to detect // git ancestry — unlike checking for a local .git entry, this works from diff --git a/src/relay/fs-list-files-cancel.integration.test.ts b/src/relay/fs-list-files-cancel.integration.test.ts index 588ddb5fe51..f54ebaea369 100644 --- a/src/relay/fs-list-files-cancel.integration.test.ts +++ b/src/relay/fs-list-files-cancel.integration.test.ts @@ -54,7 +54,6 @@ vi.mock('./fs-handler-utils', async (importOriginal) => { const original = (await importOriginal()) as Record return { ...original, - checkRgAvailable: () => Promise.resolve(true), listFilesWithRg: fakeListFiles } }) diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 60ea5913139..a9d0b63ea8a 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -91,7 +91,36 @@ export async function getStatusOp( ) // Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate. const limit = resolveGitStatusLimit(params.limit) - const conflictOperation = await detectConflictOperation(worktreePath) + const conflictPromise = detectConflictOperation(worktreePath) + // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. + const statusArgs = [ + '-c', + 'core.quotePath=false', + 'status', + '--porcelain=v2', + '--branch', + '--untracked-files=all' + ] + if (includeIgnored) { + statusArgs.push('--ignored=matching') + } + // Why: attach rejection ownership before awaiting marker I/O, so a fast Git failure cannot become unhandled. + const statusSettlementPromise = Promise.allSettled([ + (async () => { + const parser = new StatusPorcelainParser() + const result = await streamGit(statusArgs, worktreePath, { + // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. + disableOptionalLocks: true, + signal: options.signal, + onStdout: (chunk) => parser.update(chunk, limit) + }) + if (!result.stoppedEarly) { + parser.finish() + } + return { parser, stoppedEarly: result.stoppedEarly } + })() + ]) + const conflictOperation = await conflictPromise const entries: Record[] = [] let head: string | undefined let branch: string | undefined @@ -103,28 +132,11 @@ export async function getStatusOp( let branchLineTotal: GitBranchLineTotal | undefined try { - // Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish. - const statusArgs = [ - '-c', - 'core.quotePath=false', - 'status', - '--porcelain=v2', - '--branch', - '--untracked-files=all' - ] - if (includeIgnored) { - statusArgs.push('--ignored=matching') - } - const parser = new StatusPorcelainParser() - const { stoppedEarly } = await streamGit(statusArgs, worktreePath, { - // Why: status polling is read-like; avoid racing terminal Git on .git/worktrees/*/index.lock. - disableOptionalLocks: true, - signal: options.signal, - onStdout: (chunk) => parser.update(chunk, limit) - }) - if (!stoppedEarly) { - parser.finish() + const [statusResult] = await statusSettlementPromise + if (statusResult.status === 'rejected') { + throw statusResult.reason } + const { parser, stoppedEarly } = statusResult.value head = parser.branch.head branch = parser.branch.branch ignoredPaths = parser.ignoredPaths diff --git a/src/relay/git-handler-utils.test.ts b/src/relay/git-handler-utils.test.ts index e1c18a78d33..43f1d53d5d1 100644 --- a/src/relay/git-handler-utils.test.ts +++ b/src/relay/git-handler-utils.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' import { isUnsupportedWorktreeListZError, parseWorktreeList } from './git-handler-utils' describe('parseWorktreeList', () => { @@ -105,72 +104,3 @@ describe('isUnsupportedWorktreeListZError', () => { expect(isUnsupportedWorktreeListZError(error)).toBe(false) }) }) - -describe('parseStatusOutput', () => { - it('parses upstream ahead/behind from porcelain v2 branch headers', () => { - const result = parseStatusOutput( - [ - '# branch.oid abcdef1234567890', - '# branch.head feature/prompts', - '# branch.upstream origin/feature/prompts', - '# branch.ab +2 -3', - '' - ].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ - hasUpstream: true, - upstreamName: 'origin/feature/prompts', - ahead: 2, - behind: 3 - }) - }) - - it('reports no upstream when porcelain v2 omits branch.upstream', () => { - const result = parseStatusOutput( - ['# branch.oid abcdef1234567890', '# branch.head feature/prompts', ''].join('\n') - ) - - expect(result.upstreamStatus).toEqual({ hasUpstream: false, ahead: 0, behind: 0 }) - }) - - it('parses ignored porcelain records separately from actionable entries', () => { - const result = parseStatusOutput(['! dist/', '! .env', '? scratch.txt', ''].join('\n')) - - expect(result.ignoredPaths).toEqual(['dist/', '.env']) - expect(result.entries).toEqual([ - { path: 'scratch.txt', status: 'untracked', area: 'untracked' } - ]) - }) - - it('parses rename records with spaces in the paths', () => { - const result = parseStatusOutput( - '2 R. N... 100644 100644 100644 aaaa bbbb R100 src/new name.ts\tsrc/old name.ts\n' - ) - - expect(result.entries).toEqual([ - { path: 'src/new name.ts', oldPath: 'src/old name.ts', status: 'renamed', area: 'staged' } - ]) - }) - - it('parses submodule dirtiness flags from porcelain records', () => { - const result = parseStatusOutput( - '1 AM S..U 000000 160000 160000 0000000000000000000000000000000000000000 7844cb64e631f17a9ca5b548f3500ef7cecd2f17 nested-repo\n' - ) - - expect(result.entries).toEqual([ - { - path: 'nested-repo', - status: 'added', - area: 'staged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - }, - { - path: 'nested-repo', - status: 'modified', - area: 'unstaged', - submodule: { commitChanged: false, trackedChanges: false, untrackedChanges: true } - } - ]) - }) -}) diff --git a/src/relay/git-status-output-parser.test.ts b/src/relay/git-status-output-parser.test.ts deleted file mode 100644 index ec25e9b72ba..00000000000 --- a/src/relay/git-status-output-parser.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { parseStatusOutput } from './git-status-output-parser' - -describe('parseStatusOutput', () => { - it('marks staged S... submodule rows as commit-changed gitlinks', () => { - const parsed = parseStatusOutput( - '1 M. S... 160000 160000 160000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb flutter_mine\n' - ) - - expect(parsed.entries).toEqual([ - { - path: 'flutter_mine', - status: 'modified', - area: 'staged', - submodule: { commitChanged: true, trackedChanges: false, untrackedChanges: false } - } - ]) - }) -}) diff --git a/src/relay/git-status-output-parser.ts b/src/relay/git-status-output-parser.ts deleted file mode 100644 index 9bb1d344073..00000000000 --- a/src/relay/git-status-output-parser.ts +++ /dev/null @@ -1,177 +0,0 @@ -export function parseStatusChar(char: string): string { - switch (char) { - case 'M': - return 'modified' - case 'A': - return 'added' - case 'D': - return 'deleted' - case 'R': - return 'renamed' - case 'C': - return 'copied' - default: - return 'modified' - } -} - -/** - * Parse `git status --porcelain=v2` output into structured entries. - * Does NOT handle unmerged entries (those require worktree access). - */ -export function parseStatusOutput(stdout: string): { - entries: Record[] - unmergedLines: string[] - ignoredPaths: string[] - head?: string - branch?: string - upstreamStatus: { - hasUpstream: boolean - upstreamName?: string - ahead: number - behind: number - } -} { - const entries: Record[] = [] - const unmergedLines: string[] = [] - const ignoredPaths: string[] = [] - let head: string | undefined - let branch: string | undefined - let upstreamName: string | undefined - let upstreamAheadBehind: { ahead: number; behind: number } | null = null - - for (const line of stdout.split(/\r?\n/)) { - if (!line) { - continue - } - - if (line.startsWith('# branch.oid ')) { - head = line.slice('# branch.oid '.length).trim() - continue - } - - if (line.startsWith('# branch.head ')) { - const branchHead = line.slice('# branch.head '.length).trim() - branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : '' - continue - } - - if (line.startsWith('# branch.upstream ')) { - upstreamName = line.slice('# branch.upstream '.length).trim() || undefined - continue - } - - if (line.startsWith('# branch.ab ')) { - upstreamAheadBehind = parseBranchAheadBehind(line) - continue - } - - if (line.startsWith('1 ') || line.startsWith('2 ')) { - const parts = line.split(' ') - const xy = parts[1] - const indexStatus = xy[0] - const worktreeStatus = xy[1] - - if (line.startsWith('2 ')) { - // Why: porcelain v2 type-2 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`. - // The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab. - const tabParts = line.split('\t') - const filePath = tabParts[0].split(' ').slice(9).join(' ') - const oldPath = tabParts.slice(1).join('\t') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - oldPath, - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - oldPath, - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } else { - const filePath = parts.slice(8).join(' ') - if (indexStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(indexStatus), - area: 'staged', - ...submoduleStatusField(parts[2], indexStatus) - }) - } - if (worktreeStatus !== '.') { - entries.push({ - path: filePath, - status: parseStatusChar(worktreeStatus), - area: 'unstaged', - ...submoduleStatusField(parts[2], worktreeStatus) - }) - } - } - } else if (line.startsWith('? ')) { - entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' }) - } else if (line.startsWith('! ')) { - ignoredPaths.push(line.slice(2)) - } else if (line.startsWith('u ')) { - unmergedLines.push(line) - } - } - - return { - entries, - unmergedLines, - ignoredPaths, - head, - branch, - upstreamStatus: upstreamName - ? { - hasUpstream: true, - upstreamName, - ahead: upstreamAheadBehind?.ahead ?? 0, - behind: upstreamAheadBehind?.behind ?? 0 - } - : { hasUpstream: false, ahead: 0, behind: 0 } - } -} - -function parseSubmoduleStatus( - submoduleField: string | undefined, - statusChar = '.' -): { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } | undefined { - if (!submoduleField?.startsWith('S')) { - return undefined - } - return { - commitChanged: submoduleField[1] === 'C' || (submoduleField === 'S...' && statusChar === 'M'), - trackedChanges: submoduleField[2] === 'M', - untrackedChanges: submoduleField[3] === 'U' - } -} - -function submoduleStatusField( - submoduleField: string | undefined, - statusChar: string -): - | { submodule: { commitChanged: boolean; trackedChanges: boolean; untrackedChanges: boolean } } - | {} { - const submodule = parseSubmoduleStatus(submoduleField, statusChar) - return submodule ? { submodule } : {} -} - -function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null { - const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/) - if (!match) { - return null - } - return { - ahead: Number.parseInt(match[1], 10), - behind: Number.parseInt(match[2], 10) - } -} diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index 9df947b1aad..4ed3bce6ecd 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,7 +1,155 @@ -import { describe, expect, it, vi } from 'vitest' -import { parseHexAddress } from './port-scan-handler' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { MethodHandler, RequestContext } from './dispatcher' + +const { readFileMock, readdirMock, readlinkMock } = vi.hoisted(() => ({ + readFileMock: vi.fn(), + readdirMock: vi.fn(), + readlinkMock: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, + readdir: readdirMock, + readlink: readlinkMock +})) + +import { parseHexAddress, PortScanHandler } from './port-scan-handler' import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' +const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') + +beforeEach(() => { + vi.clearAllMocks() + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) +}) + +afterEach(() => { + if (originalPlatformDescriptor) { + Object.defineProperty(process, 'platform', originalPlatformDescriptor) + } +}) + +function capturePortDetectHandler(): MethodHandler { + let handler: MethodHandler | undefined + new PortScanHandler({ + onRequest: (method, nextHandler) => { + expect(method).toBe('ports.detect') + handler = nextHandler + } + }) + if (!handler) { + throw new Error('ports.detect handler was not registered') + } + return handler +} + +function requestContext(signal?: AbortSignal): RequestContext { + return { clientId: 1, isStale: () => signal?.aborted ?? false, signal } +} + +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve = (_value: T): void => { + throw new Error('deferred promise was not initialized') + } + const promise = new Promise((nextResolve) => { + resolve = nextResolve + }) + return { promise, resolve } +} + +function mockLinuxProcScan({ + pidCount, + fdCount, + firstReadlink +}: { + pidCount: number + fdCount: number + firstReadlink?: Promise +}): void { + const tcpHeader = + 'sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode' + const tcpRow = + '0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11111' + readFileMock.mockImplementation(async (path: string) => { + if (path === '/proc/net/tcp') { + return `${tcpHeader}\n${tcpRow}\n` + } + if (path === '/proc/net/tcp6') { + return `${tcpHeader}\n` + } + if (path.endsWith('/cmdline')) { + return '/usr/bin/node\0server.js' + } + throw new Error(`unexpected readFile: ${path}`) + }) + + const pids = Array.from({ length: pidCount }, (_, index) => String(1_000 + index)) + const fds = Array.from({ length: fdCount }, (_, index) => String(index)) + readdirMock.mockImplementation(async (path: string) => { + if (path === '/proc') { + return pids + } + if (path.endsWith('/fd')) { + return fds + } + throw new Error(`unexpected readdir: ${path}`) + }) + + let first = true + readlinkMock.mockImplementation(() => { + if (first && firstReadlink) { + first = false + return firstReadlink + } + first = false + return Promise.resolve('socket:[11111]') + }) +} + +describe('PortScanHandler Linux cancellation', () => { + it('does not touch procfs for an already-cancelled request', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + capturePortDetectHandler()({}, requestContext(controller.signal)) + ).rejects.toMatchObject({ name: 'AbortError' }) + + expect(readFileMock).not.toHaveBeenCalled() + expect(readdirMock).not.toHaveBeenCalled() + expect(readlinkMock).not.toHaveBeenCalled() + }) + + it('stops a large pid and fd walk at the filesystem operation already in flight', async () => { + const firstReadlink = createDeferred() + mockLinuxProcScan({ pidCount: 1_000, fdCount: 100, firstReadlink: firstReadlink.promise }) + const controller = new AbortController() + const scan = capturePortDetectHandler()({}, requestContext(controller.signal)) + + await vi.waitFor(() => expect(readlinkMock).toHaveBeenCalledTimes(1)) + controller.abort() + firstReadlink.resolve('socket:[11111]') + + await expect(scan).rejects.toMatchObject({ name: 'AbortError' }) + expect(readdirMock).toHaveBeenCalledTimes(2) + expect(readdirMock).toHaveBeenNthCalledWith(1, '/proc') + expect(readdirMock).toHaveBeenNthCalledWith(2, '/proc/1000/fd') + expect(readlinkMock).toHaveBeenCalledTimes(1) + expect(readlinkMock).toHaveBeenCalledWith('/proc/1000/fd/0') + }) + + it('preserves detected port results when the request stays live', async () => { + mockLinuxProcScan({ pidCount: 1, fdCount: 1 }) + + await expect( + capturePortDetectHandler()({}, requestContext(new AbortController().signal)) + ).resolves.toEqual({ + ports: [{ host: '127.0.0.1', port: 3000, pid: 1_000, processName: 'node' }], + platform: 'linux' + }) + }) +}) + describe('parseHexAddress', () => { it('parses IPv4 localhost (127.0.0.1)', () => { // 127.0.0.1 in little-endian hex: 0100007F diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index a576d8921fa..40150133a04 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -16,11 +16,11 @@ const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) const MAX_DETECTED_PORTS = 50 export class PortScanHandler { - constructor(dispatcher: RelayDispatcher) { + constructor(dispatcher: Pick) { dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => { if (process.platform === 'linux') { return { - ports: await this.scanLinuxListeningPorts(), + ports: await this.scanLinuxListeningPorts(context.signal), platform: process.platform } } @@ -37,11 +37,13 @@ export class PortScanHandler { }) } - private async scanLinuxListeningPorts(): Promise { + private async scanLinuxListeningPorts(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const [tcp4, tcp6] = await Promise.all([ - this.readProcNet('/proc/net/tcp'), - this.readProcNet('/proc/net/tcp6') + this.readProcNet('/proc/net/tcp', signal), + this.readProcNet('/proc/net/tcp6', signal) ]) + signal?.throwIfAborted() const listeningSockets = [...tcp4, ...tcp6] if (listeningSockets.length === 0) { @@ -49,7 +51,7 @@ export class PortScanHandler { } const inodeSet = new Set(listeningSockets.map((s) => s.inode)) - const inodeToPid = await this.mapInodesToPids(inodeSet) + const inodeToPid = await this.mapInodesToPids(inodeSet, signal) const seen = new Set() const results: DetectedPort[] = [] @@ -57,6 +59,7 @@ export class PortScanHandler { const relayParentPid = process.ppid for (const socket of listeningSockets) { + signal?.throwIfAborted() const key = `${socket.host}:${socket.port}` if (seen.has(key)) { continue @@ -72,7 +75,7 @@ export class PortScanHandler { continue } - const processName = pid != null ? await this.getProcessName(pid) : undefined + const processName = pid != null ? await this.getProcessName(pid, signal) : undefined if (processName === 'sshd') { continue @@ -93,14 +96,18 @@ export class PortScanHandler { } private async readProcNet( - path: string + path: string, + signal?: AbortSignal ): Promise<{ port: number; host: string; inode: number }[]> { + signal?.throwIfAborted() let content: string try { content = await readFile(path, 'utf-8') } catch { + signal?.throwIfAborted() return [] } + signal?.throwIfAborted() const lines = content.split('\n') const results: { port: number; host: string; inode: number }[] = [] @@ -133,7 +140,11 @@ export class PortScanHandler { return results } - private async mapInodesToPids(inodes: Set): Promise> { + private async mapInodesToPids( + inodes: Set, + signal?: AbortSignal + ): Promise> { + signal?.throwIfAborted() const result = new Map() if (inodes.size === 0) { return result @@ -143,27 +154,35 @@ export class PortScanHandler { try { pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name)) } catch { + signal?.throwIfAborted() return result } + signal?.throwIfAborted() for (const pidStr of pids) { + signal?.throwIfAborted() const fdDir = `/proc/${pidStr}/fd` let fds: string[] try { fds = await readdir(fdDir) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const pid = Number.parseInt(pidStr, 10) for (const fd of fds) { + signal?.throwIfAborted() let link: string try { link = await readlink(`${fdDir}/${fd}`) } catch { + signal?.throwIfAborted() continue } + signal?.throwIfAborted() const match = link.match(/^socket:\[(\d+)\]$/) if (!match) { @@ -180,9 +199,11 @@ export class PortScanHandler { return result } - private async getProcessName(pid: number): Promise { + private async getProcessName(pid: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted() try { const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8') + signal?.throwIfAborted() if (!cmdline) { return undefined } @@ -195,6 +216,7 @@ export class PortScanHandler { const parts = exe.split('/') return parts.at(-1) } catch { + signal?.throwIfAborted() return undefined } } diff --git a/src/relay/pty-source-replay-index.test.ts b/src/relay/pty-source-replay-index.test.ts deleted file mode 100644 index efbb17451b0..00000000000 --- a/src/relay/pty-source-replay-index.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PtySourceSpan } from '../shared/pty-source-credit-contract' -import { PtySourceReplayIndex } from './pty-source-replay-index' - -function span(id: string, data: string, sourceStartSu: number): PtySourceSpan { - return { - id: 'pty-1', - providerGeneration: 1, - clientGeneration: 1, - ownerGeneration: 1, - ptyIncarnation: 'incarnation-1', - deliveryToken: 'token-1', - spanId: id, - sourceStartSu, - sourceEndSu: sourceStartSu + data.length, - displayStart: sourceStartSu, - displayEnd: sourceStartSu + data.length, - data, - splittable: true, - transform: { transformed: false, rawLengthSu: data.length, scalarSafe: true } - } -} - -describe('PtySourceReplayIndex', () => { - it('wraps the existing chunked buffer and preserves exact retained source ranges', () => { - const replay = new PtySourceReplayIndex(6) - replay.append(span('one', 'abcd', 0)) - replay.append(span('two', 'efgh', 4)) - - expect(replay.readLegacy()).toBe('cdefgh') - expect(replay.recoveryFrom(3)).toEqual({ - ok: true, - records: [ - expect.objectContaining({ sourceStartSu: 3, data: 'd' }), - expect.objectContaining({ sourceStartSu: 4, data: 'efgh' }) - ], - liveEndSourceSu: 8 - }) - expect(replay.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range' - }) - }) - - it('does not wall-clock dedupe identical replay payloads', () => { - const first = new PtySourceReplayIndex(16) - const second = new PtySourceReplayIndex(16) - first.append(span('first', 'same', 0)) - second.append({ ...span('second', 'same', 0), deliveryToken: 'token-2' }) - - expect(first.recoveryFrom(0)).toMatchObject({ ok: true }) - expect(second.recoveryFrom(0)).toMatchObject({ ok: true }) - }) - - it('preserves indivisible-form split metadata during recovery', () => { - const replay = new PtySourceReplayIndex(16) - replay.append({ - ...span('range', 'abcd', 0), - splittable: undefined, - indivisible: false - }) - - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: true, - records: [expect.objectContaining({ sourceStartSu: 2, data: 'cd' })] - }) - }) - - it('rejects replay checkpoints and retained heads inside a scalar', () => { - const replay = new PtySourceReplayIndex(4) - replay.append(span('range', 'a😀b', 0)) - expect(replay.recoveryFrom(2)).toMatchObject({ - ok: false, - reason: 'checkpoint-inside-scalar' - }) - - const trimmed = new PtySourceReplayIndex(1) - trimmed.append(span('emoji', '😀', 0)) - expect(trimmed.recoveryFrom(1)).toMatchObject({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: 2 - }) - }) - - it('rejects a recovery checkpoint beyond the live source range', () => { - const replay = new PtySourceReplayIndex(16) - replay.append(span('range', 'abcd', 0)) - - expect(replay.recoveryFrom(5)).toMatchObject({ - ok: false, - reason: 'checkpoint-after-live-range', - liveEndSourceSu: 4 - }) - }) -}) diff --git a/src/relay/pty-source-replay-index.ts b/src/relay/pty-source-replay-index.ts deleted file mode 100644 index 1dfe5596eb1..00000000000 --- a/src/relay/pty-source-replay-index.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' -import { ptySourceSpanIsSplittable, type PtySourceSpan } from '../shared/pty-source-credit-contract' -import { - assertNonNegativeSafeInteger, - assertPtySourceSpan -} from '../shared/pty-source-credit-validation' - -export type PtySourceReplayRecord = Readonly< - Pick< - PtySourceSpan, - | 'spanId' - | 'sourceStartSu' - | 'sourceEndSu' - | 'displayStart' - | 'displayEnd' - | 'data' - | 'splittable' - | 'indivisible' - | 'transform' - > -> - -export type PtySourceReplayResult = - | Readonly<{ ok: true; records: readonly PtySourceReplayRecord[]; liveEndSourceSu: number }> - | Readonly<{ - ok: false - reason: - | 'checkpoint-before-retained-range' - | 'checkpoint-after-live-range' - | 'checkpoint-inside-indivisible-transform' - | 'checkpoint-inside-scalar' - retainedStartSourceSu: number - liveEndSourceSu: number - }> - -export class PtySourceReplayIndex { - private readonly output: RecentPtyOutputBuffer - private readonly records: PtySourceReplayRecord[] = [] - private displayEnd = 0 - private retainedStartSourceSu = 0 - private liveEndSourceSu = 0 - - constructor(limit: number) { - this.output = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) - } - - append(span: PtySourceSpan): void { - assertPtySourceSpan(span) - if ( - span.sourceStartSu !== this.liveEndSourceSu || - span.displayStart !== this.displayEnd || - span.displayEnd - span.displayStart !== span.data.length - ) { - throw new Error('PTY replay source/display ranges must be contiguous') - } - this.output.append(span.data) - this.records.push( - Object.freeze({ - spanId: span.spanId, - sourceStartSu: span.sourceStartSu, - sourceEndSu: span.sourceEndSu, - displayStart: span.displayStart, - displayEnd: span.displayEnd, - data: span.data, - splittable: span.splittable, - indivisible: span.indivisible, - transform: span.transform - }) - ) - this.displayEnd = span.displayEnd - this.liveEndSourceSu = span.sourceEndSu - this.evictUnmappedPrefix() - } - - readLegacy(): string { - return this.output.read() - } - - recoveryFrom(checkpointSourceEndSu: number): PtySourceReplayResult { - assertNonNegativeSafeInteger(checkpointSourceEndSu, 'checkpointSourceEndSu') - if (checkpointSourceEndSu < this.retainedStartSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-before-retained-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if (checkpointSourceEndSu > this.liveEndSourceSu) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-after-live-range', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const containing = this.records.find( - (record) => - record.sourceStartSu < checkpointSourceEndSu && checkpointSourceEndSu < record.sourceEndSu - ) - if ( - containing?.transform.transformed || - (containing && !ptySourceSpanIsSplittable(containing)) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-indivisible-transform', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - if ( - containing && - splitsSurrogatePair(containing.data, checkpointSourceEndSu - containing.sourceStartSu) - ) { - return Object.freeze({ - ok: false, - reason: 'checkpoint-inside-scalar', - retainedStartSourceSu: this.retainedStartSourceSu, - liveEndSourceSu: this.liveEndSourceSu - }) - } - const records = this.records - .filter((record) => record.sourceEndSu > checkpointSourceEndSu) - .map((record) => this.sliceRecord(record, checkpointSourceEndSu)) - return Object.freeze({ - ok: true, - records: Object.freeze(records), - liveEndSourceSu: this.liveEndSourceSu - }) - } - - private evictUnmappedPrefix(): void { - const retainedDisplayStart = this.displayEnd - this.output.read().length - while (this.records[0]?.displayEnd <= retainedDisplayStart) { - const removed = this.records.shift()! - this.retainedStartSourceSu = removed.sourceEndSu - } - const head = this.records[0] - if (!head || head.displayStart >= retainedDisplayStart) { - return - } - if (head.transform.transformed || !ptySourceSpanIsSplittable(head)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const displayOffset = retainedDisplayStart - head.displayStart - if (splitsSurrogatePair(head.data, displayOffset)) { - this.records.shift() - this.retainedStartSourceSu = head.sourceEndSu - return - } - const sourceStartSu = head.sourceStartSu + displayOffset - this.records[0] = Object.freeze({ - ...head, - spanId: `${head.spanId}:retained:${sourceStartSu}`, - sourceStartSu, - displayStart: retainedDisplayStart, - data: head.data.slice(displayOffset), - transform: Object.freeze({ - ...head.transform, - rawLengthSu: head.sourceEndSu - sourceStartSu - }) - }) - this.retainedStartSourceSu = sourceStartSu - } - - private sliceRecord( - record: PtySourceReplayRecord, - checkpointSourceEndSu: number - ): PtySourceReplayRecord { - if (checkpointSourceEndSu <= record.sourceStartSu) { - return record - } - const offset = checkpointSourceEndSu - record.sourceStartSu - return Object.freeze({ - ...record, - spanId: `${record.spanId}:recovery:${checkpointSourceEndSu}`, - sourceStartSu: checkpointSourceEndSu, - displayStart: record.displayStart + offset, - data: record.data.slice(offset), - transform: Object.freeze({ - ...record.transform, - rawLengthSu: record.sourceEndSu - checkpointSourceEndSu - }) - }) - } -} - -function splitsSurrogatePair(data: string, offset: number): boolean { - const preceding = data.charCodeAt(offset - 1) - const following = data.charCodeAt(offset) - return ( - offset > 0 && - preceding >= 0xd800 && - preceding <= 0xdbff && - following >= 0xdc00 && - following <= 0xdfff - ) -} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 18f3935a623..cf4b4808668 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -9,6 +9,7 @@ // reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket. import { createServer, createConnection, type Socket, type Server } from 'node:net' +import { homedir } from 'node:os' import { join } from 'node:path' import { unlinkSync, @@ -41,6 +42,9 @@ import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' import { WorkspaceSessionHandler } from './workspace-session-handler' import { AiVaultHandler } from './ai-vault-handler' +import { createRelayAiVaultService } from './ai-vault-service-factory' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import { PluginOverlayManager } from './plugin-overlay' import { @@ -723,7 +727,17 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler - const _aiVaultHandler = new AiVaultHandler(dispatcher) + const aiVaultRelayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) + const aiVaultHostPlatform = aiVaultRelayPlatform + ? getRemoteHostPlatform(aiVaultRelayPlatform) + : undefined + const aiVaultService = aiVaultHostPlatform + ? createRelayAiVaultService(homedir(), aiVaultHostPlatform) + : null + const _aiVaultHandler = new AiVaultHandler(dispatcher, { + hostPlatform: aiVaultHostPlatform, + service: aiVaultService ?? undefined + }) void _aiVaultHandler // Why: relay-hosted plugin provisioning is a later phase. Register the @@ -1285,7 +1299,12 @@ async function main(): Promise { graceBranch = null void ptyHandler .dispose() - .then(() => { + .then(async () => { + await aiVaultService?.dispose().catch((error) => { + relayLogLine( + `[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}` + ) + }) stopPoolWatch() stopPoolActiveWatch() dispatcher.dispose() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7529436ca93..87ddfd2ee3a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -361,6 +361,9 @@ const AddProjectFromFolderDialog = lazy( ) const ProjectAddedDialog = lazy(() => import('./components/sidebar/ProjectAddedDialog')) const DeleteWorktreeDialog = lazy(() => import('./components/sidebar/DeleteWorktreeDialog')) +const PreservedBranchBatchReviewModal = lazy( + () => import('./components/sidebar/PreservedBranchBatchReviewModal') +) const DictationController = lazy(() => import('./components/dictation/DictationController').then((module) => ({ default: module.DictationController @@ -742,14 +745,7 @@ function App(): React.JSX.Element { const featureTipsSuppressedByOnboardingThisSessionRef = useRef(false) const unmountAddRepoDialogTimerRef = useRef | null>(null) const [featureTipCliInstalled, setFeatureTipCliInstalled] = useState(null) - const [onboardingSettingsDetour, setOnboardingSettingsDetour] = useState(false) const shouldRenderOnboarding = onboarding !== null && shouldShowOnboarding(onboarding) - const onboardingSettingsDetourActive = - onboardingSettingsDetour && activeView === 'settings' && shouldRenderOnboarding - if (onboardingSettingsDetour && !onboardingSettingsDetourActive) { - // Why: the detour is valid only while Settings is onscreen; clear it during render so onboarding resumes without an extra Effect pass. - setOnboardingSettingsDetour(false) - } useEffect(() => { if (activeModal === 'add-repo') { @@ -872,10 +868,6 @@ function App(): React.JSX.Element { settings ]) - const beginOnboardingSettingsDetour = useCallback(() => { - setOnboardingSettingsDetour(true) - }, []) - // Why: useLayoutEffect fires before paint, so dispatching SYNC_FIT_PANES_EVENT reflows the terminal in the same frame as the width change — no wrongly-sized transient. useLayoutEffect(() => { window.dispatchEvent(new CustomEvent(SYNC_FIT_PANES_EVENT)) @@ -2347,7 +2339,8 @@ function App(): React.JSX.Element { ) ) : null}
- {stackedSidebarOpen ? ( + {/* Why: automations owns its page header; the stacked titlebar would be an empty 36px stripe. */} + {stackedSidebarOpen && activeView !== 'automations' ? (
{titlebarMainStrip}
) : null}
@@ -2703,6 +2696,16 @@ function App(): React.JSX.Element { ) : null} + {activeModal === 'preserved-branch-review' ? ( + + + + ) : null} {hasSshCredentialRequest ? ( @@ -2738,23 +2741,18 @@ function App(): React.JSX.Element { > - {onboarding && shouldRenderOnboarding && !onboardingSettingsDetourActive ? ( + {onboarding && shouldRenderOnboarding ? ( - + ) : null} diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 85d6a2c116a..099478745e4 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1027,11 +1027,6 @@ html.native-shell .app-layout { color: var(--muted-foreground); } -.titlebar-profile-switcher { - -webkit-app-region: no-drag; - flex-shrink: 0; -} - .editor-header { display: flex; align-items: center; @@ -2599,106 +2594,6 @@ html.native-shell .app-layout { animation: feature-wall-meta-pulse 1100ms ease-out 1; } -/* Onboarding step 5: the compact "Take the tour" intro expands into the - full tour shell, so animate the browser snapshots to avoid a hard jump. */ -html.onboarding-tour-start-transition::view-transition-old(root), -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-duration: 760ms; - animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); - animation-fill-mode: both; - mix-blend-mode: normal; -} - -html.onboarding-tour-start-transition::view-transition-old(root) { - animation-name: onboarding-tour-start-old; -} - -html.onboarding-tour-start-transition::view-transition-new(root) { - animation-name: onboarding-tour-start-new; -} - -@keyframes onboarding-tour-start-old { - 0% { - opacity: 1; - transform: translateY(0) scale(1); - } - 28% { - opacity: 0.9; - transform: translateY(-2px) scale(0.998); - } - 42% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } - 100% { - opacity: 0; - transform: translateY(-12px) scale(0.99); - } -} - -@keyframes onboarding-tour-start-new { - 0% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 34% { - opacity: 0; - transform: translateY(18px) scale(0.992); - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -/* ── Feature tour nudge: cycling preview ─────────────────────────── */ - -/* Why: the bottom-right nudge cycles through 4 silhouette frames - (Workspace → Orchestration → Tasks → Terminal). Each frame is visible for - 1/4 of the cycle. Holding fade-out late (24%–25%) gives the user dwell - time on the resolved state before swapping. Uses CSS-driven timing rather - than React state so we stay cheap when the popup is mounted but idle. */ -:root { - --feature-tour-cycle: 16s; -} - -.feature-tour-frame { - position: absolute; - inset: 0; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-frame[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-frame[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-frame[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-frame[data-frame='4'] { - animation-delay: 12s; -} - -@keyframes feature-tour-frame-fade { - 0% { - opacity: 0; - } - 1.5% { - opacity: 1; - } - 24% { - opacity: 1; - } - 25% { - opacity: 0; - } - 100% { - opacity: 0; - } -} - .orca-contextual-tour-target-rings { border-radius: 9999px; pointer-events: none; @@ -2736,520 +2631,6 @@ html.onboarding-tour-start-transition::view-transition-new(root) { } } -.feature-tour-copy-slide { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - gap: 4px; - opacity: 0; - animation: feature-tour-frame-fade var(--feature-tour-cycle) linear infinite both; -} -.feature-tour-copy-slide[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-copy-slide[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-copy-slide[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-copy-slide[data-frame='4'] { - animation-delay: 12s; -} - -.feature-tour-dot { - width: 5px; - height: 5px; - border-radius: 9999px; - background: color-mix(in srgb, var(--foreground) 18%, transparent); - animation: feature-tour-dot-pulse var(--feature-tour-cycle) linear infinite; -} -.feature-tour-dot[data-frame='1'] { - animation-delay: 0s; -} -.feature-tour-dot[data-frame='2'] { - animation-delay: 4s; -} -.feature-tour-dot[data-frame='3'] { - animation-delay: 8s; -} -.feature-tour-dot[data-frame='4'] { - animation-delay: 12s; -} -@keyframes feature-tour-dot-pulse { - 0%, - 24% { - background: var(--primary); - transform: scale(1.05); - } - 25%, - 100% { - background: color-mix(in srgb, var(--foreground) 18%, transparent); - transform: scale(1); - } -} - -/* Frame 1 — Workspace: agent-state dot pulses while a "fix login race" - workspace shows two agent rows working in parallel. */ -.feature-tour-ws-status { - animation: feature-tour-status-pulse 1.6s ease-in-out infinite; -} -@keyframes feature-tour-status-pulse { - 0%, - 100% { - opacity: 0.95; - transform: scale(1); - } - 50% { - opacity: 0.55; - transform: scale(0.85); - } -} - -/* Frame 2 — Orchestration: child cards flash as bubbles land along the - dashed paths fanning down from the coordinator. */ -/* Why: bubbles use percent-based left/top so they always travel from the - root card's right edge (34%) to the child card's left edge (64%), - regardless of preview width. The previous offset-path used hardcoded - pixels and drifted off the dashed lines once the preview resized. */ -.feature-tour-orch-bubble { - position: absolute; - left: 34%; - top: 50%; - width: 12px; - height: 12px; - margin-left: -6px; - margin-top: -6px; - border-radius: 3px; - background: var(--primary); - color: var(--primary-foreground); - display: inline-flex; - align-items: center; - justify-content: center; - opacity: 0; - z-index: 4; -} -.feature-tour-orch-bubble.top { - animation: feature-tour-orch-travel-top var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: 4s; -} -.feature-tour-orch-bubble.mid { - animation: feature-tour-orch-travel-mid var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-bubble.bot { - animation: feature-tour-orch-travel-bot var(--feature-tour-cycle) cubic-bezier(0.4, 0, 0.2, 1) - infinite; - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-travel-top { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 18%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 18%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 18%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-mid { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 6.5% { - left: 64%; - top: 50%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 50%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 50%; - opacity: 0; - } -} -@keyframes feature-tour-orch-travel-bot { - 0% { - left: 34%; - top: 50%; - opacity: 0; - } - 2% { - opacity: 1; - } - 3.5% { - left: 49%; - top: 50%; - opacity: 1; - } - 6.5% { - left: 64%; - top: 82%; - opacity: 1; - } - 7.5% { - left: 64%; - top: 82%; - opacity: 0; - } - 25%, - 100% { - left: 64%; - top: 82%; - opacity: 0; - } -} - -.feature-tour-orch-child { - animation: feature-tour-orch-child-flash var(--feature-tour-cycle) linear infinite; -} -.feature-tour-orch-child.top { - animation-delay: 4s; -} -.feature-tour-orch-child.mid { - animation-delay: calc(4s + 0.2s); -} -.feature-tour-orch-child.bot { - animation-delay: calc(4s + 0.4s); -} -@keyframes feature-tour-orch-child-flash { - 0%, - 6% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 7% { - box-shadow: 0 0 0 2px color-mix(in srgb, var(--foreground) 12%, transparent); - border-color: color-mix(in srgb, var(--foreground) 38%, transparent); - } - 9% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } - 25%, - 100% { - box-shadow: 0 0 0 0 transparent; - border-color: var(--border); - } -} - -/* Frame 3 — Tasks: cursor lands on the second row, the "Open" pill morphs - to a dark "Start workspace →" pill, then a workspace card slides in. */ -.feature-tour-tasks-cursor { - /* Why: anchored from the row's right edge so the cursor sits just past the - pill's right edge (12px row padding + ~6px past pill end) instead of - covering the label text. The arrow tip (top-left of the SVG) lands on the - dark pill's trailing edge with the body of the cursor extending right. */ - position: absolute; - top: 50%; - right: 14px; - width: 14px; - height: 14px; - pointer-events: none; - z-index: 8; - transform: translate(120px, -30%); - opacity: 0; - animation: feature-tour-tasks-cursor var(--feature-tour-cycle) cubic-bezier(0.45, 0.05, 0.2, 1) - infinite; - animation-delay: 8s; -} -.feature-tour-tasks-cursor svg { - width: 14px; - height: 14px; - display: block; -} -@keyframes feature-tour-tasks-cursor { - 0% { - transform: translate(160px, -10%); - opacity: 0; - } - 2% { - opacity: 1; - } - 5% { - transform: translate(60px, -30%); - opacity: 1; - } - 8% { - transform: translate(0px, -30%); - opacity: 1; - } - 9% { - transform: translate(0px, -10%); - opacity: 1; - } - 10.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 23.5% { - transform: translate(0px, -30%); - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - transform: translate(0px, -30%); - opacity: 0; - } -} - -.feature-tour-tasks-row { - animation: feature-tour-tasks-row-hover var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-row-hover { - 0%, - 4% { - background: transparent; - box-shadow: none; - } - 6% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 23.5% { - background: color-mix(in srgb, var(--foreground) 5%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); - } - 25%, - 100% { - background: transparent; - box-shadow: none; - } -} - -.feature-tour-tasks-pill { - animation: feature-tour-tasks-pill-morph var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-morph { - 0%, - 6% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } - 8% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 23.5% { - width: 78px; - background: var(--primary); - border-color: var(--primary); - box-shadow: 0 1px 2px rgb(0 0 0 / 0.18); - } - 25%, - 100% { - width: 28px; - background: rgb(34 197 94 / 0.14); - border-color: rgb(34 197 94 / 0.3); - box-shadow: none; - } -} - -.feature-tour-tasks-pill-label { - opacity: 0; - animation: feature-tour-tasks-pill-label var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-pill-label { - 0%, - 7% { - opacity: 0; - } - 9% { - opacity: 1; - } - 23.5% { - opacity: 1; - } - 24.5% { - opacity: 0; - } - 25%, - 100% { - opacity: 0; - } -} - -/* Click ring fires when the cursor "clicks" the morphed pill on the tasks - row. Positioned to overlay the right-side pill of the second task. */ -.feature-tour-tasks-click-ring { - /* Why: ring is centered on the cursor tip (right=28 from row's right edge) - so the click feedback lands where the cursor is, on the arrow icon — not - centered on the pill, which would visually disconnect from the cursor. */ - position: absolute; - top: 50%; - right: 14px; - width: 28px; - height: 28px; - margin-top: -14px; - border-radius: 9999px; - border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent); - opacity: 0; - z-index: 7; - pointer-events: none; - animation: feature-tour-tasks-click-ring var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-click-ring { - 0%, - 7% { - opacity: 0; - transform: scale(0.4); - } - 8% { - opacity: 0.85; - transform: scale(0.6); - } - 10% { - opacity: 0; - transform: scale(1.4); - } - 25%, - 100% { - opacity: 0; - transform: scale(1.4); - } -} - -/* Newly-created workspace card slides in beneath the task row once the - user clicks "Start workspace" — sells the "task → workspace" jump. */ -.feature-tour-tasks-workspace { - opacity: 0; - transform: translateY(-6px); - animation: feature-tour-tasks-workspace var(--feature-tour-cycle) linear infinite; - animation-delay: 8s; -} -@keyframes feature-tour-tasks-workspace { - 0%, - 10% { - opacity: 0; - transform: translateY(-6px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(-6px); - } -} - -/* Frame 4 — Terminal: command stays anchored while the split panes stream - Playwright and Claude activity. */ -.feature-tour-terminal-line { - width: 23ch; -} - -.feature-tour-terminal-output { - opacity: 0; - transform: translateY(2px); - animation: feature-tour-terminal-output var(--feature-tour-cycle) linear infinite; - animation-delay: 12s; -} -.feature-tour-terminal-output[data-line='1'] { - animation-delay: calc(12s + 0.05s); -} -.feature-tour-terminal-output[data-line='2'] { - animation-delay: calc(12s + 0.25s); -} -.feature-tour-terminal-output[data-line='3'] { - animation-delay: calc(12s + 0.45s); -} -@keyframes feature-tour-terminal-output { - 0%, - 10% { - opacity: 0; - transform: translateY(2px); - } - 12% { - opacity: 1; - transform: translateY(0); - } - 23.5% { - opacity: 1; - transform: translateY(0); - } - 25%, - 100% { - opacity: 0; - transform: translateY(2px); - } -} - -@media (prefers-reduced-motion: reduce) { - .feature-tour-frame, - .feature-tour-copy-slide, - .feature-tour-dot, - .feature-tour-ws-status, - .feature-tour-orch-bubble, - .feature-tour-orch-child, - .feature-tour-tasks-cursor, - .feature-tour-tasks-row, - .feature-tour-tasks-pill, - .feature-tour-tasks-pill-label, - .feature-tour-tasks-click-ring, - .feature-tour-tasks-workspace, - .feature-tour-terminal-output { - animation: none !important; - } - .feature-tour-frame[data-frame='1'], - .feature-tour-copy-slide[data-frame='1'] { - opacity: 1; - } - .feature-tour-dot[data-frame='1'] { - background: var(--primary); - } -} - /* ── Diff comment decorations ────────────────────────────────────── */ /* Why: the "+" button is appended into Monaco's editor DOM node by diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 092ba2a7d0f..eee4a5a106c 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -115,10 +115,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -126,11 +128,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { getCommentReplyTargetCandidates, resolveCommentReplyTarget diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 33cfeb2f8c6..0f41a070371 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -111,10 +111,12 @@ import { import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../shared/pr-comment-audience' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' import { getPRCommentGroupCount, @@ -122,11 +124,13 @@ import { getPRCommentGroupRoot, groupPRComments, isResolvedPRCommentGroup, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' +import { PR_COMMENT_OPEN_AUTHOR_CLASS, PR_COMMENT_RESOLVED_AUTHOR_CLASS, - PR_COMMENT_RESOLVED_CONTAINER_CLASS, - type PRCommentGroup -} from '@/lib/pr-comment-groups' + PR_COMMENT_RESOLVED_CONTAINER_CLASS +} from '@/lib/pr-comment-resolution-classes' import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' import { attachPRReviewReplyParent, diff --git a/src/renderer/src/components/QuickOpen.mount-gating.test.tsx b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx new file mode 100644 index 00000000000..fa0c57dfcf8 --- /dev/null +++ b/src/renderer/src/components/QuickOpen.mount-gating.test.tsx @@ -0,0 +1,143 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import QuickOpen from './QuickOpen' + +const contentProbe = vi.hoisted(() => ({ + enabled: vi.fn(), + renders: vi.fn(), + storeNotifications: vi.fn(), + subscriptions: vi.fn(), + unsubscriptions: vi.fn() +})) + +vi.mock('@/components/quick-open-file-list', async () => { + const React = await import('react') + return { + useRuntimeFileListForWorktree: ({ enabled }: { enabled: boolean }) => { + contentProbe.enabled(enabled) + contentProbe.renders() + React.useEffect(() => { + contentProbe.subscriptions() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unsubscriptions() + unsubscribe() + } + }, []) + return { files: [], loading: false, loadError: null } + } + } +}) + +vi.mock('@/hooks/useModalReturnFocus', () => ({ + useModalReturnFocus: () => ({ captureReturnFocus: vi.fn(), skipReturnFocus: vi.fn() }) +})) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/command', () => { + return { + CommandDialog: ({ children, open }: { children: ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: () => , + CommandList: ({ children }: { children: ReactNode }) =>
{children}
, + CommandEmpty: ({ children }: { children: ReactNode }) =>
{children}
, + CommandItem: ({ children }: { children: ReactNode }) =>
{children}
+ } +}) + +const initialAppState = useAppStore.getInitialState() +let testContainer: HTMLDivElement +let testRoot: Root + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function activeContentSubscriptions(): number { + return ( + contentProbe.subscriptions.mock.calls.length - contentProbe.unsubscriptions.mock.calls.length + ) +} + +async function churnClosedStore(count: number): Promise { + await act(async () => { + for (let index = 0; index < count; index += 1) { + useAppStore.setState({ activeWorktreeId: `remote-background-${index}` }) + } + }) +} + +describe('QuickOpen mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + contentProbe.enabled.mockClear() + contentProbe.renders.mockClear() + contentProbe.storeNotifications.mockClear() + contentProbe.subscriptions.mockClear() + contentProbe.unsubscriptions.mockClear() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ activeModal: 'quick-open', activeWorktreeId: null }) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('unmounts subscribed content after the close linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).toBeNull() + expect(contentProbe.enabled).toHaveBeenLastCalledWith(false) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(299)) + expect(activeContentSubscriptions()).toBe(1) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(activeContentSubscriptions()).toBe(0) + + const rendersAfterUnmount = contentProbe.renders.mock.calls.length + const notificationsAfterUnmount = contentProbe.storeNotifications.mock.calls.length + await churnClosedStore(1_000) + + expect(contentProbe.renders).toHaveBeenCalledTimes(rendersAfterUnmount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(notificationsAfterUnmount) + }) + + it('cancels the pending unmount when reopened during the linger', async () => { + await act(async () => testRoot.render()) + await flushEffects() + + await act(async () => useAppStore.setState({ activeModal: 'none' })) + await act(async () => vi.advanceTimersByTimeAsync(299)) + await act(async () => useAppStore.setState({ activeModal: 'quick-open' })) + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-command-dialog="true"]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + }) +}) diff --git a/src/renderer/src/components/QuickOpen.tsx b/src/renderer/src/components/QuickOpen.tsx index 05e2613ce0d..cb7c8847642 100644 --- a/src/renderer/src/components/QuickOpen.tsx +++ b/src/renderer/src/components/QuickOpen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useDeferredValue, useMemo, useState } from 'react' +import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react' import { useAppStore } from '@/store' import { useActiveWorktree } from '@/store/selectors' import { detectLanguage } from '@/lib/language-detect' @@ -21,6 +21,8 @@ import { QuickOpenInstallRgGuidance } from '@/components/quick-open-install-rg-guidance' +const QUICK_OPEN_CLOSE_LINGER_MS = 300 + function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Element { return ( @@ -31,6 +33,24 @@ function FooterKey({ children }: { children: React.ReactNode }): React.JSX.Eleme export default function QuickOpen(): React.JSX.Element | null { const visible = useAppStore((s) => s.activeModal === 'quick-open') + const [lingering, setLingering] = useState(visible) + useEffect(() => { + if (visible) { + setLingering(true) + return + } + // Why: keep scan cancellation and the dialog exit animation mounted before releasing remote file state. + const timer = window.setTimeout(() => setLingering(false), QUICK_OPEN_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [visible]) + + if (!visible && !lingering) { + return null + } + return +} + +function QuickOpenContent({ visible }: { visible: boolean }): React.JSX.Element { const closeModal = useAppStore((s) => s.closeModal) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const openFile = useAppStore((s) => s.openFile) diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx index e036dcef419..25a9f137397 100644 --- a/src/renderer/src/components/automations/AutomationListExternalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' import { ContextMenu, ContextMenuContent, @@ -7,6 +7,14 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import type { ExternalAutomationAction, @@ -22,6 +30,13 @@ import { } from './external-automation-display' import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability' +import { + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + AUTOMATIONS_TABLE_ROW_SELECTED_CLASS +} from './automations-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' +import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' export function AutomationListExternalRows({ @@ -52,10 +67,8 @@ export function AutomationListExternalRows({ {entries.map((entry) => { const providerLabel = getExternalProviderLabel(entry.manager) const targetKindLabel = getExternalTargetKindLabel(entry.manager) - const nextRunLabel = entry.job.enabled - ? formatExternalDate(entry.job.nextRunAt, relativeNow) - : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const entrySshStatus = + const isSelected = selectedExternalKey === entry.key + const sshStatus = entry.manager.target.type === 'ssh' ? sshConnectionStates.get(entry.manager.target.connectionId)?.status : undefined @@ -63,66 +76,140 @@ export function AutomationListExternalRows({ manager: entry.manager, providerLabel, targetKindLabel, - sshStatus: entrySshStatus, + sshStatus, actionInProgress: externalActionKey !== null }) const actionDisabled = disabledMessage !== null - const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) + const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label + const projectLabel = `${providerLabel} / ${entry.manager.targetLabel}` + const nextRunLabel = entry.job.enabled + ? formatExternalDate(entry.job.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + return ( - + + + onRequestAction(entry.manager, entry.job, 'run')} + > + + + {disabledMessage ?? + translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + )} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job)} + > + + {translate( + 'auto.components.automations.AutomationsPage.f4612e3f78', + 'Edit' + )} + + ) : null} + + onRequestAction( + entry.manager, + entry.job, + entry.job.enabled ? 'pause' : 'resume' + ) + } + > + {entry.job.enabled ? ( + + ) : ( + + )} + {entry.job.enabled ? translate( - 'auto.components.automations.AutomationsPage.runCount', - '{{count}} runs', - { count: entry.job.runCount } + 'auto.components.automations.AutomationsPage.b457436d6a', + 'Pause' ) - : entry.manager.canManage - ? translate( - 'auto.components.automations.AutomationsPage.aecdc3681f', - 'Manageable' - ) - : translate( - 'auto.components.automations.AutomationsPage.e059042585', - 'Read-only' - )} - - - - - - {nextRunLabel} - - + : translate( + 'auto.components.automations.AutomationsPage.376631ef2b', + 'Resume' + )} + + + onRequestAction(entry.manager, entry.job, 'delete')} + > + + {translate( + 'auto.components.automations.AutomationsPage.15e0bfb13b', + 'Delete' + )} + + + +
({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +function makeAutomation(overrides: Partial = {}): Automation { + return { + id: 'automation-1', + name: 'test', + prompt: 'Run checks', + precheck: null, + agentId: 'codex', + projectId: 'repo-1', + executionTargetType: 'local', + executionTargetId: 'local', + schedulerOwner: 'local_host_service', + workspaceMode: 'existing', + workspaceId: 'worktree-1', + baseBranch: null, + reuseSession: false, + timezone: 'America/Los_Angeles', + rrule: 'FREQ=DAILY', + dtstart: 1, + enabled: true, + nextRunAt: Date.now() + 60_000, + missedRunPolicy: 'run_once_within_grace', + missedRunGraceMinutes: 720, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function makeExternalJob(overrides: Partial = {}): ExternalAutomationJob { + return { + id: 'job-1', + managerId: 'manager-1', + provider: 'hermes', + name: 'Nightly', + schedule: '0 0 * * *', + rawSchedule: null, + enabled: true, + state: 'enabled', + prompt: null, + promptPreview: '', + nextRunAt: new Date(Date.now() + 60_000).toISOString(), + lastRunAt: null, + lastStatus: null, + lastError: null, + workdir: null, + runCount: 0, + runs: [], + ...overrides + } +} + +function makeExternalManager(): ExternalAutomationManager { + return { + id: 'manager-1', + provider: 'hermes', + label: 'Local Hermes', + targetLabel: 'Local', + target: { type: 'local' }, + status: 'available', + error: null, + canManage: true, + jobs: [] + } +} + +function renderLocalRows(handlers: { + onSelect: (automationId: string) => void + onDelete?: (automation: Automation) => void +}) { + return render( + + ) +} + +function renderExternalRows(handlers: { + onSelect: (entryKey: string) => void + onRequestAction?: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: 'run' | 'pause' | 'resume' | 'delete' + ) => void +}) { + const manager = makeExternalManager() + const job = makeExternalJob() + return render( + + ) +} + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +describe('Automation list row selection', () => { + it('does not open local detail when a quick-action menu item is chosen', async () => { + // Radix portals the menu out of the row's DOM, but React bubbles its + // clicks back through the component tree. Selecting Delete must not call + // onSelect (which opens the detail page behind the confirm dialog). + const onSelect = vi.fn() + const onDelete = vi.fn() + renderLocalRows({ onSelect, onDelete }) + const user = userEvent.setup() + + await user.click( + screen.getByRole('button', { + name: 'Automation actions' + }) + ) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onDelete).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('still selects when the local row itself is clicked', async () => { + const onSelect = vi.fn() + const { container } = renderLocalRows({ onSelect }) + const user = userEvent.setup() + + const row = container.querySelector('[data-slot="context-menu-trigger"]') + expect(row).not.toBeNull() + await user.click(row as Element) + + expect(onSelect).toHaveBeenCalledTimes(1) + expect(onSelect).toHaveBeenCalledWith('automation-1') + }) + + it('opens the actions menu on keyboard activation without selecting the row', async () => { + // The row handles Enter/Space for its own activation. Those keys bubble + // from the actions trigger too, so an unguarded row handler would + // preventDefault the button's native activation and open detail instead. + const onSelect = vi.fn() + renderLocalRows({ onSelect }) + const user = userEvent.setup() + + screen.getByRole('button', { name: 'Automation actions' }).focus() + await user.keyboard('{Enter}') + + expect(await screen.findByRole('menuitem', { name: 'Delete' })).toBeTruthy() + expect(onSelect).not.toHaveBeenCalled() + }) + + it('does not open external detail when a quick-action menu item is chosen', async () => { + const onSelect = vi.fn() + const onRequestAction = vi.fn() + renderExternalRows({ onSelect, onRequestAction }) + const user = userEvent.setup() + + await user.click( + screen.getByRole('button', { + name: 'Automation actions' + }) + ) + await user.click(await screen.findByRole('menuitem', { name: 'Delete' })) + + expect(onRequestAction).toHaveBeenCalledTimes(1) + expect(onSelect).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx index 1a5a4994aac..97ec2c7bb5f 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRows.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react' import { ContextMenu, ContextMenuContent, @@ -7,24 +7,41 @@ import { ContextMenuSeparator, ContextMenuTrigger } from '@/components/ui/context-menu' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' import type { Automation, AutomationRun } from '../../../../shared/automations-types' import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import { + getExecutionHostLabel, + getLocalExecutionHostLabel, + getRepoExecutionHostId +} from '../../../../shared/execution-host' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationHostTarget } from './automation-host-client' import { formatAutomationDateTimeWithRelative } from './automation-page-parts' -import { - formatAutomationCost, - formatAutomationTokens, - summarizeAutomationRunUsage -} from './automation-usage-model' import { getAutomationTargetAvailability } from './automation-target-availability' import { getAgentLabel } from './automation-draft-model' +import { formatAutomationCost } from './automation-usage-model' +import { + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + AUTOMATIONS_TABLE_ROW_SELECTED_CLASS +} from './automations-table-layout' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' +import { AutomationListStatusCell } from './AutomationListStatusCell' import { translate } from '@/i18n/i18n' export function AutomationListLocalRows({ @@ -40,6 +57,7 @@ export function AutomationListLocalRows({ runtimeStatusByEnvironmentId, automationHostTarget, automationSourceHostAvailabilityById, + hostLabelById, onSelect, onRunNow, onEdit, @@ -61,6 +79,7 @@ export function AutomationListLocalRows({ > automationHostTarget: AutomationHostTarget | null automationSourceHostAvailabilityById: ReadonlyMap + hostLabelById: ReadonlyMap onSelect: (automationId: string) => void onRunNow: (automation: Automation) => void onEdit: (automation: Automation) => void @@ -69,18 +88,17 @@ export function AutomationListLocalRows({ }): React.JSX.Element { // Why: one pass over runs instead of a full scan per rendered automation — // this list re-renders on the relativeNow timer. - const runsByAutomationId = React.useMemo(() => { - const grouped = new Map() + const lastRunByAutomationId = React.useMemo(() => { + const latest = new Map() for (const run of runs) { - const existing = grouped.get(run.automationId) - if (existing) { - existing.push(run) - } else { - grouped.set(run.automationId, [run]) + const existing = latest.get(run.automationId) + if (!existing || run.createdAt > existing.createdAt) { + latest.set(run.automationId, run) } } - return grouped + return latest }, [runs]) + return ( <> {automations.map((automation) => { @@ -98,148 +116,237 @@ export function AutomationListLocalRows({ automationHostTarget, sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) }) - const baseRefLabel = - automation.baseBranch ?? - automationRepo?.worktreeBaseRef ?? - translate( - 'auto.components.automations.AutomationsPage.projectDefaultBaseRef', - 'project default' - ) - const workspaceLabel = - automation.workspaceMode === 'new_per_run' - ? translate( - 'auto.components.automations.AutomationsPage.createFromBaseRef', - 'Create from {{baseRef}}', - { baseRef: baseRefLabel } - ) - : (automationWorktree?.displayName ?? - translate( - 'auto.components.automations.AutomationsPage.missingWorkspace', - 'Missing workspace' - )) - const usageSummary = summarizeAutomationRunUsage( - runsByAutomationId.get(automation.id) ?? [] - ) - const usageText = - usageSummary.knownRuns > 0 - ? translate( - 'auto.components.automations.AutomationsPage.runUsageSummary', - '{{cost}} est. · {{tokens}} tokens', - { - cost: formatAutomationCost(usageSummary.estimatedCostUsd), - tokens: formatAutomationTokens(usageSummary.totalTokens) - } - ) - : usageSummary.unavailableRuns > 0 - ? translate( - 'auto.components.automations.AutomationsPage.usageUnavailable', - 'Usage unavailable' - ) - : translate( - 'auto.components.automations.AutomationsPage.noRunUsageYet', - 'No run usage yet' - ) + const projectLabel = + automationRepo?.displayName ?? + translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project') + const scheduleLabel = formatAutomationSchedule(automation.rrule) const nextRunLabel = automation.enabled ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) : translate('auto.components.automations.AutomationsPage.paused', 'Paused') - const scheduleLabel = formatAutomationSchedule(automation.rrule) + const isSelected = isSelectedLocal && selectedId === automation.id + const agentLabel = getAgentLabel(automation.agentId) + const hostId = + automation.runContext?.hostId ?? + (automationRepo ? getRepoExecutionHostId(automationRepo) : null) + const hostLabel = hostId + ? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId)) + : getLocalExecutionHostLabel() + const lastRun = lastRunByAutomationId.get(automation.id) + const lastRunCost = + lastRun?.usage?.status === 'known' + ? formatAutomationCost(lastRun.usage.estimatedCostUsd) + : formatAutomationCost(null) + const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${lastRunCost}` + + const actionItems = ( + <> + onRunNow(automation)} + /> + } + label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + onSelect={() => onEdit(automation)} + /> + : + } + label={ + automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume') + } + onSelect={() => onToggle(automation)} + /> + + } + label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + variant="destructive" + onSelect={() => onDelete(automation)} + /> + + ) + return ( - - - - { - if (!automationRunAvailability.canRunNow) { - event.preventDefault() +
{ + // Why: Radix portals menus out of the row DOM, but React still + // bubbles those clicks here — ignore so menu actions don't open detail. + if (isPortaledRowMenuClick(event)) { return } - onRunNow(automation) + onSelect(automation.id) }} - > - - - {automationRunAvailability.canRunNow - ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now') - : automationRunAvailability.message} - - - onEdit(automation)}> - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - onToggle(automation)}> - {automation.enabled ? ( - - ) : ( - + onKeyDown={(event) => { + if (!isRowActivationKey(event)) { + return + } + event.preventDefault() + onSelect(automation.id) + }} + className={cn( + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_ROW_CLASS, + isSelected && AUTOMATIONS_TABLE_ROW_SELECTED_CLASS )} - {automation.enabled - ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') - : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} - - - onDelete(automation)}> - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} - - + > + {automation.name} + + {scheduleLabel} + + + {projectLabel} + + + {nextRunLabel} + + + + + + + + + + {agentTooltipLabel} + + + + + + + + { + if (automationRunAvailability.canRunNow) { + onRunNow(automation) + } + }} + > + + + {automationRunAvailability.canRunNow + ? translate( + 'auto.components.automations.AutomationsPage.2faecab10b', + 'Run Now' + ) + : automationRunAvailability.message} + + + onEdit(automation)}> + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + onToggle(automation)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled + ? translate( + 'auto.components.automations.AutomationsPage.b457436d6a', + 'Pause' + ) + : translate( + 'auto.components.automations.AutomationsPage.376631ef2b', + 'Resume' + )} + + + onDelete(automation)}> + + {translate( + 'auto.components.automations.AutomationsPage.15e0bfb13b', + 'Delete' + )} + + + +
+ + {actionItems}
) })} ) } + +function MenuRunItem({ + disabled, + label, + onSelect +}: { + disabled: boolean + label: string + onSelect: () => void +}): React.JSX.Element { + return ( + { + if (disabled) { + event.preventDefault() + return + } + onSelect() + }} + > + + {label} + + ) +} + +function MenuItem({ + icon, + label, + onSelect, + variant +}: { + icon: React.ReactNode + label: string + onSelect: () => void + variant?: 'destructive' +}): React.JSX.Element { + return ( + + {icon} + {label} + + ) +} + +function MenuSeparator(): React.JSX.Element { + return +} diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx index d1691a49036..fead100d80e 100644 --- a/src/renderer/src/components/automations/AutomationListSearchField.tsx +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -34,6 +34,7 @@ export function AutomationListSearchField({ onQueryChange(event.target.value)} diff --git a/src/renderer/src/components/automations/AutomationListStatusCell.tsx b/src/renderer/src/components/automations/AutomationListStatusCell.tsx new file mode 100644 index 00000000000..b8da08336de --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListStatusCell.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { X } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +export function AutomationListStatusCell({ enabled }: { enabled: boolean }): React.JSX.Element { + if (enabled) { + return ( + + + {translate('auto.components.automations.AutomationDetail.eaa02014f8', 'Enabled')} + + ) + } + return ( + + + {translate('auto.components.automations.AutomationDetail.b09b2384fd', 'Paused')} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx index 1864e085680..48ffceba88f 100644 --- a/src/renderer/src/components/automations/AutomationsDetailPane.tsx +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Eye, RefreshCw } from 'lucide-react' +import { ArrowLeft, Eye, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { cn } from '@/lib/utils' @@ -80,6 +80,7 @@ type AutomationsDetailPaneProps = { rerunAutomationRun: (automation: Automation, run: AutomationRun) => void openRunWorkspace: (run: AutomationRun) => void openAutomationRunPage: (run: AutomationRun) => void + onBackToList: () => void } export function AutomationsDetailPane({ @@ -114,12 +115,22 @@ export function AutomationsDetailPane({ requestDeleteAutomation, rerunAutomationRun, openRunWorkspace, - openAutomationRunPage + openAutomationRunPage, + onBackToList }: AutomationsDetailPaneProps): React.JSX.Element { return ( -
+
{selectedExternal ? ( -
+
+
+ +
{selectedExternalRunPage ? (
+ {translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 31d9cc9d6d9..a9c246fe782 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -1,6 +1,8 @@ import React from 'react' -import { Plus } from 'lucide-react' +import { Plus, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' import type { Automation, AutomationRun, @@ -20,6 +22,11 @@ import { getAutomationTemplates, type AutomationTemplate } from './automation-te import type { ExternalAutomationListEntry } from './external-automation-list-entries' import { AutomationListLocalRows } from './AutomationListLocalRows' import { AutomationListExternalRows } from './AutomationListExternalRows' +import { + AUTOMATIONS_TABLE_CONTAINER_CLASS, + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_HEADER_CLASS +} from './automations-table-layout' import { translate } from '@/i18n/i18n' type AutomationsListPanelProps = { @@ -31,8 +38,10 @@ type AutomationsListPanelProps = { onListSearchQueryChange: (query: string) => void filteredAutomations: readonly Automation[] filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] - selected: Automation | null - selectedExternal: ExternalAutomationListEntry | null + // Why: use explicit ids, not resolved detail selection (which falls back to the + // first row) — list highlight should only mark a user-chosen row. + selectedId: string | null + selectedExternalKey: string | null runs: readonly AutomationRun[] relativeNow: number repoMap: ReadonlyMap @@ -45,6 +54,7 @@ type AutomationsListPanelProps = { > automationHostTarget: AutomationHostTarget | null automationSourceHostAvailabilityById: ReadonlyMap + hostLabelById: ReadonlyMap externalActionKey: string | null selectAutomationId: (automationId: string | null) => void selectExternalKey: (externalKey: string | null) => void @@ -60,6 +70,9 @@ type AutomationsListPanelProps = { ) => void openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void openCreateDialog: (template?: AutomationTemplate) => void + onOpenDetail: () => void + onRefresh: () => void + isRefreshing: boolean } export function AutomationsListPanel({ @@ -71,8 +84,8 @@ export function AutomationsListPanel({ onListSearchQueryChange, filteredAutomations, filteredExternalAutomationEntries, - selected, - selectedExternal, + selectedId, + selectedExternalKey, runs, relativeNow, repoMap, @@ -82,6 +95,7 @@ export function AutomationsListPanel({ runtimeStatusByEnvironmentId, automationHostTarget, automationSourceHostAvailabilityById, + hostLabelById, externalActionKey, selectAutomationId, selectExternalKey, @@ -92,81 +106,19 @@ export function AutomationsListPanel({ requestDeleteAutomation, requestExternalAction, openEditExternalDialog, - openCreateDialog + openCreateDialog, + onOpenDetail, + onRefresh, + isRefreshing }: AutomationsListPanelProps): React.JSX.Element { return (
- {hasListItems ? ( -
- - onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) - } - onClear={() => onListSearchQueryChange('')} - /> -
- ) : null} -
- {hasFilteredListItems ? ( -
- - {translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')} - - - {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} - -
- ) : null} - { - selectExternalKey(null) - selectAutomationId(automationId) - }} - onRunNow={runNow} - onEdit={openEditDialog} - onToggle={toggleAutomation} - onDelete={requestDeleteAutomation} - /> - { - selectExternalKey(entryKey) - setActivePaneTab('overview') - }} - onRequestAction={requestExternalAction} - onEdit={openEditExternalDialog} - /> - {hasListItems && isListSearchActive && !hasFilteredListItems ? ( -
- {translate( - 'auto.components.automations.AutomationsPage.noSearchMatches', - 'No automations match your search.' - )} -
- ) : null} - {!hasListItems ? ( -
+ {!hasListItems ? ( +
+
{translate( 'auto.components.automations.AutomationsPage.d207ab4c25', @@ -199,8 +151,157 @@ export function AutomationsListPanel({ {translate('auto.components.automations.AutomationsPage.25060635c6', 'Add new')}
- ) : null} -
+
+ ) : ( +
+
+
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + /> + + + + + + {translate( + 'auto.components.automations.AutomationsPage.19a6e30eae', + 'Refresh automations' + )} + + +
+ +
+ +
+ {hasFilteredListItems ? ( + <> +
+ + {translate('auto.components.automations.AutomationsPage.tableName', 'Name')} + + + {translate( + 'auto.components.automations.AutomationDetail.18763ded26', + 'Schedule' + )} + + + {translate( + 'auto.components.automations.AutomationsPage.tableProject', + 'Project' + )} + + + {translate( + 'auto.components.automations.AutomationDetail.578ff46987', + 'Next run' + )} + + + {translate('auto.components.automations.AutomationsPage.tableStatus', 'Status')} + + + {translate('auto.components.automations.AutomationDetail.2df8970cd5', 'Agent')} + + + {translate( + 'auto.components.automations.AutomationsPage.tableActions', + 'Actions' + )} + +
+
+ { + selectExternalKey(null) + selectAutomationId(automationId) + onOpenDetail() + }} + onRunNow={runNow} + onEdit={openEditDialog} + onToggle={toggleAutomation} + onDelete={requestDeleteAutomation} + /> + { + selectAutomationId(null) + selectExternalKey(entryKey) + setActivePaneTab('overview') + onOpenDetail() + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> +
+ + ) : isListSearchActive ? ( +
+ {translate( + 'auto.components.automations.AutomationsPage.noSearchMatches', + 'No automations match your search.' + )} +
+ ) : null} +
+
+ )}
) } diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 7293b79ad22..996f474a7c9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1,16 +1,12 @@ /* eslint-disable max-lines -- Why: this page owns the automations list/detail * orchestration while the form, list, and detail presentation live in sibling files. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { CalendarClock, Plus, RefreshCw, X } from 'lucide-react' import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import { Button } from '@/components/ui/button' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' -import { cn } from '@/lib/utils' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -108,10 +104,12 @@ import { isMissingExternalRunsApiError } from './external-automation-display' import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { shouldCloseDetailForLostSelection } from './automation-detail-selection' import { useAutomationListSearch } from './use-automation-list-search' import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' import { AutomationsListPanel } from './AutomationsListPanel' import { AutomationsDetailPane } from './AutomationsDetailPane' +import { AutomationsPageSkeleton } from './AutomationsPageSkeleton' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' @@ -186,6 +184,17 @@ export default function AutomationsPage(): React.JSX.Element { const [selectedExternalKey, setSelectedExternalKey] = useState(null) const [selectedExternalRunPage, setSelectedExternalRunPage] = useState(null) + // Why: list is the primary surface; detail is a full-page drill-in, not a side pane. + const [isDetailOpen, setIsDetailOpen] = useState(false) + const selectedExternalKeyRef = useRef(null) + const isDetailOpenRef = useRef(false) + // Keep async refresh/delete handlers reading the latest selection without render-time mutation. + useEffect(() => { + selectedExternalKeyRef.current = selectedExternalKey + }, [selectedExternalKey]) + useEffect(() => { + isDetailOpenRef.current = isDetailOpen + }, [isDetailOpen]) const runtimePreflightMountedRef = useRef(true) const runtimePreflightRequestedHostIdsRef = useRef>(new Set()) const [runtimePreflightStatusByHostId, setRuntimePreflightStatusByHostId] = useState< @@ -210,7 +219,7 @@ export default function AutomationsPage(): React.JSX.Element { } | null>(null) useContextualTour( 'automations', - !createOpen && !deleteTarget && !externalDeleteTarget, + !isLoading && !createOpen && !deleteTarget && !externalDeleteTarget, 'automations_open' ) const [editingExternalTarget, setEditingExternalTarget] = useState<{ @@ -429,6 +438,11 @@ export default function AutomationsPage(): React.JSX.Element { const pendingAutomation = automations.find( (automation) => automation.id === pending.automationId ) + // Why: external selection wins over local in detail resolution; clear it so + // pending local navigation cannot open the wrong automation. + if (selectedExternalKey !== null) { + selectExternalKey(null) + } if (!pendingAutomation) { // Why: stale provenance should not silently select the first automation. setSelectedId(pending.automationId) @@ -444,9 +458,11 @@ export default function AutomationsPage(): React.JSX.Element { } if (selectedId !== pending.automationId) { setSelectedId(pending.automationId) + setIsDetailOpen(true) return } if (!pending.runId) { + setIsDetailOpen(true) setActivePaneTab('overview') setSelectedAutomationRunPageId(null) setPendingAutomationRunNavigation(null) @@ -455,6 +471,7 @@ export default function AutomationsPage(): React.JSX.Element { if (selectedAutomationRuns.automationId !== pending.automationId) { return } + setIsDetailOpen(true) setActivePaneTab('runs') const pendingRun = selectedRuns.find((run) => run.id === pending.runId) if (pendingRun) { @@ -475,7 +492,9 @@ export default function AutomationsPage(): React.JSX.Element { automationHostTargetKey, isLoading, pendingAutomationRunNavigation, + selectExternalKey, selectedAutomationRuns.automationId, + selectedExternalKey, selectedId, selectedRuns, setPendingAutomationRunNavigation, @@ -761,6 +780,21 @@ export default function AutomationsPage(): React.JSX.Element { setExternalManagers(nextExternalManagers) if (!hasCurrentSelection && !pendingNavigation) { selectAutomationId(nextAutomations[0]?.id ?? null) + if ( + shouldCloseDetailForLostSelection({ + isDetailOpen: isDetailOpenRef.current, + hasPendingNavigation: false, + isSelectedAutomationInNextList: false, + isSelectedExternalInNextList: buildExternalAutomationListEntries( + nextExternalManagers + ).some((entry) => entry.key === selectedExternalKeyRef.current) + }) + ) { + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + } } } finally { setIsLoading(false) @@ -1468,6 +1502,9 @@ export default function AutomationsPage(): React.JSX.Element { await deleteAutomationForTarget(automation, automationHostTarget) if (useAppStore.getState().selectedAutomationId === automation.id) { selectAutomationId(null) + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setActivePaneTab('overview') } await refresh() } @@ -1608,6 +1645,16 @@ export default function AutomationsPage(): React.JSX.Element { useAppStore.getState().recordFeatureInteraction('automation-run') } await refresh() + // Why: full-page detail keeps selection when the deleted external was open; + // without this, detail can fall through to an unrelated local automation. + if (action === 'delete') { + const deletedKey = getExternalAutomationKey(manager, job) + if (selectedExternalKeyRef.current === deletedKey) { + selectExternalKey(null) + setIsDetailOpen(false) + setActivePaneTab('overview') + } + } toast.success( action === 'delete' ? translate( @@ -1800,91 +1847,53 @@ export default function AutomationsPage(): React.JSX.Element { return } + // Why: detail is a full-page drill-in; step out of nested run views first, + // then return to the table before leaving Automations. + if (isDetailOpen) { + event.preventDefault() + if (selectedExternalRunPage) { + setSelectedExternalRunPage(null) + return + } + if (selectedAutomationRunPageId) { + setSelectedAutomationRunPageId(null) + return + } + setIsDetailOpen(false) + setActivePaneTab('overview') + return + } + event.preventDefault() closeAutomationsPage() } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [closeAutomationsPage, createOpen, deleteTarget, externalDeleteTarget]) + }, [ + closeAutomationsPage, + createOpen, + deleteTarget, + externalDeleteTarget, + isDetailOpen, + selectedAutomationRunPageId, + selectedExternalRunPage + ]) return ( -
-
-
- - - - - - {translate('auto.components.automations.AutomationsPage.0329f9bef1', 'Close · Esc')} - - -
- -

- {translate('auto.components.automations.AutomationsPage.77c2778945', 'Automations')} -

- - - - - - {translate( - 'auto.components.automations.AutomationsPage.8d1afa8269', - 'Add automation' - )} - - -
-
- - - - - - {translate( - 'auto.components.automations.AutomationsPage.19a6e30eae', - 'Refresh automations' - )} - - -
+
+
+

+ {translate('auto.components.automations.AutomationsPage.77c2778945', 'Automations')} +

void confirmDeleteExternalAutomation()} /> -
- void runNow(automation)} - openEditDialog={(automation) => void openEditDialog(automation)} - toggleAutomation={(automation) => void toggleAutomation(automation)} - requestDeleteAutomation={requestDeleteAutomation} - requestExternalAction={requestExternalAction} - openEditExternalDialog={openEditExternalDialog} - openCreateDialog={openCreateDialog} - /> - + {/* Why: empty list flashes templates before data arrives; keep layout stable on first load. */} + {isLoading && !hasListItems ? ( + + ) : isDetailOpen && (selected || selectedExternal) ? ( void rerunAutomationRun(automation, run)} openRunWorkspace={openRunWorkspace} openAutomationRunPage={openAutomationRunPage} + onBackToList={() => { + setIsDetailOpen(false) + setSelectedAutomationRunPageId(null) + setSelectedExternalRunPage(null) + setActivePaneTab('overview') + }} /> -
+ ) : ( + void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + requestExternalAction={requestExternalAction} + openEditExternalDialog={openEditExternalDialog} + openCreateDialog={openCreateDialog} + onOpenDetail={() => setIsDetailOpen(true)} + onRefresh={() => void refresh()} + isRefreshing={isLoading} + /> + )}
) } diff --git a/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx new file mode 100644 index 00000000000..58990205c4c --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPageSkeleton.tsx @@ -0,0 +1,129 @@ +import React from 'react' +import { cn } from '@/lib/utils' +import { + AUTOMATIONS_TABLE_CONTAINER_CLASS, + AUTOMATIONS_TABLE_GRID_CLASS, + AUTOMATIONS_TABLE_HEADER_CLASS +} from './automations-table-layout' +import { translate } from '@/i18n/i18n' + +function SkeletonBar({ className }: { className?: string }): React.JSX.Element { + return
+} + +function TableRowSkeleton({ + nameWidthClass, + scheduleWidthClass, + projectWidthClass, + nextWidthClass, + statusWidthClass +}: { + nameWidthClass: string + scheduleWidthClass: string + projectWidthClass: string + nextWidthClass: string + statusWidthClass: string +}): React.JSX.Element { + return ( +
+ + + + + + + +
+ ) +} + +const TABLE_ROW_SKELETONS = [ + { + id: 'row-1', + nameWidthClass: 'w-28', + scheduleWidthClass: 'w-36', + projectWidthClass: 'w-28', + nextWidthClass: 'w-24', + statusWidthClass: 'w-16' + }, + { + id: 'row-2', + nameWidthClass: 'w-36', + scheduleWidthClass: 'w-28', + projectWidthClass: 'w-32', + nextWidthClass: 'w-20', + statusWidthClass: 'w-14' + }, + { + id: 'row-3', + nameWidthClass: 'w-24', + scheduleWidthClass: 'w-40', + projectWidthClass: 'w-24', + nextWidthClass: 'w-28', + statusWidthClass: 'w-16' + }, + { + id: 'row-4', + nameWidthClass: 'w-32', + scheduleWidthClass: 'w-32', + projectWidthClass: 'w-36', + nextWidthClass: 'w-24', + statusWidthClass: 'w-14' + }, + { + id: 'row-5', + nameWidthClass: 'w-40', + scheduleWidthClass: 'w-24', + projectWidthClass: 'w-28', + nextWidthClass: 'w-20', + statusWidthClass: 'w-16' + }, + { + id: 'row-6', + nameWidthClass: 'w-28', + scheduleWidthClass: 'w-36', + projectWidthClass: 'w-20', + nextWidthClass: 'w-24', + statusWidthClass: 'w-14' + } +] as const + +export function AutomationsPageSkeleton(): React.JSX.Element { + return ( +
+
+
+ + +
+ +
+
+
+ + + + + + + +
+
+ {TABLE_ROW_SKELETONS.map(({ id, ...row }) => ( + + ))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/automations/automation-detail-selection.test.ts b/src/renderer/src/components/automations/automation-detail-selection.test.ts new file mode 100644 index 00000000000..8dea844ea15 --- /dev/null +++ b/src/renderer/src/components/automations/automation-detail-selection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { shouldCloseDetailForLostSelection } from './automation-detail-selection' + +const base = { + isDetailOpen: true, + hasPendingNavigation: false, + isSelectedAutomationInNextList: false, + isSelectedExternalInNextList: false +} + +describe('shouldCloseDetailForLostSelection', () => { + it('closes detail when the refreshed list no longer contains the selection', () => { + expect(shouldCloseDetailForLostSelection(base)).toBe(true) + }) + + it('keeps detail open when the selected automation survived the refresh', () => { + expect( + shouldCloseDetailForLostSelection({ ...base, isSelectedAutomationInNextList: true }) + ).toBe(false) + }) + + it('keeps detail open when the selected external automation survived the refresh', () => { + expect(shouldCloseDetailForLostSelection({ ...base, isSelectedExternalInNextList: true })).toBe( + false + ) + }) + + it('keeps detail open when a pending run navigation will re-open it', () => { + expect(shouldCloseDetailForLostSelection({ ...base, hasPendingNavigation: true })).toBe(false) + }) + + it('does nothing when detail is already closed', () => { + expect(shouldCloseDetailForLostSelection({ ...base, isDetailOpen: false })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-detail-selection.ts b/src/renderer/src/components/automations/automation-detail-selection.ts new file mode 100644 index 00000000000..ce3487ee058 --- /dev/null +++ b/src/renderer/src/components/automations/automation-detail-selection.ts @@ -0,0 +1,20 @@ +/** + * True when a refresh dropped every selected automation while full-page detail is open — + * without closing, detail silently re-renders whichever automation the list now sorts first. + */ +export function shouldCloseDetailForLostSelection({ + isDetailOpen, + hasPendingNavigation, + isSelectedAutomationInNextList, + isSelectedExternalInNextList +}: { + isDetailOpen: boolean + hasPendingNavigation: boolean + isSelectedAutomationInNextList: boolean + isSelectedExternalInNextList: boolean +}): boolean { + if (!isDetailOpen || hasPendingNavigation) { + return false + } + return !isSelectedAutomationInNextList && !isSelectedExternalInNextList +} diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.test.ts b/src/renderer/src/components/automations/automation-list-row-interaction.test.ts new file mode 100644 index 00000000000..5e0703c174f --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-interaction.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from 'vitest' +import { isPortaledRowMenuClick, isRowActivationKey } from './automation-list-row-interaction' + +describe('isPortaledRowMenuClick', () => { + it('detects clicks whose target is outside the row DOM', () => { + const row = document.createElement('div') + const portaledMenuItem = document.createElement('div') + document.body.append(row, portaledMenuItem) + + expect( + isPortaledRowMenuClick({ + target: portaledMenuItem, + currentTarget: row + }) + ).toBe(true) + + row.remove() + portaledMenuItem.remove() + }) + + it('allows in-row clicks', () => { + const row = document.createElement('div') + const child = document.createElement('span') + row.append(child) + + expect( + isPortaledRowMenuClick({ + target: child, + currentTarget: row + }) + ).toBe(false) + }) +}) + +describe('isRowActivationKey', () => { + it('accepts Enter and Space on the row itself', () => { + const row = document.createElement('div') + + expect(isRowActivationKey({ key: 'Enter', target: row, currentTarget: row })).toBe(true) + expect(isRowActivationKey({ key: ' ', target: row, currentTarget: row })).toBe(true) + expect(isRowActivationKey({ key: 'a', target: row, currentTarget: row })).toBe(false) + }) + + it('ignores keys pressed on a nested control', () => { + const row = document.createElement('div') + const trigger = document.createElement('button') + row.append(trigger) + + expect(isRowActivationKey({ key: 'Enter', target: trigger, currentTarget: row })).toBe(false) + expect(isRowActivationKey({ key: ' ', target: trigger, currentTarget: row })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-row-interaction.ts b/src/renderer/src/components/automations/automation-list-row-interaction.ts new file mode 100644 index 00000000000..c09504bc543 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-row-interaction.ts @@ -0,0 +1,26 @@ +/** True when a portaled Radix menu click re-bubbles through a row's React tree. */ +export function isPortaledRowMenuClick(event: { + target: EventTarget + currentTarget: EventTarget +}): boolean { + const target = event.target + return target instanceof Node && event.currentTarget instanceof Node + ? !event.currentTarget.contains(target) + : false +} + +/** + * True when Enter/Space landed on the row itself. Keys pressed on a nested + * control (the actions trigger) must stay with that control — preventDefault + * here would swallow its native activation. + */ +export function isRowActivationKey(event: { + key: string + target: EventTarget + currentTarget: EventTarget +}): boolean { + if (event.target !== event.currentTarget) { + return false + } + return event.key === 'Enter' || event.key === ' ' +} diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts b/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts index 6397a3fa90c..b988ad96581 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot-equivalence.test.ts @@ -103,4 +103,23 @@ describe('automation run output snapshot queue equivalence', () => { expect(candidate.snapshot()).toEqual(reference.snapshot()) }) + + it('matches the shift reference across oversized and subsequent appends', () => { + const retained = `\ude00${'A'.repeat(MAX_OUTPUT_SNAPSHOT_CHARS - 2)}\ud83d` + const chunks = [ + 'older output', + `discarded\u001b[31m prefix${retained}`, + 'TAIL', + `${'B'.repeat(MAX_OUTPUT_SNAPSHOT_CHARS)}extra`, + '\u001b[0mDone\r\n' + ] + const reference = createShiftReferenceBuffer() + const candidate = createAutomationRunOutputSnapshotBuffer() + + for (const chunk of chunks) { + reference.append(chunk) + candidate.append(chunk) + expect(candidate.snapshot()).toEqual(reference.snapshot()) + } + }) }) diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts index 6dcc87cfc53..4bf868bf94e 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.test.ts @@ -132,6 +132,22 @@ describe('automation run output snapshot buffer', () => { }) }) + it('owns the exact UTF-16 tail of one oversized chunk', () => { + const buffer = createAutomationRunOutputSnapshotBuffer() + const retained = `\ude00${'A'.repeat(256 * 1024 - 2)}\ud83d` + + buffer.append('older output') + buffer.append(`discarded prefix${retained}`) + + expect(buffer.snapshot()).toMatchObject({ content: retained, truncated: true }) + + buffer.append('TAIL') + expect(buffer.snapshot()).toMatchObject({ + content: `${retained.slice(4)}TAIL`, + truncated: true + }) + }) + it('creates a saved snapshot from agent transcript text', () => { expect(createAutomationRunOutputSnapshotFromText('\nFinal summary.\n')).toEqual({ format: 'plain_text', diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.ts index fb493e35cdf..1700b2b6ede 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.ts @@ -3,6 +3,7 @@ import { stripAnsiEscapeSequences, TERMINAL_CONTROL_CHARACTER_PATTERN } from '../../../../shared/ansi-escape-sequences' +import { copyUtf16SuffixToOwnedString } from '../../../../shared/owned-utf16-suffix' const MAX_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 const OUTPUT_SNAPSHOT_COMPACTION_HEAD_THRESHOLD = 64 @@ -68,7 +69,10 @@ export function createAutomationRunOutputSnapshotBuffer(): AutomationRunOutputSn truncated = true continue } - chunks[firstChunkIndex] = firstChunk.slice(overflowChars) + chunks[firstChunkIndex] = + firstChunk.length > MAX_OUTPUT_SNAPSHOT_CHARS + ? copyUtf16SuffixToOwnedString(firstChunk, firstChunk.length - overflowChars) + : firstChunk.slice(overflowChars) totalChars -= overflowChars truncated = true overflowChars = 0 diff --git a/src/renderer/src/components/automations/automations-table-layout.ts b/src/renderer/src/components/automations/automations-table-layout.ts new file mode 100644 index 00000000000..f6947f8c7f4 --- /dev/null +++ b/src/renderer/src/components/automations/automations-table-layout.ts @@ -0,0 +1,15 @@ +/** Shared layout classes for the automations list table. + * Matches AutomationRunHistory / ExternalAutomationRunTable / Tasks list tables. */ +// Name | Schedule | Project | Next run | Status | Agent | Actions +export const AUTOMATIONS_TABLE_GRID_CLASS = + 'grid grid-cols-[minmax(0,1.5fr)_minmax(7rem,10rem)_minmax(5rem,9rem)_minmax(10rem,1.2fr)_minmax(4.5rem,6rem)_2.5rem_2.5rem]' + +export const AUTOMATIONS_TABLE_CONTAINER_CLASS = 'rounded-md border border-border/50 bg-muted/20' + +export const AUTOMATIONS_TABLE_HEADER_CLASS = + 'sticky top-0 z-10 h-8 items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground' + +export const AUTOMATIONS_TABLE_ROW_CLASS = + 'w-full cursor-pointer items-center gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50' + +export const AUTOMATIONS_TABLE_ROW_SELECTED_CLASS = 'bg-accent text-accent-foreground' diff --git a/src/renderer/src/components/browser-pane/BrowserPane.render-ipc.test.ts b/src/renderer/src/components/browser-pane/BrowserPane.render-ipc.test.ts new file mode 100644 index 00000000000..8d3253ad460 --- /dev/null +++ b/src/renderer/src/components/browser-pane/BrowserPane.render-ipc.test.ts @@ -0,0 +1,19 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +describe('BrowserPagePane render IPC boundary', () => { + it('derives toolbar URLs without querying the webview', () => { + const source = readFileSync( + fileURLToPath(new URL('./BrowserPane.tsx', import.meta.url)), + 'utf8' + ) + const start = source.indexOf('const isBlankTab =', source.indexOf('function BrowserPagePane')) + const end = source.indexOf('useEffect(() => {', start) + const renderUrlDerivation = source.slice(start, end) + + expect(renderUrlDerivation).toContain('getLiveBrowserUrl(browserTab.id)') + expect(renderUrlDerivation).not.toContain('webviewRef') + expect(renderUrlDerivation).not.toContain('.getURL(') + }) +}) diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 90a7e1fdc2c..c22ff6a365d 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -82,7 +82,7 @@ import { browserViewportPresetToOverride, getBrowserViewportPreset } from '../../../../shared/browser-viewport-presets' -import { rememberLiveBrowserUrl } from './browser-runtime' +import { getLiveBrowserUrl, rememberLiveBrowserUrl, seedLiveBrowserUrl } from './browser-runtime' import { ensureBrowserPageWebview } from './browser-page-webview' import { RemoteBrowserStreamLifecycle } from './remote-browser-stream-lifecycle' import { isRemoteBrowserPageMissingError } from './remote-browser-stream-errors' @@ -688,35 +688,10 @@ function getRemoteBrowserDeviceScaleFactor(): number { return Math.min(2, Math.max(1, Number(scale.toFixed(2)))) } -function getOpenableExternalUrl( - webview: Electron.WebviewTag | null, - fallbackUrl: string -): string | null { - let currentUrl = fallbackUrl - if (webview) { - try { - currentUrl = webview.getURL() || fallbackUrl - } catch { - // Why: querying nav state before dom-ready throws and blanks the whole IDE on launch; fall back to the persisted URL. - currentUrl = fallbackUrl - } - } +function getOpenableExternalUrl(currentUrl: string): string | null { return normalizeExternalBrowserUrl(redactKagiSessionToken(currentUrl)) } -function getCurrentBrowserUrl(webview: Electron.WebviewTag | null, fallbackUrl: string): string { - let currentUrl = fallbackUrl - if (webview) { - try { - currentUrl = webview.getURL() || fallbackUrl - } catch { - // Why: toolbar actions need a stable URL during early guest attach/restore; fall back to the persisted URL instead of throwing. - currentUrl = fallbackUrl - } - } - return toDisplayUrl(currentUrl) -} - function retryBrowserTabLoad( webview: Electron.WebviewTag | null, browserTab: BrowserPageState, @@ -3430,6 +3405,7 @@ function BrowserPagePane({ container = ensuredWebview.container const webview = ensuredWebview.webview const needsInitialNavigation = ensuredWebview.created + seedLiveBrowserUrl(browserTab.id, redactKagiSessionToken(browserTabUrlRef.current)) if (!ensuredWebview.created) { // pointerEvents already applied inside ensureBrowserPageWebview for the reused-webview path. @@ -4556,8 +4532,10 @@ function BrowserPagePane({ // Why: a blank tab reads as 'about:blank' or the resolved data: URL, so match both to keep the "New Browser Tab" overlay visible. const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL - const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) - const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url) + // Why: synchronous webview URL access blocks render; navigation handlers update this cache before their store writes can re-render the pane. + const liveBrowserUrl = getLiveBrowserUrl(browserTab.id) ?? browserTab.url + const externalUrl = getOpenableExternalUrl(liveBrowserUrl) + const currentBrowserUrl = toDisplayUrl(liveBrowserUrl) const shareableArtifactFile = workspaceConnectionId === null ? getShareableBrowserArtifactFile(currentBrowserUrl) : null const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl diff --git a/src/renderer/src/components/browser-pane/browser-runtime.test.ts b/src/renderer/src/components/browser-pane/browser-runtime.test.ts index b225d8c3698..846b13f306d 100644 --- a/src/renderer/src/components/browser-pane/browser-runtime.test.ts +++ b/src/renderer/src/components/browser-pane/browser-runtime.test.ts @@ -1,9 +1,15 @@ -import { beforeEach, describe, expect, it } from 'vitest' -import { clearLiveBrowserUrl, getLiveBrowserUrl, rememberLiveBrowserUrl } from './browser-runtime' +import { afterEach, describe, expect, it } from 'vitest' +import { + clearLiveBrowserUrl, + getLiveBrowserUrl, + rememberLiveBrowserUrl, + seedLiveBrowserUrl +} from './browser-runtime' describe('browser runtime live URL cache', () => { - beforeEach(() => { + afterEach(() => { clearLiveBrowserUrl('page-1') + clearLiveBrowserUrl('popup-1') }) it('remembers and clears the last live URL for a browser page', () => { @@ -15,4 +21,30 @@ describe('browser runtime live URL cache', () => { expect(getLiveBrowserUrl('page-1')).toBeNull() }) + + it('seeds an initial URL without replacing a committed navigation', () => { + seedLiveBrowserUrl('page-1', 'https://initial.example/') + rememberLiveBrowserUrl('page-1', 'https://committed.example/') + seedLiveBrowserUrl('page-1', 'https://stale-persisted.example/') + + expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/') + }) + + it('retains the last committed URL when a later load fails', () => { + seedLiveBrowserUrl('page-1', 'https://initial.example/') + rememberLiveBrowserUrl('page-1', 'https://committed.example/') + // A failure event records its validated URL in loadError, not the live URL cache. + + expect(getLiveBrowserUrl('page-1')).toBe('https://committed.example/') + }) + + it('keeps popup page URLs independent and clears only the destroyed page', () => { + seedLiveBrowserUrl('page-1', 'https://opener.example/') + seedLiveBrowserUrl('popup-1', 'https://popup.example/') + + clearLiveBrowserUrl('popup-1') + + expect(getLiveBrowserUrl('page-1')).toBe('https://opener.example/') + expect(getLiveBrowserUrl('popup-1')).toBeNull() + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-runtime.ts b/src/renderer/src/components/browser-pane/browser-runtime.ts index 34ef5098010..029afc41b0f 100644 --- a/src/renderer/src/components/browser-pane/browser-runtime.ts +++ b/src/renderer/src/components/browser-pane/browser-runtime.ts @@ -1,5 +1,11 @@ const liveBrowserUrlByTabId = new Map() +export function seedLiveBrowserUrl(browserTabId: string, initialUrl: string): void { + if (!liveBrowserUrlByTabId.has(browserTabId)) { + liveBrowserUrlByTabId.set(browserTabId, initialUrl) + } +} + export function rememberLiveBrowserUrl(browserTabId: string, url: string): void { liveBrowserUrlByTabId.set(browserTabId, url) } diff --git a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx index a100ecaabef..699a698f008 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMap.test.tsx @@ -1,137 +1,16 @@ // @vitest-environment happy-dom import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - DashboardCard, - DashboardSleepWorkspaceArgs, - DashboardSpawnAgentArgs -} from '../../../../shared/dashboard-snapshot' -import type { TuiAgent } from '../../../../shared/types' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' import { AgentMap } from './AgentMap' import { agentMapAttentionMarkerScale } from './AgentMapWorktreeRingNode' import type { AgentMapState } from './agent-map-filter' import { AGENT_MAP_AGENT_RADIUS } from './agent-map-layout' - -const NOW = 2_000_000_000 - -function card(overrides: Partial = {}): DashboardCard { - return { - paneKey: 'pane-1', - ptyId: 'pty-1', - agentType: 'codex', - bucket: 'working', - dotState: 'working', - task: 'Build map', - repoId: 'repo-1', - worktreeId: 'worktree-1', - tabId: 'tab-1', - leafId: 'leaf-1', - repoName: 'Orca', - worktreeName: 'Agent map', - conversationName: 'Agent alpha', - startedAt: NOW - 10 * 60_000, - finishedAt: null, - stateChangedAt: NOW - 1_000, - unseen: false, - hostKind: 'local', - workspaceKind: 'worktree', - ...overrides - } -} - -function renderMap( - cards: DashboardCard[], - { - onOpenTerminal = vi.fn(), - selectedPaneKey = null, - compact = false, - workspaceContextMenusEnabled = false, - enabledStates, - showOrchestrationLinks, - launchableAgentsByWorktreeId, - onSpawnAgent, - onSleepWorkspace - }: { - onOpenTerminal?: (card: DashboardCard) => void - selectedPaneKey?: string | null - compact?: boolean - workspaceContextMenusEnabled?: boolean - enabledStates?: ReadonlySet - showOrchestrationLinks?: boolean - launchableAgentsByWorktreeId?: Record - onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void - onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void - } = {} -): ReturnType { - return render( - - ) -} +import { card, installAgentMapEnvironment, NOW, renderMap } from './agent-map-render-test-harness' describe('AgentMap', () => { - const originalUserAgent = navigator.userAgent - let boundsSpy: ReturnType - - beforeEach(() => { - Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) - vi.stubGlobal( - 'matchMedia', - vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) - ) - boundsSpy = vi - .spyOn(Element.prototype, 'getBoundingClientRect') - .mockImplementation(function getBounds(this: Element) { - if (this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement) { - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 400, - bottom: 300, - width: 400, - height: 300, - toJSON: () => ({}) - } - } - return { - x: 0, - y: 0, - left: 0, - top: 0, - right: 0, - bottom: 0, - width: 0, - height: 0, - toJSON: () => ({}) - } - }) - }) - - afterEach(() => { - cleanup() - vi.clearAllMocks() - vi.unstubAllGlobals() - boundsSpy.mockRestore() - Object.defineProperty(navigator, 'userAgent', { - configurable: true, - value: originalUserAgent - }) - }) + const environment = installAgentMapEnvironment() it('renders the amber marker only for unread agents', () => { const finished = card({ @@ -164,23 +43,6 @@ describe('AgentMap', () => { expect(doneNode).toHaveAccessibleName(/unread/) }) - it.each([ - { bucket: 'working', dotState: 'working', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, - { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, - { bucket: 'done', dotState: 'done', unseen: true, glows: false }, - { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } - ] as const)('applies the expected halo for $dotState agents', (state) => { - const { container } = renderMap([card(state)]) - const glow = container.querySelector('[data-agent-map-agent-status-glow]') - - if (state.glows) { - expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) - return - } - expect(glow).not.toBeInTheDocument() - }) - it('lets attention override working on the worktree glow', () => { const done = card({ paneKey: 'done', @@ -631,13 +493,13 @@ describe('AgentMap', () => { labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - const readsBeforeZoom = boundsSpy.mock.calls.length + const readsBeforeZoom = environment.boundsSpy.mock.calls.length fireEvent.click(screen.getByRole('button', { name: 'Zoom out' })) const zoomedScale = Number( labelGroup?.getAttribute('transform')?.match(/scale\(([^)]+)\)/)?.[1] ) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeZoom) expect(zoomedScale).toBeGreaterThan(initialScale) }) @@ -656,18 +518,18 @@ describe('AgentMap', () => { releasePointerCapture: vi.fn() }) - const readsBeforeHover = boundsSpy.mock.calls.length + const readsBeforeHover = environment.boundsSpy.mock.calls.length fireEvent.pointerMove(svg, { pointerId: 1, clientX: 20, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeHover) fireEvent.pointerDown(svg, { pointerId: 1, clientX: 20, clientY: 20 }) expect(svg.setPointerCapture).toHaveBeenCalledWith(1) - const readsAfterPointerDown = boundsSpy.mock.calls.length + const readsAfterPointerDown = environment.boundsSpy.mock.calls.length const initialViewBox = svg.getAttribute('viewBox') fireEvent.pointerMove(svg, { pointerId: 1, clientX: 30, clientY: 20 }) fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 20 }) - expect(boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsAfterPointerDown) expect(requestFrame).toHaveBeenCalledOnce() expect(svg).toHaveAttribute('viewBox', initialViewBox) @@ -675,7 +537,7 @@ describe('AgentMap', () => { expect(svg.getAttribute('viewBox')).not.toBe(initialViewBox) requestFrame.mockClear() - const readsBeforeWheel = boundsSpy.mock.calls.length + const readsBeforeWheel = environment.boundsSpy.mock.calls.length const wheel = (): void => { const event = new Event('wheel', { bubbles: true, cancelable: true }) Object.defineProperties(event, { @@ -688,7 +550,7 @@ describe('AgentMap', () => { } wheel() wheel() - expect(boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) + expect(environment.boundsSpy).toHaveBeenCalledTimes(readsBeforeWheel + 1) expect(requestFrame).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx index b3203ebf701..3b71f0f2006 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapCanvas.tsx @@ -28,6 +28,7 @@ import { import type { AgentMapViewport } from './agent-map-viewport-transition' import { useAgentMapContextMenus } from './useAgentMapContextMenus' import { useAgentMapCanvasSize } from './useAgentMapCanvasSize' +import { useAgentMapPointerHold } from './useAgentMapPointerHold' import { useAgentMapMotionLayout } from './useAgentMapMotionLayout' import { useAgentMapSelectedFocus } from './useAgentMapSelectedFocus' import { useAgentMapViewportTransition } from './useAgentMapViewportTransition' @@ -86,6 +87,7 @@ export const AgentMapCanvas = forwardRef(null) const interactionBoundsRef = useRef(null) const hasShownProjectsRef = useRef(layout.projects.length > 0) + const { held, hold, release: releaseHold, clearDrag } = useAgentMapPointerHold(dragRef) const clearInteractionBounds = useCallback(() => { interactionBoundsRef.current = null }, []) @@ -315,7 +317,7 @@ export const AgentMapCanvas = forwardRef { - if (event.button !== 0) { + if (event.button !== 0 || dragRef.current) { return } if ( @@ -337,11 +339,16 @@ export const AgentMapCanvas = forwardRef { const drag = dragRef.current - if (!drag || drag.pointerId !== event.pointerId) { + if (!drag) { + releaseHold() + return + } + if (drag.pointerId !== event.pointerId) { return } scheduleViewport({ @@ -353,14 +360,19 @@ export const AgentMapCanvas = forwardRef { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + if (clearDrag(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId) } }} onPointerCancel={(event) => { - if (dragRef.current?.pointerId === event.pointerId) { - dragRef.current = null + clearDrag(event.pointerId) + }} + onLostPointerCapture={(event) => { + clearDrag(event.pointerId) + }} + onPointerLeave={() => { + if (!dragRef.current) { + releaseHold() } }} > @@ -370,6 +382,8 @@ export const AgentMapCanvas = forwardRef { zoom={1} labelScale={1} mapScale={0.5} + heldProjectId={null} + heldWorktreeId={null} selectedPaneKey={null} allowAggregation showOrchestrationLinks diff --git a/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx new file mode 100644 index 00000000000..ccc3b29e89f --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapRingHover.test.tsx @@ -0,0 +1,124 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { fireEvent } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +/** A ring has to stay open for as long as the pointer is working inside it — + * across its own contents, and across a pan drag that takes pointer capture. */ +describe('AgentMap ring hover', () => { + installAgentMapEnvironment() + + it('reveals the hovered workspace name and agent count above every other label', () => { + const { container } = renderMap([ + card(), + card({ paneKey: 'pane-2', conversationName: 'Agent beta' }) + ]) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + + fireEvent.pointerOver(container.querySelector('.agent-map-worktree-group')!) + + const hovered = container.querySelector('[data-agent-map-hover-label]')! + expect(hovered.querySelector('.agent-map-worktree-label')).toHaveTextContent('Agent map') + expect(hovered.querySelector('.agent-map-worktree-count')).toHaveTextContent('2 agents') + expect(hovered.querySelector('.agent-map-worktree-label-group')).toHaveClass( + 'is-active', + 'is-count-visible' + ) + // The hovered name is hoisted, not duplicated, and draws after every ring. + const labels = container.querySelectorAll('.agent-map-worktree-label-group') + expect(labels).toHaveLength(1) + const lastRing = [...container.querySelectorAll('[data-agent-map-worktree]')].at(-1)! + expect(lastRing.compareDocumentPosition(labels[0]) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(4) + }) + + it('hides the hovered workspace label again when the pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + + fireEvent.pointerOver(group) + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + + fireEvent.pointerOut(group) + expect(container.querySelector('[data-agent-map-hover-label]')).not.toBeInTheDocument() + }) + + it('keeps the pressed rings lit for the whole pan drag', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + const worktreeGroup = container.querySelector('.agent-map-worktree-group')! + // Pointer capture retargets :hover to the mid-gesture, so CSS alone + // cannot hold the ring open — the class has to survive the drag. + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + expect(projectRing).toHaveClass('is-held') + + fireEvent.pointerUp(svg, { pointerId: 1 }) + expect(projectRing).not.toHaveClass('is-held') + expect(worktreeGroup).not.toHaveClass('is-held') + }) + + it('holds the workspace name up while panning from inside that workspace', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const worktreeGroup = container.querySelector('[data-agent-map-worktree-id]')! + // The aggregate bubble sits inside the group but is not the ring, so a press + // there pans rather than opening the workspace popover. + fireEvent.pointerDown(worktreeGroup, { button: 0, pointerId: 1 }) + fireEvent.pointerMove(svg, { pointerId: 1, clientX: 40, clientY: 24 }) + + expect(worktreeGroup).toHaveClass('is-held') + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('drops the held rings when the pan is cancelled', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.pointerCancel(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('drops the held rings and drag when pointer capture is lost', () => { + const { container } = renderMap([card()]) + const svg = container.querySelector('svg')! + const projectRing = container.querySelector('[data-agent-map-project-id]')! + + fireEvent.pointerDown(projectRing, { button: 0, pointerId: 1 }) + fireEvent.lostPointerCapture(svg, { pointerId: 1 }) + + expect(projectRing).not.toHaveClass('is-held') + }) + + it('keeps a focused workspace label visible after its pointer leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + ring.focus() + fireEvent.pointerOver(group) + fireEvent.pointerOut(group) + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) + + it('keeps a hovered workspace label visible after its focus leaves', () => { + const { container } = renderMap([card()]) + const group = container.querySelector('.agent-map-worktree-group')! + const ring = container.querySelector('.agent-map-worktree-ring')! + + fireEvent.pointerOver(group) + ring.focus() + ring.blur() + + expect(container.querySelector('[data-agent-map-hover-label]')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx index fdd80ff982e..2181e4c0f2c 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapScene.tsx @@ -22,6 +22,9 @@ type AgentMapSceneProps = { zoom: number labelScale: number mapScale: number + /** Rings the pointer was pressed in; they stay lit for the whole pan drag. */ + heldProjectId: string | null + heldWorktreeId: string | null selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -63,6 +66,8 @@ export const AgentMapScene = memo(function AgentMapScene({ zoom, labelScale, mapScale, + heldProjectId, + heldWorktreeId, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -74,9 +79,16 @@ export const AgentMapScene = memo(function AgentMapScene({ onOpenWorkspaceContextMenu, onAgentKeyDown }: AgentMapSceneProps): React.JSX.Element { - const [activeWorktreeId, setActiveWorktreeId] = useState(null) - const handleLabelActiveChange = useCallback((worktreeId: string, active: boolean): void => { - setActiveWorktreeId((current) => + const [hoveredWorktreeId, setHoveredWorktreeId] = useState(null) + const [focusedWorktreeId, setFocusedWorktreeId] = useState(null) + const activeWorktreeId = heldWorktreeId ?? hoveredWorktreeId ?? focusedWorktreeId + const handleLabelHoverChange = useCallback((worktreeId: string, active: boolean): void => { + setHoveredWorktreeId((current) => + active ? worktreeId : current === worktreeId ? null : current + ) + }, []) + const handleLabelFocusChange = useCallback((worktreeId: string, active: boolean): void => { + setFocusedWorktreeId((current) => active ? worktreeId : current === worktreeId ? null : current ) }, []) @@ -84,6 +96,19 @@ export const AgentMapScene = memo(function AgentMapScene({ () => selectVisibleAgentMapLabels(layout, labelScale, mapScale), [labelScale, layout, mapScale] ) + const activeWorktree = useMemo(() => { + if (!activeWorktreeId) { + return null + } + for (const project of layout.projects) { + for (const worktree of project.worktrees) { + if (worktree.id === activeWorktreeId) { + return worktree + } + } + } + return null + }, [activeWorktreeId, layout]) const visibleAgentsByPaneKey = useMemo(() => { const agents = new Map() for (const project of layout.projects) { @@ -125,7 +150,8 @@ export const AgentMapScene = memo(function AgentMapScene({ return ( ))} - {project.worktrees.map((worktree) => ( - - ))} + {project.worktrees.map((worktree) => + worktree.id === activeWorktreeId ? null : ( + + ) + )} ) })} + {/* Drawn last so a hovered name clears every other project's rings, and + * outside the declutter pass so it reads at any zoom. */} + {activeWorktree ? ( + + + + ) : null} ) }) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx new file mode 100644 index 00000000000..8803b3336a5 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/AgentMapStatusGlow.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' +import { describe, expect, it } from 'vitest' +import { card, installAgentMapEnvironment, renderMap } from './agent-map-render-test-harness' + +describe('AgentMap status glow', () => { + installAgentMapEnvironment() + + it.each([ + { bucket: 'working', dotState: 'working', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'waiting', unseen: false, glows: true }, + { bucket: 'attention', dotState: 'blocked', unseen: false, glows: true }, + { bucket: 'done', dotState: 'done', unseen: true, glows: false }, + { bucket: 'idle', dotState: 'idle', unseen: false, glows: false } + ] as const)('applies the expected halo for $dotState agents', (state) => { + const { container } = renderMap([card(state)]) + const glow = container.querySelector('[data-agent-map-agent-status-glow]') + + if (state.glows) { + expect(glow).toHaveAttribute('data-agent-active-status', state.dotState) + return + } + expect(glow).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx index 455b20df5bd..27a1c455341 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeLabel.tsx @@ -17,7 +17,8 @@ export const AgentMapWorktreeLabel = memo(function AgentMapWorktreeLabel({ labelScale, mapScale }: AgentMapWorktreeLabelProps): React.JSX.Element { - const showCount = visible && (active || worktree.radius * mapScale >= 80) + // Hover is an explicit ask for this workspace's detail, so it outranks declutter. + const showCount = active || (visible && worktree.radius * mapScale >= 80) const agentCountText = translate( 'dashboardPopout.map.agentCount', worktree.agents.length === 1 ? '{{count}} agent' : '{{count}} agents', diff --git a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx index 251fabbb079..ee49a574901 100644 --- a/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentMapWorktreeRingNode.tsx @@ -22,6 +22,8 @@ type AgentMapWorktreeRingNodeProps = { worktree: AgentMapWorktreeRing zoom: number mapScale: number + /** Pressed at the start of a pan drag; keeps the ring lit through the gesture. */ + held: boolean selectedPaneKey: string | null allowAggregation: boolean showOrchestrationLinks: boolean @@ -33,7 +35,8 @@ type AgentMapWorktreeRingNodeProps = { event: React.MouseEvent, worktree: AgentMapWorktreeRing ) => void - onLabelActiveChange: (worktreeId: string, active: boolean) => void + onLabelHoverChange: (worktreeId: string, active: boolean) => void + onLabelFocusChange: (worktreeId: string, active: boolean) => void onAgentKeyDown: (event: React.KeyboardEvent, agent: AgentMapAgentNode) => void } @@ -175,6 +178,7 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ worktree, zoom, mapScale, + held, selectedPaneKey, allowAggregation, showOrchestrationLinks, @@ -183,7 +187,8 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ onSelectAgent, onSpawnAgent, onOpenWorkspaceContextMenu, - onLabelActiveChange, + onLabelHoverChange, + onLabelFocusChange, onAgentKeyDown }: AgentMapWorktreeRingNodeProps): React.JSX.Element { const [detailsOpen, setDetailsOpen] = useState(false) @@ -196,14 +201,15 @@ export const AgentMapWorktreeRingNode = memo(function AgentMapWorktreeRingNode({ return ( onLabelActiveChange(worktree.id, true)} - onPointerLeave={() => onLabelActiveChange(worktree.id, false)} - onFocus={() => onLabelActiveChange(worktree.id, true)} + onPointerEnter={() => onLabelHoverChange(worktree.id, true)} + onPointerLeave={() => onLabelHoverChange(worktree.id, false)} + onFocus={() => onLabelFocusChange(worktree.id, true)} onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { - onLabelActiveChange(worktree.id, false) + onLabelFocusChange(worktree.id, false) } }} > diff --git a/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts new file mode 100644 index 00000000000..ea5bbab362e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-hover-containment.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const css = readFileSync( + resolve(process.cwd(), 'src/renderer/src/components/dashboard-popout/agent-map.css'), + 'utf8' +) + +const PROJECT_SCALE = + /:where\(\s*\.agent-map-project-node:hover,\s*\.agent-map-project-node:focus-within,\s*\.agent-map-project-node\.is-held\s*\)\s*\.agent-map-project-ring\s*\{([^}]*)\}/ +const WORKTREE_SCALE = + /:where\(\s*\.agent-map-worktree-group:hover,\s*\.agent-map-worktree-group:focus-within,\s*\.agent-map-worktree-group\.is-held\s*\)\s*\.agent-map-worktree-ring\s*\{([^}]*)\}/ + +/** A ring that only reacts to :hover on itself pulses shut whenever the pointer + * crosses onto something drawn inside it, and again when a pan drag takes + * pointer capture. Both triggers have to live on the containing group. */ +describe('Agent Map hover containment', () => { + it('scales each ring from its containing group, never from the ring element', () => { + expect(css).not.toMatch(/\.agent-map-(?:project|worktree)-ring:hover/) + expect(css.match(PROJECT_SCALE)?.[1]).toContain('transform: scale') + expect(css.match(WORKTREE_SCALE)?.[1]).toContain('transform: scale') + }) + + it('keeps the group-scoped hover at ring specificity so state rules still win', () => { + // `:where()` contributes no specificity, so the workspace state rules keep + // overriding hover fill and stroke — but only while they stay below it. + const hoverAt = css.search(WORKTREE_SCALE) + + expect(hoverAt).toBeGreaterThan(css.indexOf('.agent-map-worktree-ring {')) + for (const state of ['.is-open', '.is-selected', '.is-working', '.is-blocked']) { + expect(css.indexOf(`.agent-map-worktree-ring${state}`)).toBeGreaterThan(hoverAt) + } + }) + + it('expands containing rings for keyboard focus as well as pointer hover', () => { + expect(css.match(PROJECT_SCALE)?.[0]).toContain('.agent-map-project-node:focus-within') + expect(css.match(WORKTREE_SCALE)?.[0]).toContain('.agent-map-worktree-group:focus-within') + }) + + it('drops both hover triggers under reduced motion', () => { + const reducedMotion = css.slice(css.indexOf('@media (prefers-reduced-motion: reduce)')) + + expect(reducedMotion).toContain('.agent-map-project-node.is-held') + expect(reducedMotion).toContain('.agent-map-worktree-group.is-held') + expect(reducedMotion).toMatch(/\.agent-map-project-node:hover/) + expect(reducedMotion).toMatch(/\.agent-map-worktree-group:hover/) + }) +}) diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts index 28c71c881a1..ad02fb56243 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.test.ts @@ -90,19 +90,6 @@ describe('selectVisibleAgentMapLabels', () => { const labels = selectVisibleAgentMapLabels(layoutOf([covered]), 1, 1) expect(labels.worktreeIds.size).toBe(0) - expect(labels.worktreeAgentSafeIds.size).toBe(0) - }) - - it('keeps a collision-suppressed workspace title safe to reveal away from agents', () => { - const layout = layoutOf([ - worktree({ id: 'busy', x: 0, y: 0, statusCounts: statusCounts({ working: 3 }) }), - worktree({ id: 'calm', name: 'beta', x: 0, y: 0, statusCounts: statusCounts({ done: 1 }) }) - ]) - - const labels = selectVisibleAgentMapLabels(layout, 1, 1) - - expect([...labels.worktreeIds]).toEqual(['busy']) - expect([...labels.worktreeAgentSafeIds].sort()).toEqual(['busy', 'calm']) }) it('lets a blocked workspace outrank a busier neighbour for the surviving label', () => { diff --git a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts index 58a1f53c4d6..841197437e6 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts +++ b/src/renderer/src/components/dashboard-popout/agent-map-label-declutter.ts @@ -38,8 +38,6 @@ type LabelGrid = Map> export type AgentMapVisibleLabels = { /** Worktree ring ids whose name can render without colliding. */ worktreeIds: Set - /** Worktree names that hover/focus may reveal without covering an agent. */ - worktreeAgentSafeIds: Set /** Project ids whose agent/workspace count line still has room. The count is * the first thing dropped: it repeats what the filter rail already says. */ projectCountIds: Set @@ -221,7 +219,6 @@ export function selectVisibleAgentMapLabels( }) const worktreeIds = new Set() - const worktreeAgentSafeIds = new Set() for (const worktree of candidates.slice(0, MAX_LABEL_CANDIDATES)) { const box = baselineBox( worktree.x, @@ -234,7 +231,6 @@ export function selectVisibleAgentMapLabels( if (collides(agentGrid, box)) { continue } - worktreeAgentSafeIds.add(worktree.id) if (collides(grid, box)) { continue } @@ -261,5 +257,5 @@ export function selectVisibleAgentMapLabels( projectCountIds.add(project.id) } - return { worktreeIds, worktreeAgentSafeIds, projectCountIds } + return { worktreeIds, projectCountIds } } diff --git a/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx new file mode 100644 index 00000000000..45c83a33c8e --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/agent-map-render-test-harness.tsx @@ -0,0 +1,133 @@ +import { cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, vi } from 'vitest' +import type { + DashboardCard, + DashboardSleepWorkspaceArgs, + DashboardSpawnAgentArgs +} from '../../../../shared/dashboard-snapshot' +import type { TuiAgent } from '../../../../shared/types' +import { AgentMap } from './AgentMap' +import type { AgentMapState } from './agent-map-filter' + +export const NOW = 2_000_000_000 + +export function card(overrides: Partial = {}): DashboardCard { + return { + paneKey: 'pane-1', + ptyId: 'pty-1', + agentType: 'codex', + bucket: 'working', + dotState: 'working', + task: 'Build map', + repoId: 'repo-1', + worktreeId: 'worktree-1', + tabId: 'tab-1', + leafId: 'leaf-1', + repoName: 'Orca', + worktreeName: 'Agent map', + conversationName: 'Agent alpha', + startedAt: NOW - 10 * 60_000, + finishedAt: null, + stateChangedAt: NOW - 1_000, + unseen: false, + hostKind: 'local', + workspaceKind: 'worktree', + ...overrides + } +} + +export type RenderMapOptions = { + onOpenTerminal?: (card: DashboardCard) => void + selectedPaneKey?: string | null + compact?: boolean + workspaceContextMenusEnabled?: boolean + enabledStates?: ReadonlySet + showOrchestrationLinks?: boolean + launchableAgentsByWorktreeId?: Record + onSpawnAgent?: (args: DashboardSpawnAgentArgs) => void + onSleepWorkspace?: (args: DashboardSleepWorkspaceArgs) => void +} + +export function renderMap( + cards: DashboardCard[], + { + onOpenTerminal = vi.fn(), + selectedPaneKey = null, + compact = false, + workspaceContextMenusEnabled = false, + enabledStates, + showOrchestrationLinks, + launchableAgentsByWorktreeId, + onSpawnAgent, + onSleepWorkspace + }: RenderMapOptions = {} +): ReturnType { + return render( + + ) +} + +export type AgentMapTestEnvironment = { + /** Exposed so tests can assert the canvas does not re-read layout when idle. */ + boundsSpy: ReturnType +} + +const CANVAS_BOUNDS = { + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 300, + width: 400, + height: 300, + toJSON: () => ({}) +} +const ZERO_BOUNDS = { ...CANVAS_BOUNDS, right: 0, bottom: 0, width: 0, height: 0 } + +/** Gives the map a measurable canvas and a non-Mac platform, the way every map + * suite needs it. Call once per describe block. */ +export function installAgentMapEnvironment(): AgentMapTestEnvironment { + const environment = {} as AgentMapTestEnvironment + const originalUserAgent = navigator.userAgent + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'Linux' }) + vi.stubGlobal( + 'matchMedia', + vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) + ) + environment.boundsSpy = vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function getBounds(this: Element) { + return this.classList.contains('agent-map-canvas') || this instanceof SVGSVGElement + ? CANVAS_BOUNDS + : ZERO_BOUNDS + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.unstubAllGlobals() + environment.boundsSpy.mockRestore() + Object.defineProperty(navigator, 'userAgent', { + configurable: true, + value: originalUserAgent + }) + }) + + return environment +} diff --git a/src/renderer/src/components/dashboard-popout/agent-map.css b/src/renderer/src/components/dashboard-popout/agent-map.css index 6107332ba0d..fb1f392f707 100644 --- a/src/renderer/src/components/dashboard-popout/agent-map.css +++ b/src/renderer/src/components/dashboard-popout/agent-map.css @@ -31,7 +31,13 @@ vector-effect: non-scaling-stroke; } -.agent-map-project-ring:hover { +/* Group hover includes nested contents; held state bridges pointer capture. */ +:where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring { fill: color-mix(in srgb, var(--card) 42%, transparent); stroke: var(--ring); transform: scale(1.018); @@ -154,7 +160,12 @@ stroke: color-mix(in srgb, var(--color-yellow-500) 28%, transparent); } -.agent-map-worktree-ring:hover { +:where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring { fill: color-mix(in srgb, var(--card) 72%, transparent); stroke: var(--ring); transform: scale(1.035); @@ -203,6 +214,11 @@ text-anchor: middle; } +.agent-map-worktree-label-layer, +.agent-map-worktree-hover-label-layer { + pointer-events: none; +} + .agent-map-worktree-label-group { opacity: 0; pointer-events: none; @@ -480,8 +496,18 @@ transition: none; } - .agent-map-project-ring:hover, - .agent-map-worktree-ring:hover, + :where( + .agent-map-project-node:hover, + .agent-map-project-node:focus-within, + .agent-map-project-node.is-held + ) + .agent-map-project-ring, + :where( + .agent-map-worktree-group:hover, + .agent-map-worktree-group:focus-within, + .agent-map-worktree-group.is-held + ) + .agent-map-worktree-ring, .agent-map-agent-node:hover .agent-map-agent-visual, .agent-map-agent-node:focus-visible .agent-map-agent-visual { transform: none; diff --git a/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts new file mode 100644 index 00000000000..924c18d6586 --- /dev/null +++ b/src/renderer/src/components/dashboard-popout/useAgentMapPointerHold.ts @@ -0,0 +1,47 @@ +import { useCallback, useState, type RefObject } from 'react' + +export type AgentMapPointerHold = { + projectId: string | null + worktreeId: string | null +} + +type AgentMapPointerDragRef = RefObject<{ pointerId: number } | null> + +function closestId(target: Element, attribute: string): string | null { + return target.closest(`[${attribute}]`)?.getAttribute(attribute) ?? null +} + +/** + * Remembers which rings a pan drag started in. Pointer capture retargets + * `:hover` to the `` for the whole gesture, so the ring under the pointer + * would otherwise collapse until the gesture ends. + */ +export function useAgentMapPointerHold(dragRef: AgentMapPointerDragRef): { + held: AgentMapPointerHold | null + hold: (target: Element) => void + release: () => void + clearDrag: (pointerId: number) => boolean +} { + const [held, setHeld] = useState(null) + const hold = useCallback((target: Element): void => { + const projectId = closestId(target, 'data-agent-map-project-id') + const worktreeId = closestId(target, 'data-agent-map-worktree-id') + // A pan off empty canvas holds nothing, so leave the memoized scene alone. + setHeld(projectId === null && worktreeId === null ? null : { projectId, worktreeId }) + }, []) + const release = useCallback((): void => { + setHeld((current) => (current === null ? current : null)) + }, []) + const clearDrag = useCallback( + (pointerId: number): boolean => { + if (dragRef.current?.pointerId !== pointerId) { + return false + } + dragRef.current = null + release() + return true + }, + [dragRef, release] + ) + return { held, hold, release, clearDrag } +} diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.test.ts b/src/renderer/src/components/dictation/dictation-insertion-target.test.ts index d7b5e670c5f..59c207735be 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.test.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.test.ts @@ -1,12 +1,12 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' +import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES, - TEXT_CONTROL_PASTE_MAX_BYTES, - getTextControlPasteByteLength + TEXT_CONTROL_PASTE_MAX_BYTES } from '@/lib/text-control-paste' import { insertText } from './dictation-insertion-target' @@ -98,8 +98,7 @@ describe('dictation insertion target', () => { expect(execCommand.mock.calls.length).toBeGreaterThan(1) expect( execCommand.mock.calls.every( - (call) => - getTextControlPasteByteLength(String(call[2] ?? '')) <= TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES + (call) => getUtf8ByteLength(String(call[2] ?? '')) <= TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES ) ).toBe(true) expect(beforeInputEvents).toHaveLength(execCommand.mock.calls.length) diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.ts b/src/renderer/src/components/dictation/dictation-insertion-target.ts index 4f8573be15a..56a997a1803 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.ts @@ -1,9 +1,9 @@ import { yieldToEventLoop } from '../../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../../shared/utf8-byte-limits' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES, TEXT_CONTROL_PASTE_MAX_BYTES, - getTextControlPasteByteLength, measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield, pasteTextIntoTextControl @@ -108,11 +108,7 @@ async function insertTextIntoContentEditableTarget( if (!isContentEditableDictationTargetCurrent(element)) { return } - const nextIndex = getNextDictationChunkBoundary( - text, - textIndex, - TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES - ) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES) if ( !insertContentEditableDictationChunk(element, editorElement, text.slice(textIndex, nextIndex)) ) { @@ -162,22 +158,3 @@ function insertContentEditableDictationChunk( function isContentEditableDictationTargetCurrent(element: HTMLElement): boolean { return element.isConnected && element.contains(element.ownerDocument.activeElement) } - -function getNextDictationChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getTextControlPasteByteLength(text.slice(index, index + codeUnitLength)) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} diff --git a/src/renderer/src/components/editor/monaco-large-text-paste.ts b/src/renderer/src/components/editor/monaco-large-text-paste.ts index 7cb6ed83c76..a8e8157b78d 100644 --- a/src/renderer/src/components/editor/monaco-large-text-paste.ts +++ b/src/renderer/src/components/editor/monaco-large-text-paste.ts @@ -1,5 +1,6 @@ import type { editor } from 'monaco-editor' import { yieldToEventLoop } from '../../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../../shared/utf8-byte-limits' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -73,39 +74,6 @@ function getPlainTextFromPasteEvent(event: ClipboardEvent): string { return event.clipboardData?.getData('text/plain') ?? '' } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function getEndPositionAfterInsert(start: Position, text: string): Position { let lineNumber = start.lineNumber let column = start.column @@ -187,7 +155,7 @@ async function insertMonacoTextInChunks( return { status: 'cancelled', reason: 'target-unavailable', byteLength, chunksWritten } } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) const chunk = text.slice(textIndex, nextIndex) const endPosition = getEndPositionAfterInsert( { lineNumber: selection.startLineNumber, column: selection.startColumn }, diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts index b6f87a0b01c..815e4a27ed0 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts @@ -1,6 +1,10 @@ import type { Editor } from '@tiptap/react' import { toast } from 'sonner' import { yieldToEventLoop } from '../../../../shared/event-loop-yield' +import { + getUtf8ChunkEndIndex, + isUtf8ByteLengthWithinLimit +} from '../../../../shared/utf8-byte-limits' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -33,61 +37,6 @@ export type RichMarkdownLargeTextPasteResult = chunksWritten: number } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - -function isTextByteLengthOverLimit(text: string, maxBytes: number): boolean { - if (text.length === 0) { - return false - } - if (text.length > maxBytes) { - return true - } - - let byteLength = 0 - for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return true - } - if (codePoint > 0xffff) { - index += 1 - } - } - return false -} - -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function isEditorAvailable( editor: Editor, canContinue: RichMarkdownLargeTextPasteOptions['canContinue'] @@ -126,7 +75,7 @@ function shouldHandleLargeRichMarkdownPaste({ if (plainTextExceededLimit || plainTextByteLength > maxDirect) { return true } - return isTextByteLengthOverLimit(htmlText, maxDirect) + return !isUtf8ByteLengthWithinLimit(htmlText, maxDirect) } async function insertRichMarkdownTextInChunks( @@ -144,7 +93,7 @@ async function insertRichMarkdownTextInChunks( return { status: 'cancelled', reason: 'target-unavailable', byteLength, chunksWritten } } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) const chunk = text.slice(textIndex, nextIndex) editor.view.dispatch(editor.state.tr.insertText(chunk)) textIndex = nextIndex diff --git a/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts b/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts index cbaecb4ea64..51807b15557 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-owning-slice.ts @@ -1,4 +1,5 @@ import type { Slice } from '@tiptap/pm/model' +import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits' import { getRichMarkdownLeafVisibleText, isRichMarkdownVisibleBlockStart @@ -90,7 +91,7 @@ function addUtf8BytesWithinLimit( let byteLength = current for (let index = 0; index < value.length; index += 1) { const codePoint = value.codePointAt(index) ?? 0 - byteLength += codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4 + byteLength += getUtf8ByteLengthForCodePoint(codePoint) if (byteLength > limit) { return { byteLength, exceeded: true } } diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts index f04134100d3..2cff1ed560f 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -5,6 +5,7 @@ import { makeDiff, makePatches } from '@sanity/diff-match-patch' +import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits' // Why: cap document size in UTF-16 code units (`.length`) since re-parse cost scales with length — the per-commit throwaway TipTap safety re-parse (~50-67ms here) must stay under the 300ms serialize debounce so it can't stall the main thread on slow/SSH hosts. const RECONCILE_SIZE_CAP_CODE_UNITS = 50_000 @@ -150,7 +151,7 @@ function getUtf8OffsetsAtCodeUnitIndices( if (codePoint === undefined) { break } - byteOffset += utf8CodePointLength(codePoint) + byteOffset += getUtf8ByteLengthForCodePoint(codePoint) codeUnitIndex += codePoint > 0xffff ? 2 : 1 } offsets.set(target, byteOffset) @@ -158,19 +159,6 @@ function getUtf8OffsetsAtCodeUnitIndices( return offsets } -function utf8CodePointLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - function hasRepeatedHalfMatchSeed(textA: string, textB: string): boolean { // Match diff-match-patch's prefix/suffix trimming so a small edit isn't mistaken for a repetitive replacement. const minimumLength = Math.min(textA.length, textB.length) diff --git a/src/renderer/src/components/feature-wall/FeatureTourPreview.test.tsx b/src/renderer/src/components/feature-wall/FeatureTourPreview.test.tsx deleted file mode 100644 index 94295008e13..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourPreview.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { renderToStaticMarkup } from 'react-dom/server' -import { FEATURE_TOUR_PREVIEW_COPY, FeatureTourPreview } from './FeatureTourPreview' - -describe('FeatureTourPreview first-run copy', () => { - it('teaches that workspaces keep terminal and agent activity together', () => { - const workspaceFrame = FEATURE_TOUR_PREVIEW_COPY.find((frame) => frame.id === 1) - - expect(workspaceFrame?.caption).toContain('terminal') - expect(workspaceFrame?.caption).toContain('agent activity') - }) - - it('teaches that opening a workspace returns to its terminal', () => { - const terminalFrame = FEATURE_TOUR_PREVIEW_COPY.find((frame) => frame.id === 4) - - expect(terminalFrame?.caption).toContain('Open any workspace') - expect(terminalFrame?.caption).toContain('return to its terminal') - }) - - it('renders the cycling captions into the first-run preview', () => { - const html = renderToStaticMarkup() - - expect(html).toContain( - 'Each workspace keeps its branch, terminal, and agent activity together.' - ) - expect(html).toContain('Open any workspace to return to its terminal') - }) -}) diff --git a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx b/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx deleted file mode 100644 index c92b6c4d237..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import type { JSX } from 'react' -import { cn } from '@/lib/utils' -import { ClaudeIcon, OpenCodeGoIcon } from '../status-bar/icons' -import { - CodexInlineIcon, - CursorIcon, - MailGlyph, - WorkingSpinner -} from './feature-tour-preview-glyphs' -import { - FEATURE_TOUR_ORCHESTRATION_CHILDREN, - FEATURE_TOUR_PREVIEW_COPY -} from './feature-tour-preview-copy' -import { FeatureTourWorkspaceCard } from './FeatureTourWorkspaceCard' -import { FeatureTourTerminalFrame } from './FeatureTourTerminalFrame' -import { translate } from '@/i18n/i18n' - -export { FEATURE_TOUR_PREVIEW_COPY } from './feature-tour-preview-copy' -export type { FeatureTourPreviewFrameCopy } from './feature-tour-preview-copy' - -function WorkspaceFrame(): JSX.Element { - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.56a0271428', - 'Isolated workspaces' - )} -
- {/* Why: 3 cards in a row tells the "ship several at once" story by - composition; the wide preview aspect (~4.9:1) makes a vertical stack - read as wasted space. The grid auto-sizes (no flex-1) so the cards - don't stretch to the container's bottom edge — keeps headroom under - the dot indicators. */} -
- - - -
-
- ) -} - -function OrchestrationFrame(): JSX.Element { - // Why: a horizontal fan (root → 3 children L→R) reads naturally as - // "fans out and ships parallel PRs" at the wide aspect; the previous - // top-down tree wasted the horizontal space. SVG paths are sized to a - // 600×130 viewBox and stretched non-uniformly, so the dashed lines flex - // with the container while the absolutely-positioned cards stay aligned - // to viewport-relative anchors (root left, children right column). - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', - 'Agent orchestration' - )} -
-
- {/* Why: viewBox is percent-units (100×100, preserveAspectRatio="none") - so endpoints anchor to the same percentage anchors as the cards - and the bubbles — root right edge at 34%, child left edge at 64%, - child Y centers at 18%/50%/82%. Explicit width/height attrs are - required because an SVG with a 1:1 viewBox and only inset-0 - otherwise picks its intrinsic 1:1 aspect for height. */} - - {/* Why: vectorEffect is NOT inheritable in SVG, so the - non-scaling-stroke attribute must live on each path. Hoisting - it onto let preserveAspectRatio="none" stretch the dashes - into trapezoids on the diagonal connectors. */} - - - - - - {/* Why: parent matches WorkspaceCard composition — spinner + title row, - then a Claude agent row underneath — so the user reads it as "a - workspace running Claude as the orchestrator," consistent with how - workspaces look elsewhere in the app. */} -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.cebc7769cd', - 'redesign auth flow' - )} - -
-
- - - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.5171768676', - 'orchestrating 3 agents' - )} - -
-
- - {/* Why: children mirror the parent's WorkspaceCard composition so the - fan reads as "coordinator workspace dispatches to 3 child - workspaces, each running its own agent." */} - {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key, position, label, agent }) => ( -
-
- - - {label} - -
-
- - {agent === 'claude' ? ( - - ) : agent === 'codex' ? ( - - ) : ( - - )} - -
-
- ))} - - {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key }) => ( -
- -
- ))} -
-
- ) -} - -function TasksFrame(): JSX.Element { - // Why: a left→right pipeline reads as "pick from backlog → workspace - // appears" in one glance. The wide aspect lets the backlog and the - // resulting workspace card sit side-by-side instead of stacked, which - // makes the cause/effect visible in the composition itself. - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.bee6b4088d', - 'GitHub & Linear tasks' - )} -
-
-
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.0688842445', 'GH #1799')} - - {/* Why: surrounding rows show only the issue number + a skeleton - so the user's eye is drawn to the row that has real text — the - one the cursor clicks on. */} - -
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.fc0cc0b267', 'GH #1842')} - - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.c1f28c03b2', - 'Worktree picker truncates' - )} - - - - {translate('auto.components.feature.wall.FeatureTourPreview.40bbd92ef4', 'Start')} - - - - - - - {/* Why: cursor + ring live inside the row so they anchor to the - pill's right-edge ml-auto, instead of using fixed pixel offsets - that drift when the preview is resized. */} - - - - -
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.d54aefe09e', 'LIN-329')} - - -
-
- -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.3822d8d14b', - 'fix/worktree-picker-truncates' - )} - - - - -
-
- - - -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8', - 'Linked to GH #1842' - )} -
-
-
-
- ) -} - -export function FeatureTourPreview(props: { className?: string }): JSX.Element { - return ( -
-
- -
-
- -
-
- -
-
- -
-
- {FEATURE_TOUR_PREVIEW_COPY.map((frame) => ( -
-
- {frame.title} -
-
- {frame.caption} -
-
- ))} -
-
- - - - -
-
- ) -} diff --git a/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx b/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx deleted file mode 100644 index d21314126c2..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourTerminalFrame.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import type { JSX } from 'react' -import { ClaudeIcon } from '../status-bar/icons' -import { translate } from '@/i18n/i18n' - -export function FeatureTourTerminalFrame(): JSX.Element { - return ( -
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', - 'Splittable terminal' - )} -
-
-
- - - - - {translate('auto.components.feature.wall.FeatureTourPreview.04d54d50ec', 'orca · zsh')} - -
-
-
-
- $ - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.6218a9014d', - 'pnpm playwright test' - )} - -
-
-
- {translate( - 'auto.components.feature.wall.FeatureTourPreview.8279e9d95b', - 'Running 12 tests' - )} -
-
- ✓ - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.24fedd5a52', - 'login.spec.ts' - )} - -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0', - 'dashboard.spec.ts' - )} - -
-
-
-
-
- $ - - {translate('auto.components.feature.wall.FeatureTourPreview.771d8881c2', 'claude')} - -
-
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.952d3ddd9a', - 'session started' - )} - -
-
- - {translate('auto.components.feature.wall.FeatureTourPreview.1170621527', '>')} - - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.ef8b164dd1', - 'review src/auth' - )} - -
-
- - - {translate( - 'auto.components.feature.wall.FeatureTourPreview.304ad0dfc1', - 'Thinking...' - )} - -
-
-
-
-
-
- ) -} diff --git a/src/renderer/src/components/feature-wall/FeatureTourWorkspaceCard.tsx b/src/renderer/src/components/feature-wall/FeatureTourWorkspaceCard.tsx deleted file mode 100644 index 0004b27ef2c..00000000000 --- a/src/renderer/src/components/feature-wall/FeatureTourWorkspaceCard.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { JSX } from 'react' -import { cn } from '@/lib/utils' -import { ClaudeIcon, OpenCodeGoIcon } from '../status-bar/icons' -import { CodexInlineIcon, WorkingSpinner } from './feature-tour-preview-glyphs' - -export type FeatureTourWorkspaceCardAgent = { - kind: 'claude' | 'codex' | 'opencode-go' - barWidth: string - state: 'working' | 'done' -} - -export function FeatureTourWorkspaceCard({ - status, - title, - agents -}: { - status: 'working' | 'done' - title: string - agents: readonly FeatureTourWorkspaceCardAgent[] -}): JSX.Element { - return ( -
-
- {status === 'working' ? ( - - ) : ( - - )} - - {title} - -
-
- {agents.map((agent, idx) => ( -
- {agent.state === 'working' ? ( - - ) : ( - - )} - {agent.kind === 'claude' ? ( - - ) : agent.kind === 'codex' ? ( - - ) : ( - - )} - -
- ))} -
-
- ) -} diff --git a/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx index a5c02d28afc..600fb99b51e 100644 --- a/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/WorkbenchAnimatedVisual.tsx @@ -3,10 +3,9 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { JSX } from 'react' import { cn } from '@/lib/utils' -import { ClaudeIcon } from '../status-bar/icons' +import { ClaudeIcon, OpenAIIcon } from '../status-bar/icons' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { FeatureWallClickRing } from './FeatureWallClickRing' -import { CodexInlineIcon } from './feature-tour-preview-glyphs' import { translate } from '@/i18n/i18n' // Why: the right-click menu needs the same icons as the real Orca menu so the @@ -815,8 +814,8 @@ function RightPaneScrollback(props: { return ( {props.isCodex ? ( - - + + ) : ( ● @@ -873,8 +872,8 @@ function RightPaneScrollback(props: { {line.withGlyph ? ( props.isCodex ? ( - - + + ) : ( ● diff --git a/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts b/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts deleted file mode 100644 index 1aa0dc8abbc..00000000000 --- a/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { translate } from '@/i18n/i18n' - -type FrameId = 1 | 2 | 3 | 4 - -export type FeatureTourPreviewFrameCopy = { - id: FrameId - title: string - caption: string -} - -export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [ - { - id: 1, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.56a0271428', - 'Isolated workspaces' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.47f16ecf34', - 'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.' - ) - } - }, - { - id: 2, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', - 'Agent orchestration' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.70aa182266', - 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.' - ) - } - }, - { - id: 3, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.ef737dcee1', - 'GitHub & Linear tasks' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.f10c14dd9d', - 'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.' - ) - } - }, - { - id: 4, - get title() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', - 'Splittable terminal' - ) - }, - get caption() { - return translate( - 'auto.components.feature.wall.FeatureTourPreview.5d6ee181b6', - 'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.' - ) - } - } -] - -export type FeatureTourOrchestrationChildAgent = 'claude' | 'codex' | 'opencode-go' - -export const FEATURE_TOUR_ORCHESTRATION_CHILDREN: readonly { - key: 'top' | 'mid' | 'bot' - position: string - label: string - agent: FeatureTourOrchestrationChildAgent -}[] = [ - // Why: card vertical centers anchor to 18% / 50% / 82% — the same Y - // endpoints the dashed SVG paths terminate at — so the connectors land on - // each card's center regardless of card height. - { - key: 'top', - position: 'top-[18%] -translate-y-1/2', - get label() { - return translate('auto.components.feature.wall.FeatureTourPreview.b1f17bcc74', 'PR 1/3') - }, - agent: 'claude' - }, - { - key: 'mid', - position: 'top-1/2 -translate-y-1/2', - get label() { - return translate('auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4', 'PR 2/3') - }, - agent: 'codex' - }, - { - key: 'bot', - position: 'top-[82%] -translate-y-1/2', - get label() { - return translate('auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6', 'PR 3/3') - }, - agent: 'opencode-go' - } -] diff --git a/src/renderer/src/components/feature-wall/feature-tour-preview-glyphs.tsx b/src/renderer/src/components/feature-wall/feature-tour-preview-glyphs.tsx deleted file mode 100644 index a3fa74f8d1f..00000000000 --- a/src/renderer/src/components/feature-wall/feature-tour-preview-glyphs.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { JSX } from 'react' -import { cn } from '@/lib/utils' - -export function CodexInlineIcon(): JSX.Element { - return ( - - - - ) -} - -export function WorkingSpinner({ - size = 'sm', - reducedMotion = false -}: { - size?: 'sm' | 'xs' - reducedMotion?: boolean -}): JSX.Element { - // Why: matches AgentStateDot's working indicator so the preview teaches - // the same state language users see in the real app. - const ring = size === 'xs' ? 'size-1.5 border' : 'size-2 border-2' - return ( - - ) -} - -export function CursorIcon(): JSX.Element { - return ( - - - - ) -} - -export function MailGlyph(): JSX.Element { - return ( - - - - - ) -} diff --git a/src/renderer/src/components/github/pr-file-content-size.test.ts b/src/renderer/src/components/github/pr-file-content-size.test.ts new file mode 100644 index 00000000000..700a9579ec4 --- /dev/null +++ b/src/renderer/src/components/github/pr-file-content-size.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubPRFileContents } from '../../../../shared/types' +import { getPRFileContentsCacheByteCount } from './pr-file-content-size' + +describe('PR file content size', () => { + it('counts astral characters and lone surrogates as UTF-8 bytes', () => { + const contents: GitHubPRFileContents = { + original: '😀\ud800', + modified: '\udc00é', + originalIsBinary: false, + modifiedIsBinary: false + } + + expect(getPRFileContentsCacheByteCount(contents)).toBe(12) + }) +}) diff --git a/src/renderer/src/components/github/pr-file-content-size.ts b/src/renderer/src/components/github/pr-file-content-size.ts index 89e0f26903c..0e20c92bd1d 100644 --- a/src/renderer/src/components/github/pr-file-content-size.ts +++ b/src/renderer/src/components/github/pr-file-content-size.ts @@ -1,5 +1,6 @@ import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '@/components/editor/large-diff-render-limit' import type { GitHubPRFile, GitHubPRFileContents } from '../../../../shared/types' +import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' export const PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4 @@ -7,29 +8,6 @@ export function isPRFileViewed(file: GitHubPRFile): boolean { return file.viewerViewedState === 'VIEWED' } -export function getUtf8ByteCount(value: string): number { - let byteCount = 0 - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index) - if (code < 0x80) { - byteCount += 1 - } else if (code < 0x800) { - byteCount += 2 - } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { - const next = value.charCodeAt(index + 1) - if (next >= 0xdc00 && next <= 0xdfff) { - byteCount += 4 - index += 1 - } else { - byteCount += 3 - } - } else { - byteCount += 3 - } - } - return byteCount -} - export function isPRFileContentsTooLargeSentinel(contents: GitHubPRFileContents): boolean { return contents.originalTooLarge === true || contents.modifiedTooLarge === true } @@ -38,7 +16,7 @@ export function getPRFileContentsCacheByteCount(contents: GitHubPRFileContents): if (isPRFileContentsTooLargeSentinel(contents)) { return 0 } - return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified) + return getUtf8ByteLength(contents.original) + getUtf8ByteLength(contents.modified) } export function getRetainedPRFileContentsByteCount(contents: GitHubPRFileContents): number | null { diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index 665f95449e9..90fa4380c77 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({ sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), sendNativeChatMessageVerified: vi.fn(), + typeNativeChatCommand: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn(), draftScopeKeys: [] as string[], @@ -65,6 +66,7 @@ vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), sendNativeChatMessageVerified: (...args: unknown[]) => mocks.sendNativeChatMessageVerified(...args), + typeNativeChatCommand: (...args: unknown[]) => mocks.typeNativeChatCommand(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) @@ -181,6 +183,7 @@ describe('NativeChatComposer', () => { }) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.typeNativeChatCommand.mockResolvedValue(true) mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, @@ -550,7 +553,7 @@ describe('NativeChatComposer', () => { expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) - it('applies a Codex model change without switching to the terminal', async () => { + it('types the Codex picker command and switches to the terminal', async () => { mocks.sendHandle.settleAfterMs = 0 const onSwitchToTerminal = vi.fn() render( @@ -563,17 +566,17 @@ describe('NativeChatComposer', () => { /> ) - // Codex model changes are value-bearing commands; only effort still uses the TUI picker. await act(async () => { - await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'gpt-5.5') + await mocks.fieldProps?.sessionOptionsSurface?.invokeAction('model') }) - expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + expect(mocks.typeNativeChatCommand).toHaveBeenCalledWith( {}, 'pty-1', - '/model gpt-5.5', + '/model', expect.any(AbortSignal) ) - expect(onSwitchToTerminal).not.toHaveBeenCalled() + expect(mocks.sendNativeChatMessageVerified).not.toHaveBeenCalled() + expect(onSwitchToTerminal).toHaveBeenCalledOnce() }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 0dd5e19025d..37850bccaec 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -565,7 +565,7 @@ describe('native chat PTY session options', () => { ) const result = await surface.invokeAction('effort') - expect(dispatch).toHaveBeenCalledWith('/model') + expect(dispatch).toHaveBeenCalledWith('/model', { delivery: 'type' }) expect(onAgentPicker).toHaveBeenCalledOnce() expect(result.snapshot).toHaveLength(1) expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 93a63765a46..d5e54f205fa 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -12,6 +12,7 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ import { sendNativeChatMessage, sendNativeChatMessageVerified, + typeNativeChatCommand, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -252,6 +253,35 @@ describe('sendNativeChatMessageVerified', () => { }) }) +describe('typeNativeChatCommand', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + resetNativeChatPtySendQueuesForTests() + }) + afterEach(() => { + vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() + }) + + it('writes the Codex picker command as keys instead of one pasted text write', async () => { + const result = typeNativeChatCommand(SETTINGS, PTY, '/model') + await vi.runAllTimersAsync() + + await expect(result).resolves.toBe(true) + expectWriteOrder(sendRuntimePtyInputVerified.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + '/', + 'm', + 'o', + 'd', + 'e', + 'l', + NATIVE_CHAT_SUBMIT + ]) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a2974474b2b..02bb53fbcdd 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -19,6 +19,7 @@ import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { typeAgentTuiCommand } from '../../../../shared/agent-tui-command-typing' import { cancelNativeChatPtySends, enqueueNativeChatPtySend, @@ -213,6 +214,24 @@ export async function sendNativeChatMessageVerified( return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) } +/** Types a slash command as individual keys so Codex opens its command palette. */ +export async function typeNativeChatCommand( + settings: RuntimeSettings, + ptyId: string, + command: string, + signal?: AbortSignal +): Promise { + cancelNativeChatPtySends(ptyId) + await waitForNativeChatPtyIdle(ptyId) + const outcome = await typeAgentTuiCommand({ + command, + signal, + write: async (key) => + (await sendRuntimePtyInputVerified(settings, ptyId, key)) ? 'accepted' : 'rejected' + }) + return outcome === 'accepted' +} + export function sendNativeChatMessageWithImageAttachments( settings: RuntimeSettings, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts index 98481e1035c..5585512633a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-apply.ts @@ -109,7 +109,9 @@ async function handleAgentPicker( ctx: SessionOptionApplyContext, midSession: Extract ): Promise { - await ctx.dispatchCommand(midSession.command) + await (midSession.delivery + ? ctx.dispatchCommand(midSession.command, { delivery: midSession.delivery }) + : ctx.dispatchCommand(midSession.command)) ctx.clearModelTruth() const snapshot = ctx.publish() ctx.onAgentPicker?.() diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts index c510990dc5e..d7b071c55e3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -1,4 +1,7 @@ -import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { + CatalogAgentInteractionDetection, + CatalogCommandDelivery +} from '../../../../shared/agent-session-option-catalog' import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' export type NativeChatSessionOptionDispatchResult = { @@ -10,6 +13,7 @@ export type NativeChatSessionOptionDispatchCommand = ( options?: { detectAgentInteraction?: CatalogAgentInteractionDetection expectedChoiceLabel?: string + delivery?: CatalogCommandDelivery } ) => | Promise diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index 96d3cbdf479..5070b0230fe 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -6,7 +6,7 @@ import { type NativeChatResolvedTarget } from './native-chat-composer-target' import { pushHistory, type HistoryState } from './native-chat-composer-state' -import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { sendNativeChatMessageVerified, typeNativeChatCommand } from './native-chat-runtime-send' import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue' import { createClaudeModelSwitchConfirmationObserver, @@ -86,12 +86,20 @@ export function useNativeChatSessionOptionCommand(args: { // submit immediately so historical output cannot satisfy the match. observer.arm() } - const accepted = await sendNativeChatMessageVerified( - target.settings, - target.ptyId, - command, - sendController.signal - ) + const accepted = + options?.delivery === 'type' + ? await typeNativeChatCommand( + target.settings, + target.ptyId, + command, + sendController.signal + ) + : await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) if (!accepted) { throw new Error('The terminal did not accept the command.') } diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx deleted file mode 100644 index 06b1267f80b..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.test.tsx +++ /dev/null @@ -1,157 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - NeedsSetupProjectHostOption, - ProjectHostSetupOption -} from '@/lib/project-host-setup-options' -import ProjectHostSetupCombobox from './ProjectHostSetupCombobox' - -vi.mock('@/components/ui/popover', () => ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/command', () => ({ - Command: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandEmpty: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandList: ({ children }: { children: React.ReactNode }) =>
{children}
, - CommandItem: ({ - children, - disabled, - onSelect, - value - }: { - children: React.ReactNode - disabled?: boolean - onSelect?: (value: string) => void - value: string - }) => ( - - ) -})) - -let container: HTMLDivElement -let root: Root - -const readyOption: ProjectHostSetupOption = { - id: 'local-setup', - kind: 'ready', - projectId: 'project-1', - hostId: 'local', - repoId: 'local-repo', - label: 'Local Mac', - detail: 'Orca', - path: '/Users/alice/orca' -} - -const needsSetupOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:ssh:builder', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'ssh:builder', - label: 'Builder', - detail: 'Project location not set', - isAvailable: true, - attention: false -} - -const unavailableOption: NeedsSetupProjectHostOption = { - id: 'needs-setup:runtime:old', - kind: 'needs-setup', - projectId: 'project-1', - hostId: 'runtime:old', - label: 'Old server', - detail: 'Update Orca on this host to set up projects', - isAvailable: false, - attention: false -} - -beforeEach(() => { - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() -}) - -function renderCombobox({ - onValueChange = vi.fn() -}: { - onValueChange?: (setupId: string) => void -} = {}): void { - act(() => { - root.render( - - ) - }) -} - -describe('ProjectHostSetupCombobox', () => { - it('routes ready setup rows through onValueChange', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - act(() => { - container - .querySelector('[data-command-value="local-setup"]') - ?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(onValueChange).toHaveBeenCalledWith('local-setup') - }) - - it('hides hosts that need setup from the run target list', () => { - const onValueChange = vi.fn() - - renderCombobox({ onValueChange }) - - expect( - container.querySelector('[data-command-value="needs-setup:ssh:builder"]') - ).toBeNull() - expect(container.textContent).not.toContain('Project location not set') - expect(onValueChange).not.toHaveBeenCalled() - }) - - it('hides unavailable setup rows from the run target list', () => { - const onValueChange = vi.fn() - - act(() => { - root.render( - - ) - }) - - const unavailableButton = container.querySelector( - '[data-command-value="needs-setup:runtime:old"]' - ) - expect(unavailableButton).toBeNull() - expect(container.textContent).not.toContain('Update Orca on this host') - - expect(onValueChange).not.toHaveBeenCalled() - }) -}) diff --git a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx b/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx deleted file mode 100644 index 22df2d5f192..00000000000 --- a/src/renderer/src/components/new-workspace/ProjectHostSetupCombobox.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from 'react' -import { Check, ChevronsUpDown, Server } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { ProjectHostSetupOption } from '@/lib/project-host-setup-options' -import { translate } from '@/i18n/i18n' - -type ProjectHostSetupComboboxProps = { - options: readonly ProjectHostSetupOption[] - value: string | null - onValueChange: (setupId: string) => void -} - -export default function ProjectHostSetupCombobox({ - options, - value, - onValueChange -}: ProjectHostSetupComboboxProps): React.JSX.Element { - const [open, setOpen] = React.useState(false) - const readyOptions = options.filter((option) => option.kind === 'ready') - const selected = readyOptions.find((option) => option.id === value) ?? readyOptions[0] ?? null - - const handleSelect = React.useCallback( - (setupId: string): void => { - const option = options.find((candidate) => candidate.id === setupId) - if (!option) { - return - } - if (!readyOptions.some((candidate) => candidate.id === setupId)) { - return - } - onValueChange(setupId) - setOpen(false) - }, - [onValueChange, options, readyOptions] - ) - - return ( - - - - - - - - - {translate( - 'auto.components.new.workspace.ProjectHostSetupCombobox.empty', - 'No hosts are ready for this project.' - )} - - {readyOptions.map((option) => ( - handleSelect(option.id)} - className="items-center gap-2 px-3 py-2" - > - - -
-
{option.label}
-
- {option.path} -
-
-
- ))} -
-
-
-
- ) -} diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx deleted file mode 100644 index 832af22219c..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import { describe, expect, it, vi } from 'vitest' -import { AgentFeatureSetupStep } from './AgentFeatureSetupStep' - -describe('AgentFeatureSetupStep', () => { - it('renders the agent feature setup checklist', () => { - const html = renderToStaticMarkup( - - ) - - expect(html).toContain('Agent Browser Use') - expect(html).toContain('Computer Use') - expect(html).toContain('Agent Orchestration') - expect(html).toContain('Linear agent skill') - expect(html).toContain('Enable capabilities') - expect(html).toContain('role="checkbox"') - }) -}) diff --git a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx b/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx deleted file mode 100644 index 647600e02bb..00000000000 --- a/src/renderer/src/components/onboarding/AgentFeatureSetupStep.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Loader2, Terminal } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { FeatureSetupChecklist } from './FeatureSetupChecklist' -import { FeatureSetupInlineTerminal } from './FeatureSetupInlineTerminal' -import { - hasSelectedOnboardingFeatureSetup, - type OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type AgentFeatureSetupStepProps = { - featureSetup: OnboardingFeatureSetupSelection - onFeatureSetupChange: (value: OnboardingFeatureSetupSelection) => void - featureSetupCommand: string | null - featureSetupCommandSelection: OnboardingFeatureSetupSelection | null - setupBusyLabel: string | null - onStartFeatureSetup: () => void -} - -export function AgentFeatureSetupStep({ - featureSetup, - onFeatureSetupChange, - featureSetupCommand, - featureSetupCommandSelection, - setupBusyLabel, - onStartFeatureSetup -}: AgentFeatureSetupStepProps): React.JSX.Element { - const hasSelectedFeatures = hasSelectedOnboardingFeatureSetup(featureSetup) - const showSetupAction = !featureSetupCommand - - return ( - <> - - {showSetupAction ? ( -
- -
- ) : null} - {featureSetupCommand ? ( - - ) : null} - - ) -} diff --git a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx b/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx deleted file mode 100644 index 36fb6443ce6..00000000000 --- a/src/renderer/src/components/onboarding/FeatureSetupChecklist.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import type { ReactNode } from 'react' -import { Check, Globe2, MonitorCog, TicketCheck, Workflow } from 'lucide-react' -import { cn } from '@/lib/utils' -import type { - OnboardingFeatureSetupId, - OnboardingFeatureSetupSelection -} from './onboarding-feature-setup' -import { translate } from '@/i18n/i18n' - -type FeatureSetupChecklistProps = { - value: OnboardingFeatureSetupSelection - onChange: (value: OnboardingFeatureSetupSelection) => void -} - -type FeatureSetupRow = { - id: OnboardingFeatureSetupId - title: string - description: string - setupSummary: string - icon: ReactNode -} - -const FEATURE_SETUP_ROWS: readonly FeatureSetupRow[] = [ - { - id: 'browserUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.ea85d9e628', - 'Agent Browser Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.01426f3a23', - 'Agents can navigate sites, inspect pages, and work through browser tasks.' - ) - }, - setupSummary: 'Enables browser use, prepares orca-cli, and leaves cookies for Settings.', - icon: - }, - { - id: 'computerUse', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.1ecfb490ac', - 'Computer Use' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.c5292c409d', - 'Agents can inspect app windows and operate local apps when you ask.' - ) - }, - setupSummary: 'Registers the Orca CLI, opens permissions, and prepares the skill.', - icon: - }, - { - id: 'orchestration', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.399cf885c0', - 'Agent Orchestration' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.77f74946f5', - 'Agents can message each other, take tasks, and coordinate handoffs.' - ) - }, - setupSummary: 'Registers the Orca CLI, enables orchestration, and prepares the skill.', - icon: - }, - { - id: 'linearTickets', - get title() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsTitle', - 'Linear agent skill' - ) - }, - get description() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsDescription', - 'Agents can use linked Linear tasks for richer ticket-aware handoffs.' - ) - }, - get setupSummary() { - return translate( - 'auto.components.onboarding.FeatureSetupChecklist.linearTicketsSetupSummary', - 'Recommended for Linear workspaces; does not affect Linear connection setup.' - ) - }, - icon: - } -] - -export function FeatureSetupChecklist({ - value, - onChange -}: FeatureSetupChecklistProps): React.JSX.Element { - return ( -
-
- {FEATURE_SETUP_ROWS.map((row) => { - const selected = value[row.id] - return ( - - ) - })} -
-
- ) -} diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index c5546aa95e6..f32b401fff2 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -100,15 +100,13 @@ const stepTooltipLabels = { type OnboardingFlowProps = { onboarding: OnboardingState onOnboardingChange: (state: OnboardingState) => void - onSettingsDetourStart?: () => void } export default function OnboardingFlow({ onboarding, - onOnboardingChange, - onSettingsDetourStart + onOnboardingChange }: OnboardingFlowProps): React.JSX.Element { - const flow = useOnboardingFlow(onboarding, onOnboardingChange, { onSettingsDetourStart }) + const flow = useOnboardingFlow(onboarding, onOnboardingChange) const continueShortcutModifierLabel = getScreenSubmitModifierLabel() const { currentStep, stepIndex, busyLabel } = flow const copy = stepCopy[currentStep.id] @@ -321,7 +319,7 @@ export default function OnboardingFlow({ busyLabel={busyLabel} onSkipToRepo={() => void flow.skipToRepo()} stepIndex={stepIndex} - onBack={flow.nestedScan ? flow.cancelNested : flow.back} + onBack={flow.back} showPrimary primaryBusy={shouldShowFooterBusy} primaryLabel={footerPrimaryLabel} diff --git a/src/renderer/src/components/onboarding/RepoStep.test.tsx b/src/renderer/src/components/onboarding/RepoStep.test.tsx deleted file mode 100644 index de01325e1d5..00000000000 --- a/src/renderer/src/components/onboarding/RepoStep.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server' -import type { ComponentProps } from 'react' -import { describe, expect, it, vi } from 'vitest' -import { RepoStep } from './RepoStep' -import { TooltipProvider } from '../ui/tooltip' - -function renderRepoStep(overrides: Partial> = {}): string { - return renderToStaticMarkup( - - - - ) -} - -describe('RepoStep', () => { - it('renders the add project options without existing-project chrome', () => { - const html = renderRepoStep() - - expect(html).not.toContain('Project already added') - expect(html).toContain('Browse for a folder') - expect(html).toContain('Clone a repo') - }) - - it('presents the local-folder card itself as the browse action', () => { - const html = renderRepoStep() - - expect(html).toContain('border-border bg-muted/30') - expect(html).toContain('focus:border-foreground/70') - expect(html).toContain('focus:ring-2 focus:ring-inset focus:ring-foreground/25') - expect(html).toContain('autofocus=""') - expect(html).toContain('Browse for a folder') - expect(html).toContain('group-hover:translate-x-0.5') - expect(html).toContain('w-fit max-w-[calc(100%-3.75rem)]') - expect(html).toContain('Want to import many repos at once? Select the parent folder.') - }) - - it('disables the nested import action when no repositories are selected', () => { - const html = renderRepoStep({ - nestedScan: { - selectedPath: '/workspace/platform', - selectedPathKind: 'non_git_folder', - repos: [{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 4, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null - } - }) - - expect(html).toContain('Import repositories') - expect(html).toContain('Scanned folder: platform - /workspace/platform') - expect(html).not.toContain('Group name') - expect(html).not.toContain('Import separately') - expect(html).not.toContain('Import as project group') - expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(1) - }) - - it('shows a stop action and disables import while nested scan is still running', () => { - const html = renderRepoStep({ - nestedScan: { - selectedPath: '/workspace/platform', - selectedPathKind: 'non_git_folder', - repos: [{ path: '/workspace/platform/apps/web', displayName: 'web', depth: 2 }], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 4, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null - }, - nestedScanInProgress: true, - nestedSelectedPaths: new Set(['/workspace/platform/apps/web']) - }) - - expect(html).toContain('Scanning... Found 1 repository in this folder.') - expect(html).toContain('aria-label="Stop scan"') - expect(html).toContain('Showing partial scan results.') - expect(html.match(/disabled=""/g)?.length).toBeGreaterThanOrEqual(3) - }) - - it('shows a flat nested repository checklist with collision labels', () => { - const html = renderRepoStep({ - nestedScan: { - selectedPath: '/workspace/platform', - selectedPathKind: 'non_git_folder', - repos: [ - { path: '/workspace/platform/web', displayName: 'web', depth: 1 }, - { path: '/workspace/platform/payments/api', displayName: 'api', depth: 2 }, - { path: '/workspace/platform/billing/api', displayName: 'api', depth: 2 } - ], - truncated: false, - timedOut: false, - stopped: false, - durationMs: 4, - maxDepth: 3, - maxRepos: 100, - timeoutMs: null - }, - nestedSelectedPaths: new Set(['/workspace/platform/web']) - }) - - expect(html).toContain('Found 3 repositories in this folder.') - expect(html).toContain('web') - expect(html).toContain('payments/api') - expect(html).toContain('billing/api') - expect(html).not.toContain('Project group') - }) -}) diff --git a/src/renderer/src/components/onboarding/RepoStep.tsx b/src/renderer/src/components/onboarding/RepoStep.tsx deleted file mode 100644 index 0202ef6a6a6..00000000000 --- a/src/renderer/src/components/onboarding/RepoStep.tsx +++ /dev/null @@ -1,322 +0,0 @@ -import { - ArrowRight, - CircleStop, - FolderOpen, - GitBranch, - Lightbulb, - Loader2, - Server -} from 'lucide-react' -import type { Dispatch, SetStateAction } from 'react' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { NestedRepoScanResult } from '../../../../shared/types' -import { translate } from '@/i18n/i18n' -import { RepoStepNestedImportPanel } from './RepoStepNestedImportPanel' - -type RepoStepProps = { - cloneUrl: string - onCloneUrlChange: (value: string) => void - nestedScan: NestedRepoScanResult | null - nestedScanInProgress: boolean - nestedSelectedPaths: Set - onNestedSelectedPathsChange: Dispatch>> - onImportNested: () => void - onCancelNested: () => void - onStopNestedScan: () => void - onOpenFolder: () => void - onOpenServerFolder: (kind: 'git' | 'folder') => void - onClone: () => void - onOpenSshSettings: () => void - serverPath: string - onServerPathChange: (value: string) => void - cloneDestination: string - onCloneDestinationChange: (value: string) => void - workspaceDir: string - runtimeActive: boolean - busyLabel: string | null - error: string | null -} - -export function RepoStep({ - cloneUrl, - onCloneUrlChange, - nestedScan, - nestedScanInProgress, - nestedSelectedPaths, - onNestedSelectedPathsChange, - onImportNested, - onCancelNested, - onStopNestedScan, - onOpenFolder, - onOpenServerFolder, - onClone, - onOpenSshSettings, - serverPath, - onServerPathChange, - cloneDestination, - onCloneDestinationChange, - workspaceDir, - runtimeActive, - busyLabel, - error -}: RepoStepProps) { - const disabled = Boolean(busyLabel) - if (nestedScan) { - return ( - - ) - } - return ( -
- {runtimeActive ? ( -
{ - event.preventDefault() - onOpenServerFolder('git') - }} - > -
-
- -
-
-
- {translate( - 'auto.components.onboarding.RepoStep.8cab104e3c', - 'Open a project on this host' - )} -
-
- {translate( - 'auto.components.onboarding.RepoStep.466108ab89', - 'Enter a path that exists on the selected host.' - )} -
-
-
-
- onServerPathChange(event.target.value)} - /> - - -
-
- ) : ( - - )} - -
{ - e.preventDefault() - onClone() - }} - > -
-
- -
-
-
- {translate('auto.components.onboarding.RepoStep.132425a3e3', 'Clone a repo')} -
-
- {translate( - 'auto.components.onboarding.RepoStep.288d8444b7', - 'Paste an HTTPS or SSH URL.' - )} -
-
-
-
- onCloneUrlChange(event.target.value)} - /> - -
- {runtimeActive && ( -
- - onCloneDestinationChange(event.target.value)} - /> -
- )} -
- -
-
- {translate('auto.components.onboarding.RepoStep.7b679207e4', 'Workspace')} - - {runtimeActive - ? translate('auto.components.onboarding.RepoStep.cf23006ba7', 'Selected host') - : workspaceDir} - -
- {runtimeActive ? ( -
- - - {translate('auto.components.onboarding.RepoStep.c33b190ca3', 'Host paths only')} - -
- ) : ( - - )} -
- - {busyLabel && ( -
- {busyLabel} - {nestedScanInProgress ? ( - - - - - - {translate( - 'auto.components.onboarding.RepoStep.e8fdb36338', - 'Scanning repositories. Click to stop.' - )} - - - ) : null} -
- )} - {error && ( -
- {error} -
- )} -
- ) -} diff --git a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx b/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx deleted file mode 100644 index e7b24e2b71e..00000000000 --- a/src/renderer/src/components/onboarding/RepoStepNestedImportPanel.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import { ArrowLeft, CircleStop, FolderOpen, Loader2 } from 'lucide-react' -import type { Dispatch, SetStateAction } from 'react' -import { Button } from '@/components/ui/button' -import { NestedRepoChecklist } from '@/components/repo/NestedRepoChecklist' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import type { NestedRepoScanResult } from '../../../../shared/types' -import { getRuntimePathBasename } from '../../../../shared/cross-platform-path' -import { NestedRepoScanLimitNotice } from '../repo/NestedRepoScanLimitNotice' - -type RepoStepNestedImportPanelProps = { - nestedScan: NestedRepoScanResult - nestedScanInProgress: boolean - nestedSelectedPaths: Set - onNestedSelectedPathsChange: Dispatch>> - onImportNested: () => void - onCancelNested: () => void - onStopNestedScan: () => void - busyLabel: string | null - error: string | null - disabled: boolean -} - -export function RepoStepNestedImportPanel({ - nestedScan, - nestedScanInProgress, - nestedSelectedPaths, - onNestedSelectedPathsChange, - onImportNested, - onCancelNested, - onStopNestedScan, - busyLabel, - error, - disabled -}: RepoStepNestedImportPanelProps) { - const folderName = getRuntimePathBasename(nestedScan.selectedPath) || nestedScan.selectedPath - const nestedImportDisabled = disabled || nestedScanInProgress - return ( -
-
-
-
- -
-
-
- {translate('auto.components.onboarding.RepoStep.2d20200346', 'Import repositories')} -
-
- {nestedScanInProgress ? ( - - - - - - {translate( - 'auto.components.onboarding.RepoStep.e8fdb36338', - 'Scanning repositories. Click to stop.' - )} - - - ) : null} - - {translate( - 'auto.components.onboarding.RepoStep.2e6438dd34', - '{{value0}}Found {{value1}} {{value2}} in this folder.', - { - value0: nestedScanInProgress ? 'Scanning... ' : '', - value1: nestedScan.repos.length, - value2: nestedScan.repos.length === 1 ? 'repository' : 'repositories' - } - )} - -
-
- {translate('auto.components.onboarding.RepoStep.cecd6593fa', 'Scanned folder:')}{' '} - {folderName} - {nestedScan.selectedPath} -
-
-
- - {nestedScanInProgress || - nestedScan.truncated || - nestedScan.timedOut || - nestedScan.stopped ? ( -
- -
- ) : null} -
- - -
-
- {busyLabel ? ( -
- {busyLabel} -
- ) : null} - {error ? ( -
- {error} -
- ) : null} -
- ) -} diff --git a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.test.ts b/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.test.ts deleted file mode 100644 index fefefe03968..00000000000 --- a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - createOnboardingTourOutcomeTracker, - markOnboardingTourIntroReached, - markOnboardingTourStarted, - recordOnboardingTourDepthSummary, - resolveOnboardingTourOutcome, - resolvePendingOnboardingTourOutcome -} from './onboarding-tour-outcome-tracker' - -const depthSummary = { - furthest_step: 'review_ship', - last_group_id: 'review', - visited_workflow_count: 5, - visited_substep_count: 9, - completed_workflow_count: 4, - completed_substep_count: 7 -} as const - -describe('onboarding tour outcome tracker', () => { - it('emits one skipped outcome for one intro skip', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - - expect(resolveOnboardingTourOutcome(tracker, 'skipped_intro', 250, 'button')).toEqual({ - outcome: 'skipped_intro', - intro_duration_ms: 150, - advanced_via: 'button' - }) - expect(resolveOnboardingTourOutcome(tracker, 'skipped_intro', 260, 'button')).toBeNull() - }) - - it('emits one completed outcome with inline tour depth', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 175) - recordOnboardingTourDepthSummary(tracker, depthSummary) - - expect(resolveOnboardingTourOutcome(tracker, 'completed_inline', 575, 'button')).toEqual({ - outcome: 'completed_inline', - intro_duration_ms: 75, - tour_dwell_ms: 400, - furthest_step: 'review_ship', - visited_workflow_count: 5, - visited_substep_count: 9, - completed_workflow_count: 4, - completed_substep_count: 7, - advanced_via: 'button' - }) - expect(resolvePendingOnboardingTourOutcome(tracker, 600)).toBeNull() - }) - - it('does not resolve a back or step jump partial before session resolution', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 125) - - expect(tracker.startedInline).toBe(true) - recordOnboardingTourDepthSummary(tracker, { - visited_workflow_count: 1, - visited_substep_count: 0, - completed_workflow_count: 1, - completed_substep_count: 0 - }) - expect(resolveOnboardingTourOutcome(tracker, 'completed_inline', 225, 'button')).toMatchObject({ - outcome: 'completed_inline' - }) - expect(resolvePendingOnboardingTourOutcome(tracker, 250)).toBeNull() - }) - - it('classifies a started tour as partial even if the final action is skip', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 150) - recordOnboardingTourDepthSummary(tracker, { - furthest_step: 'tasks', - last_group_id: 'tasks', - visited_workflow_count: 2, - visited_substep_count: 0, - completed_workflow_count: 1, - completed_substep_count: 0 - }) - - expect(resolveOnboardingTourOutcome(tracker, 'skipped_intro', 400, 'button')).toEqual({ - outcome: 'started_partial', - intro_duration_ms: 50, - tour_dwell_ms: 250, - furthest_step: 'tasks', - visited_workflow_count: 2, - visited_substep_count: 0, - completed_workflow_count: 1, - completed_substep_count: 0, - advanced_via: 'button' - }) - }) - - it('emits at most one pending partial on session close or unmount', () => { - const tracker = createOnboardingTourOutcomeTracker() - markOnboardingTourIntroReached(tracker, 100) - markOnboardingTourStarted(tracker, 150) - recordOnboardingTourDepthSummary(tracker, { - furthest_step: 'workbench_editor', - last_group_id: 'workbench', - visited_workflow_count: 1, - visited_substep_count: 2, - completed_workflow_count: 0, - completed_substep_count: 2 - }) - - expect(resolvePendingOnboardingTourOutcome(tracker, 450)).toEqual({ - outcome: 'started_partial', - intro_duration_ms: 50, - tour_dwell_ms: 300, - furthest_step: 'workbench_editor', - visited_workflow_count: 1, - visited_substep_count: 2, - completed_workflow_count: 0, - completed_substep_count: 2 - }) - expect(resolvePendingOnboardingTourOutcome(tracker, 475)).toBeNull() - }) -}) diff --git a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.ts b/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.ts deleted file mode 100644 index 069f5679465..00000000000 --- a/src/renderer/src/components/onboarding/onboarding-tour-outcome-tracker.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { FEATURE_WALL_MAX_DWELL_MS } from '../../../../shared/feature-wall-telemetry' -import type { FeatureWallTourDepthSummary } from '../../../../shared/feature-wall-tour-depth' -import type { EventProps } from '../../../../shared/telemetry-events' - -type OnboardingTourOutcome = EventProps<'onboarding_tour_outcome'>['outcome'] -type OnboardingTourOutcomePayload = EventProps<'onboarding_tour_outcome'> - -export type OnboardingTourOutcomeTracker = { - reachedIntro: boolean - startedInline: boolean - completedInline: boolean - emitted: boolean - introStartedAtMs: number | null - introDurationMs: number | null - tourStartedAtMs: number | null - latestDepthSummary: FeatureWallTourDepthSummary | null -} - -export function createOnboardingTourOutcomeTracker(): OnboardingTourOutcomeTracker { - return { - reachedIntro: false, - startedInline: false, - completedInline: false, - emitted: false, - introStartedAtMs: null, - introDurationMs: null, - tourStartedAtMs: null, - latestDepthSummary: null - } -} - -function boundedDurationMs(startedAtMs: number | null, nowMs: number): number | undefined { - if (startedAtMs === null) { - return undefined - } - return Math.min(FEATURE_WALL_MAX_DWELL_MS, Math.max(0, Math.round(nowMs - startedAtMs))) -} - -export function markOnboardingTourIntroReached( - tracker: OnboardingTourOutcomeTracker, - nowMs: number -): void { - if (tracker.reachedIntro) { - return - } - tracker.reachedIntro = true - tracker.introStartedAtMs = nowMs -} - -export function markOnboardingTourStarted( - tracker: OnboardingTourOutcomeTracker, - nowMs: number -): void { - if (!tracker.reachedIntro) { - markOnboardingTourIntroReached(tracker, nowMs) - } - tracker.startedInline = true - tracker.introDurationMs = boundedDurationMs(tracker.introStartedAtMs, nowMs) ?? 0 - tracker.tourStartedAtMs = nowMs -} - -export function recordOnboardingTourDepthSummary( - tracker: OnboardingTourOutcomeTracker, - summary: FeatureWallTourDepthSummary -): void { - tracker.latestDepthSummary = summary -} - -export function resolveOnboardingTourOutcome( - tracker: OnboardingTourOutcomeTracker, - outcome: OnboardingTourOutcome, - nowMs: number, - advancedVia?: NonNullable -): OnboardingTourOutcomePayload | null { - if (!tracker.reachedIntro || tracker.emitted) { - return null - } - const resolvedOutcome = - tracker.completedInline || outcome === 'completed_inline' - ? 'completed_inline' - : tracker.startedInline - ? 'started_partial' - : outcome - const introDurationMs = - tracker.introDurationMs ?? boundedDurationMs(tracker.introStartedAtMs, nowMs) - const depthSummary = resolvedOutcome === 'skipped_intro' ? null : tracker.latestDepthSummary - const tourDwellMs = - resolvedOutcome === 'skipped_intro' - ? undefined - : boundedDurationMs(tracker.tourStartedAtMs, nowMs) - - tracker.emitted = true - if (resolvedOutcome === 'completed_inline') { - tracker.completedInline = true - } - - return { - outcome: resolvedOutcome, - ...(introDurationMs !== undefined ? { intro_duration_ms: introDurationMs } : {}), - ...(tourDwellMs !== undefined ? { tour_dwell_ms: tourDwellMs } : {}), - ...(depthSummary - ? { - ...(depthSummary.furthest_step ? { furthest_step: depthSummary.furthest_step } : {}), - visited_workflow_count: depthSummary.visited_workflow_count, - visited_substep_count: depthSummary.visited_substep_count, - completed_workflow_count: depthSummary.completed_workflow_count, - completed_substep_count: depthSummary.completed_substep_count - } - : {}), - ...(advancedVia ? { advanced_via: advancedVia } : {}) - } -} - -export function resolvePendingOnboardingTourOutcome( - tracker: OnboardingTourOutcomeTracker, - nowMs: number -): OnboardingTourOutcomePayload | null { - if (!tracker.startedInline || tracker.completedInline) { - return null - } - return resolveOnboardingTourOutcome(tracker, 'started_partial', nowMs) -} diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts index 893e1c38fac..1dfbc0c708a 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.test.ts @@ -23,9 +23,8 @@ import type { StepNumber } from './use-onboarding-flow-types' type CloseWithCallback = ( outcome: 'completed' | 'dismissed', - checklist: Partial, lastStepReached: StepNumber, - completedPath?: 'open_folder' | 'clone_url' | 'add_project_modal', + completedPath?: 'add_project_modal', dismissedExtras?: DismissedExtras ) => Promise @@ -48,7 +47,6 @@ function setApi(api: { function CloseWithProbe(props: { onReady: (closeWith: CloseWithCallback) => void }): null { const closeWith = useCloseWith({ onOnboardingChange: vi.fn(), - onboardingChecklist: makeOnboardingState().checklist, startTimeRef: { current: Date.now() }, setError: vi.fn() }) @@ -145,7 +143,7 @@ describe('onboarding flow persistence', () => { })) await act(async () => { - await closeWith?.('completed', {}, 5) + await closeWith?.('completed', 5) }) const api = ( diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts index c0354249fc5..12a8c6e22a2 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow-persistence.ts @@ -35,7 +35,6 @@ export function buildCompletedOnboardingNotificationSettings( type CloseWithDeps = { onOnboardingChange: (state: OnboardingState) => void - onboardingChecklist: OnboardingState['checklist'] startTimeRef: { current: number } setError: (msg: string | null) => void } @@ -67,12 +66,7 @@ export function trackOnboardingDismissed( track('onboarding_dismissed', buildOnboardingDismissedPayload(lastStepReached, dismissedExtras)) } -export function useCloseWith({ - onOnboardingChange, - onboardingChecklist, - startTimeRef, - setError -}: CloseWithDeps) { +export function useCloseWith({ onOnboardingChange, startTimeRef, setError }: CloseWithDeps) { // Why: onboarding closes exactly once. On the final notifications step both // the "Add your first project" handoff (completed) and a click-off/Escape // dismissal (dismissed) can reach closeWith, and next()'s persist window @@ -83,9 +77,8 @@ export function useCloseWith({ return useCallback( async ( outcome: 'completed' | 'dismissed', - checklist: Partial, lastStepReached: StepNumber, - completedPath?: 'open_folder' | 'clone_url' | 'add_project_modal', + completedPath?: 'add_project_modal', dismissedExtras?: DismissedExtras ): Promise => { if (closedRef.current) { @@ -94,18 +87,12 @@ export function useCloseWith({ closedRef.current = true let nextState: OnboardingState try { - // Why: main-process updateOnboarding already merges with current state, - // so spreading the local (potentially stale) onboarding.checklist would - // overwrite concurrent updates. nextState = await window.api.onboarding.update({ flowVersion: ONBOARDING_FLOW_VERSION, closedAt: Date.now(), outcome, lastCompletedStep: outcome === 'completed' ? ONBOARDING_FINAL_STEP : -1, - checklist: { - ...checklist, - dismissed: outcome === 'dismissed' - } + checklist: { dismissed: outcome === 'dismissed' } }) } catch (err) { // Why: the persist failed, so onboarding did not actually close — clear @@ -124,22 +111,6 @@ export function useCloseWith({ path: completedPath, total_duration_ms: total }) - // Why: checklist items completed by the wizard itself must fire - // `activation_checklist_item_completed` so the post-wizard panel and - // analytics agree. Other items (ranFirstAgent, triedCmdJ, …) emit - // from their own product surfaces. - if (checklist.addedRepo && !onboardingChecklist.addedRepo) { - track('activation_checklist_item_completed', { - item: 'addedRepo', - time_since_completed_ms: 0 - }) - } - if (checklist.addedFolder && !onboardingChecklist.addedFolder) { - track('activation_checklist_item_completed', { - item: 'addedFolder', - time_since_completed_ms: 0 - }) - } } if (outcome === 'completed') { // Why: closeWith updates parent state synchronously from this hook's @@ -152,7 +123,7 @@ export function useCloseWith({ } return true }, - [onOnboardingChange, onboardingChecklist, startTimeRef, setError] + [onOnboardingChange, startTimeRef, setError] ) } diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.default-checkout.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.default-checkout.test.ts deleted file mode 100644 index 62f2df5b254..00000000000 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.default-checkout.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { describe, expect, it } from 'vitest' - -const ONBOARDING_FLOW_PATH = join(import.meta.dirname, 'use-onboarding-flow.ts') - -describe('useOnboardingFlow project-added handoff', () => { - it('routes Git repo completion through the shared default-checkout opener', () => { - const source = readFileSync(ONBOARDING_FLOW_PATH, 'utf8') - - expect(source).toContain('openProjectDefaultCheckout({') - expect(source).toContain('repoId: projectId') - expect(source).toContain( - "source: path === 'clone_url' ? 'onboarding_clone_url' : 'onboarding_open_folder'" - ) - expect(source).toContain('setHideDefaultBranchWorkspace') - expect(source).not.toContain("openModal('project-added'") - }) -}) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts index 39270546c94..0133a720059 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.test.ts @@ -43,7 +43,7 @@ describe('prepareSkippedOnboardingPreferences', () => { }) }) - it('keeps the selected agent preference before jumping to repo setup', async () => { + it('keeps the selected agent preference before opening project setup', async () => { const setTheme = vi.fn() const applyTheme = vi.fn() const updateSettings = vi.fn().mockResolvedValue(undefined) diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow.ts b/src/renderer/src/components/onboarding/use-onboarding-flow.ts index b21b4c742e8..b5376b363f1 100644 --- a/src/renderer/src/components/onboarding/use-onboarding-flow.ts +++ b/src/renderer/src/components/onboarding/use-onboarding-flow.ts @@ -3,35 +3,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { getAgentCatalog } from '@/lib/agent-catalog' import { useAppStore } from '@/store' -import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { applyDocumentTheme } from '@/lib/document-theme' import { track } from '@/lib/telemetry' -import { getSelectedNestedRepoPathsInScanOrder } from '@/lib/nested-repo-selected-paths' import { buildAgentPickedPayload } from './agent-picked-payload' import { ONBOARDING_FINAL_STEP, ONBOARDING_FLOW_VERSION } from '../../../../shared/constants' -import { isGitRepoKind } from '../../../../shared/repo-kind' -import { - buildNestedRepoImportActionTelemetry, - buildNestedRepoImportResultTelemetry, - buildNestedRepoScanTelemetry, - createNestedRepoTelemetryAttemptId, - shouldEmitNestedRepoImportSubmitTelemetry, - type NestedRepoTelemetryRuntimeKind -} from '../../../../shared/nested-repo-telemetry' import type { EventProps } from '../../../../shared/telemetry-events' -import type { - GlobalSettings, - NestedRepoScanResult, - OnboardingState, - Repo, - TuiAgent -} from '../../../../shared/types' +import type { GlobalSettings, OnboardingState, TuiAgent } from '../../../../shared/types' import { STEPS, type StepNumber } from './use-onboarding-flow-types' import { persistStep, useCloseWith, usePersistCurrentStep } from './use-onboarding-flow-persistence' -import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' -import { buildOnboardingFolderAgentStartup } from '@/lib/onboarding-folder-agent-startup' import { resolveOnboardingSettingsHydration } from './onboarding-settings-hydration' -import { openProjectDefaultCheckout } from '../sidebar/project-added-default-checkout' import { translate } from '@/i18n/i18n' import { resolveAgentPermissionModeSummary } from '../../../../shared/tui-agent-permissions' import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' @@ -85,10 +65,6 @@ function resolveStepIndex( return nextIndex } -function createNestedRepoScanId(): string { - return `nested-repo-scan-${Date.now()}-${Math.random().toString(36).slice(2)}` -} - function getGitHubTaskSourceStatus( status: ReturnType['preflightStatus'], loading: boolean @@ -187,7 +163,7 @@ export async function prepareSkippedOnboardingPreferences({ await updateSettings({ theme: themeToRestore }) } } - // Why: the repo step seeds folder terminals from saved settings, so preserve the visible agent choice on skip. + // Why: skipping bypasses step persistence, so save the visible agent choice before closing. if (currentStepId === 'agent' && selectedAgent) { await updateSettings({ defaultTuiAgent: selectedAgent }) } @@ -208,10 +184,8 @@ export async function prepareSkippedOnboardingPreferences({ export function useOnboardingFlow( onboarding: OnboardingState, - onOnboardingChange: (state: OnboardingState) => void, - options: { onSettingsDetourStart?: () => void } = {} + onOnboardingChange: (state: OnboardingState) => void ) { - const { onSettingsDetourStart } = options const settings = useAppStore((s) => s.settings) const updateSettings = useAppStore((s) => s.updateSettings) const refreshDetectedAgents = useAppStore((s) => s.refreshDetectedAgents) @@ -219,24 +193,13 @@ export function useOnboardingFlow( const isDetectingAgents = useAppStore((s) => s.isDetectingAgents || s.isRefreshingAgents) const pathSource = useAppStore((s) => s.pathSource) const pathFailureReason = useAppStore((s) => s.pathFailureReason) - const fetchRepos = useAppStore((s) => s.fetchRepos) - const fetchWorktrees = useAppStore((s) => s.fetchWorktrees) - const setHideDefaultBranchWorkspace = useAppStore((s) => s.setHideDefaultBranchWorkspace) - const addRepoPath = useAppStore((s) => s.addRepoPath) - const scanNestedRepos = useAppStore((s) => s.scanNestedRepos) - const cancelNestedRepoScan = useAppStore((s) => s.cancelNestedRepoScan) - const importNestedRepos = useAppStore((s) => s.importNestedRepos) const openModal = useAppStore((s) => s.openModal) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) const preflightStatus = useAppStore((s) => s.preflightStatus) const preflightStatusChecked = useAppStore((s) => s.preflightStatusChecked) const preflightStatusLoading = useAppStore((s) => s.preflightStatusLoading) const refreshPreflightStatus = useAppStore((s) => s.refreshPreflightStatus) const linearStatus = useAppStore((s) => s.linearStatus) const linearStatusChecked = useAppStore((s) => s.linearStatusChecked) - // Why: repos are hydrated before onboarding mounts; the sync read lets the final step render added state without a flash. - const repos = useAppStore((s) => s.repos) // Why: renderToStaticMarkup uses Zustand's initial snapshot; the sync read keeps tests and the first client render aligned. const effectivePreflightStatus = preflightStatus ?? useAppStore.getState().preflightStatus @@ -266,20 +229,8 @@ export function useOnboardingFlow( ) // Why: hydrate theme from saved settings so users who already chose one see it preselected. const [theme, setTheme] = useState(settings?.theme ?? 'dark') - const [cloneUrl, setCloneUrl] = useState('') - const [serverPath, setServerPath] = useState('') - const [cloneDestination, setCloneDestination] = useState('') - const [nestedScan, setNestedScan] = useState(null) - const [nestedSelectedPaths, setNestedSelectedPaths] = useState>(new Set()) - const [nestedAttemptId, setNestedAttemptId] = useState(null) - const [nestedRuntimeKind, setNestedRuntimeKind] = useState( - null - ) - const [nestedScanInProgress, setNestedScanInProgress] = useState(false) - const [nestedImportScanId, setNestedImportScanId] = useState(null) - const nestedScanIdRef = useRef(null) const [busyLabel, setBusyLabel] = useState(null) - const [error, setError] = useState(null) + const [, setError] = useState(null) // Why: settings hydrate async after the lazy initializers run; re-sync once before commit unless the user edited the field. const themeInteractedRef = useRef(false) @@ -377,8 +328,6 @@ export function useOnboardingFlow( 0, progressSteps.findIndex(({ index }) => index === displayedStepIndex) ) - const hasExistingProject = repos.length > 0 - // Why: pin start time once so onboarding_completed reports a real funnel duration. const startTimeRef = useRef(Date.now()) @@ -528,58 +477,10 @@ export function useOnboardingFlow( const closeWith = useCloseWith({ onOnboardingChange, - onboardingChecklist: onboarding.checklist, startTimeRef, setError }) - const completeRepo = useCallback( - async (projectId: string, isGit: boolean, path: 'open_folder' | 'clone_url') => { - await fetchRepos() - // Why: a non-authoritative Git refresh should still complete onboarding onto the project row as a fallback. - await fetchWorktrees(projectId, isGit ? { requireAuthoritative: true } : undefined) - const worktrees = useAppStore.getState().worktreesByRepo[projectId] ?? [] - if (isGit) { - await openProjectDefaultCheckout({ - repoId: projectId, - source: path === 'clone_url' ? 'onboarding_clone_url' : 'onboarding_open_folder', - setHideDefaultBranchWorkspace - }) - } else { - const worktree = worktrees[0] ?? null - if (worktree) { - // Why: non-git folders skip the composer, so seed their first terminal with the chosen default agent here. - const startup = buildOnboardingFolderAgentStartup(settings) - activateAndRevealWorktree(worktree.id, { startup }) - } - } - // Why: next() short-circuits the repo step; emit step_completed here, gated on closeWith success so a persistence failure can't double-count. - const closed = await closeWith( - 'completed', - isGit ? { addedRepo: true } : { addedFolder: true }, - ONBOARDING_FINAL_STEP, - path - ) - if (!closed) { - return - } - // Why: the final repo step has no keyboard-vs-button distinction, so emit duration_ms without advanced_via. See docs/onboarding-telemetry-extensions.md §3. - track('onboarding_step_completed', { - step: ONBOARDING_FINAL_STEP, - value_kind: 'repo', - duration_ms: consumeStepDurationMs() - }) - }, - [ - closeWith, - consumeStepDurationMs, - fetchRepos, - fetchWorktrees, - setHideDefaultBranchWorkspace, - settings - ] - ) - const persistCurrentStep = usePersistCurrentStep({ currentStepId: currentStep.id, selectedAgent, @@ -639,12 +540,7 @@ export function useOnboardingFlow( trackCurrentStepCompleted(advancedVia) if (currentStep.id === 'notifications') { setBusyLabel('Opening Add Project...') - const closed = await closeWith( - 'completed', - {}, - ONBOARDING_FINAL_STEP, - 'add_project_modal' - ) + const closed = await closeWith('completed', ONBOARDING_FINAL_STEP, 'add_project_modal') if (closed) { openModal('add-repo') } @@ -689,388 +585,6 @@ export function useOnboardingFlow( ] ) - const showNestedRepoReview = useCallback( - ( - scan: NestedRepoScanResult, - attemptId: string, - runtimeKind: NestedRepoTelemetryRuntimeKind, - inProgress = false, - scanId: string | null = null - ) => { - setNestedScan(scan) - setNestedSelectedPaths(new Set(scan.repos.map((repo) => repo.path))) - setNestedAttemptId(attemptId) - setNestedRuntimeKind(runtimeKind) - setNestedScanInProgress(inProgress) - setNestedImportScanId(scanId) - }, - [] - ) - - const onboardingNestedRepoRuntimeKind: NestedRepoTelemetryRuntimeKind = - settings?.activeRuntimeEnvironmentId?.trim() ? 'runtime' : 'local' - - const openFolder = useCallback( - async (kind: 'git' | 'folder' = 'git') => { - // Why: re-entry guard — rapid Cmd+Enter must not launch duplicate pickers. - if (busyLabel !== null) { - return - } - setError(null) - if (settings?.activeRuntimeEnvironmentId?.trim()) { - const path = serverPath.trim() - if (!path) { - const message = 'Enter a path on the selected host.' - setError(message) - return - } - track('onboarding_step4_path_clicked', { path: 'open_folder' }) - setBusyLabel(kind === 'git' ? 'Scanning for repositories…' : 'Opening folder…') - try { - if (kind === 'git') { - const attemptId = createNestedRepoTelemetryAttemptId() - const scan = await scanNestedRepos(path) - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind: 'runtime', - scan - }) - ) - if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, attemptId, 'runtime') - return - } - } - setBusyLabel(kind === 'git' ? 'Opening project…' : 'Opening folder…') - const repo = await addRepoPath(path, kind) - if (!repo) { - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - return - } - await completeRepo(repo.id, isGitRepoKind(repo), 'open_folder') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - nestedScanIdRef.current = null - setNestedScanInProgress(false) - setBusyLabel(null) - } - return - } - track('onboarding_step4_path_clicked', { path: 'open_folder' }) - const path = await window.api.repos.pickFolder() - if (!path) { - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'cancelled' }) - return - } - setBusyLabel('Opening project…') - try { - let result = await window.api.repos.add({ path }) - if ('error' in result && result.error.includes('Not a valid git repository')) { - setBusyLabel('Scanning for repositories...') - const attemptId = createNestedRepoTelemetryAttemptId() - const scanId = createNestedRepoScanId() - nestedScanIdRef.current = scanId - setNestedScanInProgress(true) - const scan = await scanNestedRepos(path, undefined, { - scanId, - onProgress: (progressScan) => { - if ( - nestedScanIdRef.current !== scanId || - progressScan.selectedPathKind !== 'non_git_folder' || - progressScan.repos.length === 0 - ) { - return - } - showNestedRepoReview(progressScan, attemptId, 'local', true, scanId) - } - }) - if (nestedScanIdRef.current !== scanId) { - return - } - nestedScanIdRef.current = null - setNestedScanInProgress(false) - track( - 'add_repo_nested_scan_result', - buildNestedRepoScanTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind: 'local', - scan - }) - ) - if (scan?.selectedPathKind === 'non_git_folder' && scan.repos.length > 0) { - showNestedRepoReview(scan, attemptId, 'local', false, scanId) - return - } - result = await window.api.repos.add({ path, kind: 'folder' }) - } - if ('error' in result) { - throw new Error(result.error) - } - await completeRepo(result.repo.id, isGitRepoKind(result.repo), 'open_folder') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - nestedScanIdRef.current = null - setNestedScanInProgress(false) - setBusyLabel(null) - } - }, - [ - addRepoPath, - busyLabel, - completeRepo, - scanNestedRepos, - serverPath, - showNestedRepoReview, - settings?.activeRuntimeEnvironmentId - ] - ) - - const importNested = useCallback(async () => { - const mode = 'separate' - const attemptId = nestedAttemptId - if ( - !nestedScan || - !attemptId || - !shouldEmitNestedRepoImportSubmitTelemetry({ - attemptId, - selectedCount: nestedSelectedPaths.size, - isBusy: busyLabel !== null - }) - ) { - return - } - const foundCount = nestedScan.repos.length - const selectedCount = nestedSelectedPaths.size - const runtimeKind = nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind - setError(null) - setBusyLabel('Importing repositories…') - track( - 'add_repo_nested_import_action', - buildNestedRepoImportActionTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind, - action: 'import_separate', - foundCount, - selectedCount - }) - ) - let resultTracked = false - try { - const selectedProjectPaths = getSelectedNestedRepoPathsInScanOrder( - nestedScan, - nestedSelectedPaths - ) - const result = await importNestedRepos({ - parentPath: nestedScan.selectedPath, - groupName: '', - // Why: Set insertion order can drift after deselect/reselect; match the visible scan order users reviewed. - projectPaths: selectedProjectPaths, - ...(nestedImportScanId ? { scanId: nestedImportScanId } : {}), - mode - }) - track( - 'add_repo_nested_import_result', - buildNestedRepoImportResultTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind, - mode, - foundCount, - selectedCount, - result - }) - ) - resultTracked = true - const importedRepoIds = - result?.projects - .map((entry) => entry.projectId) - .filter((projectId): projectId is string => typeof projectId === 'string') ?? [] - const projectId = importedRepoIds[0] - if (!projectId) { - const firstFailure = result?.projects.find((entry) => entry.status === 'failed')?.error - throw new Error( - firstFailure ? `No repositories imported: ${firstFailure}` : 'No repositories imported' - ) - } - for (const importedRepoId of importedRepoIds) { - // Why: imported repos are already persisted, so a non-authoritative SSH refresh shouldn't block revealing the first project. - await fetchWorktrees(importedRepoId, { requireAuthoritative: true }) - } - await completeRepo(projectId, true, 'open_folder') - } catch (err) { - if (!resultTracked) { - track( - 'add_repo_nested_import_result', - buildNestedRepoImportResultTelemetry({ - attemptId, - surface: 'onboarding', - runtimeKind, - mode, - foundCount, - selectedCount, - result: null - }) - ) - } - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'open_folder', reason: 'invalid_path' }) - } finally { - setBusyLabel(null) - } - }, [ - busyLabel, - completeRepo, - fetchWorktrees, - importNestedRepos, - nestedAttemptId, - nestedScan, - nestedSelectedPaths, - nestedImportScanId, - nestedRuntimeKind, - onboardingNestedRepoRuntimeKind - ]) - - const trackNestedBackAndClear = useCallback(() => { - if (nestedScan && nestedAttemptId) { - track( - 'add_repo_nested_import_action', - buildNestedRepoImportActionTelemetry({ - attemptId: nestedAttemptId, - surface: 'onboarding', - runtimeKind: nestedRuntimeKind ?? onboardingNestedRepoRuntimeKind, - action: 'back', - foundCount: nestedScan.repos.length, - selectedCount: nestedSelectedPaths.size - }) - ) - } - setNestedScan(null) - setNestedSelectedPaths(new Set()) - setNestedAttemptId(null) - setNestedRuntimeKind(null) - setNestedScanInProgress(false) - setNestedImportScanId(null) - nestedScanIdRef.current = null - setBusyLabel(null) - setError(null) - }, [ - nestedAttemptId, - nestedRuntimeKind, - nestedScan, - nestedSelectedPaths.size, - onboardingNestedRepoRuntimeKind - ]) - - // Why: lets the user back out of the nested-repo step to re-pick a folder/clone target. - const cancelNested = useCallback(() => { - if (busyLabel !== null && !nestedScanInProgress) { - return - } - if (nestedScanInProgress && nestedScanIdRef.current) { - void cancelNestedRepoScan(nestedScanIdRef.current) - } - trackNestedBackAndClear() - }, [busyLabel, cancelNestedRepoScan, nestedScanInProgress, trackNestedBackAndClear]) - - const stopNestedScan = useCallback(() => { - const scanId = nestedScanIdRef.current - if (!scanId) { - return - } - void cancelNestedRepoScan(scanId) - }, [cancelNestedRepoScan]) - - const canImportNestedForTelemetry = useCallback((): boolean => { - return Boolean(nestedScan && nestedAttemptId && nestedSelectedPaths.size > 0) - }, [nestedAttemptId, nestedScan, nestedSelectedPaths.size]) - - const clone = useCallback(async () => { - // Why: re-entry guard — prevents Enter spamming from triggering duplicate clones. - if (busyLabel !== null) { - return - } - const trimmed = cloneUrl.trim() - if (!trimmed || !settings) { - return - } - setError(null) - track('onboarding_step4_path_clicked', { path: 'clone_url' }) - const target = getActiveRuntimeTarget(settings) - const destination = - target.kind === 'environment' ? cloneDestination.trim() : settings.workspaceDir - if (!destination) { - const message = 'Enter a host path for the clone destination.' - setError(message) - return - } - setBusyLabel('Cloning repo…') - try { - const repo = - target.kind === 'environment' - ? ( - await callRuntimeRpc<{ repo: Repo }>( - target, - 'repo.clone', - { url: trimmed, destination }, - { timeoutMs: 10 * 60_000 } - ) - ).repo - : await window.api.repos.clone({ - url: trimmed, - destination - }) - await completeRepo(repo.id, true, 'clone_url') - } catch (err) { - setError(err instanceof Error ? err.message : String(err)) - track('onboarding_step4_path_failed', { path: 'clone_url', reason: 'clone_failed' }) - toast.error( - translate('auto.components.onboarding.use.onboarding.flow.fd74e7558e', 'Clone failed'), - { - description: err instanceof Error ? err.message : String(err) - } - ) - } finally { - setBusyLabel(null) - } - }, [busyLabel, cloneDestination, cloneUrl, completeRepo, settings]) - - const continueWithExistingProject = useCallback( - async (advancedVia: 'button' | 'keyboard' = 'button') => { - if (busyLabel !== null || repos.length === 0) { - return - } - setError(null) - setBusyLabel('Finishing...') - try { - const checklist = repos.some((repo) => isGitRepoKind(repo)) - ? { addedRepo: true } - : { addedFolder: true } - const closed = await closeWith('completed', checklist, ONBOARDING_FINAL_STEP) - if (!closed) { - return - } - track('onboarding_step_completed', { - step: ONBOARDING_FINAL_STEP, - value_kind: 'repo', - duration_ms: consumeStepDurationMs(), - advanced_via: advancedVia - }) - } finally { - setBusyLabel(null) - } - }, - [busyLabel, closeWith, consumeStepDurationMs, repos] - ) - const skipToRepo = useCallback(async () => { if (busyLabel) { return @@ -1098,7 +612,7 @@ export function useOnboardingFlow( const valueKind = currentStep.valueKind setBusyLabel('Opening Add Project...') try { - const closed = await closeWith('completed', {}, ONBOARDING_FINAL_STEP, 'add_project_modal') + const closed = await closeWith('completed', ONBOARDING_FINAL_STEP, 'add_project_modal') if (!closed) { return } @@ -1147,76 +661,23 @@ export function useOnboardingFlow( return false } setError(null) - const closed = await closeWith('dismissed', {}, currentStep.stepNumber, undefined, { + return closeWith('dismissed', currentStep.stepNumber, undefined, { durationMs: consumeStepDurationMs(), advancedVia }) - if (closed) { - if (nestedScan) { - trackNestedBackAndClear() - } - } - return closed }, - [ - busyLabel, - closeWith, - consumeStepDurationMs, - currentStep.stepNumber, - nestedScan, - trackNestedBackAndClear - ] + [busyLabel, closeWith, consumeStepDurationMs, currentStep.stepNumber] ) - const openSshSettings = useCallback(async () => { - if (busyLabel) { - return - } - setError(null) - try { - onOnboardingChange(await persistStep(currentStep.stepNumber - 1)) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - setError(message) - toast.error( - translate( - 'auto.components.onboarding.use.onboarding.flow.dce4bdce5b', - 'Could not open SSH settings' - ), - { description: message } - ) - return - } - // Why: SSH users need a temporary Settings detour without marking required repo setup dismissed. - onSettingsDetourStart?.() - // Why: set the target before the lazy Settings view mounts; a timer could fire before it subscribes and strand users on General. - openSettingsTarget({ pane: 'ssh', repoId: null, sectionId: 'ssh' }) - openSettingsPage() - }, [ - busyLabel, - currentStep.stepNumber, - onOnboardingChange, - onSettingsDetourStart, - openSettingsPage, - openSettingsTarget - ]) - const back = useCallback(() => { - if (nestedScan) { - trackNestedBackAndClear() - return - } setStepIndex(getPreviousStepIndex) - }, [getPreviousStepIndex, nestedScan, trackNestedBackAndClear]) + }, [getPreviousStepIndex]) const jumpToStep = useCallback( (idx: number) => { - if (nestedScan && idx !== stepIndex) { - trackNestedBackAndClear() - } setStepIndex(resolveStepIndex(idx, skipOptions, idx < stepIndex ? 'backward' : 'forward')) }, - [nestedScan, skipOptions, stepIndex, trackNestedBackAndClear] + [skipOptions, stepIndex] ) return { @@ -1232,23 +693,7 @@ export function useOnboardingFlow( setYoloPermissions: setYoloPermissionsInteractive, theme, setTheme: setThemeInteractive, - cloneUrl, - setCloneUrl, - nestedScan, - nestedScanInProgress, - nestedSelectedPaths, - setNestedSelectedPaths, - importNested, - cancelNested, - stopNestedScan, - canImportNestedForTelemetry, - hasExistingProject, - serverPath, - setServerPath, - cloneDestination, - setCloneDestination, busyLabel, - error, detectedSet, isDetectingAgents, next, @@ -1256,10 +701,6 @@ export function useOnboardingFlow( dismissOnboarding, back, jumpToStep, - setLifecycleRootRef, - openFolder, - continueWithExistingProject, - openSshSettings, - clone + setLifecycleRootRef } } diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx deleted file mode 100644 index 07980102743..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileAvatar.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { cn } from '@/lib/utils' -import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' - -export function OrcaProfileAvatar({ - profile, - className -}: { - profile: OrcaProfileSummary - className?: string -}): React.JSX.Element { - return ( - - {profile.avatar.initials.slice(0, 2).toUpperCase()} - - ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx deleted file mode 100644 index a21681a48b8..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileCloudMenuItems.tsx +++ /dev/null @@ -1,136 +0,0 @@ -import { Check, Cloud, Loader2, LogIn, LogOut, Plus } from 'lucide-react' -import { - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger -} from '@/components/ui/dropdown-menu' -import { translate } from '@/i18n/i18n' -import type { - OrcaCloudOrgSummary, - OrcaProfileAuthStatus, - OrcaProfileSummary -} from '../../../../shared/orca-profiles' - -function getConnectLabel(authStatus: OrcaProfileAuthStatus | null, connecting: boolean): string { - if (connecting) { - return translate('auto.components.orca.profiles.switcher.signInWaiting', 'Waiting for sign-in…') - } - if (authStatus?.configured !== true) { - return translate( - 'auto.components.orca.profiles.switcher.cloud.unavailable', - 'Orca sign-in unavailable' - ) - } - if (authStatus.state === 'reconnect-required') { - return translate('auto.components.orca.profiles.switcher.signInAgain', 'Sign in again') - } - return translate('auto.components.orca.profiles.switcher.signIn', 'Sign in to Orca') -} - -export function OrcaProfileCloudMenuItems({ - activeProfile, - authStatus, - connecting, - profileActionDisabled, - allowProfileCreation, - separateAuthActions, - onConnect, - onCreateProfileForOrg, - onSelectOrg, - onRequestSignOut -}: { - activeProfile: OrcaProfileSummary - authStatus: OrcaProfileAuthStatus | null - connecting: boolean - profileActionDisabled: boolean - allowProfileCreation: boolean - separateAuthActions: boolean - onConnect: () => void - onCreateProfileForOrg: (organization: OrcaCloudOrgSummary) => void - onSelectOrg: (orgId: string) => void - onRequestSignOut: () => void -}): React.JSX.Element { - const cloudConfigured = authStatus?.configured === true - const organizations = authStatus?.organizations ?? [] - const showOrganizationChoices = activeProfile.kind === 'cloud-linked' && organizations.length > 1 - // Why: profile creation is hidden in the downscoped account menu, so the - // "Create profile for org" submenu only appears when multi-profile UI is on. - const showCloudProfileCreation = - allowProfileCreation && activeProfile.kind === 'cloud-linked' && organizations.length > 0 - const orgActionDisabled = profileActionDisabled || authStatus?.state !== 'connected' - const activeOrgId = activeProfile.cloud?.activeOrgId - const showSignIn = authStatus?.state !== 'connected' - - return ( - <> - {showOrganizationChoices || showCloudProfileCreation ? ( - <> - - - {translate('auto.components.orca.profiles.switcher.organization', 'Organization')} - - {showOrganizationChoices - ? organizations.map((organization) => ( - { - if (organization.orgId !== activeOrgId) { - onSelectOrg(organization.orgId) - } - }} - className="min-w-0" - > - - {organization.name} - {organization.orgId === activeOrgId ? : null} - - )) - : null} - {showCloudProfileCreation ? ( - - - - {translate( - 'auto.components.orca.profiles.switcher.create.profile.for.org', - 'Create profile for org' - )} - - - {organizations.map((organization) => ( - onCreateProfileForOrg(organization)} - className="min-w-0" - > - - {organization.name} - - ))} - - - ) : null} - - ) : null} - - {separateAuthActions || showOrganizationChoices || showCloudProfileCreation ? ( - - ) : null} - {showSignIn ? ( - - {connecting ? : } - {getConnectLabel(authStatus, connecting)} - - ) : null} - {activeProfile.kind === 'cloud-linked' ? ( - - - {translate('auto.components.orca.profiles.switcher.signout', 'Sign out')} - - ) : null} - - ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx deleted file mode 100644 index 40ac5022e23..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileCreateDialog.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { translate } from '@/i18n/i18n' - -export function OrcaProfileCreateDialog({ - open, - onOpenChange, - name, - onNameChange, - creating, - switching, - onSubmit -}: { - open: boolean - onOpenChange: (open: boolean) => void - name: string - onNameChange: (name: string) => void - creating: boolean - switching: boolean - onSubmit: (event: React.FormEvent) => void -}): React.JSX.Element { - return ( - - -
- - - {translate('auto.components.orca.profiles.switcher.16e3681072', 'New local profile')} - - - {translate( - 'auto.components.orca.profiles.switcher.e3b91a3d90', - 'Create an empty profile for separate projects and worktrees.' - )} - - - onNameChange(event.target.value)} - placeholder={translate( - 'auto.components.orca.profiles.switcher.f322e1f4d6', - 'Profile name' - )} - maxLength={80} - /> - - - -
-
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx deleted file mode 100644 index 8f1577c7998..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileManagementDialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -import { useMemo, useState } from 'react' -import { ArrowRightLeft, Copy, FolderGit2, Loader2, MoveRight } from 'lucide-react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { ScrollArea } from '@/components/ui/scroll-area' -import { translate } from '@/i18n/i18n' -import { useAppStore } from '@/store' -import type { - OrcaProfileSummary, - TransferOrcaProfileProjectMode -} from '../../../../shared/orca-profiles' -import type { Repo } from '../../../../shared/types' -import { OrcaProfileAvatar } from './OrcaProfileAvatar' -import { - OrcaProfileProjectTransferConfirmDialog, - type PendingProjectTransfer -} from './OrcaProfileProjectTransferConfirmDialog' -import { getOrcaProfileProjectLiveWorkSummary } from './orca-profile-switch-liveness' - -type PendingTransfer = { - repoId: string - targetProfileId: string - mode: TransferOrcaProfileProjectMode -} - -function pendingKey(value: PendingTransfer): string { - return `${value.mode}:${value.repoId}:${value.targetProfileId}` -} - -function getRepoPath(repo: Repo): string { - return repo.path || repo.displayName -} - -function ProjectTransferMenu({ - repo, - sourceProfileId, - targetProfiles, - pending, - onTransfer -}: { - repo: Repo - sourceProfileId: string - targetProfiles: OrcaProfileSummary[] - pending: PendingTransfer | null - onTransfer: ( - repo: Repo, - targetProfile: OrcaProfileSummary, - mode: TransferOrcaProfileProjectMode - ) => void -}): React.JSX.Element { - const disabled = targetProfiles.length === 0 || Boolean(pending) - const repoPending = pending?.repoId === repo.id - const renderTargetItems = (mode: TransferOrcaProfileProjectMode): React.JSX.Element[] => - targetProfiles.map((profile) => { - const targetPending = - pending && - pendingKey(pending) === pendingKey({ repoId: repo.id, targetProfileId: profile.id, mode }) - return ( - onTransfer(repo, profile, mode)} - > - {mode === 'move' ? : } - - {profile.name} - {targetPending ? : null} - - ) - }) - - return ( - - - - - - - {translate('auto.components.orca.profiles.management.128c7dfe64', 'Copy to')} - - {renderTargetItems('copy')} - - - {translate('auto.components.orca.profiles.management.df8b7d876b', 'Move to')} - - {renderTargetItems('move')} - - - ) -} - -export function OrcaProfileManagementDialog({ - open, - onOpenChange, - activeProfile, - profiles -}: { - open: boolean - onOpenChange: (open: boolean) => void - activeProfile: OrcaProfileSummary - profiles: OrcaProfileSummary[] -}): React.JSX.Element { - const repos = useAppStore((s) => s.repos) - const transferProject = useAppStore((s) => s.transferOrcaProfileProject) - const [pending, setPending] = useState(null) - const [pendingConfirmation, setPendingConfirmation] = useState( - null - ) - const targetProfiles = useMemo( - () => profiles.filter((profile) => profile.id !== activeProfile.id), - [activeProfile.id, profiles] - ) - - const runTransfer = async ( - repo: Repo, - targetProfile: OrcaProfileSummary, - mode: TransferOrcaProfileProjectMode - ): Promise => { - if (pending) { - return - } - const nextPending = { repoId: repo.id, targetProfileId: targetProfile.id, mode } - setPending(nextPending) - const result = await transferProject({ - sourceProfileId: activeProfile.id, - targetProfileId: targetProfile.id, - repoId: repo.id, - mode - }) - setPending(null) - if (result?.status === 'transferred') { - toast.success( - mode === 'move' - ? translate('auto.components.orca.profiles.management.9aa26347b3', 'Project moved') - : translate('auto.components.orca.profiles.management.816ce624b6', 'Project copied'), - { - description: targetProfile.name - } - ) - } - } - - const handleTransfer = ( - repo: Repo, - targetProfile: OrcaProfileSummary, - mode: TransferOrcaProfileProjectMode - ): void => { - if (pending) { - return - } - const liveWorkSummary = getOrcaProfileProjectLiveWorkSummary(useAppStore.getState(), repo.id) - if (mode === 'move' || liveWorkSummary.hasLiveWork) { - setPendingConfirmation({ repo, targetProfile, mode, liveWorkSummary }) - return - } - void runTransfer(repo, targetProfile, mode) - } - - const confirmTransfer = async (): Promise => { - if (!pendingConfirmation) { - return - } - const next = pendingConfirmation - await runTransfer(next.repo, next.targetProfile, next.mode) - setPendingConfirmation(null) - } - - return ( - - - - - {translate('auto.components.orca.profiles.management.2c45bda8d3', 'Manage profiles')} - - - {translate( - 'auto.components.orca.profiles.management.2db945e4a0', - 'Copy or move projects from the active profile to another local profile.' - )} - - - -
-
- -
-
{activeProfile.name}
-
- {translate( - repos.length === 1 - ? 'auto.components.orca.profiles.management.projectCountSingular' - : 'auto.components.orca.profiles.management.projectCountPlural', - repos.length === 1 ? '{{count}} project' : '{{count}} projects', - { count: repos.length } - )} -
-
-
- - {repos.length === 0 ? ( -
- {translate( - 'auto.components.orca.profiles.management.8668cb2946', - 'No projects in this profile.' - )} -
- ) : ( -
- {repos.map((repo) => ( -
- -
-
{repo.displayName}
-
- {getRepoPath(repo)} -
-
- { - handleTransfer(selectedRepo, targetProfile, mode) - }} - /> -
- ))} -
- )} -
-
- - {targetProfiles.length === 0 ? ( -
- {translate( - 'auto.components.orca.profiles.management.93034915ab', - 'Create another profile before copying projects.' - )} -
- ) : null} - setPendingConfirmation(null)} - onConfirm={() => { - void confirmTransfer() - }} - /> -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx deleted file mode 100644 index 692f11163d2..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileMenuHeader.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { CircleUserRound } from 'lucide-react' -import { DropdownMenuLabel } from '@/components/ui/dropdown-menu' -import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' -import { OrcaProfileAvatar } from './OrcaProfileAvatar' - -export function OrcaProfileMenuHeader({ - profile, - title, - subtitle, - showProfileAvatar -}: { - profile: OrcaProfileSummary - title: string - subtitle: string - showProfileAvatar: boolean -}): React.JSX.Element { - return ( - -
- {showProfileAvatar ? ( - - ) : ( - - )} -
-
{title}
-
{subtitle}
-
-
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx deleted file mode 100644 index 894e21c8481..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMemberRow.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { useState } from 'react' -import { Loader2, UserMinus } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import type { OrcaOrgMember, OrcaOrgRole } from '../../../../shared/orca-profiles' -import { - ORG_ROLE_OPTIONS, - orgMemberInitials, - orgRoleLabel -} from './orca-profile-org-members-labels' - -export function OrcaProfileOrgMemberRow({ - member, - canManage, - isSelf, - busy, - onChangeRole, - onRemove -}: { - member: OrcaOrgMember - canManage: boolean - isSelf: boolean - busy: boolean - onChangeRole: (role: OrcaOrgRole) => void - onRemove: () => void -}): React.JSX.Element { - const [confirmingRemove, setConfirmingRemove] = useState(false) - // Why: teammates provisioned server-side who never signed into Orca have no - // userId, so the API cannot target them for role/remove mutations. - const neverSignedIn = member.userId === null - const actionsDisabled = !canManage || isSelf || neverSignedIn || busy - const displayName = member.displayName?.trim() || member.email - - const roleControl = canManage ? ( - - ) : ( - {orgRoleLabel(member.role)} - ) - - return ( -
- - {orgMemberInitials(member.displayName, member.email)} - -
-
{displayName}
-
{member.email}
-
- - {neverSignedIn && canManage ? ( - - - {roleControl} - - - {translate( - 'auto.components.orca.profiles.org.members.not.signed.in', - "They haven't signed in to Orca yet." - )} - - - ) : ( - roleControl - )} - - {canManage ? ( - confirmingRemove && !actionsDisabled ? ( -
- - -
- ) : ( - - ) - ) : null} -
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx deleted file mode 100644 index 35e3a07ae90..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.test.tsx +++ /dev/null @@ -1,151 +0,0 @@ -// @vitest-environment happy-dom - -import type { ReactNode } from 'react' -import { render, screen, cleanup } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { OrcaOrgMembersRoster } from '../../../../shared/orca-profiles' -import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' - -vi.mock('@/components/ui/dialog', () => ({ - Dialog: ({ children }: { children: ReactNode }) =>
{children}
, - DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, - DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, - DialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, - DialogDescription: ({ children }: { children: ReactNode }) =>
{children}
-})) - -vi.mock('@/components/ui/scroll-area', () => ({ - ScrollArea: ({ children }: { children: ReactNode }) =>
{children}
-})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => {children} -})) - -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - disabled, - 'aria-label': ariaLabel, - type - }: { - children?: ReactNode - disabled?: boolean - 'aria-label'?: string - type?: 'button' | 'submit' - }) => ( - - ) -})) - -vi.mock('@/components/ui/input', () => ({ - Input: ({ placeholder, disabled }: { placeholder?: string; disabled?: boolean }) => ( - - ) -})) - -vi.mock('@/components/ui/select', () => ({ - Select: ({ children }: { children: ReactNode }) =>
{children}
, - SelectContent: ({ children }: { children: ReactNode }) =>
{children}
, - SelectItem: ({ children }: { children: ReactNode }) =>
{children}
, - SelectValue: () => null, - SelectTrigger: ({ - disabled, - 'aria-label': ariaLabel, - children - }: { - disabled?: boolean - 'aria-label'?: string - children?: ReactNode - }) => ( - - ) -})) - -const managerRoster: OrcaOrgMembersRoster = { - members: [ - { userId: 'user-viewer', email: 'me@example.com', displayName: 'Me', role: 'admin' }, - { userId: 'user-2', email: 'other@example.com', displayName: 'Other', role: 'member' }, - { userId: null, email: 'never@example.com', role: 'member' } - ], - pendingInvites: [{ email: 'pending@example.com', role: 'member', createdAt: 1 }], - viewerRole: 'owner', - canManageMembers: true -} - -const memberRoster: OrcaOrgMembersRoster = { - members: [ - { userId: 'user-1', email: 'boss@example.com', displayName: 'Boss', role: 'owner' }, - { userId: 'user-2', email: 'peer@example.com', displayName: 'Peer', role: 'member' } - ], - pendingInvites: [], - viewerRole: 'member', - canManageMembers: false -} - -function stubOrgMembersApi(roster: OrcaOrgMembersRoster): void { - ;(window as unknown as { api: unknown }).api = { - orcaProfiles: { - orgMembersList: vi.fn().mockResolvedValue({ status: 'ok', roster }) - } - } -} - -describe('OrcaProfileOrgMembersDialog', () => { - afterEach(() => { - cleanup() - vi.restoreAllMocks() - }) - - it('shows the invite form and a role select per member for managers', async () => { - stubOrgMembersApi(managerRoster) - render( - {}} - orgId="org-1" - orgName="Acme" - viewerUserId="user-viewer" - /> - ) - - expect(await screen.findByText('other@example.com')).toBeTruthy() - // Invite form present. - expect(screen.getByPlaceholderText('teammate@example.com')).toBeTruthy() - // Pending invite listed with a revoke action. - expect(screen.getByText('pending@example.com')).toBeTruthy() - // One role select per member. - expect(screen.getAllByLabelText('Role')).toHaveLength(3) - // Self and the never-signed-in row disable their remove action; the peer's stays enabled. - const removeButtons = screen.getAllByLabelText('Remove teammate') as HTMLButtonElement[] - expect(removeButtons).toHaveLength(3) - expect(removeButtons.filter((button) => button.disabled)).toHaveLength(2) - // Never-signed-in explanation is present. - expect(screen.getByText("They haven't signed in to Orca yet.")).toBeTruthy() - }) - - it('renders a read-only roster for non-managers', async () => { - stubOrgMembersApi(memberRoster) - render( - {}} - orgId="org-1" - viewerUserId="user-2" - /> - ) - - expect(await screen.findByText('boss@example.com')).toBeTruthy() - expect(screen.getByText('peer@example.com')).toBeTruthy() - // No management affordances. - expect(screen.queryByPlaceholderText('teammate@example.com')).toBeNull() - expect(screen.queryAllByLabelText('Role')).toHaveLength(0) - expect(screen.queryAllByLabelText('Remove teammate')).toHaveLength(0) - }) -}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx deleted file mode 100644 index 95c5344ebcb..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileOrgMembersDialog.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { Loader2, Mail, RefreshCw, Users } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from '@/components/ui/select' -import { translate } from '@/i18n/i18n' -import type { - OrcaOrgMember, - OrcaOrgMembersRoster, - OrcaOrgRole, - OrcaProfileOrgMemberMutationResult -} from '../../../../shared/orca-profiles' -import { OrcaProfileOrgMemberRow } from './OrcaProfileOrgMemberRow' -import { - describeOrgMutationError, - ORG_ROLE_OPTIONS, - orgRoleLabel -} from './orca-profile-org-members-labels' - -type DialogProps = { - open: boolean - onOpenChange: (open: boolean) => void - orgId: string - orgName?: string - viewerUserId?: string -} - -function isOk(result: OrcaProfileOrgMemberMutationResult): result is { status: 'ok' } { - return result.status === 'ok' -} - -export function OrcaProfileOrgMembersDialog({ - open, - onOpenChange, - orgId, - orgName, - viewerUserId -}: DialogProps): React.JSX.Element { - const [roster, setRoster] = useState(null) - const [loading, setLoading] = useState(false) - const [loadError, setLoadError] = useState(false) - const [actionError, setActionError] = useState(null) - const [pendingMemberId, setPendingMemberId] = useState(null) - const [pendingInviteEmail, setPendingInviteEmail] = useState(null) - const [inviteEmail, setInviteEmail] = useState('') - const [inviteRole, setInviteRole] = useState('member') - const [inviting, setInviting] = useState(false) - - const refresh = useCallback(async (): Promise => { - setLoading(true) - setLoadError(false) - setActionError(null) - const result = await window.api.orcaProfiles.orgMembersList({ orgId }) - if (result.status === 'ok') { - setRoster(result.roster) - } else { - setRoster(null) - setLoadError(true) - } - setLoading(false) - }, [orgId]) - - // Why: reload every time the dialog opens so a manager always acts on the - // current roster, and clear transient dialog state when it closes. - useEffect(() => { - if (open) { - void refresh() - } else { - setActionError(null) - setInviteEmail('') - setInviteRole('member') - } - }, [open, refresh]) - - const canManage = roster?.canManageMembers === true - - const runMutation = async ( - mutation: Promise - ): Promise => { - setActionError(null) - const result = await mutation - if (isOk(result)) { - await refresh() - return true - } - setActionError(describeOrgMutationError(result)) - return false - } - - const handleChangeRole = async (member: OrcaOrgMember, role: OrcaOrgRole): Promise => { - if (member.userId === null || role === member.role) { - return - } - setPendingMemberId(member.userId) - await runMutation( - window.api.orcaProfiles.orgMemberChangeRole({ orgId, userId: member.userId, role }) - ) - setPendingMemberId(null) - } - - const handleRemove = async (member: OrcaOrgMember): Promise => { - if (member.userId === null) { - return - } - setPendingMemberId(member.userId) - await runMutation(window.api.orcaProfiles.orgMemberRemove({ orgId, userId: member.userId })) - setPendingMemberId(null) - } - - const handleRevoke = async (email: string): Promise => { - setPendingInviteEmail(email) - await runMutation(window.api.orcaProfiles.orgInviteRevoke({ orgId, email })) - setPendingInviteEmail(null) - } - - const handleInvite = async (event: React.FormEvent): Promise => { - event.preventDefault() - const email = inviteEmail.trim() - if (!email || inviting) { - return - } - setInviting(true) - const ok = await runMutation( - window.api.orcaProfiles.orgMemberInvite({ orgId, email, role: inviteRole }) - ) - setInviting(false) - if (ok) { - setInviteEmail('') - setInviteRole('member') - } - } - - return ( - - - - - - {translate('auto.components.orca.profiles.org.members.title', 'Organization members')} - - - {orgName - ? translate( - 'auto.components.orca.profiles.org.members.subtitle.named', - 'People in {{orgName}} who can collaborate on Orca.', - { orgName } - ) - : translate( - 'auto.components.orca.profiles.org.members.subtitle.default', - 'People in your organization who can collaborate on Orca.' - )} - - - - {loading ? ( -
- -
- ) : loadError ? ( -
-

- {translate( - 'auto.components.orca.profiles.org.members.load.error', - "Couldn't load organization members." - )} -

- -
- ) : roster ? ( -
- {actionError ? ( -

- {actionError} -

- ) : null} - - -
- {roster.members.map((member) => ( - void handleChangeRole(member, role)} - onRemove={() => void handleRemove(member)} - /> - ))} -
-
- - {canManage && roster.pendingInvites.length > 0 ? ( -
-

- {translate( - 'auto.components.orca.profiles.org.members.pending.title', - 'Pending invites' - )} -

- {roster.pendingInvites.map((invite) => ( -
- - - {invite.email} - - - {orgRoleLabel(invite.role)} - - -
- ))} -
- ) : null} - - {canManage ? ( -
void handleInvite(event)} className="flex items-end gap-2"> -
- - setInviteEmail(event.target.value)} - disabled={inviting} - placeholder={translate( - 'auto.components.orca.profiles.org.members.invite.placeholder', - 'teammate@example.com' - )} - /> -
- - -
- ) : null} -
- ) : null} -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx deleted file mode 100644 index 4b5678e52b1..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileProjectTransferConfirmDialog.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { ArrowRightLeft, Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { - OrcaProfileSummary, - TransferOrcaProfileProjectMode -} from '../../../../shared/orca-profiles' -import type { Repo } from '../../../../shared/types' -import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' - -type PendingProjectTransfer = { - repo: Repo - targetProfile: OrcaProfileSummary - mode: TransferOrcaProfileProjectMode - liveWorkSummary: OrcaProfileSwitchLiveWorkSummary -} - -export type { PendingProjectTransfer } - -function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { - const lines: string[] = [] - if (summary.liveTerminalTabCount > 0) { - lines.push( - translate( - summary.liveTerminalTabCount === 1 - ? 'auto.components.orca.profiles.project.transfer.confirm.terminalSingular' - : 'auto.components.orca.profiles.project.transfer.confirm.terminalPlural', - summary.liveTerminalTabCount === 1 - ? '{{count}} live terminal tab' - : '{{count}} live terminal tabs', - { count: summary.liveTerminalTabCount } - ) - ) - } - if (summary.liveAgentCount > 0) { - lines.push( - translate( - summary.liveAgentCount === 1 - ? 'auto.components.orca.profiles.project.transfer.confirm.agentSingular' - : 'auto.components.orca.profiles.project.transfer.confirm.agentPlural', - summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents', - { count: summary.liveAgentCount } - ) - ) - } - if (summary.browserWorkspaceCount > 0) { - lines.push( - translate( - summary.browserWorkspaceCount === 1 - ? 'auto.components.orca.profiles.project.transfer.confirm.browserSingular' - : 'auto.components.orca.profiles.project.transfer.confirm.browserPlural', - summary.browserWorkspaceCount === 1 - ? '{{count}} browser workspace' - : '{{count}} browser workspaces', - { count: summary.browserWorkspaceCount } - ) - ) - } - return lines -} - -export function OrcaProfileProjectTransferConfirmDialog({ - activeProfileName, - pendingTransfer, - pending, - onCancel, - onConfirm -}: { - activeProfileName: string - pendingTransfer: PendingProjectTransfer | null - pending: boolean - onCancel: () => void - onConfirm: () => void -}): React.JSX.Element { - const mode = pendingTransfer?.mode ?? 'copy' - const repoName = pendingTransfer?.repo.displayName ?? '' - const targetName = pendingTransfer?.targetProfile.name ?? '' - const lines = pendingTransfer ? liveWorkLines(pendingTransfer.liveWorkSummary) : [] - - return ( - { - if (!open && !pending) { - onCancel() - } - }} - > - - - - - {mode === 'move' - ? translate( - 'auto.components.orca.profiles.project.transfer.confirm.move.title', - 'Move project?' - ) - : translate( - 'auto.components.orca.profiles.project.transfer.confirm.copy.title', - 'Copy project?' - )} - - - {mode === 'move' - ? translate( - 'auto.components.orca.profiles.project.transfer.confirm.move.description', - 'Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.', - { activeProfileName, repoName, targetName } - ) - : translate( - 'auto.components.orca.profiles.project.transfer.confirm.copy.description', - 'Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.', - { repoName, targetName } - )} - - - - {lines.length > 0 ? ( -
-
- {translate( - 'auto.components.orca.profiles.project.transfer.confirm.live.work', - 'Live work in this project' - )} -
-
    - {lines.map((line) => ( -
  • {line}
  • - ))} -
-
- ) : null} - - - - - -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx deleted file mode 100644 index 89c80e99aa6..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitchConfirmDialog.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { AlertTriangle, Loader2 } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { translate } from '@/i18n/i18n' -import type { OrcaProfileSummary } from '../../../../shared/orca-profiles' -import type { OrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' - -function liveWorkLines(summary: OrcaProfileSwitchLiveWorkSummary): string[] { - const lines: string[] = [] - if (summary.liveTerminalTabCount > 0) { - lines.push( - translate( - summary.liveTerminalTabCount === 1 - ? 'auto.components.orca.profiles.switch.confirm.terminalSingular' - : 'auto.components.orca.profiles.switch.confirm.terminalPlural', - summary.liveTerminalTabCount === 1 - ? '{{count}} live terminal tab' - : '{{count}} live terminal tabs', - { count: summary.liveTerminalTabCount } - ) - ) - } - if (summary.liveAgentCount > 0) { - lines.push( - translate( - summary.liveAgentCount === 1 - ? 'auto.components.orca.profiles.switch.confirm.agentSingular' - : 'auto.components.orca.profiles.switch.confirm.agentPlural', - summary.liveAgentCount === 1 ? '{{count}} active agent' : '{{count}} active agents', - { count: summary.liveAgentCount } - ) - ) - } - if (summary.browserWorkspaceCount > 0) { - lines.push( - translate( - summary.browserWorkspaceCount === 1 - ? 'auto.components.orca.profiles.switch.confirm.browserSingular' - : 'auto.components.orca.profiles.switch.confirm.browserPlural', - summary.browserWorkspaceCount === 1 - ? '{{count}} browser workspace' - : '{{count}} browser workspaces', - { count: summary.browserWorkspaceCount } - ) - ) - } - return lines -} - -export function OrcaProfileSwitchConfirmDialog({ - open, - onOpenChange, - onConfirm, - activeProfileName, - targetProfile, - liveWorkSummary, - switching -}: { - open: boolean - onOpenChange: (open: boolean) => void - onConfirm: () => void - activeProfileName: string - targetProfile: OrcaProfileSummary | null - liveWorkSummary: OrcaProfileSwitchLiveWorkSummary - switching: boolean -}): React.JSX.Element { - const targetName = - targetProfile?.name ?? - translate('auto.components.orca.profiles.switch.confirm.target', 'the selected profile') - const lines = liveWorkLines(liveWorkSummary) - - return ( - - - - - - {translate('auto.components.orca.profiles.switch.confirm.title', 'Switch profiles?')} - - - {translate( - 'auto.components.orca.profiles.switch.confirm.description', - 'Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.', - { activeProfileName, targetName } - )} - - - - {lines.length > 0 ? ( -
-
- {translate( - 'auto.components.orca.profiles.switch.confirm.live.work', - 'Live work in this profile' - )} -
-
    - {lines.map((line) => ( -
  • {line}
  • - ))} -
-
- ) : null} - - - - - -
-
- ) -} diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx deleted file mode 100644 index 3759d07a49f..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.test.tsx +++ /dev/null @@ -1,234 +0,0 @@ -// @vitest-environment happy-dom - -import type { ReactNode } from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store' -import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles' -import { OrcaProfileSwitcher } from './OrcaProfileSwitcher' - -const mocks = vi.hoisted(() => ({ - state: {} as Partial -})) - -vi.mock('@/store', () => ({ - useAppStore: (selector: (state: Partial) => unknown) => selector(mocks.state) -})) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, - DropdownMenuSeparator: () =>
, - DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <>{children}, - DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
-})) - -vi.mock('@/components/ui/tooltip', () => ({ - Tooltip: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => {children}, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children} -})) - -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - 'aria-label': ariaLabel - }: { - children: ReactNode - 'aria-label'?: string - }) => -})) - -vi.mock('./OrcaProfileAvatar', () => ({ - OrcaProfileAvatar: () => -})) - -vi.mock('./OrcaProfileCreateDialog', () => ({ - OrcaProfileCreateDialog: () =>
-})) - -vi.mock('./OrcaProfileManagementDialog', () => ({ - OrcaProfileManagementDialog: () =>
-})) - -vi.mock('./OrcaProfileSwitchConfirmDialog', () => ({ - OrcaProfileSwitchConfirmDialog: () =>
-})) - -vi.mock('./OrcaProfileSignOutConfirmDialog', () => ({ - OrcaProfileSignOutConfirmDialog: () =>
-})) - -vi.mock('./orca-profile-switch-liveness', () => ({ - getOrcaProfileSwitchLiveWorkSummary: () => ({ hasLiveWork: false }) -})) - -const cloudProfile: OrcaProfileSummary = { - id: 'local-default', - name: 'Personal', - avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, - kind: 'cloud-linked', - createdAt: 1, - updatedAt: 1, - lastOpenedAt: 1, - cloud: { - cloudProfileId: 'cloud-1', - userId: 'user-1', - email: 'nina@example.com', - activeOrgId: 'org-1', - activeOrgName: 'Acme', - linkedAt: 2 - } -} - -const localProfile: OrcaProfileSummary = { - id: 'local-default', - name: 'Personal', - avatar: { kind: 'initials', initials: 'P', color: 'neutral' }, - kind: 'local', - createdAt: 1, - updatedAt: 1, - lastOpenedAt: 1 -} - -const connectedAuthStatus: OrcaProfileAuthStatus = { - activeProfileId: 'local-default', - configured: true, - state: 'connected', - persistence: 'encrypted', - cloud: cloudProfile.cloud, - organizations: [ - { orgId: 'org-1', name: 'Acme' }, - { orgId: 'org-2', name: 'Globex' } - ] -} - -const unconfiguredAuthStatus: OrcaProfileAuthStatus = { - activeProfileId: 'local-default', - configured: false, - state: 'unconfigured', - persistence: 'none' -} - -const signedOutAuthStatus: OrcaProfileAuthStatus = { - activeProfileId: 'local-default', - configured: true, - state: 'local', - persistence: 'none' -} - -function baseState(overrides: Partial): Partial { - return { - orcaProfiles: [cloudProfile], - activeOrcaProfileId: 'local-default', - orcaProfilesLoading: false, - orcaProfileSwitching: false, - orcaProfileConnecting: false, - orcaProfileAuthStatus: connectedAuthStatus, - orcaProfilesMultiProfileUi: false, - fetchOrcaProfiles: vi.fn(), - createLocalOrcaProfile: vi.fn(), - createCloudLinkedOrcaProfile: vi.fn(), - connectCurrentOrcaProfile: vi.fn(), - signOutCurrentOrcaProfile: vi.fn(), - selectOrcaProfileOrg: vi.fn(), - switchOrcaProfile: vi.fn(), - ...overrides - } -} - -describe('OrcaProfileSwitcher', () => { - beforeEach(() => { - mocks.state = baseState({}) - }) - - it('renders an account menu without profile management when the flag is off and cloud is configured', () => { - mocks.state = baseState({ orcaProfilesMultiProfileUi: false }) - const html = renderToStaticMarkup() - - expect(html).toContain('aria-label="Account"') - expect(html).toContain('nina@example.com') - expect(html).toContain('Acme') - // Cloud actions stay reachable in the downscoped account menu. - expect(html).toContain('Sign out') - expect(html).not.toContain('Reconnect profile') - // Profile management surfaces are gone. - expect(html).not.toContain('Manage profiles') - expect(html).not.toContain('New local profile') - expect(html).not.toContain('Create profile for org') - expect(html).not.toContain('data-testid="create-dialog"') - expect(html).not.toContain('data-testid="management-dialog"') - expect(html).not.toContain('data-testid="switch-confirm-dialog"') - // Sign-out remains mounted. - expect(html).toContain('data-testid="signout-confirm-dialog"') - }) - - it('presents only the sign-in action before an account identity exists', () => { - mocks.state = baseState({ - orcaProfiles: [localProfile], - orcaProfileAuthStatus: signedOutAuthStatus, - orcaProfilesMultiProfileUi: false - }) - const html = renderToStaticMarkup() - - expect(html).toContain('Sign in to Orca') - expect(html).not.toContain('Orca account') - expect(html).not.toContain('Signed out') - expect(html).not.toContain('Personal') - expect(html).not.toContain('>Local<') - }) - - it('gives a reconnect-required account an explicit recovery action', () => { - mocks.state = baseState({ - orcaProfileAuthStatus: { - ...connectedAuthStatus, - state: 'reconnect-required' - }, - orcaProfilesMultiProfileUi: false - }) - const html = renderToStaticMarkup() - - expect(html).toContain('nina@example.com') - expect(html).toContain('Sign-in required') - expect(html).toContain('Sign in again') - }) - - it('names the pending browser authentication step', () => { - mocks.state = baseState({ - orcaProfiles: [localProfile], - orcaProfileAuthStatus: signedOutAuthStatus, - orcaProfileConnecting: true, - orcaProfilesMultiProfileUi: false - }) - const html = renderToStaticMarkup() - - expect(html).toContain('Waiting for sign-in…') - }) - - it('renders nothing when the flag is off and cloud is unconfigured', () => { - mocks.state = baseState({ - orcaProfilesMultiProfileUi: false, - orcaProfileAuthStatus: unconfiguredAuthStatus - }) - const html = renderToStaticMarkup() - - expect(html).toBe('') - }) - - it('renders the full multi-profile menu when the flag is on', () => { - mocks.state = baseState({ orcaProfilesMultiProfileUi: true }) - const html = renderToStaticMarkup() - - expect(html).toContain('aria-label="Switch profile"') - expect(html).toContain('Manage profiles') - expect(html).toContain('New local profile') - expect(html).toContain('Create profile for org') - expect(html).toContain('data-testid="create-dialog"') - expect(html).toContain('data-testid="management-dialog"') - expect(html).toContain('data-testid="switch-confirm-dialog"') - }) -}) diff --git a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx b/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx deleted file mode 100644 index f5858d4aa7d..00000000000 --- a/src/renderer/src/components/orca-profiles/OrcaProfileSwitcher.tsx +++ /dev/null @@ -1,424 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { - Check, - ChevronDown, - CircleUserRound, - Cloud, - Laptop, - Loader2, - Plus, - Settings2, - Users -} from 'lucide-react' -import { useShallow } from 'zustand/react/shallow' -import { Button } from '@/components/ui/button' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '@/components/ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { translate } from '@/i18n/i18n' -import { cn } from '@/lib/utils' -import { useAppStore } from '@/store' -import type { OrcaCloudOrgSummary, OrcaProfileSummary } from '../../../../shared/orca-profiles' -import { OrcaProfileAvatar } from './OrcaProfileAvatar' -import { OrcaProfileCloudMenuItems } from './OrcaProfileCloudMenuItems' -import { OrcaProfileCreateDialog } from './OrcaProfileCreateDialog' -import { OrcaProfileOrgMembersDialog } from './OrcaProfileOrgMembersDialog' -import { OrcaProfileManagementDialog } from './OrcaProfileManagementDialog' -import { OrcaProfileMenuHeader } from './OrcaProfileMenuHeader' -import { OrcaProfileSignOutConfirmDialog } from './OrcaProfileSignOutConfirmDialog' -import { OrcaProfileSwitchConfirmDialog } from './OrcaProfileSwitchConfirmDialog' -import { getOrcaAccountIdentity } from './orca-account-identity' -import { getOrcaProfileSwitchLiveWorkSummary } from './orca-profile-switch-liveness' - -function isWebClient(): boolean { - return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) -} - -function getProfileSubtitle(profile: OrcaProfileSummary): string { - if (profile.cloud?.activeOrgName) { - return profile.cloud.activeOrgName - } - if (profile.cloud?.email) { - return profile.cloud.email - } - return translate('auto.components.orca.profiles.switcher.b4f9d1125d', 'Local') -} - -export function OrcaProfileSwitcher({ - placement = 'titlebar' -}: { - placement?: 'titlebar' | 'sidebar' -}): React.JSX.Element | null { - const profiles = useAppStore((s) => s.orcaProfiles) - const activeProfileId = useAppStore((s) => s.activeOrcaProfileId) - const loading = useAppStore((s) => s.orcaProfilesLoading) - const switching = useAppStore((s) => s.orcaProfileSwitching) - const connecting = useAppStore((s) => s.orcaProfileConnecting) - const authStatus = useAppStore((s) => s.orcaProfileAuthStatus) - const multiProfileUi = useAppStore((s) => s.orcaProfilesMultiProfileUi) - const fetchProfiles = useAppStore((s) => s.fetchOrcaProfiles) - const createLocalProfile = useAppStore((s) => s.createLocalOrcaProfile) - const createCloudLinkedProfile = useAppStore((s) => s.createCloudLinkedOrcaProfile) - const connectCurrentProfile = useAppStore((s) => s.connectCurrentOrcaProfile) - const signOutCurrentProfile = useAppStore((s) => s.signOutCurrentOrcaProfile) - const selectOrg = useAppStore((s) => s.selectOrcaProfileOrg) - const switchProfile = useAppStore((s) => s.switchOrcaProfile) - const liveWorkSummary = useAppStore(useShallow((s) => getOrcaProfileSwitchLiveWorkSummary(s))) - const [dialogOpen, setDialogOpen] = useState(false) - const [managementOpen, setManagementOpen] = useState(false) - const [newProfileName, setNewProfileName] = useState('') - const [creating, setCreating] = useState(false) - const [creatingCloudProfile, setCreatingCloudProfile] = useState(false) - const [signOutConfirmOpen, setSignOutConfirmOpen] = useState(false) - const [signingOut, setSigningOut] = useState(false) - const [orgMembersOpen, setOrgMembersOpen] = useState(false) - const [pendingSwitchProfileId, setPendingSwitchProfileId] = useState(null) - const activeProfile = useMemo( - () => profiles.find((profile) => profile.id === activeProfileId) ?? profiles[0] ?? null, - [activeProfileId, profiles] - ) - const pendingSwitchProfile = useMemo( - () => profiles.find((profile) => profile.id === pendingSwitchProfileId) ?? null, - [pendingSwitchProfileId, profiles] - ) - - // Why: one attempt per mount — retrying on every loading toggle would spin - // an unbounded IPC loop when the list call persistently fails. - const fetchAttemptedRef = useRef(false) - useEffect(() => { - if (profiles.length === 0 && !loading && !fetchAttemptedRef.current) { - fetchAttemptedRef.current = true - void fetchProfiles() - } - }, [fetchProfiles, loading, profiles.length]) - - // Why: the Orca Cloud account UX isn't ready for production users yet, so the - // trigger stays hidden in packaged builds. Dev builds still show it when cloud - // auth is configured. electron-vite build forces NODE_ENV=production, so PROD - // is baked true even for the e2e bundle; exempt MODE==='e2e' so the switcher - // specs can exercise the render path while real packaged prod builds stay hidden. - if (import.meta.env.PROD && import.meta.env.MODE !== 'e2e') { - return null - } - - // Why: paired web/mobile clients only see the desktop stub's fabricated - // profile list; showing a switcher there would misreport the active profile - // and none of its actions can work remotely. - if (isWebClient() || !activeProfile) { - return null - } - - // Why: with multi-profile UI downscoped, local-only builds (no cloud - // configured) have nothing to offer in an account menu — show no trigger. - if (!multiProfileUi && authStatus?.configured !== true) { - return null - } - - const handleCreateProfile = async (event: React.FormEvent): Promise => { - event.preventDefault() - if (creating || switching) { - return - } - setCreating(true) - const profile = await createLocalProfile(newProfileName) - setCreating(false) - if (!profile) { - return - } - setNewProfileName('') - setDialogOpen(false) - if (liveWorkSummary.hasLiveWork) { - setPendingSwitchProfileId(profile.id) - return - } - await switchProfile(profile.id) - } - - const handleSwitchProfile = (profileId: string): void => { - if (profileId === activeProfileId || switching) { - return - } - if (liveWorkSummary.hasLiveWork) { - setPendingSwitchProfileId(profileId) - return - } - void switchProfile(profileId) - } - - const handleConfirmSwitchProfile = (): void => { - if (!pendingSwitchProfileId || switching) { - return - } - void switchProfile(pendingSwitchProfileId) - } - - const handleCreateCloudProfileForOrg = async ( - organization: OrcaCloudOrgSummary - ): Promise => { - if (creatingCloudProfile || switching) { - return - } - setCreatingCloudProfile(true) - const result = await createCloudLinkedProfile({ - orgId: organization.orgId, - name: organization.name - }) - setCreatingCloudProfile(false) - if (result?.status !== 'created') { - return - } - if (liveWorkSummary.hasLiveWork) { - setPendingSwitchProfileId(result.profile.id) - return - } - await switchProfile(result.profile.id) - } - - const handleConfirmSignOut = async (): Promise => { - if (signingOut) { - return - } - setSigningOut(true) - const result = await signOutCurrentProfile() - setSigningOut(false) - if (result) { - setSignOutConfirmOpen(false) - } - } - - const profileActionDisabled = - switching || creating || creatingCloudProfile || connecting || signingOut - // Why: teammate management needs a connected cloud profile scoped to an org; - // the server enforces role permissions, and the dialog adapts via - // canManageMembers, so cloud-linked + org + connected is enough to reveal it. - const activeOrgId = activeProfile.cloud?.activeOrgId - const showOrgMembers = - activeProfile.kind === 'cloud-linked' && - Boolean(activeOrgId) && - authStatus?.state === 'connected' - const sidebarPlacement = placement === 'sidebar' - const triggerLabel = multiProfileUi - ? translate('auto.components.orca.profiles.switcher.4815f7d163', 'Switch profile') - : translate('auto.components.orca.profiles.switcher.account', 'Account') - const accountIdentity = getOrcaAccountIdentity(activeProfile, authStatus) - const showAccountIdentity = - multiProfileUi || - authStatus?.state === 'connected' || - authStatus?.state === 'reconnect-required' - - return ( - <> - - - - - - - - - {triggerLabel} - - - - {showAccountIdentity ? ( - <> - - - - ) : null} - {multiProfileUi - ? profiles.map((profile) => { - const active = profile.id === activeProfileId - return ( - handleSwitchProfile(profile.id)} - className="min-w-0" - > - - - {profile.name} - - {getProfileSubtitle(profile)} - - - {profile.kind === 'cloud-linked' ? : } - {active && } - - ) - }) - : null} - {showOrgMembers ? ( - <> - - setOrgMembersOpen(true)} - > - - {translate( - 'auto.components.orca.profiles.switcher.org.members', - 'Organization members' - )} - - - ) : null} - { - void connectCurrentProfile() - }} - onCreateProfileForOrg={(organization) => { - void handleCreateCloudProfileForOrg(organization) - }} - onSelectOrg={(orgId) => { - void selectOrg(orgId) - }} - onRequestSignOut={() => setSignOutConfirmOpen(true)} - /> - {multiProfileUi ? ( - <> - - { - setManagementOpen(true) - }} - > - - {translate('auto.components.orca.profiles.switcher.d00d853e2a', 'Manage profiles')} - - { - setDialogOpen(true) - }} - > - - {translate( - 'auto.components.orca.profiles.switcher.c106c674fe', - 'New local profile' - )} - - - ) : null} - - - - {multiProfileUi ? ( - <> - - - - ) : null} - {showOrgMembers && activeOrgId ? ( - - ) : null} - { - if (!signingOut) { - setSignOutConfirmOpen(open) - } - }} - onConfirm={() => { - void handleConfirmSignOut() - }} - signingOut={signingOut} - /> - {multiProfileUi ? ( - { - if (!open && !switching) { - setPendingSwitchProfileId(null) - } - }} - onConfirm={handleConfirmSwitchProfile} - activeProfileName={activeProfile.name} - targetProfile={pendingSwitchProfile} - liveWorkSummary={liveWorkSummary} - switching={switching} - /> - ) : null} - - ) -} diff --git a/src/renderer/src/components/orca-profiles/orca-account-identity.ts b/src/renderer/src/components/orca-profiles/orca-account-identity.ts deleted file mode 100644 index 65ab7231058..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-account-identity.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { translate } from '@/i18n/i18n' -import type { OrcaProfileAuthStatus, OrcaProfileSummary } from '../../../../shared/orca-profiles' - -export function getOrcaAccountIdentity( - profile: OrcaProfileSummary, - authStatus: OrcaProfileAuthStatus | null -): { title: string; subtitle: string } { - // Why: the account-only menu must not present a local execution profile as - // an authenticated Orca identity. - const cloud = authStatus?.cloud ?? profile.cloud - if (authStatus?.state === 'connected') { - return { - title: - cloud?.displayName?.trim() || - cloud?.email || - translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), - subtitle: - cloud?.activeOrgName || - (cloud?.displayName && cloud.email - ? cloud.email - : translate('auto.components.orca.profiles.switcher.accountSignedIn', 'Signed in')) - } - } - if (authStatus?.state === 'reconnect-required') { - return { - title: - cloud?.displayName?.trim() || - cloud?.email || - translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), - subtitle: translate( - 'auto.components.orca.profiles.switcher.accountSignInRequired', - 'Sign-in required' - ) - } - } - return { - title: translate('auto.components.orca.profiles.switcher.accountTitle', 'Orca account'), - subtitle: translate('auto.components.orca.profiles.switcher.accountSignedOut', 'Signed out') - } -} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts b/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts deleted file mode 100644 index 9378d77cec1..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-profile-org-members-labels.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { translate } from '@/i18n/i18n' -import type { - OrcaOrgRole, - OrcaProfileOrgMemberMutationResult -} from '../../../../shared/orca-profiles' - -export const ORG_ROLE_OPTIONS: readonly OrcaOrgRole[] = ['owner', 'admin', 'member'] - -export function orgRoleLabel(role: OrcaOrgRole): string { - switch (role) { - case 'owner': - return translate('auto.components.orca.profiles.org.members.role.owner', 'Owner') - case 'admin': - return translate('auto.components.orca.profiles.org.members.role.admin', 'Admin') - case 'member': - return translate('auto.components.orca.profiles.org.members.role.member', 'Member') - } -} - -export function orgMemberInitials(displayName: string | undefined, email: string): string { - const source = displayName?.trim() || email.trim() - if (!source) { - return '?' - } - const words = source.split(/\s+/).filter(Boolean) - if (words.length >= 2) { - return `${words[0][0]}${words[1][0]}`.toUpperCase() - } - return source.slice(0, 2).toUpperCase() -} - -// Why: the dialog maps every non-ok mutation status to a precise, translated -// message so managers see why an action was rejected instead of a raw code. -export function describeOrgMutationError( - result: Exclude -): string { - switch (result.status) { - case 'forbidden': - return translate( - 'auto.components.orca.profiles.org.members.error.forbidden', - "Your role can't do that." - ) - case 'conflict': - return translate( - 'auto.components.orca.profiles.org.members.error.conflict', - 'Already a member or invited.' - ) - case 'invalid': - return result.reason === 'cannot_remove_self' - ? translate( - 'auto.components.orca.profiles.org.members.error.remove.self', - "You can't remove yourself." - ) - : translate( - 'auto.components.orca.profiles.org.members.error.own.role', - "You can't change your own role." - ) - case 'not-found': - return translate( - 'auto.components.orca.profiles.org.members.error.not.found', - 'That teammate is no longer in this organization.' - ) - case 'reconnect-required': - return translate( - 'auto.components.orca.profiles.org.members.error.reconnect', - 'Reconnect your profile to manage members.' - ) - case 'unconfigured': - return translate( - 'auto.components.orca.profiles.org.members.error.unconfigured', - 'Organization management is unavailable in this build.' - ) - case 'failed': - return translate( - 'auto.components.orca.profiles.org.members.error.failed', - 'Something went wrong. Please try again.' - ) - } -} diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts deleted file mode 100644 index 1dfc0873727..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { BrowserWorkspace, TerminalTab } from '../../../../shared/types' -import { - getOrcaProfileProjectLiveWorkSummary, - getOrcaProfileSwitchLiveWorkSummary, - type OrcaProfileSwitchLiveWorkState -} from './orca-profile-switch-liveness' - -const NOW = 1_000_000 - -function makeState( - overrides: Partial = {} -): OrcaProfileSwitchLiveWorkState { - return { - agentStatusByPaneKey: {}, - browserTabsByWorktree: {}, - ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {}, - tabsByWorktree: {}, - ...overrides - } -} - -function makeTab( - overrides: Partial> & { - id: string - worktreeId?: string - } -): TerminalTab { - const { id, worktreeId = 'worktree-1', ...rest } = overrides - return { - id, - color: null, - createdAt: NOW, - customTitle: null, - ptyId: null, - sortOrder: 0, - title: 'zsh', - worktreeId, - ...rest - } -} - -function makeAgentEntry(overrides: Partial = {}): AgentStatusEntry { - return { - paneKey: 'tab-1:0', - prompt: '', - state: 'working', - stateHistory: [], - stateStartedAt: NOW, - updatedAt: NOW, - ...overrides - } -} - -function makeBrowserWorkspace(id: string): BrowserWorkspace { - return { - id, - activePageId: null, - canGoBack: false, - canGoForward: false, - createdAt: NOW, - faviconUrl: null, - label: 'Browser', - loadError: null, - loading: false, - pageIds: [], - title: 'Browser', - url: 'about:blank', - worktreeId: 'worktree-1' - } -} - -describe('getOrcaProfileSwitchLiveWorkSummary', () => { - it('reports quiet profiles as safe to switch without confirmation', () => { - expect(getOrcaProfileSwitchLiveWorkSummary(makeState(), NOW)).toEqual({ - browserWorkspaceCount: 0, - hasLiveWork: false, - liveAgentCount: 0, - livePtyCount: 0, - liveTerminalTabCount: 0 - }) - }) - - it('counts live PTYs as live terminal work', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - ptyIdsByTabId: { - 'tab-1': ['pty-1', 'pty-2'], - 'tab-2': [] - } - }), - NOW - ) - - expect(summary).toMatchObject({ - hasLiveWork: true, - livePtyCount: 2, - liveTerminalTabCount: 1 - }) - }) - - it('counts fresh working explicit agent status', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - agentStatusByPaneKey: { - 'tab-1:0': makeAgentEntry({ paneKey: 'tab-1:0', state: 'working' }), - 'tab-2:0': makeAgentEntry({ paneKey: 'tab-2:0', state: 'done' }), - 'tab-3:0': makeAgentEntry({ - paneKey: 'tab-3:0', - state: 'waiting', - updatedAt: -999_999_999 - }) - } - }), - NOW - ) - - expect(summary.liveAgentCount).toBe(1) - expect(summary.hasLiveWork).toBe(true) - }) - - it('counts title-detected agents only when the tab has a live PTY', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - ptyIdsByTabId: { - 'tab-live': ['pty-1'] - }, - tabsByWorktree: { - 'worktree-1': [ - makeTab({ id: 'tab-live', title: 'Codex working' }), - makeTab({ id: 'tab-slept', title: 'Codex working' }) - ] - } - }), - NOW - ) - - expect(summary.liveAgentCount).toBe(1) - expect(summary.hasLiveWork).toBe(true) - }) - - it('counts browser workspaces as live browser work', () => { - const summary = getOrcaProfileSwitchLiveWorkSummary( - makeState({ - browserTabsByWorktree: { - 'worktree-1': [makeBrowserWorkspace('browser-1'), makeBrowserWorkspace('browser-2')] - } - }), - NOW - ) - - expect(summary).toMatchObject({ - browserWorkspaceCount: 2, - hasLiveWork: true - }) - }) - - it('filters live work to the selected project', () => { - const summary = getOrcaProfileProjectLiveWorkSummary( - makeState({ - agentStatusByPaneKey: { - 'other-tab:0': makeAgentEntry({ - paneKey: 'other-tab:0', - worktreeId: 'repo-other::/workspace/other' - }) - }, - browserTabsByWorktree: { - 'repo-1::/workspace/orca': [makeBrowserWorkspace('browser-1')], - 'repo-other::/workspace/other': [makeBrowserWorkspace('browser-2')] - }, - ptyIdsByTabId: { - 'tab-live': ['pty-1'], - 'other-tab': ['pty-2'] - }, - tabsByWorktree: { - 'repo-1::/workspace/orca': [makeTab({ id: 'tab-live', title: 'Codex working' })], - 'repo-other::/workspace/other': [makeTab({ id: 'other-tab', title: 'Codex working' })] - } - }), - 'repo-1', - NOW - ) - - expect(summary).toMatchObject({ - browserWorkspaceCount: 1, - hasLiveWork: true, - liveAgentCount: 1, - livePtyCount: 1, - liveTerminalTabCount: 1 - }) - }) -}) diff --git a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts b/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts deleted file mode 100644 index c96a1812157..00000000000 --- a/src/renderer/src/components/orca-profiles/orca-profile-switch-liveness.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { AppState } from '@/store/types' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' -import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' - -export type OrcaProfileSwitchLiveWorkState = Pick< - AppState, - | 'agentStatusByPaneKey' - | 'browserTabsByWorktree' - | 'ptyIdsByTabId' - | 'runtimePaneTitlesByTabId' - | 'tabsByWorktree' -> - -export type OrcaProfileSwitchLiveWorkSummary = { - hasLiveWork: boolean - liveAgentCount: number - livePtyCount: number - liveTerminalTabCount: number - browserWorkspaceCount: number -} - -const LIVE_AGENT_STATES = new Set(['working', 'blocked', 'waiting']) - -export function getOrcaProfileSwitchLiveWorkSummary( - state: OrcaProfileSwitchLiveWorkState, - now = Date.now() -): OrcaProfileSwitchLiveWorkSummary { - const terminalSummary = getLiveTerminalSummary(state) - const liveAgentCount = getLiveAgentKeys(state, now).size - const browserWorkspaceCount = Object.values(state.browserTabsByWorktree).reduce( - (count, workspaces) => count + workspaces.length, - 0 - ) - - return { - hasLiveWork: - terminalSummary.livePtyCount > 0 || liveAgentCount > 0 || browserWorkspaceCount > 0, - liveAgentCount, - livePtyCount: terminalSummary.livePtyCount, - liveTerminalTabCount: terminalSummary.liveTerminalTabCount, - browserWorkspaceCount - } -} - -export function getOrcaProfileProjectLiveWorkSummary( - state: OrcaProfileSwitchLiveWorkState, - repoId: string, - now = Date.now() -): OrcaProfileSwitchLiveWorkSummary { - const tabsByWorktree = Object.fromEntries( - Object.entries(state.tabsByWorktree).filter(([worktreeId]) => - worktreeBelongsToRepo(worktreeId, repoId) - ) - ) - const tabIds = new Set(Object.values(tabsByWorktree).flatMap((tabs) => tabs.map((tab) => tab.id))) - const browserTabsByWorktree = Object.fromEntries( - Object.entries(state.browserTabsByWorktree).filter(([worktreeId]) => - worktreeBelongsToRepo(worktreeId, repoId) - ) - ) - const ptyIdsByTabId = Object.fromEntries( - Object.entries(state.ptyIdsByTabId).filter(([tabId]) => tabIds.has(tabId)) - ) - const runtimePaneTitlesByTabId = Object.fromEntries( - Object.entries(state.runtimePaneTitlesByTabId).filter(([tabId]) => tabIds.has(tabId)) - ) - const agentStatusByPaneKey = Object.fromEntries( - Object.entries(state.agentStatusByPaneKey).filter(([paneKey, entry]) => - agentEntryBelongsToProject(paneKey, entry, repoId, tabIds) - ) - ) - - return getOrcaProfileSwitchLiveWorkSummary( - { - agentStatusByPaneKey, - browserTabsByWorktree, - ptyIdsByTabId, - runtimePaneTitlesByTabId, - tabsByWorktree - }, - now - ) -} - -function getLiveTerminalSummary(state: OrcaProfileSwitchLiveWorkState): { - livePtyCount: number - liveTerminalTabCount: number -} { - const liveTabIds = new Set() - let livePtyCount = 0 - - for (const [tabId, ptyIds] of Object.entries(state.ptyIdsByTabId)) { - if (ptyIds.length === 0) { - continue - } - livePtyCount += ptyIds.length - liveTabIds.add(tabId) - } - - return { - livePtyCount, - liveTerminalTabCount: liveTabIds.size - } -} - -function getLiveAgentKeys(state: OrcaProfileSwitchLiveWorkState, now: number): Set { - const keys = new Set() - for (const entry of Object.values(state.agentStatusByPaneKey)) { - if ( - LIVE_AGENT_STATES.has(entry.state) && - isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) - ) { - keys.add(entry.paneKey) - } - } - - for (const tabs of Object.values(state.tabsByWorktree)) { - for (const tab of tabs) { - addTitleDetectedLiveAgentKeys(keys, state, tab) - } - } - - return keys -} - -function addTitleDetectedLiveAgentKeys( - keys: Set, - state: OrcaProfileSwitchLiveWorkState, - tab: TerminalTab -): void { - if (!tabHasLivePty(state.ptyIdsByTabId, tab.id)) { - return - } - - const paneTitles = state.runtimePaneTitlesByTabId[tab.id] - if (paneTitles && Object.keys(paneTitles).length > 0) { - for (const [paneId, title] of Object.entries(paneTitles)) { - if (isLiveAgentTitle(title)) { - keys.add(`${tab.id}:${paneId}`) - } - } - return - } - - if (isLiveAgentTitle(tab.title)) { - keys.add(`${tab.id}:title`) - } -} - -function isLiveAgentTitle(title: string): boolean { - const status = detectAgentStatusFromTitle(title) - return status === 'working' || status === 'permission' -} - -function agentEntryBelongsToProject( - paneKey: string, - entry: AgentStatusEntry, - repoId: string, - tabIds: Set -): boolean { - if (entry.worktreeId && worktreeBelongsToRepo(entry.worktreeId, repoId)) { - return true - } - return tabIds.has(getPaneKeyTabId(paneKey)) -} - -function getPaneKeyTabId(paneKey: string): string { - const separatorIndex = paneKey.lastIndexOf(':') - return separatorIndex === -1 ? paneKey : paneKey.slice(0, separatorIndex) -} - -function worktreeBelongsToRepo(worktreeId: string, repoId: string): boolean { - return getRepoIdFromWorktreeId(worktreeId) === repoId -} diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.test.ts b/src/renderer/src/components/pr-comments-resolution-prompt.test.ts index 875794753af..902481e9ba3 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.test.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { PRComment } from '../../../shared/types' -import { groupPRComments } from '@/lib/pr-comment-groups' +import { groupPRComments } from '../../../shared/pr-comment-groups' import { buildPRCommentsResolutionPrompt, isResolvablePRCommentGroup diff --git a/src/renderer/src/components/pr-comments-resolution-prompt.ts b/src/renderer/src/components/pr-comments-resolution-prompt.ts index 8aecb0ad3e2..a31d4340844 100644 --- a/src/renderer/src/components/pr-comments-resolution-prompt.ts +++ b/src/renderer/src/components/pr-comments-resolution-prompt.ts @@ -1,5 +1,5 @@ import type { PRComment } from '../../../shared/types' -import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import type { PRCommentGroup } from '../../../shared/pr-comment-groups' export type PRCommentsResolutionReviewKind = 'PR' | 'MR' diff --git a/src/renderer/src/components/quick-open-file-list.react.test.tsx b/src/renderer/src/components/quick-open-file-list.react.test.tsx index 8dc6c550344..fc00fac9891 100644 --- a/src/renderer/src/components/quick-open-file-list.react.test.tsx +++ b/src/renderer/src/components/quick-open-file-list.react.test.tsx @@ -192,6 +192,43 @@ describe('useRuntimeFileListForWorktree', () => { expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken) }) + it('cancels the in-flight scan as soon as listing is disabled', async () => { + const workspaceKey = folderWorkspaceKey('folder-workspace-1') + listRuntimeFilesMock.mockReturnValue(new Promise(() => {})) + + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ connectionId: 'ssh-1' })], + projectGroups: [makeProjectGroup({ connectionId: 'ssh-1' })], + repos: [], + worktreesByRepo: {} + } as Partial) + + const root = await renderProbe({ + enabled: true, + onState: () => {}, + worktreeId: workspaceKey + }) + await waitForListRuntimeFilesCall() + + const [listContext, listRequest] = listRuntimeFilesMock.mock.calls[0] + expect(listContext.connectionId).toBe('ssh-1') + expect(cancelRuntimeFileListMock).not.toHaveBeenCalled() + + await act(async () => { + root.render( + createElement(HookProbe, { + enabled: false, + onState: () => {}, + worktreeId: workspaceKey + }) + ) + }) + await flushEffects() + + expect(cancelRuntimeFileListMock).toHaveBeenCalledTimes(1) + expect(cancelRuntimeFileListMock).toHaveBeenCalledWith(listContext, listRequest.requestToken) + }) + it('does not restart the scan when unrelated ownership metadata changes', async () => { const workspaceKey = folderWorkspaceKey('folder-workspace-1') useAppStore.setState({ diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index d41233815d1..29db6fbcf2a 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -191,7 +191,7 @@ import { formatCreateError } from './create-pull-request-review-copy' import { stripBaseRef, useCreatePullRequestDialogFields } from './useCreatePullRequestDialogFields' import { localizedHostedReviewCopy } from '@/i18n/hosted-review-localized-copy' import { translate } from '@/i18n/i18n' -import { groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { groupPRComments, type PRCommentGroup } from '../../../../shared/pr-comment-groups' import { openChecksPanelHostedReviewUrl, resolveChecksPanelHostedReviewModifierDestination, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts new file mode 100644 index 00000000000..9d10440e1b0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.test.ts @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../../../../shared/ai-vault-types' +import { + markInputQuietSchedulerInput, + resetInputQuietSchedulerForTest +} from '@/lib/input-quiet-scheduler' +import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate' + +const FIRST: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-08-09T00:00:00.000Z' +} +const SECOND: AiVaultListResult = { + ...FIRST, + scannedAt: '2026-08-09T00:00:01.000Z' +} + +beforeEach(() => { + vi.useFakeTimers() + resetInputQuietSchedulerForTest() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('AiVaultSessionPublicationGate', () => { + it('publishes immediately when terminal input is already quiet', () => { + const apply = vi.fn() + + new AiVaultSessionPublicationGate().publish(FIRST, apply) + + expect(apply).toHaveBeenCalledWith(FIRST) + }) + + it('retains only the newest result while input is active', () => { + const apply = vi.fn() + const gate = new AiVaultSessionPublicationGate() + markInputQuietSchedulerInput() + + gate.publish(FIRST, apply) + gate.publish(SECOND, apply) + vi.advanceTimersByTime(101) + + expect(apply).toHaveBeenCalledOnce() + expect(apply).toHaveBeenCalledWith(SECOND) + }) + + it('publishes by one second even when input never becomes quiet', () => { + const apply = vi.fn() + const gate = new AiVaultSessionPublicationGate() + markInputQuietSchedulerInput() + gate.publish(FIRST, apply) + + for (let elapsed = 90; elapsed < 1_000; elapsed += 90) { + vi.advanceTimersByTime(90) + markInputQuietSchedulerInput() + } + vi.advanceTimersByTime(10) + + expect(apply).toHaveBeenCalledWith(FIRST) + }) + + it('cancels a pending publication on scope change or unmount', () => { + const apply = vi.fn() + const gate = new AiVaultSessionPublicationGate() + markInputQuietSchedulerInput() + gate.publish(FIRST, apply) + + gate.cancel() + vi.advanceTimersByTime(1_000) + + expect(apply).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.ts new file mode 100644 index 00000000000..325c2b51910 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-publication-gate.ts @@ -0,0 +1,41 @@ +import { startTransition } from 'react' +import type { AiVaultListResult } from '../../../../shared/ai-vault-types' +import { hasInputBeenQuietFor, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' + +const AI_VAULT_PUBLICATION_QUIET_MS = 100 +const AI_VAULT_PUBLICATION_MAX_WAIT_MS = 1_000 + +export class AiVaultSessionPublicationGate { + private generation = 0 + private cancelPending: (() => void) | null = null + + publish(result: AiVaultListResult, apply: (result: AiVaultListResult) => void): void { + this.cancel() + const generation = this.generation + if (hasInputBeenQuietFor(AI_VAULT_PUBLICATION_QUIET_MS)) { + startTransition(() => apply(result)) + return + } + this.cancelPending = scheduleAfterInputQuiet( + () => { + if (generation !== this.generation) { + return + } + this.cancelPending = null + startTransition(() => apply(result)) + }, + { + delayMs: 0, + quietMs: AI_VAULT_PUBLICATION_QUIET_MS, + idleTimeoutMs: AI_VAULT_PUBLICATION_QUIET_MS, + maxWaitMs: AI_VAULT_PUBLICATION_MAX_WAIT_MS + } + ) + } + + cancel(): void { + this.generation += 1 + this.cancelPending?.() + this.cancelPending = null + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index d991a9510e1..e0d9eea9b3e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -7,6 +7,7 @@ import { import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import type { AiVaultSessionLimit } from './ai-vault-session-limit' +import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate' import { aiVaultSessionResultCacheKey, cacheAiVaultSessionResult, @@ -53,6 +54,7 @@ export function useAiVaultSessionRefresh( const pendingBackgroundRef = useRef(true) const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) + const publicationGateRef = useRef(new AiVaultSessionPublicationGate()) const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}` const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths @@ -89,8 +91,10 @@ export function useAiVaultSessionRefresh( const scanKey = `${baseKey}\n${selectedLimit}` lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt } setError(null) - setScanResult(cachedResult) - setSessions(cachedResult.sessions) + publicationGateRef.current.publish(cachedResult, (published) => { + setScanResult(published) + setSessions(published.sessions) + }) setLoading(false) return } @@ -155,8 +159,12 @@ export function useAiVaultSessionRefresh( result, replaceHostEntries: args.force === true }) - setScanResult(result) - setSessions(result.sessions) + publicationGateRef.current.publish(result, (published) => { + if (mountedRef.current && scanKey === currentScanScopeKey()) { + setScanResult(published) + setSessions(published.sessions) + } + }) } catch (err) { // A cancelled scan is not a failure: another caller's forced refresh // preempts the shared scan, and painting its abort would replace the @@ -214,8 +222,10 @@ export function useAiVaultSessionRefresh( useEffect(() => { mountedRef.current = true const requestToken = requestTokenRef.current + const publicationGate = publicationGateRef.current return () => { mountedRef.current = false + publicationGate.cancel() refreshIdRef.current += 1 refreshInFlightRef.current = false void window.api.aiVault.cancelListSessions({ @@ -230,6 +240,7 @@ export function useAiVaultSessionRefresh( // Panel entry reuses the renderer result first, then the host scan cache. useEffect(() => { + publicationGateRef.current.cancel() if (refreshInFlightRef.current) { void window.api.aiVault.cancelListSessions({ requestToken: requestTokenRef.current diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index 0d00acd3f09..7091d995996 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -60,14 +60,20 @@ import { CheckJobLogTail } from './check-job-log-tail' import { filterPRCommentsByAudience, getPRCommentAudienceCounts, - getPRCommentAudienceEmptyLabel, isBotPRComment, - normalizePRCommentAuthorLogin, - getPrCommentAudienceFilters, type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' +} from '../../../../shared/pr-comment-audience' +import { normalizePRCommentAuthorLogin } from '../../../../shared/pr-bot-author-overrides' +import { + getPRCommentAudienceEmptyLabel, + getPrCommentAudienceFilters +} from '@/lib/pr-comment-audience-labels' import { setPRBotAuthorOverride, usePRBotAuthorOverrides } from '@/lib/pr-bot-author-overrides' -import { getPRCommentGroupId, groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { + getPRCommentGroupId, + groupPRComments, + type PRCommentGroup +} from '../../../../shared/pr-comment-groups' import { getPRCommentGroupActionState, isPRCommentGroupQueueableForAI, @@ -75,7 +81,7 @@ import { sortPRCommentGroupsForTimeline, type PRCommentGroupActionState } from '@/lib/pr-comment-action-state' -import { formatPrCommentRelativeTime } from '@/lib/pr-comment-time' +import { formatPrCommentRelativeTime } from '../../../../shared/pr-comment-time' import { getPRCommentPresentationClasses, getPRCommentGroupSurfaceClasses, diff --git a/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts deleted file mode 100644 index 9bcb57cf3f0..00000000000 --- a/src/renderer/src/components/right-sidebar/pr-comment-filters.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { PRComment } from '../../../../shared/types' -import { filterPRCommentsByAudience, isAutomatedPRComment } from './pr-comment-filters' - -function comment(author: string): PRComment { - return { - id: author.length, - author, - authorAvatarUrl: '', - body: 'body', - createdAt: '2026-04-26T00:00:00.000Z', - url: '' - } -} - -describe('pr-comment-filters', () => { - it('classifies GitHub app and automation authors as bot comments', () => { - expect(isAutomatedPRComment(comment('github-actions[bot]'))).toBe(true) - expect(isAutomatedPRComment(comment('dependabot[bot]'))).toBe(true) - expect(isAutomatedPRComment(comment('renovate-bot'))).toBe(true) - expect(isAutomatedPRComment(comment('preview-automation'))).toBe(true) - }) - - it('keeps normal user logins as human comments', () => { - expect(isAutomatedPRComment(comment('octocat'))).toBe(false) - expect(isAutomatedPRComment(comment('robotics-dev'))).toBe(false) - }) - - it('trusts the GitHub-provided isBot flag when true', () => { - expect(isAutomatedPRComment({ ...comment('github-actions[bot]'), isBot: true })).toBe(true) - }) - - it('classifies known AI review services that register as User accounts', () => { - // These sign in as regular GitHub users, so GitHub's user.type is 'User' - // and their logins have no "bot" / "automation" tokens. Allowlist picks - // them up regardless. - expect(isAutomatedPRComment({ ...comment('qodo-ai-reviewer'), isBot: false })).toBe(true) - expect(isAutomatedPRComment({ ...comment('coderabbitai'), isBot: false })).toBe(true) - expect(isAutomatedPRComment(comment('sonarcloud'))).toBe(true) - expect(isAutomatedPRComment(comment('codium-ai-reviewer'))).toBe(true) - }) - - it('filters comments by audience', () => { - const comments = [ - comment('octocat'), - comment('github-actions[bot]'), - comment('mona'), - comment('dependabot[bot]') - ] - - expect(filterPRCommentsByAudience(comments, 'all')).toEqual(comments) - expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.author)).toEqual([ - 'octocat', - 'mona' - ]) - expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.author)).toEqual([ - 'github-actions[bot]', - 'dependabot[bot]' - ]) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-filters.ts b/src/renderer/src/components/right-sidebar/pr-comment-filters.ts deleted file mode 100644 index 6da2c17c3df..00000000000 --- a/src/renderer/src/components/right-sidebar/pr-comment-filters.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - filterPRCommentsByAudience, - isBotPRComment as isAutomatedPRComment, - type PRCommentAudienceFilter -} from '@/lib/pr-comment-audience' diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts index 2f4708f212a..dec478b8e1f 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { PRComment } from '../../../../shared/types' -import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import type { PRCommentGroup } from '../../../../shared/pr-comment-groups' import { acknowledgePRCommentsAfterAiLaunch, attachPRReviewReplyParent, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts index c73ab1aee1d..8eadffdcdfb 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -1,6 +1,6 @@ import type { PRComment, PRInfo } from '../../../../shared/types' import type { HostedReviewInfo } from '../../../../shared/hosted-review' -import { getPRCommentGroupRoot, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { getPRCommentGroupRoot, type PRCommentGroup } from '../../../../shared/pr-comment-groups' import { isResolvablePRCommentGroup } from '../pr-comments-resolution-prompt' import { buildPRCommentBatchConversationReplyBody, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx index a7b9af2153f..b8ab4111a77 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.test.tsx @@ -46,7 +46,7 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children} })) import type { PRComment } from '../../../../shared/types' -import type { PRCommentGroup } from '@/lib/pr-comment-groups' +import type { PRCommentGroup } from '../../../../shared/pr-comment-groups' import { MAX_PERSISTED_PR_COMMENTS_LIST_SELECTIONS, clearPRCommentsListSelectionsForTests, diff --git a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts index 6767e405c05..6245f6ff131 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-list-selection.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { getPRCommentGroupId, groupPRComments, type PRCommentGroup } from '@/lib/pr-comment-groups' +import { + getPRCommentGroupId, + groupPRComments, + type PRCommentGroup +} from '../../../../shared/pr-comment-groups' import { isPRCommentGroupQueueableForAI } from '@/lib/pr-comment-action-state' import type { PRComment } from '../../../../shared/types' diff --git a/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx b/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx new file mode 100644 index 00000000000..d5e6ecc54bd --- /dev/null +++ b/src/renderer/src/components/settings/TerminalFontSizeSetting.test.tsx @@ -0,0 +1,89 @@ +// @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 { GlobalSettings } from '../../../../shared/types' +import { TerminalFontSizeSetting } from './TerminalFontSizeSetting' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, defaultValue: string) => defaultValue +})) + +describe('TerminalFontSizeSetting', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + document.body.replaceChildren() + }) + + function renderSetting(terminalFontSize: number, updateSettings = vi.fn()): void { + act(() => { + root.render( + + ) + }) + } + + function getInput(): HTMLInputElement { + const input = container.querySelector('input[type="number"]') + if (!input) { + throw new Error('font size input not found') + } + return input + } + + function getStepperButtons(): { decrement: HTMLButtonElement; increment: HTMLButtonElement } { + const [decrement, increment] = Array.from( + container.querySelectorAll('button') + ) + if (!decrement || !increment) { + throw new Error('stepper buttons not found') + } + return { decrement, increment } + } + + it('keeps the two-digit input compact without native spin buttons', () => { + renderSetting(15) + + expect(Array.from(getInput().classList)).toEqual( + expect.arrayContaining(['number-input-clean', 'w-14']) + ) + }) + + it('steps the font size within the supported range', () => { + const updateSettings = vi.fn() + renderSetting(15, updateSettings) + + const { decrement, increment } = getStepperButtons() + + act(() => increment.click()) + expect(updateSettings).toHaveBeenCalledWith({ terminalFontSize: 16 }) + + act(() => decrement.click()) + expect(updateSettings).toHaveBeenCalledWith({ terminalFontSize: 14 }) + }) + + it('disables the steppers at the range bounds', () => { + renderSetting(10) + expect(getStepperButtons().decrement.disabled).toBe(true) + expect(getStepperButtons().increment.disabled).toBe(false) + + renderSetting(24) + expect(getStepperButtons().decrement.disabled).toBe(false) + expect(getStepperButtons().increment.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx index 3ce31694e67..61a451c7377 100644 --- a/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx +++ b/src/renderer/src/components/settings/TerminalFontSizeSetting.tsx @@ -45,6 +45,7 @@ export function TerminalFontSizeSetting({ > + {/* Why: native spin buttons overlap the value and duplicate the −/+ steppers. */} + + + + + ) +} diff --git a/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx new file mode 100644 index 00000000000..34bdbc9d4e8 --- /dev/null +++ b/src/renderer/src/components/sidebar/PreservedBranchBatchReviewModal.tsx @@ -0,0 +1,52 @@ +import { useMemo } from 'react' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' +import { useAppStore } from '@/store' +import { PreservedBranchBatchReviewDialog } from './PreservedBranchBatchReviewDialog' +import { + forceDeletePreservedBranchBatch, + type ActionablePreservedBranch +} from './preserved-branch-batch-toast' + +function isPreservedBranchCleanup(value: unknown): value is PreservedBranchCleanup { + if (!value || typeof value !== 'object') { + return false + } + const branch = value as Partial + return ( + typeof branch.worktreeId === 'string' && + typeof branch.branchName === 'string' && + (branch.expectedHead === undefined || typeof branch.expectedHead === 'string') && + (branch.hostId === undefined || typeof branch.hostId === 'string') && + (branch.runtimeEnvironmentId === undefined || typeof branch.runtimeEnvironmentId === 'string') + ) +} + +function getModalBranches(value: unknown): PreservedBranchCleanup[] { + return Array.isArray(value) ? value.filter(isPreservedBranchCleanup) : [] +} + +export default function PreservedBranchBatchReviewModal(): React.JSX.Element { + const activeModal = useAppStore((state) => state.activeModal) + const modalData = useAppStore((state) => state.modalData) + const closeModal = useAppStore((state) => state.closeModal) + const branches = useMemo(() => getModalBranches(modalData.branches), [modalData.branches]) + const open = activeModal === 'preserved-branch-review' && branches.length > 0 + + const handleForceDelete = (selectedBranches: readonly ActionablePreservedBranch[]): void => { + closeModal() + void forceDeletePreservedBranchBatch(selectedBranches) + } + + return ( + { + if (!nextOpen) { + closeModal() + } + }} + onForceDelete={handleForceDelete} + /> + ) +} diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx new file mode 100644 index 00000000000..32f399610fa --- /dev/null +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.mount-gating.test.tsx @@ -0,0 +1,269 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { readStoreListenerCount } from '@/store/store-listener-census' +import type * as ContextualTour from '@/components/contextual-tours/use-contextual-tour' +import type * as VisibleWorkspaceKanban from './use-visible-workspace-kanban-worktree-ids' +import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer' + +const { contentProbe, contextualTourMock } = vi.hoisted(() => ({ + contentProbe: { + mounts: vi.fn(), + projections: vi.fn(), + renders: vi.fn(), + storeNotifications: vi.fn(), + unmounts: vi.fn() + }, + contextualTourMock: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } })) + +vi.mock('@/components/ui/sheet', () => ({ + // Why: keep children mounted while closed so this measures Orca's gate, not Radix's portal. + Sheet: ({ children, open }: { children: React.ReactNode; open?: boolean }) => ( +
{children}
+ ), + SheetContent: ({ + children, + 'data-workspace-board-drag-preview': dragPreview + }: { + children: React.ReactNode + 'data-workspace-board-drag-preview'?: string + }) => ( +
+ {children} +
+ ) +})) + +vi.mock('./WorkspaceKanbanDrawerHeader', () => ({ + default: () =>
+})) +vi.mock('./WorkspaceKanbanLaneGrid', () => ({ default: () =>
})) +vi.mock('./WorkspaceKanbanPinDropTarget', () => ({ default: () =>
})) +vi.mock('./WorkspaceKanbanAreaSelectionOverlay', () => ({ + default: React.forwardRef((_, ref) =>
) +})) + +vi.mock('./use-visible-workspace-kanban-worktree-ids', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useVisibleWorkspaceKanbanWorktreeIds: ( + params: Parameters[0] + ) => { + const visibleIds = actual.useVisibleWorkspaceKanbanWorktreeIds(params) + contentProbe.renders() + contentProbe.projections() + React.useEffect(() => { + contentProbe.mounts() + const unsubscribe = useAppStore.subscribe(() => contentProbe.storeNotifications()) + return () => { + contentProbe.unmounts() + unsubscribe() + } + }, []) + return visibleIds + } + } +}) + +vi.mock('./use-workspace-kanban-selection', () => ({ + useWorkspaceKanbanSelection: () => ({ + selectedWorktreeIds: new Set(), + selectedWorktrees: [], + selectionAnchorId: null, + updateSelectionForGesture: vi.fn(), + updateSelectionForArea: vi.fn(), + clearSelection: vi.fn(), + selectForContextMenu: vi.fn(() => []) + }) +})) +vi.mock('./use-workspace-kanban-area-selection', () => ({ + useWorkspaceKanbanAreaSelection: () => ({ handleAreaSelectionPointerDown: vi.fn() }) +})) +vi.mock('./use-workspace-kanban-column-resize', () => ({ + useWorkspaceKanbanColumnResize: () => ({ + columnWidth: 308, + isResizingColumn: false, + onColumnResizeStart: vi.fn(), + onColumnResizeKeyDown: vi.fn() + }) +})) +vi.mock('./use-workspace-kanban-card-pointer-drag', () => ({ + useWorkspaceKanbanCardPointerDrag: () => ({ + isPointerDragActiveRef: { current: false }, + onCardPointerDownCapture: vi.fn() + }) +})) +vi.mock('./use-workspace-kanban-shift-wheel-scroll', () => ({ + useWorkspaceKanbanShiftWheelScroll: vi.fn() +})) +vi.mock('./use-workspace-kanban-outside-dismiss', () => ({ + isWorkspaceBoardKeepOpenTarget: () => false, + useWorkspaceKanbanOutsideDismiss: vi.fn() +})) +vi.mock('./use-workspace-status-drop', () => ({ useWorkspaceStatusDocumentDrop: vi.fn() })) +vi.mock('@/components/contextual-tours/use-contextual-tour', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useContextualTour: (...args: Parameters) => { + contextualTourMock(...args) + actual.useContextualTour(...args) + } + } +}) + +const initialAppState = useAppStore.getInitialState() +const onOpenChange = vi.fn() +const onMenuOpenChange = vi.fn() +let testContainer: HTMLDivElement +let testRoot: Root + +function activeContentSubscriptions(): number { + return contentProbe.mounts.mock.calls.length - contentProbe.unmounts.mock.calls.length +} + +function requireStoreListenerCount(): number { + const count = readStoreListenerCount() + expect(count).not.toBeNull() + return count ?? 0 +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderDrawer(open: boolean, dragPreview = false): Promise { + await act(async () => { + testRoot.render( + + ) + }) + await flushEffects() +} + +async function churnRemoteWorkspaceState(count: number): Promise { + await act(async () => { + for (let index = 0; index < count; index += 1) { + useAppStore.setState({ + worktreesByRepo: {}, + tabsByWorktree: {}, + ptyIdsByTabId: {}, + browserTabsByWorktree: {}, + agentStatusEpoch: index + }) + } + }) +} + +describe('WorkspaceKanbanDrawer mount gating', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + Object.values(contentProbe).forEach((probe) => probe.mockClear()) + contextualTourMock.mockClear() + onOpenChange.mockClear() + onMenuOpenChange.mockClear() + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => testRoot.unmount()) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('unmounts heavy content after the close animation and isolates closed churn', async () => { + const listenerBaseline = requireStoreListenerCount() + await renderDrawer(false) + const closedListenerCount = requireStoreListenerCount() + + expect(closedListenerCount).toBe(listenerBaseline) + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).toBeNull() + expect(activeContentSubscriptions()).toBe(0) + + await renderDrawer(true) + const openListenerCount = requireStoreListenerCount() + expect(openListenerCount).toBeGreaterThan(closedListenerCount + 15) + expect(activeContentSubscriptions()).toBe(1) + + await renderDrawer(false) + expect(testContainer.querySelector('[data-sheet-open="false"]')).not.toBeNull() + await act(async () => vi.advanceTimersByTimeAsync(299)) + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + + const lingeringNotifications = contentProbe.storeNotifications.mock.calls.length + await churnRemoteWorkspaceState(1) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(lingeringNotifications + 1) + + await act(async () => vi.advanceTimersByTimeAsync(1)) + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).toBeNull() + expect(activeContentSubscriptions()).toBe(0) + expect(requireStoreListenerCount()).toBe(closedListenerCount) + + const hiddenProjectionCount = contentProbe.projections.mock.calls.length + const hiddenRenderCount = contentProbe.renders.mock.calls.length + const hiddenNotificationCount = contentProbe.storeNotifications.mock.calls.length + await churnRemoteWorkspaceState(1_000) + + expect(contentProbe.projections).toHaveBeenCalledTimes(hiddenProjectionCount) + expect(contentProbe.renders).toHaveBeenCalledTimes(hiddenRenderCount) + expect(contentProbe.storeNotifications).toHaveBeenCalledTimes(hiddenNotificationCount) + }) + + it('cancels the pending unmount when reopened at 299 ms', async () => { + await renderDrawer(true) + await renderDrawer(false) + await act(async () => vi.advanceTimersByTimeAsync(299)) + await renderDrawer(true) + await act(async () => vi.advanceTimersByTimeAsync(1_000)) + + expect(testContainer.querySelector('[data-sheet-open="true"]')).not.toBeNull() + expect(testContainer.querySelector('[data-workspace-board-heavy-content]')).not.toBeNull() + expect(activeContentSubscriptions()).toBe(1) + }) + + it('preserves drag preview presentation when the preview becomes a full board', async () => { + await renderDrawer(true, true) + + expect(testContainer.querySelector('[data-workspace-board-drag-preview="true"]')).not.toBeNull() + expect(contextualTourMock).toHaveBeenLastCalledWith( + 'workspace-board', + false, + 'workspace_board_visible' + ) + + await renderDrawer(true, false) + + expect(testContainer.querySelector('[data-workspace-board-drag-preview]')).toBeNull() + expect(contextualTourMock).toHaveBeenLastCalledWith( + 'workspace-board', + true, + 'workspace_board_visible' + ) + expect(contentProbe.mounts).toHaveBeenCalledTimes(1) + expect(activeContentSubscriptions()).toBe(1) + }) +}) diff --git a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx index d3998031dcb..b457c378a15 100644 --- a/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx +++ b/src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx @@ -66,6 +66,9 @@ type WorkspaceKanbanDrawerProps = { onMenuOpenChange: (open: boolean) => void } +// Why: outlast the Sheet close animation so the board does not disappear mid-slide. +const WORKSPACE_BOARD_CLOSE_LINGER_MS = 300 + function formatTaskStatusSyncMessage(message: WorkspaceBoardTaskStatusSyncMessage): string { switch (message.kind) { case 'issue-read-failed': @@ -140,7 +143,26 @@ function formatTaskStatusSyncDescription(result: WorkspaceBoardTaskStatusSyncRes .join('. ') } -export default function WorkspaceKanbanDrawer({ +export default function WorkspaceKanbanDrawer( + props: WorkspaceKanbanDrawerProps +): React.JSX.Element | null { + const [lingering, setLingering] = useState(props.open) + useEffect(() => { + if (props.open) { + setLingering(true) + return + } + const timer = window.setTimeout(() => setLingering(false), WORKSPACE_BOARD_CLOSE_LINGER_MS) + return () => window.clearTimeout(timer) + }, [props.open]) + + if (!props.open && !lingering) { + return null + } + return +} + +function WorkspaceKanbanDrawerContent({ leftSidebarStyle, open, statusBarVisible, diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx deleted file mode 100644 index 633dce8a46e..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeStatus } from '@/lib/worktree-status' -import { WorktreeActivityStatusIndicator } from './WorktreeActivityStatusIndicator' - -const mocks = vi.hoisted(() => ({ - status: 'inactive' as WorktreeStatus -})) - -vi.mock('./use-worktree-activity-status', () => ({ - useWorktreeActivityStatus: vi.fn(() => mocks.status) -})) - -function renderMarkup(status: WorktreeStatus): string { - mocks.status = status - return renderToStaticMarkup( - React.createElement(WorktreeActivityStatusIndicator, { worktreeId: 'wt-child' }) - ) -} - -describe('WorktreeActivityStatusIndicator', () => { - beforeEach(() => { - mocks.status = 'inactive' - }) - - it('renders the shared inactive status for slept worktrees', () => { - const markup = renderMarkup('inactive') - - expect(markup).toContain('Inactive') - expect(markup).toContain('bg-neutral-500/40') - expect(markup).not.toContain('bg-emerald-500') - }) - - it('renders the shared active status when the worktree is live', () => { - const markup = renderMarkup('active') - - expect(markup).toContain('Active') - expect(markup).toContain('bg-emerald-500') - }) -}) diff --git a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx b/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx deleted file mode 100644 index be187336da8..00000000000 --- a/src/renderer/src/components/sidebar/WorktreeActivityStatusIndicator.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react' -import { getWorktreeStatusLabel } from '@/lib/worktree-status' -import StatusIndicator from './StatusIndicator' -import { useWorktreeActivityStatus } from './use-worktree-activity-status' - -export function WorktreeActivityStatusIndicator({ - worktreeId, - className -}: { - worktreeId: string - className?: string -}): React.JSX.Element { - const status = useWorktreeActivityStatus(worktreeId) - - return ( - <> -
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx b/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx new file mode 100644 index 00000000000..1c4e3343af5 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-meta-row.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import { GitMerge } from 'lucide-react' + +import { DetachedHeadBadge } from '@/components/DetachedHeadBadge' +import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel' +import { Badge } from '@/components/ui/badge' +import CacheTimer from './CacheTimer' +import { CONFLICT_OPERATION_LABELS } from './WorktreeCardHelpers' +import { TruncatedSidebarLabel } from './truncated-sidebar-label' +import { getDirectoryName } from './worktree-card-model' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardMetaRow({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + repo, + hostContextLabel, + identityDisplay, + isFolder, + newCardStyle, + branch, + detachedHeadDisplay, + conflictOperation, + cacheStartedAt, + cacheTtlMs + } = card + const { + showRepoBadgeInMetaRow, + showHostContextBadge, + showIdentityInNewCard, + hasHoverDetails, + showBranch, + showDetachedHeadInMetaRow, + showConflictOperationBadge, + showMetaRowDetails, + detailsAndPorts + } = presentation + + return ( +
+
+ {showRepoBadgeInMetaRow && repo && ( +
+ + + {repo.displayName} + +
+ )} + + {showHostContextBadge && ( + + {hostContextLabel} + + )} + + {showIdentityInNewCard ? ( + + ) : isFolder && !newCardStyle ? ( + + {getDirectoryName(worktree.path)} + + ) : showBranch ? ( + + ) : showDetachedHeadInMetaRow && detachedHeadDisplay ? ( + + ) : null} + + {showConflictOperationBadge && ( + + + {CONFLICT_OPERATION_LABELS[conflictOperation]} + + )} + + {cacheStartedAt != null && } +
+ + {showMetaRowDetails && ( +
{detailsAndPorts}
+ )} +
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-model.ts b/src/renderer/src/components/sidebar/worktree-card-model.ts new file mode 100644 index 00000000000..2331b92d2cd --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-model.ts @@ -0,0 +1,116 @@ +import type React from 'react' + +import type { Repo, WorkspaceStatus, Worktree } from '../../../../shared/types' +import type { WorktreeCardPrDisplay } from './worktree-card-pr-display' + +export type WorktreeRenameRequest = { + worktreeId: string + rowKey?: string +} + +export type ActiveSurfaceVariant = 'primary' | 'secondary' + +export type WorktreeCardProps = { + worktree: Worktree + repo: Repo | undefined + isActive: boolean + isCurrentWorktree?: boolean + isActiveSurface?: boolean + activeSurfaceVariant?: ActiveSurfaceVariant + isMultiSelected?: boolean + revealHighlight?: boolean + revealHighlightTone?: 'default' | 'ai' + selectedWorktrees?: readonly Worktree[] + hideRepoBadge?: boolean + hostContextLabel?: string + inPinnedSection?: boolean + activationRowKey?: string + renameRowKey?: string + contentIndent?: number + flushSurface?: boolean + lineageChildCount?: number + lineageCollapsed?: boolean + lineageChildren?: React.ReactNode + lineageChildrenStyle?: React.CSSProperties + onLineageToggle?: (event: React.MouseEvent) => void + isLineageDropTarget?: boolean + onActivate?: () => void + onImmediateActivate?: (worktreeId: string, rowKey: string | undefined) => void + onSelectionGesture?: (event: React.MouseEvent, worktreeId: string) => boolean + onContextMenuSelect?: ( + event: React.MouseEvent, + worktree: Worktree + ) => readonly Worktree[] + onAssignWorkspaceStatus?: (worktreeIds: readonly string[], status: WorkspaceStatus) => void + onCardDragStart?: ( + event: React.DragEvent, + worktreeId: string, + draggedIds: readonly string[] + ) => void + onCardDragEnd?: (event: React.DragEvent) => void + nativeDragEnabled?: boolean + affiliateListMode?: boolean + statusPrDisplay?: WorktreeCardPrDisplay | null +} + +type DefaultedWorktreeCardProp = + | 'isActiveSurface' + | 'activeSurfaceVariant' + | 'isMultiSelected' + | 'revealHighlight' + | 'revealHighlightTone' + | 'nativeDragEnabled' + | 'inPinnedSection' + | 'contentIndent' + | 'flushSurface' + | 'lineageChildCount' + | 'lineageCollapsed' + | 'isLineageDropTarget' + | 'affiliateListMode' + | 'statusPrDisplay' + +export type ResolvedWorktreeCardProps = Omit & { + isActiveSurface: boolean + activeSurfaceVariant: ActiveSurfaceVariant + isMultiSelected: boolean + revealHighlight: boolean + revealHighlightTone: 'default' | 'ai' + nativeDragEnabled: boolean + inPinnedSection: boolean + contentIndent: number + flushSurface: boolean + lineageChildCount: number + lineageCollapsed: boolean + isLineageDropTarget: boolean + affiliateListMode: boolean + statusPrDisplay: WorktreeCardPrDisplay | null +} + +export const EMPTY_WORKSPACE_PORTS = [] +export const HOSTED_REVIEW_CARD_REFRESH_INTERVAL_MS = 60_000 + +export function shouldBeginWorktreeRename( + request: WorktreeRenameRequest | null, + worktreeId: string, + rowKey: string | undefined +): boolean { + return ( + request?.worktreeId === worktreeId && + (request.rowKey === undefined || request.rowKey === rowKey) + ) +} + +export function formatSparseDirectoryPreview(directories: string[]): string { + const preview = directories.slice(0, 4).join(', ') + return directories.length <= 4 ? preview : `${preview}, +${directories.length - 4} more` +} + +export function isWebClient(): boolean { + return Boolean((window as unknown as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__) +} + +export function getDirectoryName(folderPath: string): string { + const normalized = folderPath.replace(/[\\/]+$/, '') + const parts = normalized.split(/[\\/]+/) + return parts.at(-1) || normalized || folderPath +} diff --git a/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx new file mode 100644 index 00000000000..d1b24cca2ad --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-parent-content.tsx @@ -0,0 +1,86 @@ +import React from 'react' + +import { cn } from '@/lib/utils' +import { WorktreeCardHeader } from './worktree-card-header' +import { WorktreeCardMetaRow } from './worktree-card-meta-row' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import { WorktreeCardSecondaryRows } from './worktree-card-secondary-rows' +import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardParentContent({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + affiliateListMode, + newCardStyle, + lineageChildren, + showStatus, + unreadTooltip, + stopQuickActionPointerPropagation, + handleToggleUnreadQuick, + statusLaneReview, + branchIdentityDisplay, + showInlineAgentList + } = card + const { titleOnlyCard, parentContentMarginLeft, showCombinedStatusSlot, showUnreadQuickAction } = + presentation + + return ( +
+ {showCombinedStatusSlot ? ( +
+ +
+ ) : null} + + {/* Content area */} +
+ {/* Header row: Title */} + + {presentation.hasMetaRow && } + +
+
+ ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-presentation.tsx b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx new file mode 100644 index 00000000000..5ce1e94501c --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-presentation.tsx @@ -0,0 +1,288 @@ +import React from 'react' + +import { + getFlushWorktreeCardPaddingLeft, + getNewCardStyleParentContentMarginLeft +} from './worktree-list-indentation' +import { + hasWorktreeCardDetails, + WorktreeCardDetailsHover, + WorktreeCardMetaBadges +} from './WorktreeCardMeta' +import { WorktreeCardPortsDetails, WorktreeCardPortsTrigger } from './WorktreeCardPorts' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function buildWorktreeCardPresentation(card: WorktreeCardController) { + const { + worktree, + repo, + inPinnedSection, + hideRepoBadge, + hostContextLabel, + affiliateListMode, + flushSurface, + contentIndent, + newCardStyle, + compactCards, + isFolder, + detachedHeadDisplay, + branch, + identityDisplay, + folderMetaRowContent, + showIdentityInNewCard, + conflictOperation, + cardProps, + cacheStartedAt, + hasDetails, + hasPorts, + showStatus, + showInlineAgentList, + showLineageChildChip, + remoteBranchConflict, + visibleCardTitle, + workspacePorts, + metaIssue, + metaLinearIssue, + metaJiraIssue, + metaReview, + metaComment, + metaAutomationProvenance, + metaCliProvenance, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + hoverComment, + linearIssue, + handleEditIssue, + handleEditComment, + handleOpenGitHubIssueInOrca, + handleOpenLinearIssueInOrca, + handleOpenReviewInOrca, + handleOpenAutomation, + handleOpenAutomationRun, + hasExplicitLinkedReview, + handleUnlinkReview, + detailsHoverControl, + showDeleteQuickAction + } = card + + // Why: pinned trees mix repos, so the repo icon shows regardless of groupBy's hideRepoBadge. + const showPinnedRepoIcon = inPinnedSection && !!repo + // Why: new card style retired the Compact/Detailed switch; repo identity uses the compact chip, not a lower pill. + const showRepoIdentityInTitle = newCardStyle || compactCards + const showInlineRepoBadge = + showRepoIdentityInTitle && !!repo && !hideRepoBadge && !isFolder && !showPinnedRepoIcon + const showRepoBadgeInMetaRow = + !showRepoIdentityInTitle && !!repo && !hideRepoBadge && !showPinnedRepoIcon + const showHostContextBadge = !compactCards && !!hostContextLabel + const showDetachedHeadInMetaRow = !compactCards && !isFolder && detachedHeadDisplay !== null + const showBranch = + !isFolder && + branch.length > 0 && + !newCardStyle && + (!compactCards || branch !== worktree.displayName) + // Why: rebases already surface in source control, so dense cards skip the persistent rebase chip. + const showConflictOperationBadge = + !!conflictOperation && conflictOperation !== 'unknown' && conflictOperation !== 'rebase' + const hasMetadataBadge = showConflictOperationBadge + const showUnreadQuickAction = !affiliateListMode && showStatus && !newCardStyle + // Why: the slot owns the unread/status lane; legacy keeps the bell toggle, the new card keeps the glyph passive. + const showCombinedStatusSlot = showStatus + const showTitleRowPrimary = compactCards && worktree.isMainWorktree && !isFolder + const showMetaRowDetails = !newCardStyle && !compactCards && (hasDetails || hasPorts) + const showTitleRowIndicators = (newCardStyle || compactCards) && (hasDetails || hasPorts) + // Why: grouped views can hide the repo badge; don't reserve a blank metadata lane unless there's real content. + const hasDetailedMetaRowContent = Boolean( + (showRepoBadgeInMetaRow && repo) || + showHostContextBadge || + folderMetaRowContent || + showBranch || + showIdentityInNewCard || + showDetachedHeadInMetaRow || + showConflictOperationBadge || + cacheStartedAt != null || + showMetaRowDetails + ) + const hasMetaRow = compactCards + ? hasMetadataBadge || cacheStartedAt != null + : hasDetailedMetaRowContent + const showHeaderActions = showTitleRowPrimary || showDeleteQuickAction + // Why: normalize the title once so title/branch de-dupe and identity-only hover eligibility stay in sync. + const trimmedVisibleCardTitle = visibleCardTitle.trim() + const showBranchIdentityHover = newCardStyle + ? Boolean(identityDisplay) && + !cardProps.includes('branch') && + identityDisplay !== trimmedVisibleCardTitle + : compactCards && showBranch + const hoverBranchName = newCardStyle + ? identityDisplay + : showBranchIdentityHover + ? branch + : undefined + const hoverWorkspaceTitle = + trimmedVisibleCardTitle.length > 0 && trimmedVisibleCardTitle !== hoverBranchName + ? trimmedVisibleCardTitle + : undefined + const hasHoverIdentity = Boolean(hoverWorkspaceTitle || hoverBranchName) + const hasHoverDetails = + newCardStyle && + (hasWorktreeCardDetails({ + issue: hoverIssue, + linearIssue: hoverLinearIssue, + jiraIssue: hoverJiraIssue, + review: hoverReview, + comment: hoverComment, + automationProvenance: metaAutomationProvenance, + cliProvenance: metaCliProvenance + }) || + workspacePorts.length > 0 || + hasHoverIdentity) + // Why: the parent row owns metadata hover; don't stack the title's truncation tooltip on the details popover. + const titleWrapper = newCardStyle + ? hasHoverDetails + ? (title: React.ReactElement): React.ReactElement => title + : undefined + : compactCards && (showBranchIdentityHover || hasDetails || hasPorts) + ? (title: React.ReactElement): React.ReactElement => ( + : null} + openDelay={100} + // Why: compact mode also renders the plug/badge hover root; sharing one open-state made hovering the + // plug force-open the wider title card and race it closed (#9304), so let this title hover own its state. + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + metaIssue && 'url' in metaIssue && metaIssue.url + ? handleOpenGitHubIssueInOrca + : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + metaReview?.url && metaReview.provider === 'github' + ? handleOpenReviewInOrca + : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: compact mode hides the metadata badge row, so title hover carries the explicit-link affordance. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {title} + + ) + : undefined + // Why: sidebar rows need a small surface inset while content stays aligned with the pre-inset layout. + const applyNewCardStyleStatusLaneOffset = newCardStyle && showCombinedStatusSlot + const cardPaddingLeft = flushSurface + ? getFlushWorktreeCardPaddingLeft(contentIndent, applyNewCardStyleStatusLaneOffset) + : contentIndent > 0 + ? `calc(0.125rem + ${contentIndent}px)` + : null + const parentContentMarginLeft = + flushSurface && applyNewCardStyleStatusLaneOffset + ? getNewCardStyleParentContentMarginLeft(contentIndent) + : 0 + const cardStyle = cardPaddingLeft ? { paddingLeft: cardPaddingLeft } : undefined + const detailsAndPortsContent = + hasDetails || hasPorts ? ( +
+ {hasPorts && } + {hasDetails && ( + + )} +
+ ) : null + const detailsAndPorts = + detailsAndPortsContent && !newCardStyle ? ( + : null} + hoverControl={detailsHoverControl} + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + metaIssue && 'url' in metaIssue && metaIssue.url ? handleOpenGitHubIssueInOrca : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + metaReview?.url && metaReview.provider === 'github' ? handleOpenReviewInOrca : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {detailsAndPortsContent} + + ) : ( + detailsAndPortsContent + ) + const titleRowIndicators = showTitleRowIndicators ? ( +
{detailsAndPorts}
+ ) : null + const hasSecondaryCardContent = + hasMetaRow || !!remoteBranchConflict || showInlineAgentList || showLineageChildChip + const titleOnlyCard = !hasSecondaryCardContent + + return { + showPinnedRepoIcon, + showInlineRepoBadge, + showRepoBadgeInMetaRow, + showHostContextBadge, + showIdentityInNewCard, + showDetachedHeadInMetaRow, + showBranch, + showConflictOperationBadge, + showUnreadQuickAction, + showCombinedStatusSlot, + showTitleRowPrimary, + showMetaRowDetails, + showTitleRowIndicators, + hasMetaRow, + showHeaderActions, + showDeleteQuickAction, + hoverBranchName, + hoverWorkspaceTitle, + hasHoverDetails, + titleWrapper, + parentContentMarginLeft, + cardStyle, + detailsAndPorts, + titleRowIndicators, + titleOnlyCard + } +} + +export type WorktreeCardPresentation = ReturnType diff --git a/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx new file mode 100644 index 00000000000..c71d9d6b0cb --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-secondary-rows.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import { AlertTriangle, ChevronDown, Workflow } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' +import WorktreeCardAgents from './WorktreeCardAgents' +import type { WorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardSecondaryRows({ + card, + presentation +}: { + card: WorktreeCardController + presentation: WorktreeCardPresentation +}): React.JSX.Element { + const { + worktree, + repo, + settings, + isActive, + newCardStyle, + lineageChildren, + lineageCollapsed, + onLineageToggle, + remoteBranchConflict, + showInlineAgentList, + agentActivityDisplayMode, + compactInlineAgentRows, + showLineageChildChip, + lineageChildAriaLabel, + childWorkspaceShortLabel + } = card + const { hasMetaRow } = presentation + + return ( + <> + {remoteBranchConflict && ( +
+ + + {translate( + 'auto.components.sidebar.WorktreeCard.a88c92d0e3', + '{{value0}}/{{value1}} already exists.', + { + value0: remoteBranchConflict.remote, + value1: remoteBranchConflict.branchName + } + )} + +
+ )} + + {isActive && worktree.linkedLinearIssue ? ( + + ) : null} + + {/* Why: counterbalance the card stack gap (-mt-1) so agents right after the title read as one header group. */} + {showInlineAgentList && ( + + )} + + {showLineageChildChip && ( +
+ + + + + + {lineageCollapsed + ? translate( + 'auto.components.sidebar.WorktreeCard.8cb634cda6', + 'Show child workspaces' + ) + : translate( + 'auto.components.sidebar.WorktreeCard.57eaa61b55', + 'Hide child workspaces' + )} + + +
+ )} + + {!newCardStyle && lineageChildren && ( +
+ {lineageChildren} +
+ )} + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx new file mode 100644 index 00000000000..7bc0abdcb89 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -0,0 +1,208 @@ +import React from 'react' +import { LoaderCircle } from 'lucide-react' + +import { cn } from '@/lib/utils' +import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' +import WorktreeContextMenu from './WorktreeContextMenu' +import { WorktreeCardDetailsHover } from './WorktreeCardMeta' +import { WorktreeCardPortsDetails } from './WorktreeCardPorts' +import { WorktreeCardParentContent } from './worktree-card-parent-content' +import { buildWorktreeCardPresentation } from './worktree-card-presentation' +import type { WorktreeCardController } from './use-worktree-card-controller' + +export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): React.JSX.Element { + const presentation = buildWorktreeCardPresentation(card) + const { + worktree, + selectedWorktrees, + onAssignWorkspaceStatus, + affiliateListMode, + isActiveSurface, + activeSurfaceVariant, + isMultiSelected, + revealHighlight, + revealHighlightTone, + flushSurface, + isLineageDropTarget, + nativeDragEnabled, + lineageChildren, + lineageChildrenStyle, + newCardStyle, + titleRenaming, + isDeleting, + isRuntimeDisconnected, + isQueuedForDeletion, + deleteLabel, + handleClick, + handleDoubleClick, + handleDragStart, + handleDragEnd, + handleContextMenuSelect, + hoverIssue, + hoverLinearIssue, + hoverJiraIssue, + hoverReview, + hoverComment, + metaAutomationProvenance, + metaCliProvenance, + workspacePorts, + detailsHoverControl, + handleRenameTitle, + handleEditIssue, + handleEditComment, + handleOpenGitHubIssueInOrca, + linearIssue, + handleOpenLinearIssueInOrca, + handleOpenReviewInOrca, + handleOpenAutomation, + handleOpenAutomationRun, + hasExplicitLinkedReview, + handleUnlinkReview, + showRenameErrorDialog, + setShowRenameErrorDialog + } = card + const { titleOnlyCard, hasHoverDetails, hoverBranchName, hoverWorkspaceTitle, cardStyle } = + presentation + + const parentCardContent = + + const parentHoverTriggerBody = ( +
+ {parentCardContent} +
+ ) + + const parentCardBodyWithHoverDetails = + hasHoverDetails && !titleRenaming ? ( + 0 ? : null + } + openDelay={100} + hoverControl={detailsHoverControl} + onRenameWorkspaceTitle={affiliateListMode ? undefined : handleRenameTitle} + onEditIssue={affiliateListMode ? undefined : handleEditIssue} + onEditComment={affiliateListMode ? undefined : handleEditComment} + onOpenGitHubIssueInOrca={ + hoverIssue && 'url' in hoverIssue && hoverIssue.url + ? handleOpenGitHubIssueInOrca + : undefined + } + onOpenLinearIssueInOrca={linearIssue?.url ? handleOpenLinearIssueInOrca : undefined} + onOpenReviewInOrca={ + hoverReview?.url && hoverReview.provider === 'github' ? handleOpenReviewInOrca : undefined + } + onOpenAutomation={affiliateListMode ? undefined : handleOpenAutomation} + onOpenAutomationRun={affiliateListMode ? undefined : handleOpenAutomationRun} + // Why: branch lookup can surface a review without persisted metadata; only unlink when explicitly linked. + onUnlinkReview={ + !affiliateListMode && hasExplicitLinkedReview ? handleUnlinkReview : undefined + } + > + {parentHoverTriggerBody} + + ) : ( + parentHoverTriggerBody + ) + + const cardBody = ( +
+ {isDeleting && ( +
+
+ {!isQueuedForDeletion ? ( + + ) : null} + {deleteLabel} +
+
+ )} + {parentCardBodyWithHoverDetails} + + {newCardStyle && lineageChildren ? ( +
+ {lineageChildren} +
+ ) : null} +
+ ) + + return ( + <> + {affiliateListMode ? ( + cardBody + ) : ( + + {cardBody} + + )} + + {typeof worktree.firstAgentMessageRenameError === 'string' && + worktree.firstAgentMessageRenameError.length > 0 && ( + + )} + + ) +} diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts new file mode 100644 index 00000000000..20d28499be9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ runDelete: vi.fn(), runBatchDelete: vi.fn() })) + +vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } })) +vi.mock('./delete-worktree-flow', () => ({ + runWorktreeDelete: mocks.runDelete, + runWorktreeBatchDelete: mocks.runBatchDelete +})) + +import { deferWorktreeContextMenuDeleteIntent } from './worktree-context-menu-delete-intent' + +describe('deferWorktreeContextMenuDeleteIntent', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('dispatches the selected workspace identity after the menu event completes', () => { + const defer = vi.fn<(callback: () => void) => void>() + const intent = { + kind: 'worktree' as const, + worktree: { id: 'repo::/work/wt', instanceId: 'instance-1' } + } + const onDispatched = vi.fn() + + deferWorktreeContextMenuDeleteIntent(intent, onDispatched, defer) + + expect(mocks.runDelete).not.toHaveBeenCalled() + expect(onDispatched).not.toHaveBeenCalled() + expect(defer).toHaveBeenCalledOnce() + + const [deferred] = defer.mock.calls[0] + deferred() + + expect(mocks.runDelete).toHaveBeenCalledWith('repo::/work/wt', { + expectedInstanceId: 'instance-1' + }) + expect(onDispatched).toHaveBeenCalledOnce() + }) + + it('preserves every selected workspace identity for batch validation', () => { + const intent = { + kind: 'batch' as const, + worktrees: [ + { id: 'wt-1', instanceId: 'instance-1' }, + { id: 'wt-2', instanceId: 'instance-2' } + ] + } + + deferWorktreeContextMenuDeleteIntent(intent, undefined, (callback) => callback()) + + expect(mocks.runBatchDelete).toHaveBeenCalledWith(intent.worktrees) + }) + + it('dispatches on the next macrotask by default', () => { + vi.useFakeTimers() + vi.stubGlobal('window', { setTimeout }) + const intent = { + kind: 'worktree' as const, + worktree: { id: 'wt-1', instanceId: 'instance-1' } + } + + deferWorktreeContextMenuDeleteIntent(intent) + + expect(mocks.runDelete).not.toHaveBeenCalled() + vi.runAllTimers() + expect(mocks.runDelete).toHaveBeenCalledWith('wt-1', { + expectedInstanceId: 'instance-1' + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts new file mode 100644 index 00000000000..7662af6d247 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-context-menu-delete-intent.ts @@ -0,0 +1,38 @@ +import { useAppStore } from '@/store' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow' +import type { WorktreeDeleteIdentity } from './worktree-delete-request' + +export type WorktreeContextMenuDeleteIntent = + | { kind: 'worktree'; worktree: WorktreeDeleteIdentity } + | { kind: 'batch'; worktrees: readonly WorktreeDeleteIdentity[] } + | { kind: 'folder'; folderWorkspaceId: string } + +export function runWorktreeContextMenuDeleteIntent(intent: WorktreeContextMenuDeleteIntent): void { + if (intent.kind === 'batch') { + runWorktreeBatchDelete(intent.worktrees) + return + } + if (intent.kind === 'worktree') { + runWorktreeDelete(intent.worktree.id, { expectedInstanceId: intent.worktree.instanceId }) + return + } + const state = useAppStore.getState() + void state.deleteFolderWorkspace(intent.folderWorkspaceId).then((deleted) => { + const current = useAppStore.getState() + if (deleted && current.activeWorktreeId === folderWorkspaceKey(intent.folderWorkspaceId)) { + current.setActiveWorktree(null) + } + }) +} + +export function deferWorktreeContextMenuDeleteIntent( + intent: WorktreeContextMenuDeleteIntent, + onDispatched?: () => void, + defer: (callback: () => void) => void = (callback) => window.setTimeout(callback, 0) +): void { + defer(() => { + runWorktreeContextMenuDeleteIntent(intent) + onDispatched?.() + }) +} diff --git a/src/renderer/src/components/sidebar/worktree-delete-execution.ts b/src/renderer/src/components/sidebar/worktree-delete-execution.ts new file mode 100644 index 00000000000..889587306d0 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-delete-execution.ts @@ -0,0 +1,226 @@ +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { getWorktreeMapFromState } from '@/store/selectors' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { translate } from '@/i18n/i18n' +import { + isPathInsideOrEqual, + normalizeRuntimePathForComparison +} from '../../../../shared/cross-platform-path' +import type { Worktree } from '../../../../shared/types' +import { prepareActiveWorktreeFocusAfterDelete } from './active-worktree-focus-after-delete' +import { showDeleteWorktreeFailureToast } from './delete-worktree-failure-toast' +import { showWorkspaceListChangedToast } from './stale-workspace-list-toast' +import { showPreservedBranchBatchToast } from './preserved-branch-batch-toast' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' +import type { WorktreeDeleteWithToastOptions } from './worktree-delete-request' + +// A failed delete usually means unresolved changes, so land on the diff panel. +function viewWorktreeDiff(worktreeId: string): void { + activateAndRevealWorktree(worktreeId) + const state = useAppStore.getState() + state.setRightSidebarTab('source-control') + state.setRightSidebarOpen(true) +} + +function isStrictDescendantPath(parentPath: string, childPath: string): boolean { + return ( + normalizeRuntimePathForComparison(parentPath) !== + normalizeRuntimePathForComparison(childPath) && isPathInsideOrEqual(parentPath, childPath) + ) +} + +export async function runWorktreeDeletesInParallel( + targets: readonly Pick[], + options: WorktreeDeleteWithToastOptions = {} +): Promise { + // A destructive command must run once per identity even if a refresh duplicated rows. + const uniqueTargets = Array.from(new Map(targets.map((target) => [target.id, target])).values()) + // Batch focus is committed once after every target settles. + const activeWorktreeIdBefore = useAppStore.getState().activeWorktreeId + const commitBatchFocus = activeWorktreeIdBefore + ? prepareActiveWorktreeFocusAfterDelete(activeWorktreeIdBefore) + : null + // Mark all targets up front so the sidebar shows immediate progress. + useAppStore.getState().markWorktreesDeleting(uniqueTargets.map((target) => target.id)) + // Git worktree removal shares repo locks, while separate repos can proceed in parallel. + const groups = new Map() + for (const target of uniqueTargets) { + const group = groups.get(target.repoId) + if (group) { + group.push(target) + } else { + groups.set(target.repoId, [target]) + } + } + for (const group of groups.values()) { + // Children must leave first or Git rejects their registered ancestor. + group.sort((a, b) => b.path.length - a.path.length) + } + const preservedBranches: PreservedBranchCleanup[] = [] + const aggregatePreservedBranches = uniqueTargets.length > 1 + let listChanged = false + const groupResults = await Promise.all( + Array.from(groups.values()).map(async (group) => { + const deletedInGroup: string[] = [] + const failedInGroup: (typeof group)[number][] = [] + for (const target of group) { + // A queued target may be recreated while an earlier repo sibling is deleting. + const currentTarget = getWorktreeMapFromState(useAppStore.getState()).get(target.id) + if (!currentTarget || currentTarget.instanceId !== target.instanceId) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + listChanged = true + continue + } + if (failedInGroup.some((failed) => isStrictDescendantPath(target.path, failed.path))) { + useAppStore.getState().clearWorktreeDeleteState(target.id) + continue + } + const deleted = await runWorktreeDeleteWithToast(target.id, target.displayName, { + ...options, + focusSuccessorOnDelete: false, + suppressPreservedBranchToast: aggregatePreservedBranches, + onPreservedBranch: (branch) => { + preservedBranches.push(branch) + options.onPreservedBranch?.(branch) + } + }) + if (deleted) { + deletedInGroup.push(target.id) + } else { + // A failed child makes deleting its ancestor unsafe because the child lives below it. + failedInGroup.push(target) + } + } + return deletedInGroup + }) + ) + if (listChanged) { + showWorkspaceListChangedToast() + } + const deletedSet = new Set(groupResults.flat()) + // Intermediate focus can spawn a terminal in another target that is still queued. + if (activeWorktreeIdBefore && deletedSet.has(activeWorktreeIdBefore)) { + commitBatchFocus?.() + } + if (aggregatePreservedBranches && preservedBranches.length > 0) { + const targetOrder = new Map(uniqueTargets.map((target, index) => [target.id, index])) + preservedBranches.sort( + (left, right) => + (targetOrder.get(left.worktreeId) ?? Number.MAX_SAFE_INTEGER) - + (targetOrder.get(right.worktreeId) ?? Number.MAX_SAFE_INTEGER) + ) + showPreservedBranchBatchToast(deletedSet.size, preservedBranches) + } + return uniqueTargets.filter((target) => deletedSet.has(target.id)).map((target) => target.id) +} + +/** Shared confirmed and skip-confirm execution with consistent failure recovery. */ +export function runWorktreeDeleteWithToast( + worktreeId: string, + worktreeName: string, + options: WorktreeDeleteWithToastOptions = {} +): Promise { + const removeWorktree = useAppStore.getState().removeWorktree + const commitFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) + const focusSuccessor = options.focusSuccessorOnDelete !== false + + const removal = options.suppressPreservedBranchToast + ? removeWorktree(worktreeId, options.force === true, { suppressPreservedBranchToast: true }) + : removeWorktree(worktreeId, options.force === true) + return removal + .then((result) => { + if (result.ok) { + if (result.preservedBranch) { + options.onPreservedBranch?.({ + worktreeId, + branchName: result.preservedBranch.branchName, + expectedHead: result.preservedBranch.head, + ...(result.preservedBranch.hostId ? { hostId: result.preservedBranch.hostId } : {}), + ...(result.preservedBranch.runtimeEnvironmentId + ? { runtimeEnvironmentId: result.preservedBranch.runtimeEnvironmentId } + : {}) + }) + } + if (focusSuccessor) { + commitFocus() + } + return true + } + const state = useAppStore.getState().deleteStateByWorktreeId[worktreeId] + const canForceDelete = state?.canForceDelete ?? false + const hasKnownChanges = + (useAppStore.getState().gitStatusByWorktree[worktreeId]?.length ?? 0) > 0 + showDeleteWorktreeFailureToast({ + error: result.error, + canForceDelete, + forceDeleteReason: state?.forceDeleteReason ?? null, + lockReason: state?.lockReason ?? null, + hasKnownChanges, + onViewChanges: () => viewWorktreeDiff(worktreeId), + onForceDelete: () => { + // Recapture focus because the user may have navigated while the toast was open. + const commitForceFocus = prepareActiveWorktreeFocusAfterDelete(worktreeId) + // The explicit Force Delete retry may waive an unverified PTY-stop proof. + const forceRemoval = useAppStore + .getState() + .removeWorktree(worktreeId, true, { allowUnverifiedPtyStop: true }) + forceRemoval + .then((forceResult) => { + if (!forceResult.ok) { + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.4f3876c0f5', + 'Force delete failed' + ), + { + description: forceResult.error, + action: { + label: translate( + 'auto.components.sidebar.delete.worktree.flow.7488ed8711', + 'View' + ), + onClick: () => viewWorktreeDiff(worktreeId) + } + } + ) + return + } + commitForceFocus() + options.onForceDeleted?.(worktreeId) + }) + .catch((err: unknown) => { + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', + 'Failed to delete workspace' + ), + { + description: err instanceof Error ? err.message : String(err), + action: { + label: translate( + 'auto.components.sidebar.delete.worktree.flow.7488ed8711', + 'View' + ), + onClick: () => viewWorktreeDiff(worktreeId) + } + } + ) + }) + }, + worktreeId, + worktreeName + }) + return false + }) + .catch((err: unknown) => { + toast.error( + translate( + 'auto.components.sidebar.delete.worktree.flow.ae57cbf6e4', + 'Failed to delete workspace' + ), + { description: err instanceof Error ? err.message : String(err) } + ) + return false + }) +} diff --git a/src/renderer/src/components/sidebar/worktree-delete-request.ts b/src/renderer/src/components/sidebar/worktree-delete-request.ts new file mode 100644 index 00000000000..71ef9e87cda --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-delete-request.ts @@ -0,0 +1,68 @@ +import type { Worktree } from '../../../../shared/types' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' + +export type WorktreeBatchDeleteOptions = { + forceConfirm?: boolean + onDeleted?: (worktreeIds: string[]) => void +} + +export type WorktreeDeleteIdentity = Pick + +export type WorktreeDeleteOptions = { + expectedInstanceId?: string +} + +export type WorktreeDeleteWithToastOptions = { + force?: boolean + onForceDeleted?: (worktreeId: string) => void + onPreservedBranch?: (branch: PreservedBranchCleanup) => void + suppressPreservedBranchToast?: boolean + // Batch deletion commits one focus handoff after all targets settle. + focusSuccessorOnDelete?: boolean +} + +export function toWorktreeDeleteIdentities( + worktrees: readonly Pick[] +): WorktreeDeleteIdentity[] { + return worktrees.map(({ id, instanceId }) => ({ id, instanceId })) +} + +export function resolveWorktreeBatchDeleteTargets( + requestedWorktrees: readonly string[] | readonly WorktreeDeleteIdentity[], + worktreeMap: ReadonlyMap +): Worktree[] | null { + const uniqueRequests = Array.from( + new Map( + requestedWorktrees.map( + (request) => [typeof request === 'string' ? request : request.id, request] as const + ) + ).values() + ) + const targets: Worktree[] = [] + for (const request of uniqueRequests) { + const worktreeId = typeof request === 'string' ? request : request.id + const target = worktreeMap.get(worktreeId) ?? null + if (typeof request !== 'string' && (!target || target.instanceId !== request.instanceId)) { + return null + } + if (target && !target.isMainWorktree) { + targets.push(target) + } + } + return targets +} + +export function readWorktreeDeleteIdentities(value: unknown): WorktreeDeleteIdentity[] { + if (!Array.isArray(value)) { + return [] + } + return value.flatMap((entry) => { + if (!entry || typeof entry !== 'object' || !('id' in entry) || typeof entry.id !== 'string') { + return [] + } + const instanceId = 'instanceId' in entry ? entry.instanceId : undefined + return instanceId === undefined || typeof instanceId === 'string' + ? [{ id: entry.id, instanceId }] + : [] + }) +} diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts b/src/renderer/src/components/sidebar/worktree-section-activity.test.ts deleted file mode 100644 index 5de876fedb3..00000000000 --- a/src/renderer/src/components/sidebar/worktree-section-activity.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { makePaneKey } from '../../../../shared/stable-pane-id' -import type { ProjectGroup, Repo, TerminalTab, Worktree } from '../../../../shared/types' -import { - getProjectGroupHeaderKey, - PINNED_GROUP_KEY, - type WorktreeGroupBy -} from './worktree-list-groups' -import { - buildWorktreeSectionActivitySummaries, - type WorktreeSectionActivityState -} from './worktree-section-activity' - -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -function makeRepo(overrides: Partial = {}): Repo { - return { - id: 'repo-1', - path: '/tmp/repo-1', - displayName: 'repo', - badgeColor: '#000000', - addedAt: 0, - ...overrides - } -} - -function makeProjectGroup(overrides: Partial): ProjectGroup { - return { - id: 'group-1', - name: 'Group', - parentPath: null, - parentGroupId: null, - createdFrom: 'manual', - tabOrder: 0, - isCollapsed: false, - color: null, - createdAt: 0, - updatedAt: 0, - ...overrides - } -} - -function makeWorktree(overrides: Partial = {}): Worktree { - return { - id: 'wt-1', - repoId: 'repo-1', - path: '/tmp/repo-1-feature', - branch: 'refs/heads/feature', - head: 'abc123', - isBare: false, - isMainWorktree: false, - linkedIssue: null, - linkedPR: null, - linkedLinearIssue: null, - isArchived: false, - isUnread: false, - isPinned: false, - comment: '', - displayName: 'feature', - sortOrder: 0, - lastActivityAt: 0, - ...overrides - } -} - -function makeTerminalTab(overrides: Partial = {}): TerminalTab { - return { - id: 'tab-1', - ptyId: null, - worktreeId: 'wt-1', - title: 'zsh', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 0, - ...overrides - } -} - -function makeState( - overrides: Partial = {} -): WorktreeSectionActivityState { - return { - tabsByWorktree: {}, - browserTabsByWorktree: {}, - ptyIdsByTabId: {}, - runtimePaneTitlesByTabId: {}, - terminalLayoutRootsByTabId: {}, - agentStatusEpoch: 0, - agentStatusByPaneKey: {}, - migrationUnsupportedByPtyId: {}, - retainedAgentsByPaneKey: {}, - ...overrides - } -} - -function buildSummaries({ - groupBy = 'repo', - worktrees, - repos, - projectGroups = [], - state -}: { - groupBy?: WorktreeGroupBy - worktrees: readonly Worktree[] - repos: readonly Repo[] - projectGroups?: readonly ProjectGroup[] - state: WorktreeSectionActivityState -}) { - return buildWorktreeSectionActivitySummaries({ - groupBy, - worktrees, - repoMap: new Map(repos.map((repo) => [repo.id, repo])), - prCache: null, - workspaceStatuses: [], - projectGroups, - state - }) -} - -describe('buildWorktreeSectionActivitySummaries', () => { - it('counts running worktrees across repo project-group ancestors', () => { - const parent = makeProjectGroup({ id: 'parent', name: 'Parent' }) - const child = makeProjectGroup({ - id: 'child', - name: 'Child', - parentGroupId: parent.id - }) - const repo = makeRepo({ projectGroupId: child.id }) - const worktree = makeWorktree({ repoId: repo.id }) - const state = makeState({ - tabsByWorktree: { - [worktree.id]: [ - makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id, title: 'Codex working' }) - ] - }, - ptyIdsByTabId: { - 'tab-1': ['pty-1'] - } - }) - - const summaries = buildSummaries({ - worktrees: [worktree], - repos: [repo], - projectGroups: [parent, child], - state - }) - - expect(summaries.get(getProjectGroupHeaderKey(parent.id))).toEqual({ - runningCount: 1 - }) - expect(summaries.get(getProjectGroupHeaderKey(child.id))).toEqual({ - runningCount: 1 - }) - expect(summaries.get(`repo:${repo.id}`)).toEqual({ - runningCount: 1 - }) - }) - - it('counts pinned workspace activity on pinned and natural headers', () => { - const repo = makeRepo({ projectGroupId: 'group-1' }) - const worktree = makeWorktree({ repoId: repo.id, isPinned: true }) - const now = Date.now() - const entry: AgentStatusEntry = { - state: 'working', - prompt: '', - updatedAt: now, - stateStartedAt: now, - paneKey: makePaneKey('tab-1', LEAF_ID), - stateHistory: [] - } - const state = makeState({ - tabsByWorktree: { - [worktree.id]: [makeTerminalTab({ id: 'tab-1', worktreeId: worktree.id })] - }, - agentStatusEpoch: 1, - agentStatusByPaneKey: { - [entry.paneKey]: entry - } - }) - - const summaries = buildSummaries({ - worktrees: [worktree], - repos: [repo], - projectGroups: [makeProjectGroup({ id: 'group-1' })], - state - }) - - expect(summaries.get(PINNED_GROUP_KEY)).toEqual({ - runningCount: 1 - }) - expect(summaries.get(`repo:${repo.id}`)).toEqual({ - runningCount: 1 - }) - expect(summaries.get(getProjectGroupHeaderKey('group-1'))).toEqual({ - runningCount: 1 - }) - }) -}) diff --git a/src/renderer/src/components/sidebar/worktree-section-activity.ts b/src/renderer/src/components/sidebar/worktree-section-activity.ts deleted file mode 100644 index fb81da4f0cf..00000000000 --- a/src/renderer/src/components/sidebar/worktree-section-activity.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { AppState } from '@/store/types' -import { resolveWorktreeStatus } from '@/lib/worktree-status' -import type { - ProjectGroup, - Repo, - TerminalPaneLayoutNode, - TerminalTab, - Worktree, - WorkspaceStatusDefinition -} from '../../../../shared/types' -import { - getGroupKeysForWorktree, - type WorktreeGroupBy, - PINNED_GROUP_KEY -} from './worktree-list-groups' -import { - selectLivePtyIdsForWorktree, - selectRuntimePaneTitlesForWorktree -} from './worktree-card-status-inputs' -import { selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' -import type { BrowserActivityTab } from './visible-worktree-activity-inputs' - -export type WorktreeSectionActivityState = Pick< - AppState, - | 'ptyIdsByTabId' - | 'runtimePaneTitlesByTabId' - | 'agentStatusEpoch' - | 'agentStatusByPaneKey' - | 'migrationUnsupportedByPtyId' - | 'retainedAgentsByPaneKey' -> & { - tabsByWorktree: Record[]> - browserTabsByWorktree: Record - terminalLayoutRootsByTabId: Record -} - -export type WorktreeSectionActivitySummary = { - runningCount: number -} - -export const EMPTY_WORKTREE_SECTION_ACTIVITY: WorktreeSectionActivitySummary = { - runningCount: 0 -} - -export function buildWorktreeSectionActivitySummaries({ - groupBy, - worktrees, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups, - state -}: { - groupBy: WorktreeGroupBy - worktrees: readonly Worktree[] - repoMap: Map - prCache: Record | null - workspaceStatuses: readonly WorkspaceStatusDefinition[] - settings?: AppState['settings'] - projectGroups: readonly ProjectGroup[] - state: WorktreeSectionActivityState -}): Map { - const summaries = new Map() - - for (const worktree of worktrees) { - const groupKeys = [ - ...(worktree.isPinned ? [PINNED_GROUP_KEY] : []), - ...getGroupKeysForWorktree( - groupBy, - worktree, - repoMap, - prCache, - workspaceStatuses, - settings, - projectGroups - ) - ] - if (groupKeys.length === 0) { - continue - } - - const status = getSectionWorktreeStatus(state, worktree.id) - for (const groupKey of groupKeys) { - const summary = summaries.get(groupKey) ?? { ...EMPTY_WORKTREE_SECTION_ACTIVITY } - if (status === 'working') { - summary.runningCount++ - } - summaries.set(groupKey, summary) - } - } - - return summaries -} - -function getSectionWorktreeStatus( - state: WorktreeSectionActivityState, - worktreeId: string -): ReturnType { - const agentSummary = selectWorktreeAgentActivitySummary(state, worktreeId) - - // Why: collapsed headers must mirror the card dot semantics exactly; otherwise - // a hidden section can advertise different activity than its visible cards. - return resolveWorktreeStatus({ - tabs: state.tabsByWorktree[worktreeId] ?? [], - browserTabs: state.browserTabsByWorktree[worktreeId] ?? [], - ptyIdsByTabId: selectLivePtyIdsForWorktree(state, worktreeId), - runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(state, worktreeId), - agentStatusPaneIdsByTabId: agentSummary.agentStatusPaneIdsByTabId, - terminalLayoutRootsByTabId: state.terminalLayoutRootsByTabId, - hasPermission: agentSummary.hasPermission, - hasLiveWorking: agentSummary.hasLiveWorking, - hasLiveDone: agentSummary.hasLiveDone, - hasRetainedDone: agentSummary.hasRetainedDone - }) -} diff --git a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx index fc16182b759..6e484c1bd0e 100644 --- a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx +++ b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx @@ -5,8 +5,8 @@ import type { ClaudeUsageSummary } from '../../../../shared/claude-usage-types' import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart' -import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable' import { UsageBreakdownSection } from './UsageBreakdownSection' +import { UsageRecentSessionsTable } from './UsageRecentSessionsTable' import { translate } from '@/i18n/i18n' type ClaudeUsageDetailsProps = { @@ -57,7 +57,30 @@ export function ClaudeUsageDetails({ />
- + + {translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '} + {summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined + ? `${Math.round(summary.cacheReuseRate * 100)}%` + : translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} + + } + headings={[ + translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active'), + translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project'), + translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model'), + translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns'), + translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input'), + translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output'), + translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache') + ]} + unknownModel={translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')} + rows={recentSessions} + getActivity={(row) => row.turns} + getTrailingTokens={(row) => row.cacheReadTokens + row.cacheWriteTokens} + /> ) } diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 8b24f0aca39..c47846c8c28 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -5,29 +5,16 @@ import { DatabaseZap, FolderKanban, Gauge, - RefreshCw, - SlidersHorizontal, Sparkles, Waypoints } from 'lucide-react' import type { ClaudeUsageRange, ClaudeUsageScope } from '../../../../shared/claude-usage-types' import { useAppStore } from '../../store' -import { Button } from '../ui/button' -import { Switch } from '../ui/switch' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '../ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageDetails } from './ClaudeUsageDetails' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' import { translate } from '@/i18n/i18n' @@ -86,34 +73,27 @@ export function ClaudeUsagePane(): React.JSX.Element { void setClaudeUsageEnabled(enabled) } + const title = translate( + 'auto.components.stats.ClaudeUsagePane.6afacbee37', + 'Claude Usage Tracking' + ) + const enableLabel = translate( + 'auto.components.stats.ClaudeUsagePane.424cd50412', + 'Enable Claude usage analytics' + ) + if (!scanState?.enabled) { return ( -
-
-
-

- {translate( - 'auto.components.stats.ClaudeUsagePane.6afacbee37', - 'Claude Usage Tracking' - )} -

-

- {translate( - 'auto.components.stats.ClaudeUsagePane.0cb1a36d7d', - 'Reads local Claude usage logs to show token, model, and session stats.' - )} -

-
- -
-
+ ) } @@ -124,204 +104,145 @@ export function ClaudeUsagePane(): React.JSX.Element { const hasAnyData = summary?.hasAnyClaudeData ?? scanState.hasAnyClaudeData return ( -
-
-
-

- {translate('auto.components.stats.ClaudeUsagePane.6afacbee37', 'Claude Usage Tracking')} -

-

- {formatUpdatedAt(scanState.lastScanCompletedAt)} - {scanState.lastScanError - ? translate( - 'auto.components.stats.ClaudeUsagePane.2d41fd45c6', - ' • Last scan error: {{value0}}', - { value0: scanState.lastScanError } - ) - : ''} -

-
-
- {summary && daily.length > 0 && ( - - )} - - - - - - - - - - {translate('auto.components.stats.ClaudeUsagePane.dd29209b21', 'Filters')} - - - - - - {translate('auto.components.stats.ClaudeUsagePane.f61cffb9c8', 'Scope')} - - void setClaudeUsageScope(value as ClaudeUsageScope)} - > - {SCOPE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {translate('auto.components.stats.ClaudeUsagePane.505be9aac4', 'Range')} - - void setClaudeUsageRange(value as ClaudeUsageRange)} - > - {RANGE_OPTIONS.map((option) => ( - - {RANGE_LABELS[option]} - - ))} - - - - - - - - - - {translate('auto.components.stats.ClaudeUsagePane.8d18bbb771', 'Refresh')} - - - - -
-
- -
-

- {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} -

-
- - {!hasAnyData ? ( -
- {translate( - 'auto.components.stats.ClaudeUsagePane.7dde9331fd', - 'No local Claude usage found yet for this scope.' - )} -
- ) : ( + -
- } - /> - } - /> - } - /> - } - /> - } - /> - 0 - ? `${Math.round((summary.zeroCacheReadTurns / summary.turns) * 100)}%` - : 'n/a' - } - icon={} - /> - } - /> - } - /> -
-

- {translate( - 'auto.components.stats.ClaudeUsagePane.51ae85fa00', - 'Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).' - )} -

- - + {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError + ? translate( + 'auto.components.stats.ClaudeUsagePane.2d41fd45c6', + ' • Last scan error: {{value0}}', + { value0: scanState.lastScanError } + ) + : ''} + } + isRefreshing={scanState.isScanning} + hasData={hasAnyData} + enableLabel={enableLabel} + optionsLabel={translate( + 'auto.components.stats.ClaudeUsagePane.e9bf9fce0e', + 'Claude usage options' )} -
+ filtersLabel={translate('auto.components.stats.ClaudeUsagePane.dd29209b21', 'Filters')} + refreshAriaLabel={translate( + 'auto.components.stats.ClaudeUsagePane.c5b9b344d0', + 'Refresh Claude usage' + )} + refreshLabel={translate('auto.components.stats.ClaudeUsagePane.8d18bbb771', 'Refresh')} + filterSections={[ + void setClaudeUsageScope(value)} + />, + ({ value, label: RANGE_LABELS[value] }))} + onValueChange={(value) => void setClaudeUsageRange(value)} + /> + ]} + selectionSummary={ + <> + {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} + + } + emptyMessage={translate( + 'auto.components.stats.ClaudeUsagePane.7dde9331fd', + 'No local Claude usage found yet for this scope.' + )} + onEnabledChange={handleSetEnabled} + onRefresh={() => void refreshClaudeUsage()} + headerAction={ + summary && daily.length > 0 ? ( + + ) : undefined + } + > + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + 0 + ? `${Math.round((summary.zeroCacheReadTurns / summary.turns) * 100)}%` + : 'n/a' + } + icon={} + /> + } + /> + } + /> +
+

+ {translate( + 'auto.components.stats.ClaudeUsagePane.51ae85fa00', + 'Cache reuse rate is calculated as cache read tokens / (input tokens + cache read tokens).' + )} +

+ + + +
) } diff --git a/src/renderer/src/components/stats/ClaudeUsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/ClaudeUsageRecentSessionsTable.tsx deleted file mode 100644 index 57752c46515..00000000000 --- a/src/renderer/src/components/stats/ClaudeUsageRecentSessionsTable.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { - ClaudeUsageSessionRow, - ClaudeUsageSummary -} from '../../../../shared/claude-usage-types' -import { translate } from '@/i18n/i18n' -import { formatSessionTime, formatTokens } from './usage-formatters' - -export function ClaudeUsageRecentSessionsTable({ - recentSessions, - summary -}: { - recentSessions: ClaudeUsageSessionRow[] - summary: ClaudeUsageSummary | null -}): React.JSX.Element { - return ( -
-
-

- {translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')} -

-

- {translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '} - {summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined - ? `${Math.round(summary.cacheReuseRate * 100)}%` - : translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')} - - {translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')} - - {translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')} - - {translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')} - - {translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')} - - {translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')} - - {translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')} - {row.turns}{formatTokens(row.inputTokens)} - {formatTokens(row.outputTokens)} - - {formatTokens(row.cacheReadTokens + row.cacheWriteTokens)} -
-
-
- ) -} diff --git a/src/renderer/src/components/stats/CodexUsageDetails.tsx b/src/renderer/src/components/stats/CodexUsageDetails.tsx index 7e7a46fd17e..c297d60ab4c 100644 --- a/src/renderer/src/components/stats/CodexUsageDetails.tsx +++ b/src/renderer/src/components/stats/CodexUsageDetails.tsx @@ -5,8 +5,8 @@ import type { CodexUsageSummary } from '../../../../shared/codex-usage-types' import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable' import { UsageBreakdownSection } from './UsageBreakdownSection' +import { UsageRecentSessionsTable } from './UsageRecentSessionsTable' import { translate } from '@/i18n/i18n' type CodexUsageDetailsProps = { @@ -58,7 +58,27 @@ export function CodexUsageDetails({ />
- + row.events} + getTrailingTokens={(row) => row.totalTokens} + getModelSuffix={(row) => (row.hasInferredPricing ? ' *' : '')} + /> ) } diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index ad6e02c7b56..1f36402a87c 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -1,32 +1,12 @@ import { useEffect } from 'react' -import { - Activity, - Brain, - Coins, - DatabaseZap, - FolderKanban, - RefreshCw, - SlidersHorizontal, - Sparkles -} from 'lucide-react' +import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react' import type { CodexUsageRange, CodexUsageScope } from '../../../../shared/codex-usage-types' import { useAppStore } from '../../store' -import { Button } from '../ui/button' -import { Switch } from '../ui/switch' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '../ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' import { CodexUsageDetails } from './CodexUsageDetails' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' import { translate } from '@/i18n/i18n' @@ -85,38 +65,31 @@ export function CodexUsagePane(): React.JSX.Element { void setCodexUsageEnabled(enabled) } + const title = translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking') + const enableLabel = translate( + 'auto.components.stats.CodexUsagePane.f7c1affbd5', + 'Enable Codex usage analytics' + ) + if (!scanState?.enabled) { return ( -
-
-
-

- {translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')} -

-

- {translate( - 'auto.components.stats.CodexUsagePane.13badcd8f2', - 'Reads local Codex usage logs to show token, model, and session stats.' - )} -

-
- -
-
+ ) } if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) { return ( @@ -126,183 +99,121 @@ export function CodexUsagePane(): React.JSX.Element { const hasAnyData = summary?.hasAnyCodexData ?? scanState.hasAnyCodexData return ( -
-
-
-

- {translate('auto.components.stats.CodexUsagePane.408210470c', 'Codex Usage Tracking')} -

-

- {formatUpdatedAt(scanState.lastScanCompletedAt)} - {scanState.lastScanError - ? translate( - 'auto.components.stats.CodexUsagePane.8a6655f7a2', - ' • Last scan error: {{value0}}', - { value0: scanState.lastScanError } - ) - : ''} -

-
-
- {summary && daily.length > 0 && ( - - )} - - - - - - - - - - {translate('auto.components.stats.CodexUsagePane.1af1a39b2f', 'Filters')} - - - - - - {translate('auto.components.stats.CodexUsagePane.6d68e8399a', 'Scope')} - - void setCodexUsageScope(value as CodexUsageScope)} - > - {SCOPE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {translate('auto.components.stats.CodexUsagePane.89162e019b', 'Range')} - - void setCodexUsageRange(value as CodexUsageRange)} - > - {RANGE_OPTIONS.map((option) => ( - - {RANGE_LABELS[option]} - - ))} - - - - - - - - - - {translate('auto.components.stats.CodexUsagePane.3022cda443', 'Refresh')} - - - - -
-
- -
-

- {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} -

-
- - {!hasAnyData ? ( -
- {translate( - 'auto.components.stats.CodexUsagePane.4c865393b4', - 'No local Codex usage found yet for this scope.' - )} -
- ) : ( + -
- } - /> - } - /> - } - /> - } - /> - } - /> - } - /> -
-

- {translate( - 'auto.components.stats.CodexUsagePane.94ac1f1ee7', - 'Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.' - )} -

- - + {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError + ? translate( + 'auto.components.stats.CodexUsagePane.8a6655f7a2', + ' • Last scan error: {{value0}}', + { value0: scanState.lastScanError } + ) + : ''} + } + isRefreshing={scanState.isScanning} + hasData={hasAnyData} + enableLabel={enableLabel} + optionsLabel={translate( + 'auto.components.stats.CodexUsagePane.70b5b8581f', + 'Codex usage options' )} -
+ filtersLabel={translate('auto.components.stats.CodexUsagePane.1af1a39b2f', 'Filters')} + refreshAriaLabel={translate( + 'auto.components.stats.CodexUsagePane.ec4d270e2c', + 'Refresh Codex usage' + )} + refreshLabel={translate('auto.components.stats.CodexUsagePane.3022cda443', 'Refresh')} + filterSections={[ + void setCodexUsageScope(value)} + />, + ({ value, label: RANGE_LABELS[value] }))} + onValueChange={(value) => void setCodexUsageRange(value)} + /> + ]} + selectionSummary={ + <> + {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} + + } + emptyMessage={translate( + 'auto.components.stats.CodexUsagePane.4c865393b4', + 'No local Codex usage found yet for this scope.' + )} + onEnabledChange={handleSetEnabled} + onRefresh={() => void refreshCodexUsage()} + headerAction={ + summary && daily.length > 0 ? ( + + ) : undefined + } + > + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+

+ {translate( + 'auto.components.stats.CodexUsagePane.94ac1f1ee7', + 'Reasoning tokens are shown for visibility, but cost is calculated from uncached input, cached input, and output only.' + )} +

+ + + +
) } diff --git a/src/renderer/src/components/stats/CodexUsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/CodexUsageRecentSessionsTable.tsx deleted file mode 100644 index f5ee1a7a430..00000000000 --- a/src/renderer/src/components/stats/CodexUsageRecentSessionsTable.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { CodexUsageSessionRow } from '../../../../shared/codex-usage-types' -import { translate } from '@/i18n/i18n' -import { formatSessionTime, formatTokens } from './usage-formatters' - -export function CodexUsageRecentSessionsTable({ - recentSessions -}: { - recentSessions: CodexUsageSessionRow[] -}): React.JSX.Element { - return ( -
-
-

- {translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')} -

-

- {translate( - 'auto.components.stats.CodexUsagePane.0bd8655475', - 'Most recent local Codex sessions in this scope.' - )} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')} - - {translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')} - - {translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')} - - {translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')} - - {translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')} - - {translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')} - - {translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')} - {row.hasInferredPricing ? ' *' : ''} - {row.events}{formatTokens(row.inputTokens)} - {formatTokens(row.outputTokens)} - {formatTokens(row.totalTokens)}
-
-
- ) -} diff --git a/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx index b3b58733d78..541db1cb7d4 100644 --- a/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx @@ -5,8 +5,8 @@ import type { OpenCodeUsageSummary } from '../../../../shared/opencode-usage-types' import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable' import { UsageBreakdownSection } from './UsageBreakdownSection' +import { UsageRecentSessionsTable } from './UsageRecentSessionsTable' import { translate } from '@/i18n/i18n' type OpenCodeUsageDetailsProps = { @@ -58,7 +58,26 @@ export function OpenCodeUsageDetails({ />
- + row.events} + getTrailingTokens={(row) => row.totalTokens} + /> ) } diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index 6c11240a866..861800c99e5 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -1,34 +1,14 @@ import { useEffect } from 'react' -import { - Activity, - Brain, - Coins, - DatabaseZap, - FolderKanban, - RefreshCw, - SlidersHorizontal, - Sparkles -} from 'lucide-react' +import { Activity, Brain, Coins, DatabaseZap, FolderKanban, Sparkles } from 'lucide-react' import type { OpenCodeUsageRange, OpenCodeUsageScope } from '../../../../shared/opencode-usage-types' import { useAppStore } from '../../store' -import { Button } from '../ui/button' -import { Switch } from '../ui/switch' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger -} from '../ui/dropdown-menu' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' import { OpenCodeUsageDetails } from './OpenCodeUsageDetails' import { StatCard } from './StatCard' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' import { translate } from '@/i18n/i18n' @@ -90,44 +70,34 @@ export function OpenCodeUsagePane(): React.JSX.Element { void setOpenCodeUsageEnabled(enabled) } + const title = translate( + 'auto.components.stats.OpenCodeUsagePane.bea80ceae0', + 'OpenCode Usage Tracking' + ) + const enableLabel = translate( + 'auto.components.stats.OpenCodeUsagePane.f04131b3be', + 'Enable OpenCode usage analytics' + ) + if (!scanState?.enabled) { return ( -
-
-
-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.bea80ceae0', - 'OpenCode Usage Tracking' - )} -

-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.b8b3522436', - 'Reads local OpenCode usage logs to show token, model, and session stats.' - )} -

-
- -
-
+ ) } if (!summary && (scanState.isScanning || scanState.lastScanCompletedAt === null)) { return ( @@ -137,192 +107,116 @@ export function OpenCodeUsagePane(): React.JSX.Element { const hasAnyData = summary?.hasAnyOpenCodeData ?? scanState.hasAnyOpenCodeData return ( -
-
-
-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.bea80ceae0', - 'OpenCode Usage Tracking' - )} -

-

- {formatUpdatedAt(scanState.lastScanCompletedAt)} - {scanState.lastScanError - ? translate( - 'auto.components.stats.OpenCodeUsagePane.6cc7782458', - ' • Last scan error: {{value0}}', - { value0: scanState.lastScanError } - ) - : ''} -

-
-
- - - - - - - - - - {translate('auto.components.stats.OpenCodeUsagePane.01583b30aa', 'Filters')} - - - - - - {translate('auto.components.stats.OpenCodeUsagePane.40d283c837', 'Scope')} - - void setOpenCodeUsageScope(value as OpenCodeUsageScope)} - > - {SCOPE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - {translate('auto.components.stats.OpenCodeUsagePane.b5ed5c9fd0', 'Range')} - - void setOpenCodeUsageRange(value as OpenCodeUsageRange)} - > - {RANGE_OPTIONS.map((option) => ( - - {RANGE_LABELS[option]} - - ))} - - - - - - - - - - {translate('auto.components.stats.OpenCodeUsagePane.603cd138dc', 'Refresh')} - - - - -
-
- -
-

- {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} -

-
- - {!hasAnyData ? ( -
- {translate( - 'auto.components.stats.OpenCodeUsagePane.bb6363e08c', - 'No local OpenCode usage found yet for this scope.' - )} -
- ) : ( + -
- } - /> - } - /> - } - /> - } - /> - } - /> - } - /> -
-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.e5bb23d85e', - 'Cost comes from the local OpenCode database when the assistant message recorded one.' - )} -

- - + {formatUpdatedAt(scanState.lastScanCompletedAt)} + {scanState.lastScanError + ? translate( + 'auto.components.stats.OpenCodeUsagePane.6cc7782458', + ' • Last scan error: {{value0}}', + { value0: scanState.lastScanError } + ) + : ''} + } + isRefreshing={scanState.isScanning} + hasData={hasAnyData} + enableLabel={enableLabel} + optionsLabel={translate( + 'auto.components.stats.OpenCodeUsagePane.230d6de108', + 'OpenCode usage options' )} -
+ filtersLabel={translate('auto.components.stats.OpenCodeUsagePane.01583b30aa', 'Filters')} + refreshAriaLabel={translate( + 'auto.components.stats.OpenCodeUsagePane.bed558df0b', + 'Refresh OpenCode usage' + )} + refreshLabel={translate('auto.components.stats.OpenCodeUsagePane.603cd138dc', 'Refresh')} + filterSections={[ + void setOpenCodeUsageScope(value)} + />, + ({ value, label: RANGE_LABELS[value] }))} + onValueChange={(value) => void setOpenCodeUsageRange(value)} + /> + ]} + selectionSummary={ + <> + {SCOPE_OPTIONS.find((option) => option.value === scope)?.label} • {RANGE_LABELS[range]} + + } + emptyMessage={translate( + 'auto.components.stats.OpenCodeUsagePane.bb6363e08c', + 'No local OpenCode usage found yet for this scope.' + )} + onEnabledChange={handleSetEnabled} + onRefresh={() => void refreshOpenCodeUsage()} + > + <> +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+

+ {translate( + 'auto.components.stats.OpenCodeUsagePane.e5bb23d85e', + 'Cost comes from the local OpenCode database when the assistant message recorded one.' + )} +

+ + + +
) } diff --git a/src/renderer/src/components/stats/OpenCodeUsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/OpenCodeUsageRecentSessionsTable.tsx deleted file mode 100644 index ad8e3a38152..00000000000 --- a/src/renderer/src/components/stats/OpenCodeUsageRecentSessionsTable.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import type { OpenCodeUsageSessionRow } from '../../../../shared/opencode-usage-types' -import { translate } from '@/i18n/i18n' -import { formatSessionTime, formatTokens } from './usage-formatters' - -export function OpenCodeUsageRecentSessionsTable({ - recentSessions -}: { - recentSessions: OpenCodeUsageSessionRow[] -}): React.JSX.Element { - return ( -
-
-

- {translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')} -

-

- {translate( - 'auto.components.stats.OpenCodeUsagePane.81817a641a', - 'Most recent local OpenCode sessions in this scope.' - )} -

-
-
- - - - - - - - - - - - - - {recentSessions.map((row) => ( - - - - - - - - - - ))} - -
- {translate('auto.components.stats.OpenCodeUsagePane.d97bdf6e27', 'Last active')} - - {translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')} - - {translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')} - - {translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')} - - {translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')} - - {translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')} - - {translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')} -
- {formatSessionTime(row.lastActiveAt)} - {row.projectLabel} - {row.model ?? - translate('auto.components.stats.OpenCodeUsagePane.362231082f', 'Unknown')} - {row.events}{formatTokens(row.inputTokens)} - {formatTokens(row.outputTokens)} - {formatTokens(row.totalTokens)}
-
-
- ) -} diff --git a/src/renderer/src/components/stats/UsageRecentSessionsTable.tsx b/src/renderer/src/components/stats/UsageRecentSessionsTable.tsx new file mode 100644 index 00000000000..1ffe503b858 --- /dev/null +++ b/src/renderer/src/components/stats/UsageRecentSessionsTable.tsx @@ -0,0 +1,86 @@ +import { formatSessionTime, formatTokens } from './usage-formatters' + +type UsageRecentSession = { + sessionId: string + lastActiveAt: string + projectLabel: string + model: string | null + inputTokens: number + outputTokens: number +} + +type UsageRecentSessionHeadings = readonly [ + lastActive: string, + project: string, + model: string, + activity: string, + input: string, + output: string, + trailing: string +] + +type UsageRecentSessionsTableProps = { + title: React.ReactNode + description: React.ReactNode + headings: UsageRecentSessionHeadings + unknownModel: string + rows: readonly Row[] + getActivity: (row: Row) => number + getTrailingTokens: (row: Row) => number + getModelSuffix?: (row: Row) => React.ReactNode +} + +export function UsageRecentSessionsTable({ + title, + description, + headings, + unknownModel, + rows, + getActivity, + getTrailingTokens, + getModelSuffix +}: UsageRecentSessionsTableProps): React.JSX.Element { + return ( +
+
+

{title}

+

{description}

+
+
+ + + + {headings.map((heading) => ( + + ))} + + + + {rows.map((row) => ( + + + + + + + + + + ))} + +
+ {heading} +
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel} + {row.model ?? unknownModel} + {getModelSuffix?.(row)} + {getActivity(row)}{formatTokens(row.inputTokens)} + {formatTokens(row.outputTokens)} + + {formatTokens(getTrailingTokens(row))} +
+
+
+ ) +} diff --git a/src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx b/src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx new file mode 100644 index 00000000000..b4ba3dd2678 --- /dev/null +++ b/src/renderer/src/components/stats/UsageTrackingPaneShell.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { UsageFilterRadioGroup, UsageTrackingPaneShell } from './UsageTrackingPaneShell' + +afterEach(cleanup) + +function renderEnabled({ + hasData = false, + isRefreshing = false +}: { hasData?: boolean; isRefreshing?: boolean } = {}) { + const onEnabledChange = vi.fn() + const onRefresh = vi.fn() + const onScopeChange = vi.fn() + + render( + , +
Range filter
+ ]} + selectionSummary="Orca worktrees only • Last 7 days" + emptyMessage="No local Codex usage found yet for this scope." + onEnabledChange={onEnabledChange} + onRefresh={onRefresh} + headerAction={Share usage} + > +
Ready content
+
+ ) + + return { onEnabledChange, onRefresh, onScopeChange } +} + +describe('UsageTrackingPaneShell', () => { + it('renders the disabled state and enables tracking', async () => { + const onEnabledChange = vi.fn() + const user = userEvent.setup() + render( + + ) + + expect(screen.getByText('Reads local Codex usage logs.')).toBeInTheDocument() + await user.click(screen.getByRole('switch', { name: 'Enable Codex usage analytics' })) + expect(onEnabledChange).toHaveBeenCalledWith(true) + }) + + it('renders the empty state and wires header actions', async () => { + const user = userEvent.setup() + const { onEnabledChange, onRefresh, onScopeChange } = renderEnabled() + + expect(screen.getByText('Updated now')).toBeInTheDocument() + expect(screen.getByText('Share usage')).toBeInTheDocument() + expect(screen.getByText('Orca worktrees only • Last 7 days')).toBeInTheDocument() + expect(screen.getByText('No local Codex usage found yet for this scope.')).toBeInTheDocument() + expect(screen.queryByText('Ready content')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Codex usage options' })) + expect(screen.getByRole('separator')).toBeInTheDocument() + await user.click(screen.getByRole('menuitemradio', { name: 'All local usage' })) + await user.click(screen.getByRole('button', { name: 'Refresh Codex usage' })) + await user.click(screen.getByRole('switch', { name: 'Enable Codex usage analytics' })) + expect(onScopeChange).toHaveBeenCalledWith('all') + expect(onRefresh).toHaveBeenCalledOnce() + expect(onEnabledChange).toHaveBeenCalledWith(false) + }) + + it('renders ready content and disables refresh while scanning', () => { + renderEnabled({ hasData: true, isRefreshing: true }) + + expect(screen.getByText('Ready content')).toBeInTheDocument() + expect( + screen.queryByText('No local Codex usage found yet for this scope.') + ).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Refresh Codex usage' })).toBeDisabled() + }) +}) diff --git a/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx new file mode 100644 index 00000000000..5ca091f4d19 --- /dev/null +++ b/src/renderer/src/components/stats/UsageTrackingPaneShell.tsx @@ -0,0 +1,160 @@ +import { Fragment, type ReactElement, type ReactNode } from 'react' +import { RefreshCw, SlidersHorizontal } from 'lucide-react' +import { Button } from '../ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '../ui/dropdown-menu' +import { Switch } from '../ui/switch' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' + +type UsageFilterRadioGroupProps = { + label: ReactNode + value: Value + options: readonly { value: Value; label: ReactNode }[] + onValueChange: (value: Value) => void | Promise +} + +export function UsageFilterRadioGroup({ + label, + value, + options, + onValueChange +}: UsageFilterRadioGroupProps): React.JSX.Element { + return ( + <> + {label} + void onValueChange(nextValue as Value)} + > + {options.map((option) => ( + + {option.label} + + ))} + + + ) +} + +type UsageTrackingPaneShellProps = { + title: ReactNode + enableLabel: string + onEnabledChange: (enabled: boolean) => void +} & ( + | { + enabled: false + disabledDescription: ReactNode + } + | { + enabled: true + status: ReactNode + isRefreshing: boolean + hasData: boolean + optionsLabel: string + filtersLabel: ReactNode + refreshAriaLabel: string + refreshLabel: ReactNode + filterSections: readonly ReactElement[] + selectionSummary: ReactNode + emptyMessage: ReactNode + onRefresh: () => void | Promise + headerAction?: ReactNode + children: ReactNode + } +) + +export function UsageTrackingPaneShell(props: UsageTrackingPaneShellProps): React.JSX.Element { + if (!props.enabled) { + return ( +
+
+
+

{props.title}

+

{props.disabledDescription}

+
+ +
+
+ ) + } + + return ( +
+
+
+

{props.title}

+

{props.status}

+
+
+ {props.headerAction} + + + + + + + + + + {props.filtersLabel} + + + + + {props.filterSections.map((section, index) => ( + + {index > 0 && } + {section} + + ))} + + + + + + + + + {props.refreshLabel} + + + + +
+
+ +
+

{props.selectionSummary}

+
+ + {!props.hasData ? ( +
+ {props.emptyMessage} +
+ ) : ( + props.children + )} +
+ ) +} diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index bbbdddabc98..5e989f9e4a9 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -23,6 +23,8 @@ import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' +import { useProjectHostSetupProjection } from '@/store/selectors' +import { terminalQuickCommandMatchesWorkspaceProject } from '@/lib/terminal-quick-command-project-scope' type TabBarQuickCommandsButtonProps = { worktreeId: string @@ -35,6 +37,7 @@ export function TabBarQuickCommandsButton({ }: TabBarQuickCommandsButtonProps): React.JSX.Element | null { const recentByGroup = useAppStore((s) => s.recentQuickCommandIdByGroup) const repos = useAppStore((s) => s.repos) + const projectHostSetupProjection = useProjectHostSetupProjection() const { executionHostId, hosts, refreshRemoteHost, remoteHostLoadFailed, remoteHostPending } = useTerminalQuickCommandHosts(worktreeId) const confirm = useConfirmationDialog() @@ -61,12 +64,20 @@ export function TabBarQuickCommandsButton({ const scope = getTerminalQuickCommandScope(command) if (scope.type === 'global') { globalList.push(entry) - } else if (scope.type === 'repo' && repoId !== null && scope.repoId === repoId) { + } else if ( + scope.type === 'repo' && + terminalQuickCommandMatchesWorkspaceProject(command, { + commandHostId: entry.hostId, + projectHostSetups: projectHostSetupProjection.setups, + targetHostId: executionHostId, + targetRepoId: repoId + }) + ) { repoList.push(entry) } } return { repoCommands: repoList, globalCommands: globalList } - }, [hosts, repoId]) + }, [executionHostId, hosts, projectHostSetupProjection.setups, repoId]) const recentId = recentByGroup[groupId] ?? null // Why: split-button label prefers the most recently used command for this diff --git a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx index e69e9ea18a3..8105e0f3263 100644 --- a/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-title-tooltip.test.tsx @@ -188,11 +188,13 @@ function textSpanHtml(markup: string, text: string): string { function expectTabContainerWidth(markup: string, root: string): void { const container = firstOpeningTag(markup) - const widthClasses = 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' + // Why: pinned literally — a definite `w-*` is what stops live title updates from resizing + // every tab, so asserting against the constant would let that guarantee be edited away. + const widthClasses = 'w-[180px] min-w-[88px] min-[1280px]:w-[220px]' expect(container).toContain(widthClasses) + expect(root).not.toContain('w-[180px]') expect(root).not.toContain('min-w-[88px]') - expect(root).not.toContain('max-w-[280px]') - expect(root).not.toContain('flex-[1_1_180px]') + expect(root).not.toContain('min-[1280px]:w-[220px]') } function expectTooltipContent(markup: string, text: string): void { diff --git a/src/renderer/src/components/tab-bar/tab-width-rules.ts b/src/renderer/src/components/tab-bar/tab-width-rules.ts index 9001b6d780f..c96fe099650 100644 --- a/src/renderer/src/components/tab-bar/tab-width-rules.ts +++ b/src/renderer/src/components/tab-bar/tab-width-rules.ts @@ -1,6 +1,5 @@ -// Why: tab strips should reveal as much title as space allows, then shrink to -// a readable floor before horizontal overflow takes over. -export const TAB_CONTAINER_WIDTH_CLASSES = - 'min-w-[88px] max-w-[280px] flex-[1_1_180px] min-[1280px]:flex-[1_1_220px]' +// Why: the strip shrink-wraps its tabs, so a content-derived width lets one live title update +// resize every tab; a definite width pins them and flex-shrink still narrows to the floor. +export const TAB_CONTAINER_WIDTH_CLASSES = 'w-[180px] min-w-[88px] min-[1280px]:w-[220px]' export const TAB_LABEL_WIDTH_CLASSES = 'min-w-0 flex-1 truncate' diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 28d36689bcf..da6070241f9 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -32,16 +32,8 @@ import TerminalSearch from '@/components/TerminalSearch' import type { PtyTransport } from './pty-transport' import type { PtyTransportRecoveryState } from './pty-transport-types' import { fitPanes, isWindowsUserAgent } from './pane-helpers' -import { getConnectionId, getConnectionIdFromState } from '@/lib/connection-context' -import { - getExplicitRuntimeEnvironmentIdForWorktree, - getRuntimeEnvironmentIdForWorktree -} from '@/lib/worktree-runtime-owner' -import { - selectRuntimeAwareSshStatus, - selectRuntimeAwareSshTargetLabel, - selectRuntimeAwareSshTargetRemoved -} from '@/store/slices/runtime-environment-ssh' +import { getConnectionId } from '@/lib/connection-context' +import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' import { handleInternalTerminalFileDrop } from './terminal-drop-handler' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' @@ -123,7 +115,6 @@ import { resolveNativeChatLeafRoute, type NativeChatLeafRoute } from '../native-chat/native-chat-leaf-routing' -import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' @@ -169,18 +160,17 @@ import { import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { getRepoExecutionHostId, - isRuntimeOwnedSshTargetId, LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' -import { useRepoById } from '@/store/selectors' +import { useProjectHostSetupProjection, useRepoById } from '@/store/selectors' import { refitAndRefreshAllTerminalPanes } from '@/lib/pane-manager/pane-manager-registry' import { getTerminalQuickCommandScope, - isTerminalQuickCommandComplete, - terminalQuickCommandMatchesRepo + isTerminalQuickCommandComplete } from '../../../../shared/terminal-quick-commands' +import { terminalQuickCommandMatchesWorkspaceProject } from '@/lib/terminal-quick-command-project-scope' import { createTerminalQuickCommandDraft, TerminalQuickCommandDialog @@ -243,6 +233,7 @@ import { getCachedUnifiedTerminalTabForWorktree } from './terminal-unified-tab-lookup' import { resolveNativeChatLeafTitleAgent } from './native-chat-leaf-title-agent' +import { selectTerminalPaneHostState } from './terminal-pane-host-state' import { isXtermHelperTextarea, releaseTerminalFocusForOutsidePointerDown, @@ -349,37 +340,14 @@ function TerminalPane( const isRendererVisible = isVisible && isWorktreeActive const isVisibleRef = useRef(isRendererVisible) isVisibleRef.current = isRendererVisible - const sshReconnectTargetId = useAppStore((store) => { - const connectionId = getConnectionIdFromState(store, worktreeId) - // Why: runtime-owned SSH targets are internal plumbing users can't connect to, so a reconnect prompt would mislead. - if (!connectionId || isRuntimeOwnedSshTargetId(connectionId)) { - return null - } - return connectionId - }) - const nativeChatTranscriptIsLocalReadable = useAppStore((store) => - isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) - ) - // Which machine's SSH store this target belongs to: a remote server's per-environment bucket, or null for this machine's local SSH maps. - const sshReconnectEnvironmentId = useAppStore((store) => - sshReconnectTargetId ? getExplicitRuntimeEnvironmentIdForWorktree(store, worktreeId) : null - ) - const sshReconnectStatus = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshStatus(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : null - ) - const sshReconnectTargetLabel = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshTargetLabel(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : '' - ) - // Why: a ghost target (removed from its host) can only fail reconnect, so the overlay offers Remove instead of Connect. - const sshReconnectTargetRemoved = useAppStore((store) => - sshReconnectTargetId - ? selectRuntimeAwareSshTargetRemoved(store, sshReconnectEnvironmentId, sshReconnectTargetId) - : false - ) + const { + nativeChatTranscriptIsLocalReadable, + sshReconnectEnvironmentId, + sshReconnectStatus, + sshReconnectTargetId, + sshReconnectTargetLabel, + sshReconnectTargetRemoved + } = useAppStore(useShallow((store) => selectTerminalPaneHostState(store, worktreeId))) useEffect(() => { if (!sshReconnectEnvironmentId) { return @@ -819,6 +787,7 @@ function TerminalPane( const quickCommandRepoId = worktreeId === FLOATING_TERMINAL_WORKTREE_ID ? null : getRepoIdFromWorktreeId(worktreeId) const quickCommandRepo = useRepoById(quickCommandRepoId) + const projectHostSetupProjection = useProjectHostSetupProjection() const quickCommandRepoLabel = quickCommandRepo ? quickCommandRepo.displayName || quickCommandRepo.path : quickCommandRepoId @@ -2550,6 +2519,7 @@ function TerminalPane( rightClickToPaste }) const { + executionHostId: quickCommandExecutionHostId, hosts: quickCommandHosts, refreshRemoteHost: refreshQuickCommandRemoteHost, remoteHostLoadFailed: quickCommandHostLoadFailed, @@ -2568,12 +2538,23 @@ function TerminalPane( repoCommands: commands.filter((command) => { const scope = getTerminalQuickCommandScope(command) return ( - scope.type === 'repo' && terminalQuickCommandMatchesRepo(command, quickCommandRepoId) + scope.type === 'repo' && + terminalQuickCommandMatchesWorkspaceProject(command, { + commandHostId: host.hostId, + projectHostSetups: projectHostSetupProjection.setups, + targetHostId: quickCommandExecutionHostId, + targetRepoId: quickCommandRepoId + }) ) }) } }), - [quickCommandHosts, quickCommandRepoId] + [ + projectHostSetupProjection.setups, + quickCommandExecutionHostId, + quickCommandHosts, + quickCommandRepoId + ] ) useEffect(() => { if (contextMenu.open) { diff --git a/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts new file mode 100644 index 00000000000..539348edf8b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, it, vi } from 'vitest' +import { + DeferredReattachLiveDataQueue, + MAX_DEFERRED_REATTACH_LIVE_CHARS, + MAX_DEFERRED_REATTACH_LIVE_CHUNKS, + type DeferredReattachLiveDataChunk +} from './deferred-reattach-live-data-queue' + +function createChunk( + data: string, + streamGeneration = 1, + options: Omit = { + ptyId: 'pty-1' + } +): DeferredReattachLiveDataChunk { + return { data, streamGeneration, ...options } +} + +function releaseTransferred(chunks: DeferredReattachLiveDataChunk[]): void { + for (const chunk of chunks) { + chunk.ackCredit?.() + } +} + +class ReferenceDeferredQueue { + private chunks: DeferredReattachLiveDataChunk[] = [] + private retainedChars = 0 + + enqueue(chunk: DeferredReattachLiveDataChunk): void { + this.chunks = this.chunks.filter((queued) => { + const keep = queued.streamGeneration === chunk.streamGeneration + if (!keep) { + queued.ackCredit?.() + } + return keep + }) + this.retainedChars = this.chunks.reduce((total, queued) => total + queued.data.length, 0) + const oversized = chunk.data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + this.chunks.push({ + ...chunk, + data: oversized ? chunk.data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : chunk.data + }) + this.retainedChars += this.chunks.at(-1)?.data.length ?? 0 + + let dropped = oversized + while ( + this.chunks.length > 1 && + (this.chunks.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + this.retainedChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) + ) { + const removed = this.chunks.shift() + this.retainedChars -= removed?.data.length ?? 0 + removed?.ackCredit?.() + dropped = true + } + if (dropped && this.chunks[0]) { + this.chunks[0].meta = { ...this.chunks[0].meta, droppedOutput: true } + } + } + + takeAll(): DeferredReattachLiveDataChunk[] { + const chunks = this.chunks + this.chunks = [] + this.retainedChars = 0 + return chunks + } + + discard(): void { + for (const chunk of this.takeAll()) { + chunk.ackCredit?.() + } + } +} + +function normalizedChunks(chunks: DeferredReattachLiveDataChunk[]): unknown[] { + return chunks.map(({ ackCredit: _ackCredit, ...chunk }) => chunk) +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } +} + +describe('deferred reattach live data queue', () => { + it('does not rescan or shift the live queue while saturated', () => { + const originalFilter = Array.prototype.filter + const originalReduce = Array.prototype.reduce + const originalShift = Array.prototype.shift + const queue = new DeferredReattachLiveDataQueue() + + try { + for (const method of ['filter', 'reduce', 'shift'] as const) { + Object.defineProperty(Array.prototype, method, { + configurable: true, + writable: true, + value() { + throw new Error(`Array.${method} should not run on enqueue`) + } + }) + } + for (let index = 0; index < MAX_DEFERRED_REATTACH_LIVE_CHUNKS * 2; index += 1) { + queue.enqueue(createChunk('x'.repeat(512))) + } + } finally { + Object.defineProperties(Array.prototype, { + filter: { configurable: true, writable: true, value: originalFilter }, + reduce: { configurable: true, writable: true, value: originalReduce }, + shift: { configurable: true, writable: true, value: originalShift } + }) + } + + expect(queue.takeAll()).toHaveLength(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + }) + + it('evicts whole chunks only after the count and character boundaries are exceeded', () => { + const countQueue = new DeferredReattachLiveDataQueue() + const countAcks = Array.from({ length: MAX_DEFERRED_REATTACH_LIVE_CHUNKS + 1 }, () => vi.fn()) + for (let index = 0; index < MAX_DEFERRED_REATTACH_LIVE_CHUNKS; index += 1) { + countQueue.enqueue( + createChunk('', 1, { ptyId: 'pty-1', meta: { seq: index }, ackCredit: countAcks[index] }) + ) + } + + expect(countQueue.getStorageForTest().retainedChunks).toBe(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + expect(countAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + countQueue.enqueue( + createChunk('', 1, { + ptyId: 'pty-1', + meta: { seq: MAX_DEFERRED_REATTACH_LIVE_CHUNKS }, + ackCredit: countAcks.at(-1) + }) + ) + + const counted = countQueue.takeAll() + expect(countAcks[0]).toHaveBeenCalledOnce() + expect(counted).toHaveLength(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + expect(counted[0]?.meta).toEqual({ seq: 1, droppedOutput: true }) + expect(counted.at(-1)?.meta?.seq).toBe(MAX_DEFERRED_REATTACH_LIVE_CHUNKS) + releaseTransferred(counted) + expect(countAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + + const charQueue = new DeferredReattachLiveDataQueue() + const fullAck = vi.fn() + const tailAck = vi.fn() + charQueue.enqueue( + createChunk('a'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS), 1, { + ptyId: 'pty-1', + ackCredit: fullAck + }) + ) + expect(charQueue.getStorageForTest().retainedChars).toBe(MAX_DEFERRED_REATTACH_LIVE_CHARS) + charQueue.enqueue(createChunk('tail', 1, { ptyId: 'pty-1', ackCredit: tailAck })) + + const characterBounded = charQueue.takeAll() + expect(fullAck).toHaveBeenCalledOnce() + expect(characterBounded.map((chunk) => chunk.data)).toEqual(['tail']) + expect(characterBounded[0]?.meta).toEqual({ droppedOutput: true }) + expect(tailAck).not.toHaveBeenCalled() + releaseTransferred(characterBounded) + expect(tailAck).toHaveBeenCalledOnce() + }) + + it('keeps the exact oversized tail and preserves metadata under the gap marker', () => { + const queue = new DeferredReattachLiveDataQueue() + const ackCredit = vi.fn() + const meta = { seq: 9, rawLength: MAX_DEFERRED_REATTACH_LIVE_CHARS + 1, background: true } + queue.enqueue( + createChunk(`P${'T'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS)}`, 3, { + ptyId: 'pty-3', + meta, + ackCredit + }) + ) + + const retained = queue.takeAll() + expect(retained).toHaveLength(1) + expect(retained[0]?.data).toBe('T'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS)) + expect(retained[0]?.meta).toEqual({ ...meta, droppedOutput: true }) + expect(meta).not.toHaveProperty('droppedOutput') + expect(ackCredit).not.toHaveBeenCalled() + releaseTransferred(retained) + expect(ackCredit).toHaveBeenCalledOnce() + }) + + it('releases a replaced generation exactly once without carrying its gap marker forward', () => { + const queue = new DeferredReattachLiveDataQueue() + const firstAck = vi.fn() + const secondAck = vi.fn() + const thirdAck = vi.fn() + const replacementAck = vi.fn() + queue.enqueue(createChunk('a'.repeat(300 * 1024), 4, { ptyId: 'old', ackCredit: firstAck })) + queue.enqueue(createChunk('b'.repeat(300 * 1024), 4, { ptyId: 'old', ackCredit: secondAck })) + queue.enqueue(createChunk('c', 4, { ptyId: 'old', ackCredit: thirdAck })) + + expect(firstAck).toHaveBeenCalledOnce() + queue.enqueue( + createChunk('replacement', 5, { + ptyId: 'new', + meta: { seq: 5, transformed: true }, + ackCredit: replacementAck + }) + ) + + const replacement = queue.takeAll() + expect(firstAck).toHaveBeenCalledOnce() + expect(secondAck).toHaveBeenCalledOnce() + expect(thirdAck).toHaveBeenCalledTimes(1) + expect(replacement).toHaveLength(1) + expect(replacement[0]?.data).toBe('replacement') + expect(replacement[0]?.meta).toEqual({ seq: 5, transformed: true }) + expect(replacementAck).not.toHaveBeenCalled() + releaseTransferred(replacement) + expect(replacementAck).toHaveBeenCalledOnce() + }) + + it('compacts a cleared prefix repeatedly while settling every credit once', () => { + const queue = new DeferredReattachLiveDataQueue() + const acks = Array.from({ length: 513 }, () => vi.fn()) + const fullChunk = 'x'.repeat(MAX_DEFERRED_REATTACH_LIVE_CHARS) + for (let index = 0; index < acks.length; index += 1) { + queue.enqueue( + createChunk(fullChunk, 1, { + ptyId: 'pty-1', + ackCredit: acks[index] + }) + ) + const storage = queue.getStorageForTest() + expect(storage.retainedChunks).toBe(1) + expect(storage.retainedChars).toBe(MAX_DEFERRED_REATTACH_LIVE_CHARS) + expect(storage.backingLength).toBeLessThanOrEqual(64) + } + + const retained = queue.takeAll() + expect(retained).toHaveLength(1) + releaseTransferred(retained) + expect(acks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + expect(queue.getStorageForTest()).toEqual({ + backingLength: 0, + head: 0, + retainedChars: 0, + retainedChunks: 0 + }) + }) + + it('transfers credits from takeAll but settles retained credits on discard', () => { + const queue = new DeferredReattachLiveDataQueue() + const transferredAcks = [vi.fn(), vi.fn()] + queue.enqueue(createChunk('first', 1, { ptyId: 'pty-1', ackCredit: transferredAcks[0] })) + queue.enqueue(createChunk('second', 1, { ptyId: 'pty-1', ackCredit: transferredAcks[1] })) + + const transferred = queue.takeAll() + expect(transferred.map((chunk) => chunk.data)).toEqual(['first', 'second']) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + + const discardedAcks = [vi.fn(), vi.fn()] + queue.enqueue(createChunk('third', 2, { ptyId: 'pty-2', ackCredit: discardedAcks[0] })) + queue.enqueue(createChunk('fourth', 2, { ptyId: 'pty-2', ackCredit: discardedAcks[1] })) + queue.discard() + queue.discard() + expect(discardedAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 0)).toBe(true) + releaseTransferred(transferred) + expect(transferredAcks.every((ack) => ack.mock.calls.length === 1)).toBe(true) + }) + + it('matches the previous algorithm across a seeded adversarial stream', () => { + const queue = new DeferredReattachLiveDataQueue() + const reference = new ReferenceDeferredQueue() + const random = createSeededRandom(0x5eedc0de) + const queueAcks = new Map() + const referenceAcks = new Map() + let generation = 1 + + const recordAck = + (acks: Map, index: number): (() => void) => + () => { + acks.set(index, (acks.get(index) ?? 0) + 1) + } + for (let index = 0; index < 5_000; index += 1) { + const value = random() + if (index > 0 && value % 997 === 0) { + generation += 1 + } + const bucket = value % 100 + const length = + index < 1_500 + ? value % 3 + : bucket < 70 + ? value % 1_025 + : bucket < 90 + ? 4 * 1024 + : bucket < 97 + ? 64 * 1024 + : bucket < 99 + ? 300 * 1024 + : MAX_DEFERRED_REATTACH_LIVE_CHARS + 1 + const data = String.fromCharCode(65 + (index % 26)).repeat(length) + const base = { + data, + ptyId: `pty-${generation % 3}`, + streamGeneration: generation, + meta: { + seq: index, + rawLength: length, + ...(value % 7 === 0 ? { background: true } : {}) + } + } + queue.enqueue({ ...base, ackCredit: recordAck(queueAcks, index) }) + reference.enqueue({ ...base, ackCredit: recordAck(referenceAcks, index) }) + + if ((index + 1) % 1_000 !== 0) { + continue + } + if ((index + 1) % 2_000 === 0) { + queue.discard() + reference.discard() + } else { + const actual = queue.takeAll() + const expected = reference.takeAll() + expect(normalizedChunks(actual)).toEqual(normalizedChunks(expected)) + releaseTransferred(actual) + releaseTransferred(expected) + } + expect(queueAcks).toEqual(referenceAcks) + } + + expect(queueAcks).toEqual(referenceAcks) + expect([...queueAcks.values()].every((count) => count === 1)).toBe(true) + expect(queueAcks.size).toBe(5_000) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts new file mode 100644 index 00000000000..3045569b1e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/deferred-reattach-live-data-queue.ts @@ -0,0 +1,111 @@ +import type { PtyDataMeta } from './pty-dispatcher' + +export const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 +export const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 + +const COMPACTION_MIN_HEAD = 64 + +export type DeferredReattachLiveDataChunk = { + data: string + ptyId: string | null + streamGeneration: number + meta?: PtyDataMeta + ackCredit?: () => void +} + +export type DeferredReattachLiveDataQueueStorage = { + backingLength: number + head: number + retainedChars: number + retainedChunks: number +} + +export class DeferredReattachLiveDataQueue { + private chunks: (DeferredReattachLiveDataChunk | undefined)[] = [] + private head = 0 + private retainedChars = 0 + private streamGeneration: number | null = null + + enqueue(chunk: DeferredReattachLiveDataChunk): void { + if (this.streamGeneration !== null && this.streamGeneration !== chunk.streamGeneration) { + this.discard() + } + this.streamGeneration = chunk.streamGeneration + + const oversized = chunk.data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + const queuedChunk = { + ...chunk, + data: oversized ? chunk.data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : chunk.data + } + this.chunks.push(queuedChunk) + this.retainedChars += queuedChunk.data.length + + let dropped = oversized + while ( + this.retainedChunkCount > 1 && + (this.retainedChunkCount > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + this.retainedChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) + ) { + const removed = this.chunks[this.head] + this.chunks[this.head] = undefined + this.head += 1 + if (removed) { + this.retainedChars -= removed.data.length + removed.ackCredit?.() + } + dropped = true + } + + const oldest = this.chunks[this.head] + if (dropped && oldest) { + oldest.meta = { ...oldest.meta, droppedOutput: true } + } + this.compact() + } + + takeAll(): DeferredReattachLiveDataChunk[] { + const retained: DeferredReattachLiveDataChunk[] = [] + for (let index = this.head; index < this.chunks.length; index += 1) { + const chunk = this.chunks[index] + if (chunk) { + retained.push(chunk) + } + } + this.reset() + return retained + } + + discard(): void { + for (const chunk of this.takeAll()) { + chunk.ackCredit?.() + } + } + + getStorageForTest(): DeferredReattachLiveDataQueueStorage { + return { + backingLength: this.chunks.length, + head: this.head, + retainedChars: this.retainedChars, + retainedChunks: this.retainedChunkCount + } + } + + private get retainedChunkCount(): number { + return this.chunks.length - this.head + } + + private compact(): void { + if (this.head < COMPACTION_MIN_HEAD || this.head * 2 < this.chunks.length) { + return + } + this.chunks = this.chunks.slice(this.head) + this.head = 0 + } + + private reset(): void { + this.chunks = [] + this.head = 0 + this.retainedChars = 0 + this.streamGeneration = null + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 5f3f0ef2f02..2ff73043cc3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -31,6 +31,13 @@ import { } from '@/lib/agent-startup-delayed-delivery' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +const { resetAndRefreshAllTerminalWebglAtlases, scheduleTerminalWebglAtlasRecovery } = vi.hoisted( + () => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn() + }) +) + // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw" @@ -334,7 +341,6 @@ type MockTransport = { const scheduleRuntimeGraphSync = vi.fn() const shouldSeedCacheTimerOnInitialTitle = vi.fn(() => false) -const scheduleTerminalWebglAtlasRecovery = vi.fn() let mockStoreState: StoreState let transportFactoryQueue: MockTransport[] = [] @@ -345,6 +351,15 @@ vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + vi.mock('@/store', () => ({ useAppStore: { getState: () => mockStoreState, @@ -357,16 +372,18 @@ vi.mock('@/store', () => ({ } })) -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - function notifyStoreSubscribers(): void { for (const listener of storeSubscribers.slice()) { listener(mockStoreState) } } +function expectNoGlobalAtlasRecovery(): void { + // Why: the removed output path requested recovery 200ms before its global reset ran. + expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expect(resetAndRefreshAllTerminalWebglAtlases).not.toHaveBeenCalled() +} + vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal>() const isGeminiTerminalTitle = actual.isGeminiTerminalTitle as (title: string) => boolean @@ -8677,6 +8694,211 @@ describe('connectPanePty', () => { ) }) + it('drops a too-wide daemon alt frame and keeps the scrollback prefix', async () => { + // Why: fixed-grid alt rows clip at a narrower viewport until the owner repaints. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + }) + + it('drops a live daemon alt frame until a hidden pane has a final grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0)) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 1, rows: 1 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + }) + + it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'LEGACY-MERGED-SNAPSHOT', + snapshotPrefixAnsi: 'INCOMPLETE-PREFIX', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + connectPanePty( + pane as never, + createManager(1) as never, + createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) as never + ) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('LEGACY-MERGED-SNAPSHOT') + expect(writes.join('')).not.toContain('INCOMPLETE-PREFIX') + }) + + it('keeps a daemon alt frame when the pane is not narrower than the capture', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 120, + snapshotRows: 40, + isAlternateScreen: true + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect( + (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]).join('') + ).toContain('ALT-FRAME-BODY') + }) + + it('drops a too-wide daemon alt frame on cold restore and keeps its history', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { + id: sessionId, + snapshot: 'PREFIX-SCROLLBACK' + 'ALT-FRAME-BODY', + snapshotPrefixAnsi: 'PREFIX-SCROLLBACK', + snapshotFrameAnsi: 'ALT-FRAME-BODY', + snapshotFrameRestoreAnsi: 'RESTORE-LIVE-STATE', + snapshotCols: 200, + snapshotRows: 50, + isAlternateScreen: true, + coldRestore: { scrollback: 'x', cwd: '/tmp' } + } + } + return null + }) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) + const manager = createManager(1) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map((c) => c[0]) + expect(writes.join('')).toContain('PREFIX-SCROLLBACK') + expect(writes.join('')).toContain('RESTORE-LIVE-STATE') + expect(writes.join('')).not.toContain('ALT-FRAME-BODY') + expect(writes).toContain(POST_REPLAY_MODE_RESET) + }) + it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { // Why: the daemon serializes soft-wrapped lines flat, so reattach must resize xterm to the snapshot grid before writing, or rows rewrap wrong. const { connectPanePty } = await import('./pty-connection') @@ -11377,11 +11599,12 @@ describe('connectPanePty', () => { expect(mockStoreState.sleepingAgentSessionsByPaneKey[paneKey]).toBeDefined() }) - it('does not write the restored banner through xterm bytes for sidebar-resumed startup commands', async () => { + it('forwards one sidebar resume spawn without writing the restored banner through xterm', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-1') transportFactoryQueue.push(transport) const pane = createPane(1) + const providerSession = { key: 'session_id', id: 'codex-session-1' } as const connectPanePty( pane as never, @@ -11389,6 +11612,7 @@ describe('connectPanePty', () => { createDeps({ startup: { command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession, showSessionRestoredBanner: true } }) as never @@ -11404,7 +11628,11 @@ describe('connectPanePty', () => { expect.stringContaining('--- session restored ---'), expect.any(Function) ) - expect(createdTransportOptions[0]?.command).toBe("codex 'resume' 'codex-session-1'") + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(createdTransportOptions[0]).toMatchObject({ + command: "codex 'resume' 'codex-session-1'", + resumeProviderSession: providerSession + }) }) it('does not consume the sleeping record when daemon reattach returns a live snapshot', async () => { @@ -12705,7 +12933,7 @@ describe('connectPanePty', () => { }) }) - it('defers hidden synchronized-output atlas recovery until reveal', async () => { + it('keeps hidden synchronized output off the global atlas recovery path', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -12741,17 +12969,15 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(50) expect(writes).toEqual([`${startChunk}${plainRowChunk}${endChunk}`]) - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() parseCallbacks[0]?.() - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { vi.useRealTimers() } }) - it('defers split hidden synchronized-output markers until reveal', async () => { + it('keeps split hidden synchronized output off the global atlas recovery path', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -12783,11 +13009,9 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(50) expect(writes.join('')).toBe('\x1b[?2026hbody row\r\ntail\x1b[?2026l') - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() parseCallbacks[0]?.() - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { vi.useRealTimers() } @@ -12825,14 +13049,13 @@ describe('connectPanePty', () => { for (const callback of parseCallbacks) { callback() } - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { vi.useRealTimers() } }) - it('defers hidden high-confidence TUI redraw recovery until reveal', async () => { + it('keeps hidden TUI redraw output off the global atlas recovery path', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -12859,17 +13082,15 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[2J\x1b[Hredrawn hidden table\x1b[K') vi.advanceTimersByTime(50) - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() parseCallbacks[0]?.() - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { vi.useRealTimers() } }) - it('advances hidden rewrite state without scheduling atlas recovery', async () => { + it('keeps hidden rewrite frames on the pane-local output path', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -12897,26 +13118,22 @@ describe('connectPanePty', () => { vi.advanceTimersByTime(50) expect(writes).toEqual(['prompt rewrite\r']) parseCallbacks.shift()?.() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() capturedDataCallback.current?.('\x1b[?2026hredraw frame\x1b[?2026l') vi.advanceTimersByTime(50) expect(writes).toEqual(['prompt rewrite\r', '\x1b[?2026hredraw frame\x1b[?2026l']) parseCallbacks.shift()?.() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() - scheduleTerminalWebglAtlasRecovery.mockClear() capturedDataCallback.current?.('plain after frame') vi.advanceTimersByTime(50) parseCallbacks.shift()?.() - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { vi.useRealTimers() } }) - it('resets hidden synchronized state when hidden renderer output is skipped', async () => { + it('keeps output after a skipped hidden alternate-screen frame pane-local', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -12946,8 +13163,6 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?2026h') vi.advanceTimersByTime(50) parseCallbacks.shift()?.() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() - scheduleTerminalWebglAtlasRecovery.mockClear() writes.length = 0 isVisibleRef.current = true @@ -12962,7 +13177,7 @@ describe('connectPanePty', () => { parseCallbacks.shift()?.() expect(writes).toEqual(['plain after skipped close\r\n']) - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { vi.useRealTimers() } @@ -15203,6 +15418,67 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('pulses a skipped hidden alt frame when reveal keeps the snapshot grid', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('pty-id') + let onData: ConnectCallbacks['onData'] + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + onData = callbacks.onData + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + getMainBufferSnapshot.mockResolvedValue({ + data: 'TOO-WIDE-ALT-FRAME', + frameRestoreAnsi: '\x1b[?1004h\x1b[?25l', + cols: 120, + rows: 40, + seq: hidden.length + 1, + alternateScreen: true, + scrollbackAnsi: 'preserved history' + }) + const pane = createPane(1) + let paneRect = createRect(0, 0) + pane.container.getBoundingClientRect = vi.fn(() => paneRect) + let xtermContainerDisplay = 'none' + const xtermContainer = new EventTarget() as HTMLElement + Object.defineProperty(xtermContainer, 'parentElement', { value: null }) + Object.defineProperty(xtermContainer, 'ownerDocument', { + value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } } + }) + ;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + onData?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + onData?.('x', { seq: hidden.length + 1, rawLength: 1 }) + await flushAsyncTicks(20) + + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.join('')).not.toContain('TOO-WIDE-ALT-FRAME') + expect(writes.join('')).toContain('\x1b[?1004h\x1b[?25l') + expect(transport.resize).not.toHaveBeenCalled() + + xtermContainerDisplay = 'block' + paneRect = createRect(800, 600) + safeFit(pane as never) + await flushAsyncTicks(12) + + const pulseStart = transport.resize.mock.calls.findIndex( + ([cols, rows]) => cols === 119 && rows === 40 + ) + expect(pulseStart).toBeGreaterThanOrEqual(0) + expect(transport.resize.mock.calls[pulseStart + 1]).toEqual([120, 40]) + disposable.dispose() + }) + it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -17160,7 +17436,6 @@ describe('connectPanePty', () => { expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) it('forces a viewport refresh for foreground CJK output without CSI', async () => { @@ -17189,10 +17464,9 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('没改什么(护城河)\r\n') expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) - it('schedules WebGL atlas recovery after renderer-risk foreground output parses', async () => { + it('refreshes renderer-risk output without clearing the shared glyph atlas', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -17217,15 +17491,13 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) capturedDataCallback.current?.('没改什么(护城河)\r\n') - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() expect(refresh).not.toHaveBeenCalled() parseCallback?.() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expectNoGlobalAtlasRecovery() }) - it('schedules WebGL atlas recovery for Vim-style foreground alternate-screen redraws', async () => { + it('refreshes alternate-screen redraws without clearing the shared glyph atlas', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17257,17 +17529,15 @@ describe('connectPanePty', () => { capturedDataCallback.current?.( '\x1b[2J\x1b[H{"name":"eepo"}\r\n\x1b[2;1H{"name":"expo"}\x1b[K' ) - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() parseCallback?.() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when a foreground rewrite enters alternate screen', async () => { + it('refreshes a foreground rewrite entering alternate screen without an atlas clear', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17296,19 +17566,17 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[HVim package.json') - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() // Why: xterm switches to the alternate buffer while parsing; the write callback observes the post-parse state. ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' parseCallback?.() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when the alternate-screen enter sequence splits across chunks', async () => { + it('avoids atlas clears when alternate-screen entry splits across chunks', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17339,18 +17607,18 @@ describe('connectPanePty', () => { // Why: PTY reads split CSI sequences at arbitrary byte boundaries, so the enter sequence can straddle two onData chunks. capturedDataCallback.current?.('\x1b[?104') parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() capturedDataCallback.current?.('9h\x1b[2J\x1b[H~\x1b[K') ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'alternate' parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when a foreground rewrite leaves alternate screen', async () => { + it('avoids atlas clears when a foreground rewrite leaves alternate screen', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17379,17 +17647,17 @@ describe('connectPanePty', () => { connectPanePty(pane as never, createManager(1) as never, createDeps() as never) await flushAsyncTicks(6) - // Vim's final frame restores the normal buffer in one chunk; the atlas must still rebuild even though the post-parse buffer reads normal. capturedDataCallback.current?.('\x1b[34;1H\x1b[K\x1b[34;1H\x1b[?1049l\x1b[?25h') ;(pane.terminal.buffer.active as { type: 'normal' | 'alternate' }).type = 'normal' parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery when one chunk enters and exits alternate screen', async () => { + it('avoids atlas clears when one chunk enters and exits alternate screen', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17404,15 +17672,6 @@ describe('connectPanePty', () => { transportFactoryQueue.push(transport) const pane = createPane(1) - let bufferChangeListener: (() => void) | undefined - ;( - pane.terminal.buffer as { - onBufferChange?: (listener: () => void) => { dispose: () => void } - } - ).onBufferChange = (listener) => { - bufferChangeListener = listener - return { dispose: vi.fn() } - } const refresh = vi.fn() let parseCallback: (() => void) | undefined const terminal = pane.terminal as typeof pane.terminal & { @@ -17426,18 +17685,16 @@ describe('connectPanePty', () => { connectPanePty(pane as never, createManager(1) as never, createDeps() as never) await flushAsyncTicks(6) - // A coalesced flush can parse enter -> draw -> exit in one write (buffer nets back to 'normal'); the switch count still marks it alternate-screen work. capturedDataCallback.current?.('\x1b[?1049h\x1b[2J\x1b[Hpager frame\x1b[K\x1b[?1049l') - bufferChangeListener?.() - bufferChangeListener?.() parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } }) - it('schedules WebGL atlas recovery for real captured Vim redraw chunks split mid-sequence', async () => { + it('avoids atlas clears for captured redraw chunks split mid-sequence', async () => { const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)') try { const { connectPanePty } = await import('./pty-connection') @@ -17469,13 +17726,13 @@ describe('connectPanePty', () => { // Captured from a real vim session: a 1024-byte PTY read boundary cuts the cursor move \x1b[30;5H into "\x1b[30" + ";5H". capturedDataCallback.current?.('"rules": {\x1b[29;15H\x1b[K\x1b[30') parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) capturedDataCallback.current?.( ';5H "js-combine-iterations": "off"\r\n }\x1b[31;6H\x1b[K\x1b[33;1H\x1b[?25h' ) parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(2) + expect(refresh).toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } @@ -17513,7 +17770,7 @@ describe('connectPanePty', () => { parseCallback?.() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } @@ -17543,10 +17800,8 @@ describe('connectPanePty', () => { await flushAsyncTicks(6) capturedDataCallback.current?.('\x1b[?2026hplain claude frame\x1b[?2026l') - - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() parseCallback?.() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() + expectNoGlobalAtlasRecovery() } finally { restoreNavigator() } @@ -17583,7 +17838,6 @@ describe('connectPanePty', () => { expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) it('forces a viewport refresh when the foreground CSI introducer is split across PTY chunks', async () => { @@ -17617,7 +17871,6 @@ describe('connectPanePty', () => { expect(manager.markPaneHasComplexScriptOutput).not.toHaveBeenCalled() expect(refresh).toHaveBeenCalledWith(0, 39, true) - expect(scheduleTerminalWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) it('does not keep forcing viewport refresh after completed background redraws', async () => { @@ -17647,11 +17900,9 @@ describe('connectPanePty', () => { expect(refresh).toHaveBeenCalledWith(0, 39, true) refresh.mockClear() - scheduleTerminalWebglAtlasRecovery.mockClear() capturedDataCallback.current?.('plain follow-up output\r\n') expect(refresh).not.toHaveBeenCalled() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() }) it('forces a viewport refresh for native Windows CJK foreground output after terminal input', async () => { @@ -17762,7 +18013,6 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('abc 123 ✓') expect(refresh).not.toHaveBeenCalled() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } @@ -17841,7 +18091,6 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('abc 123 ✓') expect(refresh).not.toHaveBeenCalled() - expect(scheduleTerminalWebglAtlasRecovery).not.toHaveBeenCalled() } finally { restoreNavigator() } @@ -21108,7 +21357,7 @@ describe('connectPanePty', () => { expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH') }) - it('paints a parked SSH model before the remote connection settles', async () => { + it('keeps a too-wide parked SSH alt frame while no live process can repaint it', async () => { const { connectPanePty } = await import('./pty-connection') const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1') const sshConnect = createDeferred() @@ -21117,10 +21366,12 @@ describe('connectPanePty', () => { vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise) vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({ data: 'PARKED-SSH-PAINTED-WITHOUT-NETWORK\r\n', - cols: 101, + cols: 140, rows: 31, seq: 123, - source: 'headless' + source: 'headless', + alternateScreen: true, + scrollbackAnsi: 'PARKED-SSH-SCROLLBACK\r\n' }) await parkTabForReveal('tab-1', sshPtyId) mockStoreState = { @@ -21134,6 +21385,7 @@ describe('connectPanePty', () => { } const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 80, rows: 24 })) const { writes } = captureCallbackTerminalWrites(pane) const binding = connectPanePty( pane as never, @@ -21147,6 +21399,7 @@ describe('connectPanePty', () => { expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'conn-1' }) expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce() + // No live SSH process can repaint this preconnect frame after a SIGWINCH. expect(writes.join('')).toContain('PARKED-SSH-PAINTED-WITHOUT-NETWORK') expect(transport.connect).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 98868cbe2e6..5ee42f9c784 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -236,8 +236,11 @@ import { createCommandCodeOutputStatusDetector } from '../../../../shared/comman import { createCodexBackfillErrorDetector } from './codex-backfill-error-detector' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' +import { + DeferredReattachLiveDataQueue, + MAX_DEFERRED_REATTACH_LIVE_CHARS +} from './deferred-reattach-live-data-queue' import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' -import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { CONPTY_DA1_RESPONSE, DEFAULT_DA1_RESPONSE, @@ -260,7 +263,9 @@ import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-sc import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, - resolvePositiveTerminalDimensions + readProposedTerminalCols, + resolvePositiveTerminalDimensions, + shouldSkipAltFrameForWidthMismatch } from './terminal-snapshot-replay-paint' import { decideSshReattachPaintSource, @@ -3745,21 +3750,10 @@ export function connectPanePty( const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false - let deferredReattachLiveData: - | { - data: string - ptyId: string | null - streamGeneration: number - meta?: PtyDataMeta - ackCredit?: () => void - }[] - | null = null - let deferredReattachLiveDataChars = 0 + let deferredReattachLiveData: DeferredReattachLiveDataQueue | null = null let reattachLiveDataDeferralDepth = 0 let deferredReattachLiveDataOwners = new Map() let transportStreamGeneration = 0 - const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 - const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { lastTerminalInputAt = performance.now() // Why: input must probe a wedged xterm even when the PTY produces no renderer output. @@ -4281,14 +4275,6 @@ export function connectPanePty( forwardPtyResize(cols, rows) }) - // Why: a rewrite chunk can enter AND exit the alternate screen in one parse - // (fast-quitting TUI), netting buffer.active.type back to 'normal'; counting - // switches keeps those redraws visible to the atlas-recovery check. - let alternateScreenBufferSwitches = 0 - const onBufferChangeDisposable = pane.terminal.buffer.onBufferChange?.(() => { - alternateScreenBufferSwitches += 1 - }) - // Why: renderer resize forwarding is fire-and-forget. A visible pane can // finish with xterm at the right grid while the PTY silently kept an older // grid, so Codex keeps composing against stale columns. Fit first so xterm's @@ -5521,96 +5507,6 @@ export function connectPanePty( return prefersRefresh } - function resetHiddenRendererRiskState(ptyId: string | null = null): void { - hiddenRiskPtyId = ptyId - hiddenSynchronizedOutputActive = false - hiddenSynchronizedOutputMarkerTail = '' - hiddenRewriteChunkEndedWithCarriageReturn = false - hiddenRewriteCsiScanTail = '' - } - - function ensureHiddenRendererRiskStateForCurrentPty(): void { - const ptyId = transport.getPtyId() - if (hiddenRiskPtyId === ptyId) { - return - } - resetHiddenRendererRiskState(ptyId) - } - - function resetSkippedHiddenRendererRiskState(): void { - // Why: skipped/backlog bytes were not parsed by xterm; reset any live hidden - // frame instead of letting dropped DEC starts make later plain bytes risky. - resetHiddenRendererRiskState(transport.getPtyId()) - } - - function hiddenSynchronizedOutputTouchesParsedFrame(data: string): boolean { - const scanData = hiddenSynchronizedOutputMarkerTail - ? `${hiddenSynchronizedOutputMarkerTail}${data}` - : data - const currentChunkStartIndex = scanData.length - data.length - let active = hiddenSynchronizedOutputActive - let touchesParsedFrame = active && data.length > 0 - let offset = 0 - - while (offset < scanData.length) { - const startIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_START_SEQUENCE, offset) - const endIndex = scanData.indexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE, offset) - if (startIndex === -1 && endIndex === -1) { - break - } - if (endIndex !== -1 && (startIndex === -1 || endIndex < startIndex)) { - if ( - active && - endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length > currentChunkStartIndex - ) { - touchesParsedFrame = true - } - active = false - offset = endIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length - continue - } - if (startIndex !== -1) { - active = true - if (startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length > currentChunkStartIndex) { - touchesParsedFrame = true - } - offset = startIndex + SYNCHRONIZED_OUTPUT_START_SEQUENCE.length - continue - } - } - - if (active && data.length > 0) { - touchesParsedFrame = true - } - hiddenSynchronizedOutputActive = active - hiddenSynchronizedOutputMarkerTail = scanData.slice(-SYNCHRONIZED_OUTPUT_MARKER_TAIL_CHARS) - return touchesParsedFrame - } - - function hiddenTuiRedrawOutputPrefersAtlasRecovery(data: string): boolean { - if (!data) { - return false - } - const scanData = hiddenRewriteCsiScanTail ? `${hiddenRewriteCsiScanTail}${data}` : data - const decision = terminalRewriteOutputRenderRefreshDecision(data, { - previousChunkEndsWithCarriageReturn: hiddenRewriteChunkEndedWithCarriageReturn, - previousRewriteCsiScanTail: hiddenRewriteCsiScanTail - }) - hiddenRewriteChunkEndedWithCarriageReturn = decision.nextChunkEndsWithCarriageReturn - hiddenRewriteCsiScanTail = decision.nextRewriteCsiScanTail - return decision.prefersRenderRefresh || containsCursorPositionSequence(scanData) - } - - function hiddenOutputNeedsAtlasRecoveryAfterParse(data: string): boolean { - if (!data) { - return false - } - ensureHiddenRendererRiskStateForCurrentPty() - const synchronizedOutputTouchesParsedFrame = hiddenSynchronizedOutputTouchesParsedFrame(data) - const tuiRedrawOutputPrefersAtlasRecovery = hiddenTuiRedrawOutputPrefersAtlasRecovery(data) - return synchronizedOutputTouchesParsedFrame || tuiRedrawOutputPrefersAtlasRecovery - } - // The replay path uses the guard so xterm auto-replies to embedded query // sequences don't leak into the shell. xterm.write() buffers internally // regardless of DOM visibility and the guard stays engaged via the @@ -6048,11 +5944,6 @@ export function connectPanePty( const shouldSnapshotHiddenCodexOutput = shouldKeepHiddenStartupRendererQueriesLive(paneStartup) let hiddenStartupRendererQueryPending = '' let hiddenRendererStateDirty = false - let hiddenRiskPtyId: string | null = null - let hiddenSynchronizedOutputActive = false - let hiddenSynchronizedOutputMarkerTail = '' - let hiddenRewriteChunkEndedWithCarriageReturn = false - let hiddenRewriteCsiScanTail = '' let rendererOrderedPtyId: string | null = null let rendererOrderedSeq: number | null = null let rendererChannelSeqPtyId: string | null = null @@ -6416,25 +6307,9 @@ export function connectPanePty( return decision.prefersRenderRefresh } - // Why: Vim-style rewrites leave stale WebGL glyphs until the atlas rebuilds; alt-screen membership is only authoritative post-parse (enter/exit can split chunks), so capture pre-parse and decide at parse completion. - function alternateScreenRewriteAtlasRecoveryOnParsed(): () => void { - const wasAlternateScreenBuffer = pane.terminal.buffer.active.type === 'alternate' - const switchesBeforeParse = alternateScreenBufferSwitches - return () => { - if ( - wasAlternateScreenBuffer || - alternateScreenBufferSwitches !== switchesBeforeParse || - pane.terminal.buffer.active.type === 'alternate' - ) { - scheduleTerminalWebglAtlasRecovery() - } - } - } - function shouldForceForegroundRenderRefresh(data: string): { refresh: boolean inPlaceRewrite: boolean - recoverWebglAtlasAfterParse: boolean } { const rewriteOutputPrefersRenderRefresh = foregroundRewriteOutputPrefersRenderRefresh(data) const recentInput = @@ -6442,13 +6317,12 @@ export function connectPanePty( if (foregroundRendererRiskOutputPrefersRenderRefresh(data)) { return { refresh: true, - inPlaceRewrite: rewriteOutputPrefersRenderRefresh, - recoverWebglAtlasAfterParse: true + inPlaceRewrite: rewriteOutputPrefersRenderRefresh } } if (rewriteOutputPrefersRenderRefresh) { // Why: xterm's buffer is right but in-place redraw cells stay stale in the renderer until a repaint (resize fixes it). - return { refresh: true, inPlaceRewrite: true, recoverWebglAtlasAfterParse: false } + return { refresh: true, inPlaceRewrite: true } } if ( windowsEastAsianOutputPrefersRenderRefresh(data, { @@ -6459,15 +6333,14 @@ export function connectPanePty( }) ) { // Why: CJK/Korean from Microsoft Pinyin commits and native ConPTY output can leave stale wide-glyph cells in the Windows DOM renderer. - return { refresh: true, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false } + return { refresh: true, inPlaceRewrite: false } } return { refresh: shouldApplyNativeWindowsRewriteRefresh && containsNonAsciiOutput(data) && containsWindowsRewriteControl(data), - inPlaceRewrite: false, - recoverWebglAtlasAfterParse: false + inPlaceRewrite: false } } @@ -6516,7 +6389,6 @@ export function connectPanePty( kittyKeyboardModes.scan(data) if (foreground) { resetHiddenOutputRestoreIfPtyChanged() - resetHiddenRendererRiskState() } const parseHiddenStartupOutput = !foreground && @@ -6546,21 +6418,7 @@ export function connectPanePty( } const renderRefreshDecision = foregroundOutput ? shouldForceForegroundRenderRefresh(data) - : { refresh: false, inPlaceRewrite: false, recoverWebglAtlasAfterParse: false } - if (!foregroundOutput) { - // Advance hidden rewrite state; reveal owns atlas recovery. - void hiddenOutputNeedsAtlasRecoveryAfterParse(data) - } - const recoverWebglAtlasAfterParse = - foreground && renderRefreshDecision.recoverWebglAtlasAfterParse - // Why: atlas recovery must repaint from the parsed xterm buffer, not a pre-write snapshot a late TUI redraw can stale. - const onParsedAtlasRecovery = foreground - ? recoverWebglAtlasAfterParse - ? scheduleTerminalWebglAtlasRecovery - : renderRefreshDecision.inPlaceRewrite - ? alternateScreenRewriteAtlasRecoveryOnParsed() - : undefined - : undefined + : { refresh: false, inPlaceRewrite: false } const foregroundRenderRefreshNeeded = renderRefreshDecision.refresh // Why: Claude Code's in-place prompt redraws on Windows ConPTY can paint one frame late; a follow-up repaint fixes the column desync without a resize. const nativeWindowsInPlaceRewriteFollowup = nativeWindowsRewriteNeedsFollowupRenderRefresh({ @@ -6600,7 +6458,6 @@ export function connectPanePty( nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, // Why: xterm already queued a WebGL frame parsing this chunk; merge the repair into it instead of rendering the grid twice. shouldRefreshForegroundSynchronously, - onParsed: onParsedAtlasRecovery, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, holdForeground: synchronizedForegroundOutput && nextSynchronizedForegroundOutputActive @@ -6618,7 +6475,6 @@ export function connectPanePty( } function markHiddenOutputRestoreNeeded(): void { - resetSkippedHiddenRendererRiskState() const ptyId = transport.getPtyId() if (!canUseHiddenOutputSnapshot(ptyId)) { return @@ -7174,7 +7030,6 @@ export function connectPanePty( hiddenOutputRestoreScheduled = false hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false - resetHiddenRendererRiskState() cancelScheduledHiddenOutputRestore(pane.terminal) clearHiddenOutputRestoreDeferredRetryTimer() clearHiddenOutputRestoreForegroundDeadlineTimer() @@ -7249,7 +7104,6 @@ export function connectPanePty( hiddenOutputRestoreLocalGateAttempts = 0 hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false - resetHiddenRendererRiskState() hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null hiddenOutputRestoreReplayingSnapshot = null @@ -7300,7 +7154,6 @@ export function connectPanePty( function skipBackgroundAlternateScreenOutput(data: string): void { writeHiddenStartupRendererQueries(data) - resetSkippedHiddenRendererRiskState() hiddenRendererStateDirty = true recordHiddenRendererSkip(data.length) const ptyId = transport.getPtyId() @@ -7340,6 +7193,7 @@ export function connectPanePty( async function applyMainBufferSnapshot(snapshot: { data: string + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -7363,6 +7217,7 @@ export function connectPanePty( const colsBeforeReplay = pane.terminal.cols const rowsBeforeReplay = pane.terminal.rows const hasSnapshotDimensions = hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) + let skippedAltFrame = false try { await structuralReplayCoordinator.run( async () => { @@ -7399,7 +7254,15 @@ export function connectPanePty( // Why shared: the SSH reattach model paint inlines the same // choreography (coordinator nesting would deadlock there); one // builder keeps the alt-screen branches from drifting. - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { + skippedAltFrame = + snapshot.alternateScreen === true && + snapshot.frameRestoreAnsi !== undefined && + shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane), { + skipIfTargetUnknown: true + }) + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame: skippedAltFrame + })) { writeReplayData(replayChunk) } // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). @@ -7414,7 +7277,6 @@ export function connectPanePty( } hiddenRendererStateDirty = false recordRendererOrderedSeq(snapshot) - resetHiddenRendererRiskState() recordTerminalOutput(pane.terminal) await waitForTerminalReplayWritesParsed(pane.terminal) }, @@ -7434,7 +7296,14 @@ export function connectPanePty( return } const currentPtyId = transport.getPtyId() - if (!currentPtyId || getFitOverrideForPty(currentPtyId)) { + if (!currentPtyId) { + return + } + if (getFitOverrideForPty(currentPtyId)) { + safeFit(pane) + if (skippedAltFrame && !isRemoteRuntimePtyId(currentPtyId)) { + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } return } const fit = safeFitAndThen( @@ -7448,6 +7317,10 @@ export function connectPanePty( ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay + if (skippedAltFrame) { + pulseVisibleLocalPtySizeForTuiRepaint(currentPtyId) + return + } if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { transport.resize(pane.terminal.cols, pane.terminal.rows) if (!isRemoteRuntimePtyId(currentPtyId)) { @@ -7456,7 +7329,13 @@ export function connectPanePty( } } }, - { shouldContinue: isCurrentRestore, retryIfUnmeasurable: true } + { + shouldContinue: isCurrentRestore, + retryIfUnmeasurable: true, + // A skipped frame is blank until this fit pushes the final + // grid/SIGWINCH, so carry the continuation through reveal. + deferIfHidden: true + } ) pendingHiddenSnapshotFit = fit try { @@ -7758,47 +7637,14 @@ export function connectPanePty( return } if (deferredReattachLiveData !== null) { - // Why: a replacement stream must not inherit bytes or a gap marker from the replay owner it superseded. - deferredReattachLiveData = deferredReattachLiveData.filter((chunk) => { - const keep = chunk.streamGeneration === streamGeneration - if (!keep) { - chunk.ackCredit?.() - } - return keep - }) - deferredReattachLiveDataChars = deferredReattachLiveData.reduce( - (total, chunk) => total + chunk.data.length, - 0 - ) - const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS - const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data const ackCredit = takeCurrentTerminalDeliveryCredit() - deferredReattachLiveData.push({ - data: deferredData, + deferredReattachLiveData.enqueue({ + data, ptyId: transport.getPtyId(), streamGeneration, ...(meta ? { meta } : {}), ...(ackCredit ? { ackCredit } : {}) }) - deferredReattachLiveDataChars += deferredData.length - // Why: one huge IPC frame would bypass the queue's memory bound; mark a stream gap so snapshot recovery replaces it, not a partial ANSI frame. - let dropped = oversized - while ( - deferredReattachLiveData.length > 1 && - (deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || - deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) - ) { - const removed = deferredReattachLiveData.shift() - deferredReattachLiveDataChars -= removed?.data.length ?? 0 - removed?.ackCredit?.() - dropped = true - } - if (dropped && deferredReattachLiveData[0]) { - deferredReattachLiveData[0].meta = { - ...deferredReattachLiveData[0].meta, - droppedOutput: true - } - } return } if (data.length > 0) { @@ -7935,7 +7781,6 @@ export function connectPanePty( queueLiveChunkDuringRestore(orderedRendererData, rendererMeta) requestHiddenOutputRestoreIfNeeded() } else if (hiddenOutputRestoreInFlight) { - resetSkippedHiddenRendererRiskState() hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } @@ -7964,8 +7809,7 @@ export function connectPanePty( const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { reattachLiveDataDeferralDepth += 1 if (reattachLiveDataDeferralDepth === 1) { - deferredReattachLiveData = [] - deferredReattachLiveDataChars = 0 + deferredReattachLiveData = new DeferredReattachLiveDataQueue() deferredReattachLiveDataOwners = new Map() } if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { @@ -7998,19 +7842,17 @@ export function connectPanePty( if (reattachLiveDataDeferralDepth > 0) { return } - const chunks = deferredReattachLiveData + const queue = deferredReattachLiveData deferredReattachLiveData = null - deferredReattachLiveDataChars = 0 const currentPtyId = transport.getPtyId() const currentGeneration = transportStreamGeneration const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) deferredReattachLiveDataOwners = new Map() - if (disposed || !chunks) { - for (const chunk of chunks ?? []) { - chunk.ackCredit?.() - } + if (disposed || !queue) { + queue?.discard() return } + const chunks = queue.takeAll() // Why: paint the authoritative replay first, then admit deferred live chunks so the replay clear can't erase newer output. let deliveredDeferredChunks = 0 for (const chunk of chunks) { @@ -8133,6 +7975,7 @@ export function connectPanePty( } } kittyKeyboardModes.scanReplay(modelData) + // Why keep a too-wide frame: preconnect SSH has no live repaint owner or post-restore fit. for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) { writeReplayData(replayChunk) } @@ -8321,7 +8164,18 @@ export function connectPanePty( return } if (connectResult?.snapshot) { - rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + const snapshotPrefixAnsi = connectResult.snapshotPrefixAnsi + const snapshotFrameAnsi = connectResult.snapshotFrameAnsi + const snapshotFrameRestoreAnsi = connectResult.snapshotFrameRestoreAnsi + const hasSplitDaemonAltFrame = + typeof snapshotPrefixAnsi === 'string' && + snapshotPrefixAnsi.length > 0 && + typeof snapshotFrameAnsi === 'string' && + snapshotFrameAnsi.length > 0 + const daemonSnapshotReplay = hasSplitDaemonAltFrame + ? snapshotPrefixAnsi + snapshotFrameAnsi + : connectResult.snapshot + rememberReattachPayloadAgentSignal(daemonSnapshotReplay, { fullScreenReplay: true }) // Why: replay at the snapshot's own dimensions to avoid rewrapping soft-wrapped rows at a different column count (#7279); suppress the PTY forward so this layout-only resize doesn't SIGWINCH the remote TUI. const snapshotDimensions = resolvePositiveTerminalDimensions( connectResult.snapshotCols, @@ -8341,12 +8195,26 @@ export function connectPanePty( } writeReplayData('\x1b[2J\x1b[3J\x1b[H') // Why: re-arm the kitty keyboard mirror from the snapshot preamble so Option chords keep their encoding after a window reload. - kittyKeyboardModes.scanReplay(connectResult.snapshot) - writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset — unless this is a cold restore, whose owner is gone. + kittyKeyboardModes.scanReplay(daemonSnapshotReplay) + // A narrower fit clips the fixed-grid alt frame; drop it and let SIGWINCH repaint. + // A dead-owner restore keeps history plus its restored-session treatment; + // a frozen foreign-width frame would look live when no owner remains. + const daemonAltFrameSkippable = + hasSplitDaemonAltFrame && + typeof snapshotFrameRestoreAnsi === 'string' && + shouldSkipAltFrameForWidthMismatch( + connectResult.snapshotCols, + readProposedTerminalCols(pane), + { skipIfTargetUnknown: true } + ) + writeReplayData( + daemonAltFrameSkippable + ? snapshotPrefixAnsi + snapshotFrameRestoreAnsi + : daemonSnapshotReplay + ) writeReplayData( reattachReplayResetSequence( - connectResult.snapshot, + daemonSnapshotReplay, Boolean(connectResult.coldRestore), connectResult.isAlternateScreen ) @@ -8400,7 +8268,15 @@ export function connectPanePty( // ?1049l/?1049h rebuild as applyMainBufferSnapshot (main strips // the ?1049h marker when splitting scrollbackAnsi) — inlined here // because nesting structuralReplayCoordinator would deadlock. - for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot)) { + for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, { + skipAltFrame: shouldSkipAltFrameForWidthMismatch( + modelCols, + readProposedTerminalCols(pane), + { + skipIfTargetUnknown: true + } + ) + })) { writeReplayData(replayChunk) } writeReplayData( @@ -9402,11 +9278,9 @@ export function connectPanePty( } directSshPaneRetrySettlementTimers.clear() // Why: a stalled xterm replay may never reach its finally; release live-frame credit when this renderer no longer owns the stream. - for (const chunk of deferredReattachLiveData ?? []) { - chunk.ackCredit?.() - } + const queue = deferredReattachLiveData deferredReattachLiveData = null - deferredReattachLiveDataChars = 0 + queue?.discard() reattachLiveDataDeferralDepth = 0 deferredReattachLiveDataOwners = new Map() cancelPendingSafeFitContinuations(pane) @@ -9506,7 +9380,6 @@ export function connectPanePty( userInputActivityDisposable?.dispose() terminalCapabilityRepliesDisposable.dispose() onResizeDisposable.dispose() - onBufferChangeDisposable?.dispose() pane.container.removeEventListener(PANE_PTY_RESIZE_HOLD_FLUSH_EVENT, onHeldPtyResizeFlush) geometryReportObserver?.disconnect() if (pendingGeometryReportRaf !== null) { diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts index 45846d08143..fcaf70e0a5a 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.test.ts @@ -3,12 +3,16 @@ import { bufferPtyShutdownData, bufferPtyShutdownReplayData, drainRolledBackPtyShutdownData, + isPtyDataHandlerShutdownPending, ptyDataHandlers, + ptyDataSidecars, ptyReplayHandlers, ptyShutdownLifecycleHandlers, ptyTeardownHandlers, unregisterPtyDataHandlers } from './pty-shutdown-data-suspension' +import { bufferPreHandlerPtyData } from './pty-pre-handler-buffer' +import { PtyShutdownOutputQueue } from './pty-shutdown-output-queue' it('does not remove handlers installed by a remount while sleep is pending', () => { const ptyId = 'pty-shutdown-remount' @@ -33,16 +37,45 @@ it('does not remove handlers installed by a remount while sleep is pending', () it('replays rollback output in its original replay and live arrival order', () => { const ptyId = 'pty-shutdown-output-order' const delivered: string[] = [] + const meta = { seq: 9, rawLength: 3, transformed: true } + const sidecar = vi.fn((data: string) => delivered.push(`sidecar:${data}`)) ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`)) ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) + ptyDataSidecars.set(ptyId, new Set([sidecar])) const [snapshot] = unregisterPtyDataHandlers([ptyId]) bufferPtyShutdownReplayData(ptyId, 'old') - bufferPtyShutdownData(ptyId, 'new') + bufferPtyShutdownData(ptyId, 'new', meta) snapshot.rollback() - expect(delivered).toEqual(['replay:old', 'data:new']) + expect(delivered).toEqual(['replay:old', 'data:new', 'sidecar:new']) + expect(sidecar).toHaveBeenCalledOnce() ptyDataHandlers.delete(ptyId) + ptyDataSidecars.delete(ptyId) + ptyReplayHandlers.delete(ptyId) +}) + +it('drains pre-handler data before ordered shutdown output', () => { + const ptyId = 'pty-shutdown-pre-handler-order' + const delivered: string[] = [] + const preHandlerMeta = { seq: 1, rawLength: 3 } + const shutdownMeta = { seq: 2, rawLength: 4, background: true } + const dataHandler = vi.fn((data: string) => delivered.push(`data:${data}`)) + ptyDataHandlers.set(ptyId, dataHandler) + ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) + ptyDataSidecars.set(ptyId, new Set([(data) => delivered.push(`sidecar:${data}`)])) + + const [snapshot] = unregisterPtyDataHandlers([ptyId]) + bufferPreHandlerPtyData(ptyId, 'pre', preHandlerMeta) + bufferPtyShutdownReplayData(ptyId, 'old') + bufferPtyShutdownData(ptyId, 'live', shutdownMeta) + snapshot.rollback() + + expect(delivered).toEqual(['data:pre', 'sidecar:pre', 'replay:old', 'data:live', 'sidecar:live']) + expect(dataHandler).toHaveBeenNthCalledWith(1, 'pre', preHandlerMeta) + expect(dataHandler).toHaveBeenNthCalledWith(2, 'live', shutdownMeta) + ptyDataHandlers.delete(ptyId) + ptyDataSidecars.delete(ptyId) ptyReplayHandlers.delete(ptyId) }) @@ -50,27 +83,65 @@ it('retains ordered rollback output across detach and another pending shutdown', const ptyId = 'pty-shutdown-detached-overlap' const originalData = vi.fn() const originalReplay = vi.fn() + const drain = vi.spyOn(PtyShutdownOutputQueue.prototype, 'drain') + const encode = vi.spyOn(TextEncoder.prototype, 'encode') ptyDataHandlers.set(ptyId, originalData) ptyReplayHandlers.set(ptyId, originalReplay) const [first] = unregisterPtyDataHandlers([ptyId]) ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) - bufferPtyShutdownData(ptyId, 'live-first') + const meta = { seq: 3, rawLength: 10, transformed: true } + bufferPtyShutdownData(ptyId, 'live-first', meta) bufferPtyShutdownReplayData(ptyId, 'replay-second') first.rollback() + expect(drain).not.toHaveBeenCalled() const [second] = unregisterPtyDataHandlers([ptyId]) + expect(encode).not.toHaveBeenCalled() const delivered: string[] = [] - ptyDataHandlers.set(ptyId, (data) => delivered.push(`data:${data}`)) + const replacementData = vi.fn((data: string) => delivered.push(`data:${data}`)) + ptyDataHandlers.set(ptyId, replacementData) ptyReplayHandlers.set(ptyId, (data) => delivered.push(`replay:${data}`)) drainRolledBackPtyShutdownData(ptyId) expect(delivered).toEqual([]) + expect(drain).not.toHaveBeenCalled() second.rollback() expect(delivered).toEqual(['data:live-first', 'replay:replay-second']) + expect(replacementData).toHaveBeenCalledWith('live-first', meta) + expect(drain).toHaveBeenCalledOnce() + drain.mockRestore() + encode.mockRestore() ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) ptyTeardownHandlers.delete(ptyId) ptyShutdownLifecycleHandlers.delete(ptyId) }) + +it('detaches the delivered batch from a reentrant shutdown queue', () => { + const ptyId = 'pty-shutdown-reentrant-delivery' + const delivered: string[] = [] + let reentrantSnapshot: ReturnType[number] | undefined + ptyDataHandlers.set(ptyId, (data) => { + delivered.push(data) + if (data === 'old-first') { + reentrantSnapshot = unregisterPtyDataHandlers([ptyId])[0] + bufferPtyShutdownData(ptyId, 'new-pending') + } + }) + ptyReplayHandlers.set(ptyId, vi.fn()) + + const [first] = unregisterPtyDataHandlers([ptyId]) + bufferPtyShutdownData(ptyId, 'old-first') + bufferPtyShutdownData(ptyId, 'old-second') + first.rollback() + + expect(delivered).toEqual(['old-first', 'old-second']) + expect(isPtyDataHandlerShutdownPending(ptyId)).toBe(true) + reentrantSnapshot?.rollback() + expect(delivered).toEqual(['old-first', 'old-second', 'new-pending']) + expect(isPtyDataHandlerShutdownPending(ptyId)).toBe(false) + ptyDataHandlers.delete(ptyId) + ptyReplayHandlers.delete(ptyId) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts index 0bc654bfe8e..508ea88b2be 100644 --- a/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-data-suspension.ts @@ -1,7 +1,6 @@ -import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import { clampUtf8Tail } from './pty-eager-buffer-clamp' import { clearPreHandlerPtyState, drainPreHandlerPtyData } from './pty-pre-handler-buffer' import type { PtyDataMeta } from './pty-dispatcher' +import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutdown-output-queue' export const ptyDataHandlers = new Map void>() export const ptyDataSidecars = new Map void>>() @@ -25,8 +24,7 @@ export type PtyDataHandlerShutdownSnapshot = { type PendingPtyHandlerShutdown = { owners: number committed: boolean - bufferedBytes: number - events: PtyShutdownOutputEvent[] + outputQueue: PtyShutdownOutputQueue dataHandler?: (data: string, meta?: PtyDataMeta) => void replayHandler?: (data: string) => void teardownHandler?: () => void @@ -34,13 +32,8 @@ type PendingPtyHandlerShutdown = { } const pendingPtyHandlerShutdowns = new Map() -type PtyShutdownOutputEvent = - | { kind: 'data'; data: string; meta?: PtyDataMeta } - | { kind: 'replay'; data: string } - -const rolledBackShutdownEvents = new Map() +const rolledBackShutdownEvents = new Map() const ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS = 64 -const shutdownBufferTextEncoder = new TextEncoder() /** Suspend delivery until every overlapping shutdown owner commits or rolls back. */ export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutdownSnapshot[] { @@ -50,16 +43,12 @@ export function unregisterPtyDataHandlers(ptyIds: string[]): PtyDataHandlerShutd if (pending) { pending.owners += 1 } else { - const retainedEvents = rolledBackShutdownEvents.get(id) ?? [] + const outputQueue = rolledBackShutdownEvents.get(id) ?? new PtyShutdownOutputQueue() rolledBackShutdownEvents.delete(id) pending = { owners: 1, committed: false, - bufferedBytes: retainedEvents.reduce( - (total, event) => total + shutdownBufferTextEncoder.encode(event.data).byteLength, - 0 - ), - events: retainedEvents, + outputQueue, dataHandler: ptyDataHandlers.get(id), replayHandler: ptyReplayHandlers.get(id), teardownHandler: ptyTeardownHandlers.get(id), @@ -113,17 +102,7 @@ function bufferPtyShutdownOutput(ptyId: string, event: PtyShutdownOutputEvent): if (!pending) { return false } - const clamped = clampUtf8Tail(event.data, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) - pending.events.push({ ...event, data: clamped.data }) - pending.bufferedBytes += clamped.bytes - while ( - pending.bufferedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT && - pending.events.length > 1 - ) { - pending.bufferedBytes -= shutdownBufferTextEncoder.encode( - pending.events.shift()?.data ?? '' - ).byteLength - } + pending.outputQueue.enqueue(event) return true } @@ -131,14 +110,14 @@ export function drainRolledBackPtyShutdownData(ptyId: string): void { if (pendingPtyHandlerShutdowns.has(ptyId)) { return } - const events = rolledBackShutdownEvents.get(ptyId) + const outputQueue = rolledBackShutdownEvents.get(ptyId) const dataHandler = ptyDataHandlers.get(ptyId) const replayHandler = ptyReplayHandlers.get(ptyId) - if (!events || !dataHandler || !replayHandler) { + if (!outputQueue || !dataHandler || !replayHandler) { return } rolledBackShutdownEvents.delete(ptyId) - deliverShutdownEvents(ptyId, events, dataHandler, replayHandler) + outputQueue.drain((event) => deliverShutdownEvent(ptyId, event, dataHandler, replayHandler)) } function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { @@ -154,6 +133,7 @@ function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { pendingPtyHandlerShutdowns.delete(ptyId) if (pending.committed) { rolledBackShutdownEvents.delete(ptyId) + pending.outputQueue.discard() pending.lifecycleHandler?.commit() deleteCapturedHandler(ptyDataHandlers, ptyId, pending.dataHandler) deleteCapturedHandler(ptyReplayHandlers, ptyId, pending.replayHandler) @@ -173,10 +153,12 @@ function settlePtyDataHandlerShutdown(ptyId: string, committed: boolean): void { const dataHandler = ptyDataHandlers.get(ptyId) const replayHandler = ptyReplayHandlers.get(ptyId) if (dataHandler && replayHandler) { - deliverShutdownEvents(ptyId, pending.events, dataHandler, replayHandler) - } else if (pending.events.length > 0) { + pending.outputQueue.drain((event) => + deliverShutdownEvent(ptyId, event, dataHandler, replayHandler) + ) + } else if (pending.outputQueue.length > 0) { // Why: a hidden pane can detach during the RPC; retain rollback output until both ordered channels reattach. - rolledBackShutdownEvents.set(ptyId, pending.events) + rolledBackShutdownEvents.set(ptyId, pending.outputQueue) while (rolledBackShutdownEvents.size > ROLLED_BACK_SHUTDOWN_REPLAY_MAX_PTYS) { const oldestPtyId = rolledBackShutdownEvents.keys().next().value if (typeof oldestPtyId !== 'string') { @@ -197,20 +179,18 @@ function deleteCapturedHandler( } } -function deliverShutdownEvents( +function deliverShutdownEvent( ptyId: string, - events: readonly PtyShutdownOutputEvent[], + event: PtyShutdownOutputEvent, dataHandler: (data: string, meta?: PtyDataMeta) => void, replayHandler: (data: string) => void ): void { - for (const event of events) { - if (event.kind === 'replay') { - replayHandler(event.data) - continue - } - dataHandler(event.data, event.meta) - for (const sidecar of Array.from(ptyDataSidecars.get(ptyId) ?? [])) { - sidecar(event.data) - } + if (event.kind === 'replay') { + replayHandler(event.data) + return + } + dataHandler(event.data, event.meta) + for (const sidecar of Array.from(ptyDataSidecars.get(ptyId) ?? [])) { + sidecar(event.data) } } diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts new file mode 100644 index 00000000000..b2d835e4258 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, it } from 'vitest' +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { clampUtf8Tail } from './pty-eager-buffer-clamp' +import { PtyShutdownOutputQueue, type PtyShutdownOutputEvent } from './pty-shutdown-output-queue' + +const BYTE_LIMIT = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT +const CHUNK_SIZE = 64 + +class ReferenceShutdownOutputQueue { + private events: PtyShutdownOutputEvent[] = [] + private retainedBytes = 0 + private readonly encoder = new TextEncoder() + + enqueue(event: PtyShutdownOutputEvent): void { + const clamped = clampUtf8Tail(event.data, BYTE_LIMIT) + this.events.push({ ...event, data: clamped.data }) + this.retainedBytes += clamped.bytes + while (this.retainedBytes > BYTE_LIMIT && this.events.length > 1) { + this.retainedBytes -= this.encoder.encode(this.events.shift()?.data ?? '').byteLength + } + } + + takeAll(): PtyShutdownOutputEvent[] { + const events = this.events + this.events = [] + this.retainedBytes = 0 + return events + } +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } +} + +function createDifferentialData(value: number): string { + const bucket = value % 100 + if (bucket < 8) { + return '' + } + if (bucket < 48) { + return String.fromCharCode(65 + (value % 26)).repeat(value % 513) + } + if (bucket < 70) { + return '界'.repeat(value % 257) + } + if (bucket < 88) { + return '😀'.repeat(value % 129) + } + if (bucket < 96) { + return `\ud800${'x'.repeat(value % 511)}` + } + return String.fromCharCode(65 + (value % 26)).repeat(64 * 1024) +} + +describe('PTY shutdown output queue', () => { + it('does not shift or re-encode retained events while saturated', () => { + const originalShift = Array.prototype.shift + const originalEncode = TextEncoder.prototype.encode + const queue = new PtyShutdownOutputQueue() + + try { + Object.defineProperties(Array.prototype, { + shift: { + configurable: true, + writable: true, + value() { + throw new Error('Array.shift should not run on enqueue') + } + } + }) + Object.defineProperties(TextEncoder.prototype, { + encode: { + configurable: true, + writable: true, + value() { + throw new Error('TextEncoder.encode should not run on enqueue') + } + } + }) + for (let index = 0; index < 1_536; index += 1) { + queue.enqueue({ kind: 'data', data: 'x'.repeat(1_024), meta: { seq: index } }) + } + } finally { + Object.defineProperties(Array.prototype, { + shift: { configurable: true, writable: true, value: originalShift } + }) + Object.defineProperties(TextEncoder.prototype, { + encode: { configurable: true, writable: true, value: originalEncode } + }) + } + + expect(queue.takeAll()).toHaveLength(512) + }) + + it('keeps exact UTF-8 boundaries, Unicode tails, and metadata identity', () => { + const boundaryQueue = new PtyShutdownOutputQueue() + const emptyMeta = { rawLength: 9, transformed: true, droppedOutput: true } + boundaryQueue.enqueue({ kind: 'replay', data: 'a'.repeat(BYTE_LIMIT) }) + boundaryQueue.enqueue({ kind: 'data', data: '', meta: emptyMeta }) + expect(boundaryQueue.getStorageForTest()).toMatchObject({ + retainedBytes: BYTE_LIMIT, + retainedEvents: 2 + }) + boundaryQueue.enqueue({ kind: 'replay', data: 'b' }) + + const boundary = boundaryQueue.takeAll() + expect(boundary).toEqual([ + { kind: 'data', data: '', meta: emptyMeta }, + { kind: 'replay', data: 'b' } + ]) + expect(boundary[0]?.kind === 'data' ? boundary[0].meta : undefined).toBe(emptyMeta) + + const unicodeQueue = new PtyShutdownOutputQueue() + const emojiTail = '😀'.repeat(BYTE_LIMIT / 4) + const unicodeMeta = { seq: 7, rawLength: emojiTail.length + 1, background: true } + unicodeQueue.enqueue({ kind: 'data', data: `P${emojiTail}`, meta: unicodeMeta }) + const [unicode] = unicodeQueue.takeAll() + expect(unicode?.data).toBe(emojiTail) + expect(unicode?.kind === 'data' ? unicode.meta : undefined).toBe(unicodeMeta) + + const loneSurrogateQueue = new PtyShutdownOutputQueue() + const loneSurrogateTail = `\ud800${'z'.repeat(BYTE_LIMIT - 3)}` + loneSurrogateQueue.enqueue({ kind: 'replay', data: `P${loneSurrogateTail}` }) + expect(loneSurrogateQueue.takeAll()[0]?.data).toBe(loneSurrogateTail) + }) + + it('evicts whole oldest events without merging replay and live boundaries', () => { + const queue = new PtyShutdownOutputQueue() + const meta = { seq: 42, rawLength: BYTE_LIMIT - 3, transformed: true } + queue.enqueue({ kind: 'replay', data: 'old' }) + queue.enqueue({ kind: 'data', data: 'x'.repeat(BYTE_LIMIT - 3), meta }) + queue.enqueue({ kind: 'replay', data: 'new' }) + + const retained = queue.takeAll() + expect(retained).toEqual([ + { kind: 'data', data: 'x'.repeat(BYTE_LIMIT - 3), meta }, + { kind: 'replay', data: 'new' } + ]) + expect(retained[0]?.kind === 'data' ? retained[0].meta : undefined).toBe(meta) + }) + + it('retains zero-byte event boundaries without imposing a count cap', () => { + const queue = new PtyShutdownOutputQueue() + for (let index = 0; index < 4_097; index += 1) { + queue.enqueue({ kind: 'data', data: '', meta: { seq: index, transformed: true } }) + } + + expect(queue.getStorageForTest()).toMatchObject({ + retainedBytes: 0, + retainedEvents: 4_097 + }) + const retained = queue.takeAll() + expect(retained).toHaveLength(4_097) + expect(retained[0]).toEqual({ kind: 'data', data: '', meta: { seq: 0, transformed: true } }) + expect(retained.at(-1)).toEqual({ + kind: 'data', + data: '', + meta: { seq: 4_096, transformed: true } + }) + }) + + it('repeatedly compacts cleared prefixes and resets transferred storage', () => { + const queue = new PtyShutdownOutputQueue() + const fullEvent = '界'.repeat(Math.floor(BYTE_LIMIT / 3)) + for (let index = 0; index < 513; index += 1) { + queue.enqueue({ kind: 'replay', data: fullEvent }) + const storage = queue.getStorageForTest() + expect(storage.retainedEvents).toBe(1) + expect(storage.retainedBytes).toBe(BYTE_LIMIT - 2) + expect(storage.backingLength).toBeLessThanOrEqual(CHUNK_SIZE) + expect(storage.byteBackingLength).toBeLessThanOrEqual(CHUNK_SIZE) + } + + expect(queue.takeAll()).toEqual([{ kind: 'replay', data: fullEvent }]) + expect(queue.getStorageForTest()).toEqual({ + backingLength: 0, + byteBackingLength: 0, + chunkBackingLength: 0, + head: 0, + headChunk: 0, + retainedBytes: 0, + retainedEvents: 0 + }) + + queue.enqueue({ kind: 'data', data: 'discarded' }) + queue.discard() + expect(queue.takeAll()).toEqual([]) + }) + + it('releases dead backing storage under release-scale one-byte churn', () => { + const queue = new PtyShutdownOutputQueue() + for (let index = 0; index < BYTE_LIMIT + CHUNK_SIZE * 3 + 17; index += 1) { + queue.enqueue({ kind: 'replay', data: 'x' }) + } + + const storage = queue.getStorageForTest() + expect(storage.retainedEvents).toBe(BYTE_LIMIT) + expect(storage.retainedBytes).toBe(BYTE_LIMIT) + expect(storage.backingLength - storage.retainedEvents).toBeLessThan(CHUNK_SIZE) + expect(storage.byteBackingLength).toBe(0) + expect(storage.head).toBeLessThan(CHUNK_SIZE) + expect(storage.headChunk).toBeLessThan(storage.chunkBackingLength) + }) + + it('repeatedly compacts the consumed chunk cursor', () => { + const queue = new PtyShutdownOutputQueue() + const data = 'x'.repeat(BYTE_LIMIT / CHUNK_SIZE) + for (let index = 0; index < CHUNK_SIZE; index += 1) { + queue.enqueue({ kind: 'replay', data }) + } + + for (let cycle = 0; cycle < 3; cycle += 1) { + for (let index = 0; index < CHUNK_SIZE * CHUNK_SIZE; index += 1) { + queue.enqueue({ kind: 'replay', data }) + } + expect(queue.getStorageForTest()).toEqual({ + backingLength: CHUNK_SIZE, + byteBackingLength: 0, + chunkBackingLength: 1, + head: 0, + headChunk: 0, + retainedBytes: BYTE_LIMIT, + retainedEvents: CHUNK_SIZE + }) + } + }) + + it('recounts a returned Unicode event after mutation and re-enqueue', () => { + const queue = new PtyShutdownOutputQueue() + const reference = new ReferenceShutdownOutputQueue() + const unicode: PtyShutdownOutputEvent = { kind: 'replay', data: '界' } + queue.enqueue(unicode) + reference.enqueue(unicode) + const returned = queue.takeAll()[0] + const referenceReturned = reference.takeAll()[0] + if (!returned || !referenceReturned) { + throw new Error('Expected queued events') + } + returned.data = 'x' + referenceReturned.data = 'x' + queue.enqueue(returned) + reference.enqueue(referenceReturned) + const newest: PtyShutdownOutputEvent = { kind: 'data', data: 'y'.repeat(BYTE_LIMIT) } + queue.enqueue(newest) + reference.enqueue(newest) + expect(queue.getStorageForTest().retainedBytes).toBe(BYTE_LIMIT) + const overflow: PtyShutdownOutputEvent = { kind: 'replay', data: 'z' } + queue.enqueue(overflow) + reference.enqueue(overflow) + + expect(queue.takeAll()).toEqual(reference.takeAll()) + }) + + it('matches the previous algorithm across a seeded adversarial stream', () => { + const queue = new PtyShutdownOutputQueue() + const reference = new ReferenceShutdownOutputQueue() + const random = createSeededRandom(0x15c0ffee) + + for (let index = 0; index < 5_000; index += 1) { + const value = random() + const data = createDifferentialData(value) + const event: PtyShutdownOutputEvent = + value % 3 === 0 + ? { kind: 'replay', data } + : { + kind: 'data', + data, + meta: { + seq: index, + rawLength: data.length + (value % 5), + transformed: value % 2 === 0, + ...(value % 7 === 0 ? { background: true } : {}), + ...(value % 97 === 0 ? { droppedOutput: true } : {}) + } + } + queue.enqueue(event) + reference.enqueue(event) + + if ((index + 1) % 1_000 === 0) { + expect(queue.takeAll()).toEqual(reference.takeAll()) + } + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts new file mode 100644 index 00000000000..b54b787309f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-shutdown-output-queue.ts @@ -0,0 +1,149 @@ +import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { clampUtf8Tail } from './pty-eager-buffer-clamp' +import type { PtyDataMeta } from './pty-dispatcher' + +const EVENT_CHUNK_SIZE = 64 +const COMPACTION_MIN_HEAD_CHUNKS = 64 + +export type PtyShutdownOutputEvent = + | { kind: 'data'; data: string; meta?: PtyDataMeta } + | { kind: 'replay'; data: string } + +type PtyShutdownOutputChunk = { + events: (PtyShutdownOutputEvent | undefined)[] + eventBytes?: Uint32Array +} + +export type PtyShutdownOutputQueueStorage = { + backingLength: number + byteBackingLength: number + chunkBackingLength: number + head: number + headChunk: number + retainedBytes: number + retainedEvents: number +} + +export class PtyShutdownOutputQueue { + private chunks: (PtyShutdownOutputChunk | undefined)[] = [] + private headChunk = 0 + private headOffset = 0 + private retainedBytes = 0 + private retainedEvents = 0 + + enqueue(event: PtyShutdownOutputEvent): void { + const clamped = clampUtf8Tail(event.data, TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) + let tail = this.chunks.at(-1) + if (!tail || tail.events.length >= EVENT_CHUNK_SIZE) { + tail = { events: [] } + this.chunks.push(tail) + } + const tailOffset = tail.events.length + tail.events.push({ ...event, data: clamped.data }) + if (clamped.bytes !== clamped.data.length) { + tail.eventBytes ??= new Uint32Array(EVENT_CHUNK_SIZE) + tail.eventBytes[tailOffset] = clamped.bytes + } + this.retainedBytes += clamped.bytes + this.retainedEvents += 1 + + while (this.length > 1 && this.retainedBytes > TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT) { + this.evictOldest() + } + } + + takeAll(): PtyShutdownOutputEvent[] { + const events: PtyShutdownOutputEvent[] = [] + this.drain((event) => events.push(event)) + return events + } + + drain(consumer: (event: PtyShutdownOutputEvent) => void): void { + const chunks = this.chunks + const headChunk = this.headChunk + const headOffset = this.headOffset + this.reset() + for (let chunkIndex = headChunk; chunkIndex < chunks.length; chunkIndex += 1) { + const chunk = chunks[chunkIndex] + if (!chunk) { + continue + } + const start = chunkIndex === headChunk ? headOffset : 0 + for (let eventIndex = start; eventIndex < chunk.events.length; eventIndex += 1) { + const event = chunk.events[eventIndex] + if (event) { + consumer(event) + } + } + } + } + + discard(): void { + this.reset() + } + + get length(): number { + return this.retainedEvents + } + + getStorageForTest(): PtyShutdownOutputQueueStorage { + let backingLength = 0 + let byteBackingLength = 0 + for (const chunk of this.chunks) { + if (chunk) { + backingLength += chunk.events.length + byteBackingLength += chunk.eventBytes?.length ?? 0 + } + } + return { + backingLength, + byteBackingLength, + chunkBackingLength: this.chunks.length, + head: this.headOffset, + headChunk: this.headChunk, + retainedBytes: this.retainedBytes, + retainedEvents: this.length + } + } + + private evictOldest(): void { + const chunk = this.chunks[this.headChunk] + if (!chunk) { + throw new Error('Missing PTY shutdown output queue chunk') + } + const event = chunk.events[this.headOffset] + if (!event) { + throw new Error('Missing PTY shutdown output queue event') + } + this.retainedBytes -= chunk.eventBytes?.[this.headOffset] || event.data.length + chunk.events[this.headOffset] = undefined + if (chunk.eventBytes) { + chunk.eventBytes[this.headOffset] = 0 + } + this.headOffset += 1 + this.retainedEvents -= 1 + if (this.headOffset < chunk.events.length) { + return + } + this.chunks[this.headChunk] = undefined + this.headChunk += 1 + this.headOffset = 0 + this.compactChunks() + } + + private compactChunks(): void { + if (this.headChunk < COMPACTION_MIN_HEAD_CHUNKS || this.headChunk * 2 < this.chunks.length) { + return + } + this.chunks = this.chunks.slice(this.headChunk) + this.headChunk = 0 + } + + private reset(): void { + this.chunks = [] + this.headChunk = 0 + this.headOffset = 0 + this.retainedBytes = 0 + this.retainedEvents = 0 + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 8c82e7584a1..d0c20224e5a 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -18,6 +18,8 @@ import type { RemoteRuntimeSnapshotOutcome } from '../../runtime/remote-runtime- export type PtyBufferSnapshot = { data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string cols: number rows: number seq?: number @@ -59,6 +61,12 @@ export type PtyConnectResult = { snapshot?: string snapshotCols?: number snapshotRows?: number + /** Normal-buffer history and mode preamble before an alternate-screen frame. */ + snapshotPrefixAnsi?: string + /** Visual alternate-screen frame. Both fields are absent on older hosts. */ + snapshotFrameAnsi?: string + /** Live state to append when omitting `snapshotFrameAnsi`. */ + snapshotFrameRestoreAnsi?: string isAlternateScreen?: boolean sessionExpired?: boolean coldRestore?: { scrollback: string; cwd: string; cols?: number; rows?: number } diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index 902652ad082..14ff327146e 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -1763,7 +1763,7 @@ describe('createIpcPtyTransport', () => { expect(writeMock).not.toHaveBeenCalled() }) - it('preserves snapshot dimensions when reattaching', async () => { + it('preserves snapshot dimensions and split alt-frame strings when reattaching', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawnMock = vi.fn().mockResolvedValue({ id: 'pty-reattach', @@ -1771,7 +1771,10 @@ describe('createIpcPtyTransport', () => { launchAgent: 'droid', snapshot: 'snapshot data', snapshotCols: 132, - snapshotRows: 43 + snapshotRows: 43, + snapshotPrefixAnsi: 'history and modes', + snapshotFrameAnsi: 'visual frame', + snapshotFrameRestoreAnsi: 'live state' }) ;(globalThis as { window: typeof window }).window = { @@ -1811,6 +1814,9 @@ describe('createIpcPtyTransport', () => { snapshot: 'snapshot data', snapshotCols: 132, snapshotRows: 43, + snapshotPrefixAnsi: 'history and modes', + snapshotFrameAnsi: 'visual frame', + snapshotFrameRestoreAnsi: 'live state', isAlternateScreen: undefined, coldRestore: undefined, replay: undefined, diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index f7248ec704d..3198281df9b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -893,6 +893,15 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra snapshot: spawnResult.snapshot, snapshotCols: spawnResult.snapshotCols, snapshotRows: spawnResult.snapshotRows, + ...(spawnResult.snapshotPrefixAnsi !== undefined + ? { snapshotPrefixAnsi: spawnResult.snapshotPrefixAnsi } + : {}), + ...(spawnResult.snapshotFrameAnsi !== undefined + ? { snapshotFrameAnsi: spawnResult.snapshotFrameAnsi } + : {}), + ...(spawnResult.snapshotFrameRestoreAnsi !== undefined + ? { snapshotFrameRestoreAnsi: spawnResult.snapshotFrameRestoreAnsi } + : {}), isAlternateScreen: spawnResult.isAlternateScreen, sessionExpired: spawnResult.sessionExpired, coldRestore: spawnResult.coldRestore, diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts index 47bc5af02e2..0c9b286703c 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts @@ -63,7 +63,6 @@ type MockTransport = { const scheduleRuntimeGraphSync = vi.fn() const shouldSeedCacheTimerOnInitialTitle = vi.fn(() => false) -const scheduleTerminalWebglAtlasRecovery = vi.fn() const toastInfo = vi.fn() const notifyCodexPaneBoundForStaleSweep = vi.fn() @@ -88,10 +87,6 @@ vi.mock('@/store', () => ({ } })) -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal>() const isGeminiTerminalTitle = actual.isGeminiTerminalTitle as (title: string) => boolean diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts index 3ce3f3ad193..ea6f69c1ab7 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts @@ -85,7 +85,6 @@ type MockTransport = { const scheduleRuntimeGraphSync = vi.fn() const shouldSeedCacheTimerOnInitialTitle = vi.fn(() => false) -const scheduleTerminalWebglAtlasRecovery = vi.fn() const toastInfo = vi.fn() const notifyCodexPaneBoundForStaleSweep = vi.fn() @@ -110,10 +109,6 @@ vi.mock('@/store', () => ({ } })) -vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery -})) - vi.mock('@/lib/agent-status', async (importOriginal) => { const actual = await importOriginal>() const isGeminiTerminalTitle = actual.isGeminiTerminalTitle as (title: string) => boolean diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx new file mode 100644 index 00000000000..902c3254557 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-pre-gate-loop.react185.test.tsx @@ -0,0 +1,342 @@ +/** @vitest-environment happy-dom */ +import { act, StrictMode } from 'react' +import type * as ReactModule from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const harness = vi.hoisted(() => ({ + worktreeId: 'repo::/cold-park-pre-gate', + discardMemoCaches: false, + coverageCalls: 0, + syncCalls: 0, + slotRenders: 0, + renderedParkedSets: [] as string[][] +})) + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useMemo: ((calculate, dependencies) => + harness.discardMemoCaches + ? calculate() + : actual.useMemo(calculate, dependencies)) as typeof actual.useMemo + } +}) + +vi.mock('../../store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + activeGroupIdByWorktree: {} as Record, + groupsByWorktree: {} as Record, + pendingStartupByTabId: {} as Record, + ptyIdsByTabId: {} as Record, + runtimeStatusByEnvironmentId: new Map(), + runtimePaneTitlesByTabId: {} as Record>, + settings: {} as Record, + sleepingAgentSessionsByPaneKey: {} as Record, + tabsByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record, + unifiedTabsByWorktree: {} as Record, + consumeSuppressedPtyExit: () => false, + focusGroup: () => {}, + reconcileWorktreeTabModel: () => ({ renderableTabCount: 2 }), + setActiveWorktree: () => {} + })) + return { useAppStore } +}) + +vi.mock('../native-chat/use-native-chat-toggle-shortcut', () => ({ + useNativeChatToggleShortcut: () => {} +})) + +vi.mock('./TerminalOverlaySlot', () => ({ + TerminalOverlaySlot: () => { + harness.slotRenders += 1 + return null + } +})) + +vi.mock('./terminal-parked-tab-watchers', async () => { + const { useAppStore } = await import('../../store') + return { + canWatcherCoverParkedTerminalTab: () => { + harness.coverageCalls += 1 + return harness.coverageCalls % 2 === 1 + }, + disposeParkedTerminalWatchersForWorktree: () => {}, + syncParkedTerminalTabWatchers: (args: { + worktreeId: string + parkedTabIds: ReadonlySet + }) => { + harness.syncCalls += 1 + harness.renderedParkedSets.push([...args.parkedTabIds].sort()) + if (args.parkedTabIds.size === 0) { + return + } + ;( + useAppStore as unknown as { + setState: (update: (state: ParkingStoreState) => Partial) => void + } + ).setState((state) => ({ + tabsByWorktree: { + ...state.tabsByWorktree, + [args.worktreeId]: state.tabsByWorktree[args.worktreeId].map((tab) => ({ + ...tab, + title: `${tab.id}-${harness.syncCalls}` + })) + }, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [args.worktreeId]: state.unifiedTabsByWorktree[args.worktreeId].map((tab) => ({ + ...tab, + label: `${tab.entityId}-${harness.syncCalls}` + })) + } + })) + } + } +}) + +vi.mock('./terminal-parking-e2e-overrides', () => ({ + getTerminalParkingPolicyOverrides: () => ({ coldParkDelayMs: 0, hotRetainMs: 0 }) +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +import { useAppStore } from '../../store' +import TerminalPaneOverlayLayer from './TerminalPaneOverlayLayer' + +const TAB_IDS = ['tab-a', 'tab-b'] as const +const GROUP_ID = 'group-a' + +type ParkingStoreState = { + groupsByWorktree: Record + tabsByWorktree: Record + unifiedTabsByWorktree: Record +} + +const parkingStore = useAppStore as unknown as { + getState: () => ParkingStoreState + setState: (partial: Record) => void +} + +function terminalTab(id: string): TerminalTab { + return { + id, + worktreeId: harness.worktreeId, + ptyId: `${harness.worktreeId}@@session-${id}`, + title: id, + generation: 0 + } as TerminalTab +} + +function unifiedTerminalTab(id: string): Tab { + return { + id: `unified-${id}`, + entityId: id, + worktreeId: harness.worktreeId, + groupId: GROUP_ID, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function renderProductionLayer(root: Root, coldParkTerminalPanes = true): unknown { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + let thrown: unknown = null + try { + act(() => { + root.render( + + + + ) + }) + } catch (error) { + thrown = error + } + consoleError.mockRestore() + return thrown +} + +describe('TerminalPaneOverlayLayer cold-park pre-gate loop', () => { + let container: HTMLDivElement + let root: Root | undefined + + beforeEach(() => { + harness.discardMemoCaches = false + harness.coverageCalls = 0 + harness.syncCalls = 0 + harness.slotRenders = 0 + harness.renderedParkedSets.length = 0 + const tabs = TAB_IDS.map(terminalTab) + const unifiedTabs = TAB_IDS.map(unifiedTerminalTab) + parkingStore.setState({ + activeGroupIdByWorktree: { [harness.worktreeId]: GROUP_ID }, + groupsByWorktree: { + [harness.worktreeId]: [ + { + id: GROUP_ID, + worktreeId: harness.worktreeId, + activeTabId: unifiedTabs[0].id, + tabOrder: unifiedTabs.map((tab) => tab.id), + recentTabIds: [unifiedTabs[0].id] + } + ] + }, + tabsByWorktree: { [harness.worktreeId]: tabs }, + unifiedTabsByWorktree: { [harness.worktreeId]: unifiedTabs } + }) + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + try { + act(() => root?.unmount()) + } catch { + // A failed initial commit leaves no mounted tree to clean up. + } + root = undefined + container.remove() + }) + + it('settles title publications without suppressing PTY rechecks', () => { + root = createRoot(container) + const thrown = renderProductionLayer(root) + + expect(thrown).toBeNull() + expect(harness.coverageCalls).toBe(2) + expect(harness.syncCalls).toBeLessThan(10) + expect(harness.renderedParkedSets).not.toHaveLength(0) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + expect(harness.slotRenders).toBe(0) + + const settledSyncCalls = harness.syncCalls + const state = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...state.tabsByWorktree, + [harness.worktreeId]: state.tabsByWorktree[harness.worktreeId].map((tab) => + tab.id === TAB_IDS[1] ? { ...tab, ptyId: `${tab.ptyId}-replacement` } : tab + ) + } + }) + }) + + expect(harness.coverageCalls).toBe(3) + expect(harness.syncCalls).toBeGreaterThan(settledSyncCalls) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + expect(harness.slotRenders).toBe(0) + + const beforePendingSpawnSyncCalls = harness.syncCalls + const pendingSpawnState = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...pendingSpawnState.tabsByWorktree, + [harness.worktreeId]: pendingSpawnState.tabsByWorktree[harness.worktreeId].map((tab) => + tab.id === TAB_IDS[1] ? { ...tab, pendingActivationSpawn: 1 } : tab + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforePendingSpawnSyncCalls) + + const beforeTabOrderSyncCalls = harness.syncCalls + const tabOrderState = parkingStore.getState() + act(() => { + parkingStore.setState({ + tabsByWorktree: { + ...tabOrderState.tabsByWorktree, + [harness.worktreeId]: tabOrderState.tabsByWorktree[harness.worktreeId].toReversed() + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeTabOrderSyncCalls) + + const beforeActiveTabChangeSyncCalls = harness.syncCalls + const activeTabState = parkingStore.getState() + act(() => { + parkingStore.setState({ + groupsByWorktree: { + ...activeTabState.groupsByWorktree, + [harness.worktreeId]: activeTabState.groupsByWorktree[harness.worktreeId].map( + (group) => ({ + ...group, + activeTabId: `unified-${TAB_IDS[1]}` + }) + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeActiveTabChangeSyncCalls) + + const beforeGroupChangeSyncCalls = harness.syncCalls + const groupState = parkingStore.getState() + const nextGroupId = 'group-b' + act(() => { + parkingStore.setState({ + groupsByWorktree: { + ...groupState.groupsByWorktree, + [harness.worktreeId]: [ + ...groupState.groupsByWorktree[harness.worktreeId], + { + id: nextGroupId, + worktreeId: harness.worktreeId, + activeTabId: `unified-${TAB_IDS[1]}`, + tabOrder: [`unified-${TAB_IDS[1]}`], + recentTabIds: [`unified-${TAB_IDS[1]}`] + } + ] + }, + unifiedTabsByWorktree: { + ...groupState.unifiedTabsByWorktree, + [harness.worktreeId]: groupState.unifiedTabsByWorktree[harness.worktreeId].map((tab) => + tab.entityId === TAB_IDS[1] ? { ...tab, groupId: nextGroupId } : tab + ) + } + }) + }) + expect(harness.syncCalls).toBeGreaterThan(beforeGroupChangeSyncCalls) + + expect(renderProductionLayer(root, false)).toBeNull() + expect(harness.slotRenders).toBeGreaterThan(0) + + expect(renderProductionLayer(root)).toBeNull() + expect(harness.renderedParkedSets.at(-1)).toEqual([...TAB_IDS]) + }) + + it('settles equivalent publications after memo cache discard', () => { + harness.discardMemoCaches = true + root = createRoot(container) + + const thrown = renderProductionLayer(root) + + expect(thrown).toBeNull() + expect(harness.syncCalls).toBeLessThan(10) + expect(new Set(harness.renderedParkedSets.map((ids) => ids.join(',')))).toEqual( + new Set([TAB_IDS.join(',')]) + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx index 97cb2db3428..9a97a10745b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-cold-park-verdict-loop.test.tsx @@ -31,6 +31,7 @@ vi.mock('../../store', async () => { const { create } = await import('zustand') const useAppStore = create(() => ({ pendingStartupByTabId: {} as Record, + ptyIdsByTabId: {} as Record, runtimeStatusByEnvironmentId: new Map(), settings: {} as Record, terminalLayoutsByTabId: {} as Record, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts new file mode 100644 index 00000000000..90825f42980 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { selectTerminalPaneHostState } from './terminal-pane-host-state' + +function makeState(overrides: Record): AppState { + return { + activeWorktreeId: null, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + projectGroups: [], + removedSshTargetLabels: new Map(), + repos: [], + runtimeStatusByEnvironmentId: new Map(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + sshTargetLabels: new Map(), + sshTargetsHydrated: true, + worktreesByRepo: {}, + ...overrides + } as unknown as AppState +} + +describe('selectTerminalPaneHostState', () => { + it('resolves local and direct SSH workspaces without changing reconnect semantics', () => { + const localState = makeState({ + repos: [{ id: 'repo-local' }], + worktreesByRepo: { + 'repo-local': [{ id: 'wt-local', repoId: 'repo-local' }] + } + }) + + expect(selectTerminalPaneHostState(localState, 'wt-local')).toEqual({ + nativeChatTranscriptIsLocalReadable: true, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + }) + + const sshState = makeState({ + repos: [{ id: 'repo-ssh', connectionId: 'ssh-a' }], + sshConnectionStates: new Map([ + ['ssh-a', { targetId: 'ssh-a', status: 'connected', error: null, reconnectAttempt: 0 }] + ]), + sshTargetLabels: new Map([['ssh-a', 'devbox']]), + worktreesByRepo: { + 'repo-ssh': [{ id: 'wt-ssh', repoId: 'repo-ssh' }] + } + }) + + expect(selectTerminalPaneHostState(sshState, 'wt-ssh')).toEqual({ + nativeChatTranscriptIsLocalReadable: false, + sshReconnectEnvironmentId: null, + sshReconnectStatus: 'connected', + sshReconnectTargetId: 'ssh-a', + sshReconnectTargetLabel: 'devbox', + sshReconnectTargetRemoved: false + }) + }) + + it('reads nested SSH state from the worktree owner runtime', () => { + const state = makeState({ + repos: [ + { + id: 'repo-runtime', + connectionId: 'ssh-nested', + executionHostId: 'runtime:env-a' + } + ], + runtimeStatusByEnvironmentId: new Map([ + ['env-a', { status: { runtimeId: 'runtime-a' }, checkedAt: 1 }] + ]), + sshStateByEnvironment: new Map([ + [ + 'env-a', + { + connectionStates: new Map([ + [ + 'ssh-nested', + { + targetId: 'ssh-nested', + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + ] + ]), + targetLabels: new Map([['ssh-nested', 'build box']]), + removedTargetLabels: new Map(), + targetsHydrated: true + } + ] + ]), + worktreesByRepo: { + 'repo-runtime': [ + { + id: 'wt-runtime', + repoId: 'repo-runtime', + hostId: 'runtime:env-a', + runtimeOwnerEnvironmentId: 'env-a' + } + ] + } + }) + + expect(selectTerminalPaneHostState(state, 'wt-runtime')).toEqual({ + nativeChatTranscriptIsLocalReadable: false, + sshReconnectEnvironmentId: 'env-a', + sshReconnectStatus: 'disconnected', + sshReconnectTargetId: 'ssh-nested', + sshReconnectTargetLabel: 'build box', + sshReconnectTargetRemoved: false + }) + }) + + it('keeps runtime-owned SSH plumbing out of reconnect UI', () => { + const state = makeState({ + repos: [{ id: 'repo-ephemeral', connectionId: 'runtime-ssh-vm-a' }], + worktreesByRepo: { + 'repo-ephemeral': [{ id: 'wt-ephemeral', repoId: 'repo-ephemeral' }] + } + }) + + expect(selectTerminalPaneHostState(state, 'wt-ephemeral')).toEqual({ + nativeChatTranscriptIsLocalReadable: true, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts new file mode 100644 index 00000000000..17a7e1a354c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-host-state.ts @@ -0,0 +1,61 @@ +import type { AppState } from '@/store/types' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' +import { + selectRuntimeAwareSshStatus, + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved +} from '@/store/slices/runtime-environment-ssh' +import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' + +export type TerminalPaneHostState = { + nativeChatTranscriptIsLocalReadable: boolean + sshReconnectEnvironmentId: string | null + sshReconnectStatus: ReturnType + sshReconnectTargetId: string | null + sshReconnectTargetLabel: string + sshReconnectTargetRemoved: boolean +} + +export function selectTerminalPaneHostState( + state: AppState, + worktreeId: string +): TerminalPaneHostState { + const connectionId = getConnectionIdFromState(state, worktreeId) + const nativeChatTranscriptIsLocalReadableResult = + isNativeChatTranscriptLocalReadable(connectionId) + const sshReconnectTargetId = + connectionId && !isRuntimeOwnedSshTargetId(connectionId) ? connectionId : null + if (!sshReconnectTargetId) { + return { + nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult, + sshReconnectEnvironmentId: null, + sshReconnectStatus: null, + sshReconnectTargetId: null, + sshReconnectTargetLabel: '', + sshReconnectTargetRemoved: false + } + } + const sshReconnectEnvironmentId = getExplicitRuntimeEnvironmentIdForWorktree(state, worktreeId) + return { + nativeChatTranscriptIsLocalReadable: nativeChatTranscriptIsLocalReadableResult, + sshReconnectEnvironmentId, + sshReconnectStatus: selectRuntimeAwareSshStatus( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ), + sshReconnectTargetId, + sshReconnectTargetLabel: selectRuntimeAwareSshTargetLabel( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ), + sshReconnectTargetRemoved: selectRuntimeAwareSshTargetRemoved( + state, + sshReconnectEnvironmentId, + sshReconnectTargetId + ) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts b/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts index 394259d3923..7082f7aff9d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts +++ b/src/renderer/src/components/terminal-pane/terminal-paste-chunks.ts @@ -1,6 +1,7 @@ import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from './terminal-bracketed-paste' import { TERMINAL_PASTE_CHUNK_MAX_BYTES } from './terminal-paste-limits' import type { TerminalPastePlan } from './terminal-paste-coordinator' +import { getUtf8ByteLengthForCodePoint } from '../../../../shared/utf8-byte-limits' const TERMINAL_PASTE_ESCAPE_CODE_POINT = 0x1b const TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT = 0x241b @@ -58,7 +59,7 @@ function* iterateTextByUtf8Bytes( : normalizedCodePoint === codePoint ? text.slice(index, index + codeUnitLength) : '\r' - const nextBytes = utf8BytesForCodePoint( + const nextBytes = getUtf8ByteLengthForCodePoint( sanitizedEscape ? TERMINAL_PASTE_INERT_ESCAPE_CODE_POINT : normalizedCodePoint ) if (chunk && chunkBytes + nextBytes > maxBytes) { @@ -80,16 +81,3 @@ function* iterateTextByUtf8Bytes( yield chunk } } - -function utf8BytesForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts index ccae12ee863..9a3aa6621c5 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.test.ts @@ -1,10 +1,16 @@ import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' import { buildMainModelSnapshotReplayWrites, hasPositiveTerminalDimensions, - resolvePositiveTerminalDimensions + resolvePositiveTerminalDimensions, + shouldSkipAltFrameForWidthMismatch } from './terminal-snapshot-replay-paint' +function writeTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + describe('hasPositiveTerminalDimensions', () => { it('accepts only finite positive numeric pairs', () => { expect(hasPositiveTerminalDimensions(80, 24)).toBe(true) @@ -66,3 +72,104 @@ describe('buildMainModelSnapshotReplayWrites', () => { ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'alt-frame']) }) }) + +describe('shouldSkipAltFrameForWidthMismatch', () => { + it('skips only when the snapshot is WIDER than the target', () => { + expect(shouldSkipAltFrameForWidthMismatch(135, 128)).toBe(true) + expect(shouldSkipAltFrameForWidthMismatch(129, 128)).toBe(true) + expect(shouldSkipAltFrameForWidthMismatch(128, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(100, 120)).toBe(false) + }) + + it('never skips when a width is missing or nonsensical', () => { + // Why: an unknown width must not cost the user their restored frame. + expect(shouldSkipAltFrameForWidthMismatch(undefined, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(135, undefined)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(0, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(Number.NaN, 128)).toBe(false) + expect(shouldSkipAltFrameForWidthMismatch(Number.POSITIVE_INFINITY, 128)).toBe(false) + }) + + it('can conservatively skip a live frame until a hidden pane has a final grid', () => { + expect(shouldSkipAltFrameForWidthMismatch(135, undefined, { skipIfTargetUnknown: true })).toBe( + true + ) + expect( + shouldSkipAltFrameForWidthMismatch(undefined, undefined, { skipIfTargetUnknown: true }) + ).toBe(false) + }) +}) + +describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => { + it('keeps normal history and a clean alt grid through the real resize path', async () => { + const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 }) + const snapshot = { + data: '\x1b[1;1HWIDE-FRAME', + frameRestoreAnsi: '\x1b[?25l', + alternateScreen: true, + scrollbackAnsi: 'log' + } + + try { + for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame: true })) { + await writeTerminal(terminal, chunk) + } + terminal.resize(4, 5) + + expect(terminal.buffer.active.type).toBe('alternate') + expect(terminal.buffer.active.getLine(0)?.translateToString(true)).toBe('') + expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('log') + } finally { + terminal.dispose() + } + }) + + it('drops only the frame paint, keeping scrollback and the alt-buffer choreography', () => { + expect( + buildMainModelSnapshotReplayWrites( + { + data: 'mode-prefixalt-frame', + frameRestoreAnsi: 'complete-live-state', + alternateScreen: true, + scrollbackAnsi: 'normal-history' + }, + { skipAltFrame: true } + ) + ).toEqual([ + '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', + 'normal-history', + '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', + 'complete-live-state' + ]) + }) + + it('still enters a cleared alt screen when skipping without split scrollback', () => { + // Why the clear still runs: the caller's SIGWINCH must land on a clean + // screen the application repaints, not the stale pre-park frame. + expect( + buildMainModelSnapshotReplayWrites( + { + data: 'mode-prefixalt-frame', + frameRestoreAnsi: 'complete-live-state', + alternateScreen: true + }, + { skipAltFrame: true } + ) + ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'complete-live-state']) + }) + + it('keeps composed data when an older producer omits the mode boundary', () => { + expect( + buildMainModelSnapshotReplayWrites( + { data: 'legacy-modes-and-frame', alternateScreen: true }, + { skipAltFrame: true } + ) + ).toEqual(['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', 'legacy-modes-and-frame']) + }) + + it('never drops a normal-buffer snapshot, whose rows reflow correctly', () => { + expect( + buildMainModelSnapshotReplayWrites({ data: 'shell-output' }, { skipAltFrame: true }) + ).toEqual(['\x1b[2J\x1b[3J\x1b[H', 'shell-output']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index b17bdd5435a..643ca0de925 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -1,3 +1,6 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { readProposedPaneFitDimensions } from '@/lib/pane-manager/pane-fit' + /** * Shared guards and write choreography for painting a main-model snapshot into * a (possibly fresh) xterm. One source for the reattach/hidden-restore paint @@ -28,23 +31,65 @@ export function resolvePositiveTerminalDimensions( : null } +/** + * The column count the post-replay fit will land on. Why not terminal.cols: a + * pane that has not been fitted yet still reads xterm's 80-column default, so + * comparing against it would drop frames whose width actually matches the + * container. Returns undefined when the pane cannot be measured; callers with + * a repaint owner can clear and defer, while offline preconnect paint cannot. + */ +export function readProposedTerminalCols(pane: ManagedPane): number | undefined { + return readProposedPaneFitDimensions(pane)?.cols +} + +export function shouldSkipAltFrameForWidthMismatch( + snapshotCols: number | undefined, + targetCols: number | undefined, + options: { skipIfTargetUnknown?: boolean } = {} +): boolean { + if (typeof snapshotCols !== 'number' || !Number.isFinite(snapshotCols) || snapshotCols <= 0) { + return false + } + if (typeof targetCols !== 'number' || !Number.isFinite(targetCols) || targetCols <= 0) { + // A hidden pane has no final grid; its live app can repaint after the deferred fit. + return options.skipIfTargetUnknown === true + } + // Fixed-grid alt rows clip at narrower columns; normal history remains reflowable. + return snapshotCols > targetCols +} + /** * Ordered replay writes for a main-model snapshot, including the alt-screen * choreography: main strips the `?1049h` marker when splitting scrollbackAnsi * from an alt frame, so the restorer owns the transition — rebuild the normal * buffer while on it, then paint the alt frame clean. Callers write these * before their post-replay reset/escape-tail sequences. + * + * `skipAltFrame` drops only the frame paint, never the buffer choreography or + * scrollback or mode rehydration: the alt buffer is still entered and cleared + * so the caller's SIGWINCH lands on a clean screen the application repaints. */ -export function buildMainModelSnapshotReplayWrites(snapshot: { - data: string - alternateScreen?: boolean - scrollbackAnsi?: string -}): string[] { +export function buildMainModelSnapshotReplayWrites( + snapshot: { + data: string + /** Live state that can be restored without an alternate-screen frame. */ + frameRestoreAnsi?: string + alternateScreen?: boolean + scrollbackAnsi?: string + }, + options: { skipAltFrame?: boolean } = {} +): string[] { if (!snapshot.alternateScreen) { // Why: \x1b[3J wipes xterm scrollback; safe here because a normal-buffer // snapshot carries its own history in data (mirrors pty-transport.ts). return ['\x1b[2J\x1b[3J\x1b[H', snapshot.data] } + // Older snapshot producers do not expose the mode/frame boundary. Keep their + // composed data rather than dropping terminal modes together with the frame. + const altFrame = + options.skipAltFrame && snapshot.frameRestoreAnsi !== undefined + ? [snapshot.frameRestoreAnsi] + : [snapshot.data] if (snapshot.scrollbackAnsi !== undefined) { // Why: main serializes normal + alt buffers separately; rebuild normal // while active, then return to a clean alt frame. @@ -52,11 +97,11 @@ export function buildMainModelSnapshotReplayWrites(snapshot: { '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', snapshot.scrollbackAnsi, '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', - snapshot.data + ...altFrame ] } // Why: the snapshot's ?1049h no-ops when already on alt screen and skips // blank cells; clear the alt buffer so the pre-hide frame can't bleed // through blank cells (spares normal-buffer scrollback). - return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', snapshot.data] + return ['\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', ...altFrame] } diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 441e532dd2e..3b198054861 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -23,8 +23,6 @@ vi.mock('./pane-helpers', () => ({ })) const scheduleTabRevealWebglAtlasRecovery = vi.fn() vi.mock('./terminal-webgl-atlas-recovery', () => ({ - // Why: the light-tab reveal must recover the atlas immediately, decoupled from - // the terminal-output debounce (which a background stream could otherwise defer). scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) const flushDeferredPaneMetricOptionsIfMeasurable = vi.fn((_pane: unknown) => false) @@ -90,8 +88,6 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) expect(manager.resumeRendering).not.toHaveBeenCalled() - // Reveal recovery is immediate (not the terminal-output debounce), so a - // background stream in another pane cannot defer this tab's atlas rebuild. expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 0933bbd47bd..34907085ccb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -88,9 +88,7 @@ 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 recovery must be immediate, not the terminal-output debounce - // — a background agent streaming in another pane must not defer this tab's - // atlas rebuild. + // 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 diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts deleted file mode 100644 index 4add011e474..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery-rate.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - registerLivePaneManager, - unregisterLivePaneManager -} from '@/lib/pane-manager/pane-manager-registry' -import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics' -import { - resetTerminalWebglAtlasRecoveryBudgetForTesting, - scheduleImagePasteWebglAtlasRecovery, - scheduleTabRevealWebglAtlasRecovery, - scheduleTerminalWebglAtlasRecovery, - TERMINAL_OUTPUT_RECOVERY_QUIET_MS -} from './terminal-webgl-atlas-recovery' - -describe('terminal WebGL atlas recovery rate', () => { - const managers: { resetWebglTextureAtlases(): void }[] = [] - - function registerManager() { - const manager = { - resetWebglTextureAtlases: vi.fn(), - refreshAllPanes: vi.fn(), - scheduleRevealPresent: vi.fn() - } - registerLivePaneManager(manager) - managers.push(manager) - return manager - } - - function useImmediateAnimationFrames(): void { - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - ) - } - - afterEach(() => { - for (const manager of managers.splice(0)) { - unregisterLivePaneManager(manager) - } - setTerminalWebglDiagnosticRecorder(null) - resetTerminalWebglAtlasRecoveryBudgetForTesting() - vi.clearAllTimers() - vi.useRealTimers() - vi.unstubAllGlobals() - }) - - it('caps atlas wipes under a sustained redraw cadence', () => { - vi.useFakeTimers() - vi.setSystemTime(0) - useImmediateAnimationFrames() - const wipeTimes: number[] = [] - const manager = { - resetWebglTextureAtlases: vi.fn(() => wipeTimes.push(Date.now())), - refreshAllPanes: vi.fn(), - scheduleRevealPresent: vi.fn() - } - registerLivePaneManager(manager) - managers.push(manager) - - for (let elapsed = 0; elapsed < 300_000; elapsed += 300) { - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(300) - } - - expect(wipeTimes.length).toBeGreaterThan(1) - const gaps = wipeTimes.slice(1).map((at, index) => at - wipeTimes[index]!) - expect(wipeTimes.length).toBeLessThanOrEqual(75) - expect(Math.max(...gaps)).toBeLessThanOrEqual(6_500) - expect(Date.now() - wipeTimes.at(-1)!).toBeLessThanOrEqual(6_500) - - const streamEndedAt = Date.now() - const wipesBeforeTailRepair = wipeTimes.length - vi.advanceTimersByTime(6_500) - expect(wipeTimes).toHaveLength(wipesBeforeTailRepair + 1) - expect(wipeTimes.at(-1)! - streamEndedAt).toBeLessThanOrEqual(6_500) - }) - - it('cancels pending settle and deferred repair timers when recovery state resets', () => { - vi.useFakeTimers() - vi.setSystemTime(0) - useImmediateAnimationFrames() - const manager = registerManager() - - scheduleTerminalWebglAtlasRecovery() - resetTerminalWebglAtlasRecoveryBudgetForTesting() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - - expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() - expect(manager.scheduleRevealPresent).not.toHaveBeenCalled() - - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - resetTerminalWebglAtlasRecoveryBudgetForTesting() - vi.advanceTimersByTime(3_000) - - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledOnce() - expect(manager.scheduleRevealPresent).toHaveBeenCalledOnce() - }) - - it('presents a suppressed wipe and cancels its repair while output resumes', () => { - vi.useFakeTimers() - vi.setSystemTime(0) - useImmediateAnimationFrames() - const manager = registerManager() - - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledOnce() - expect(manager.scheduleRevealPresent).toHaveBeenCalledOnce() - - for (let elapsed = 0; elapsed < 3_000; elapsed += 50) { - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(50) - } - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledOnce() - - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) - }) - - it('repairs the final suppressed settle when the budget becomes available', () => { - vi.useFakeTimers() - vi.setSystemTime(0) - useImmediateAnimationFrames() - const manager = registerManager() - - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - vi.advanceTimersByTime(2_799) - - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledOnce() - vi.advanceTimersByTime(1) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) - }) - - it('reports suppressed reset counts on the next permitted wipe', () => { - vi.useFakeTimers() - vi.setSystemTime(0) - useImmediateAnimationFrames() - registerManager() - const diagnostics: { kind: string; detail?: Record }[] = [] - setTerminalWebglDiagnosticRecorder((kind, detail) => diagnostics.push({ kind, detail })) - - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - vi.advanceTimersByTime(2_600) - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - - expect(diagnostics.filter(({ kind }) => kind === 'webgl-atlas-reset-rate')).toEqual([ - { - kind: 'webgl-atlas-reset-rate', - detail: { - reason: 'terminal-output', - attemptsSinceLastReset: 1, - atlasResetsSuppressed: 0, - intervalMs: 0 - } - }, - { - kind: 'webgl-atlas-reset-rate', - detail: { - reason: 'terminal-output', - attemptsSinceLastReset: 2, - atlasResetsSuppressed: 1, - intervalMs: 3_000 - } - } - ]) - }) - - it('recovers when the wall clock steps backwards', () => { - vi.useFakeTimers() - vi.setSystemTime(600_000) - useImmediateAnimationFrames() - const manager = registerManager() - - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - vi.setSystemTime(60_000) - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) - }) - - it('keeps one-shot paste and reveal recovery outside the streaming budget', () => { - vi.useFakeTimers() - vi.setSystemTime(0) - useImmediateAnimationFrames() - const manager = registerManager() - - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - manager.resetWebglTextureAtlases.mockClear() - - scheduleTabRevealWebglAtlasRecovery() - vi.advanceTimersByTime(500) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - - manager.resetWebglTextureAtlases.mockClear() - scheduleImagePasteWebglAtlasRecovery() - vi.advanceTimersByTime(500) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 69d5df93f3d..80013abb860 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -3,13 +3,10 @@ import { registerLivePaneManager, unregisterLivePaneManager } from '@/lib/pane-manager/pane-manager-registry' -import { - resetTerminalWebglAtlasRecoveryBudgetForTesting, - scheduleImagePasteWebglAtlasRecovery, - scheduleTabRevealWebglAtlasRecovery, - scheduleTerminalWebglAtlasRecovery, - TERMINAL_OUTPUT_RECOVERY_QUIET_MS -} from './terminal-webgl-atlas-recovery' +import * as terminalWebglAtlasRecovery from './terminal-webgl-atlas-recovery' + +const { scheduleImagePasteWebglAtlasRecovery, scheduleTabRevealWebglAtlasRecovery } = + terminalWebglAtlasRecovery describe('terminal WebGL atlas recovery', () => { const registeredManagers: { resetWebglTextureAtlases(): void }[] = [] @@ -33,12 +30,15 @@ describe('terminal WebGL atlas recovery', () => { for (const manager of registeredManagers.splice(0)) { unregisterLivePaneManager(manager) } - resetTerminalWebglAtlasRecoveryBudgetForTesting() vi.clearAllTimers() vi.useRealTimers() vi.unstubAllGlobals() }) + it('limits atlas recovery to explicit renderer lifecycle events', () => { + expect(terminalWebglAtlasRecovery).not.toHaveProperty('scheduleTerminalWebglAtlasRecovery') + }) + it('clears atlases and refreshes panes through the post-paste redraw window', () => { vi.useFakeTimers() const rafCallbacks: FrameRequestCallback[] = [] @@ -152,10 +152,7 @@ describe('terminal WebGL atlas recovery', () => { expect(manager.refreshAllPanes).not.toHaveBeenCalled() }) - it('recovers immediately on a tab reveal, not through the streaming debounce', () => { - // Regression guard (STA-1365 review): reveal recovery must stay immediate so a - // background agent streaming in another pane cannot defer a revealed tab's - // atlas rebuild. It shares the paste path's immediate burst, not the debounce. + it('recovers immediately on a tab reveal', () => { vi.useFakeTimers() const rafCallbacks: FrameRequestCallback[] = [] vi.stubGlobal( @@ -168,7 +165,6 @@ describe('terminal WebGL atlas recovery', () => { const manager = registerManager() scheduleTabRevealWebglAtlasRecovery() - // First burst leg fires on the next frame — no 200ms debounce wait. rafCallbacks[0]?.(0) expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) @@ -177,142 +173,4 @@ describe('terminal WebGL atlas recovery', () => { expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) }) - - it('does not recover mid-stream while terminal output keeps arriving', () => { - // Regression (STA-1365): an alternate-screen TUI requests atlas recovery on - // every redraw frame. Recovering mid-stream clears the shared glyph atlas and - // repaints every pane several times a second, which reads as a flicker. The - // debounce must swallow a sustained stream entirely until it goes quiet. - vi.useFakeTimers() - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - ) - const manager = registerManager() - - // Simulate ~1s of continuous streaming: a redraw chunk every 50ms, each - // shorter than the quiet window, so the debounce keeps resetting. - for (let elapsed = 0; elapsed < 1000; elapsed += 50) { - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(50) - } - - expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() - expect(manager.refreshAllPanes).not.toHaveBeenCalled() - }) - - it('recovers exactly once after terminal output settles, not a 3-stage burst', () => { - // Brennan-approved single-recovery decision: the streaming settle fires one - // clear+refresh, not the rAF+120+500 burst, so a clear can only ever land - // after 200ms of global quiet. Advancing past the burst delays must add no - // further fires on the streaming path. - vi.useFakeTimers() - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - ) - const manager = registerManager() - - scheduleTerminalWebglAtlasRecovery() - scheduleTerminalWebglAtlasRecovery() - scheduleTerminalWebglAtlasRecovery() - - // Still nothing before the quiet window elapses. - vi.advanceTimersByTime(199) - expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() - - // Quiet window elapsed → exactly one clear+refresh for the coalesced stream. - vi.advanceTimersByTime(1) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - - // The 120ms/500ms burst legs must NOT fire on the streaming path. - vi.advanceTimersByTime(120) - vi.advanceTimersByTime(380) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - }) - - it('does not clear mid-stream when a settle is followed by resumed streaming', () => { - // Cancelable invariant (STA-1365): a stream that pauses just over the quiet - // window lets the settle fire its single recovery during the gap, but the - // resumed chunks must not trigger any further clear while they keep arriving - // <200ms apart — the re-arm cancels the only pending handle (the debounce - // timer), so no clear lands mid-resumed-stream. - vi.useFakeTimers() - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - callback(0) - return 1 - }) - ) - const manager = registerManager() - - // Initial stream, then a gap just over the quiet window → one settle recovery. - for (let elapsed = 0; elapsed < 300; elapsed += 50) { - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(50) - } - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - - // Resume streaming: fire-count must stay pinned while chunks keep arriving. - for (let elapsed = 0; elapsed < 1000; elapsed += 50) { - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(50) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - } - }) - - it('coalesces the terminal-output callers onto one shared timer while paste stays immediate', () => { - // Cross-caller single-timer invariant: the terminal-output re-arm sites - // (foreground and hidden-output PTY writes) funnel through - // scheduleTerminalWebglAtlasRecovery and re-arm the one module-global debounce - // timer, so a single continuous stream (even a hidden one) keeps the recovery - // deferred for everyone. Image paste and tab reveal use their own immediate - // burst and are not coupled to the shared debounce timer. - vi.useFakeTimers() - const rafCallbacks: FrameRequestCallback[] = [] - vi.stubGlobal( - 'requestAnimationFrame', - vi.fn((callback: FrameRequestCallback) => { - rafCallbacks.push(callback) - return rafCallbacks.length - }) - ) - const manager = registerManager() - - // The terminal-output re-arm sites (foreground / hidden-output) both funnel - // through this one function; none may fire mid-stream. - for (let elapsed = 0; elapsed < 600; elapsed += 50) { - scheduleTerminalWebglAtlasRecovery() - vi.advanceTimersByTime(50) - } - expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() - - // Global quiet → exactly one coalesced recovery for the whole shared stream. - vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) - - // Paste is independent: its own immediate 3-stage burst, not the shared timer. - manager.resetWebglTextureAtlases.mockClear() - manager.refreshAllPanes.mockClear() - scheduleImagePasteWebglAtlasRecovery() - rafCallbacks.at(-1)?.(0) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(120) - vi.advanceTimersByTime(380) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index d7d54dc0995..72dc08e46c1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -1,29 +1,7 @@ -import { - presentAllTerminalPanesWithoutAtlasClear, - resetAndRefreshAllTerminalWebglAtlases -} from '@/lib/pane-manager/pane-manager-registry' -import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import { resetAndRefreshAllTerminalWebglAtlases } from '@/lib/pane-manager/pane-manager-registry' const ATLAS_RECOVERY_DELAYS_MS = [120, 500] -// Why: a streaming TUI requests output atlas recovery every frame; recovering -// mid-stream clears the shared atlas and repaints every pane, which flickers -// (STA-1365). Wait for output to go quiet so recovery runs once, on settle. -export const TERMINAL_OUTPUT_RECOVERY_QUIET_MS = 200 - -// Bound reset storms while preserving prompt recovery and steady repair attempts. -const TERMINAL_OUTPUT_RECOVERY_MIN_INTERVAL_MS = 3_000 -const TERMINAL_OUTPUT_RECOVERY_BURST_WIPES = 10 -const TERMINAL_OUTPUT_RECOVERY_REFILL_INTERVAL_MS = 6_000 - -let terminalOutputRecoveryDebounceTimer: ReturnType | null = null -let terminalOutputRecoveryRetryTimer: ReturnType | null = null -let terminalOutputRecoveryWipeTokens = TERMINAL_OUTPUT_RECOVERY_BURST_WIPES -let terminalOutputRecoveryTokensRefilledAt: number | null = null -let terminalOutputRecoveryLastWipeAt: number | null = null -let terminalOutputRecoveryAttemptsSinceLastWipe = 0 -let terminalOutputRecoverySuppressedSinceLastWipe = 0 - function scheduleNextFrame(callback: () => void): void { if (typeof globalThis.requestAnimationFrame === 'function') { globalThis.requestAnimationFrame(callback) @@ -42,14 +20,6 @@ function resetAtlasesAndRefreshPanes(reason: string): void { } } -function presentPanesWithoutAtlasClear(): void { - try { - presentAllTerminalPanesWithoutAtlasClear() - } catch { - /* ignore - terminal pane may have unmounted after scheduling recovery */ - } -} - function scheduleAtlasRecoveryBurst(reason: string): void { scheduleNextFrame(() => resetAtlasesAndRefreshPanes(reason)) for (const delayMs of ATLAS_RECOVERY_DELAYS_MS) { @@ -65,113 +35,7 @@ export function scheduleImagePasteWebglAtlasRecovery(): void { } export function scheduleTabRevealWebglAtlasRecovery(): void { - // Why: a tab reveal is one-shot, so recover immediately — decoupled from the - // streaming debounce so a background stream can't defer a revealed tab's rebuild. + // 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') } - -export function scheduleTerminalWebglAtlasRecovery(): void { - // Why: terminal-output recovery (foreground + hidden PTY writes). Trailing-edge - // debounce so a clear only ever runs after 200ms of quiet — never mid-stream; - // a resumed stream cancels the pending timer, so a pause-then-resume can't leak. - if (terminalOutputRecoveryDebounceTimer != null) { - globalThis.clearTimeout(terminalOutputRecoveryDebounceTimer) - } - if (terminalOutputRecoveryRetryTimer != null) { - globalThis.clearTimeout(terminalOutputRecoveryRetryTimer) - terminalOutputRecoveryRetryTimer = null - } - terminalOutputRecoveryDebounceTimer = globalThis.setTimeout(() => { - terminalOutputRecoveryDebounceTimer = null - terminalOutputRecoveryAttemptsSinceLastWipe += 1 - const decision = consumeTerminalOutputRecoveryWipeBudget() - if (!decision.allowed) { - terminalOutputRecoverySuppressedSinceLastWipe += 1 - presentPanesWithoutAtlasClear() - scheduleTerminalOutputRecoveryRetry(decision.retryAfterMs) - return - } - resetTerminalOutputAtlases(decision.intervalMs) - }, TERMINAL_OUTPUT_RECOVERY_QUIET_MS) -} - -function scheduleTerminalOutputRecoveryRetry(delayMs: number): void { - terminalOutputRecoveryRetryTimer = globalThis.setTimeout(() => { - terminalOutputRecoveryRetryTimer = null - const decision = consumeTerminalOutputRecoveryWipeBudget() - if (!decision.allowed) { - scheduleTerminalOutputRecoveryRetry(decision.retryAfterMs) - return - } - resetTerminalOutputAtlases(decision.intervalMs) - }, delayMs) -} - -function resetTerminalOutputAtlases(intervalMs: number): void { - recordTerminalWebglDiagnostic('webgl-atlas-reset-rate', { - reason: 'terminal-output', - attemptsSinceLastReset: terminalOutputRecoveryAttemptsSinceLastWipe, - atlasResetsSuppressed: terminalOutputRecoverySuppressedSinceLastWipe, - intervalMs - }) - terminalOutputRecoveryAttemptsSinceLastWipe = 0 - terminalOutputRecoverySuppressedSinceLastWipe = 0 - resetAtlasesAndRefreshPanes('terminal-output') -} - -export function resetTerminalWebglAtlasRecoveryBudgetForTesting(): void { - if (terminalOutputRecoveryDebounceTimer != null) { - globalThis.clearTimeout(terminalOutputRecoveryDebounceTimer) - } - terminalOutputRecoveryDebounceTimer = null - if (terminalOutputRecoveryRetryTimer != null) { - globalThis.clearTimeout(terminalOutputRecoveryRetryTimer) - } - terminalOutputRecoveryRetryTimer = null - terminalOutputRecoveryWipeTokens = TERMINAL_OUTPUT_RECOVERY_BURST_WIPES - terminalOutputRecoveryTokensRefilledAt = null - terminalOutputRecoveryLastWipeAt = null - terminalOutputRecoveryAttemptsSinceLastWipe = 0 - terminalOutputRecoverySuppressedSinceLastWipe = 0 -} - -function refillTerminalOutputRecoveryWipeTokens(now: number): void { - const elapsedMs = now - (terminalOutputRecoveryTokensRefilledAt ?? now) - terminalOutputRecoveryTokensRefilledAt = now - // Sleep, NTP, and remote clock corrections must not wedge recovery. - if (elapsedMs < 0) { - terminalOutputRecoveryWipeTokens = TERMINAL_OUTPUT_RECOVERY_BURST_WIPES - terminalOutputRecoveryLastWipeAt = null - return - } - terminalOutputRecoveryWipeTokens = Math.min( - TERMINAL_OUTPUT_RECOVERY_BURST_WIPES, - terminalOutputRecoveryWipeTokens + elapsedMs / TERMINAL_OUTPUT_RECOVERY_REFILL_INTERVAL_MS - ) -} - -function consumeTerminalOutputRecoveryWipeBudget(): { - allowed: boolean - intervalMs: number - retryAfterMs: number -} { - const now = Date.now() - refillTerminalOutputRecoveryWipeTokens(now) - const intervalMs = - terminalOutputRecoveryLastWipeAt == null ? 0 : now - terminalOutputRecoveryLastWipeAt - const intervalBudgetMs = - terminalOutputRecoveryLastWipeAt == null - ? 0 - : Math.max(0, TERMINAL_OUTPUT_RECOVERY_MIN_INTERVAL_MS - intervalMs) - const tokenBudgetMs = - terminalOutputRecoveryWipeTokens >= 1 - ? 0 - : (1 - terminalOutputRecoveryWipeTokens) * TERMINAL_OUTPUT_RECOVERY_REFILL_INTERVAL_MS - const retryAfterMs = Math.ceil(Math.max(intervalBudgetMs, tokenBudgetMs)) - if (retryAfterMs > 0) { - return { allowed: false, intervalMs, retryAfterMs } - } - terminalOutputRecoveryWipeTokens -= 1 - terminalOutputRecoveryLastWipeAt = now - return { allowed: true, intervalMs, retryAfterMs: 0 } -} diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx new file mode 100644 index 00000000000..f3ea986601f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.react185.test.tsx @@ -0,0 +1,251 @@ +/** @vitest-environment happy-dom */ +import { act, StrictMode, useEffect } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const WORKTREE_ID = 'repo::/parked-watcher-sync' +const TAB_ID = 'tab-1' +const FIRST_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const FIRST_PTY_ID = `${WORKTREE_ID}@@session-1` +const OLD_SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` +const NEW_SECOND_PTY_ID = `${WORKTREE_ID}@@session-3` + +const harness = vi.hoisted(() => ({ + syncCalls: 0, + disposeCalls: 0, + reconciliationPtyReads: 0, + watchedPtyIds: new Set() +})) + +vi.mock('../../store', async () => { + const { create } = await import('zustand') + const useAppStore = create(() => ({ + ptyIdsByTabId: {} as Record, + runtimePaneTitlesByTabId: {} as Record>, + terminalLayoutsByTabId: {} as Record + })) + return { useAppStore } +}) + +vi.mock('./terminal-parked-tab-watchers', async () => { + const { useAppStore } = await import('../../store') + return { + disposeParkedTerminalWatchersForWorktree: () => { + harness.disposeCalls += 1 + harness.watchedPtyIds.clear() + }, + syncParkedTerminalTabWatchers: (args: { + tabs: readonly TerminalTab[] + parkedTabIds: ReadonlySet + }) => { + harness.syncCalls += 1 + const state = useAppStore.getState() + harness.watchedPtyIds.clear() + for (const tab of args.tabs) { + if (!args.parkedTabIds.has(tab.id)) { + continue + } + const layoutPtyIds = Object.values( + state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {} + ) + for (const ptyId of layoutPtyIds.length > 0 ? layoutPtyIds : [tab.ptyId]) { + if (ptyId) { + harness.watchedPtyIds.add(ptyId) + } + } + } + } + } +}) + +import { useAppStore } from '../../store' +import { disposeParkedTerminalWatchersForWorktree } from './terminal-parked-tab-watchers' +import { + captureParkedTerminalPaneCandidates, + capturedPanesByTabId +} from './terminal-parked-watcher-registry' +import { useParkedTerminalWatcherSynchronization } from './use-parked-terminal-watcher-synchronization' + +const terminalTabs = [{ id: TAB_ID, ptyId: FIRST_PTY_ID }] as TerminalTab[] +const parkedTabIds = new Set([TAB_ID]) +const EMPTY_PARKED_TAB_IDS = new Set() +const firstUnparkedTabs = ['tab-a', 'tab-b', 'tab-c'].map( + (id) => ({ id, ptyId: `${WORKTREE_ID}@@${id}` }) as TerminalTab +) +const secondUnparkedTabs = ['tab-d', 'tab-e', 'tab-f'].map( + (id) => ({ id, ptyId: `${WORKTREE_ID}@@${id}` }) as TerminalTab +) + +function splitLayout(secondPtyId: string): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: FIRST_LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: FIRST_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [FIRST_LEAF_ID]: FIRST_PTY_ID, + [SECOND_LEAF_ID]: secondPtyId + } + } +} + +function WatcherSynchronizationHarness(): null { + useParkedTerminalWatcherSynchronization({ + worktreeId: WORKTREE_ID, + terminalTabs, + inputsKey: JSON.stringify([[TAB_ID, FIRST_PTY_ID, null]]), + assignmentsKey: JSON.stringify([[TAB_ID, 'group-1', false]]), + parkedTabIds + }) + // Models the pre-fix sibling disposal effect that StrictMode replays after synchronization. + useEffect(() => () => disposeParkedTerminalWatchersForWorktree(WORKTREE_ID), []) + return null +} + +function UnparkedWatcherSynchronizationHarness({ + worktreeId, + tabs +}: { + worktreeId: string + tabs: readonly TerminalTab[] +}): null { + useParkedTerminalWatcherSynchronization({ + worktreeId, + terminalTabs: tabs, + inputsKey: worktreeId, + assignmentsKey: worktreeId, + parkedTabIds: EMPTY_PARKED_TAB_IDS + }) + return null +} + +describe('parked terminal watcher synchronization', () => { + let container: HTMLDivElement + let root: Root | undefined + + beforeEach(() => { + harness.syncCalls = 0 + harness.disposeCalls = 0 + harness.reconciliationPtyReads = 0 + harness.watchedPtyIds.clear() + useAppStore.setState({ + ptyIdsByTabId: { [TAB_ID]: [FIRST_PTY_ID, OLD_SECOND_PTY_ID] }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'active', 2: 'split' } }, + terminalLayoutsByTabId: { [TAB_ID]: splitLayout(OLD_SECOND_PTY_ID) } + }) + container = document.createElement('div') + document.body.appendChild(container) + }) + + afterEach(() => { + act(() => root?.unmount()) + root = undefined + capturedPanesByTabId.delete(TAB_ID) + container.remove() + }) + + it('reinstates parked watchers after StrictMode effect replay cleanup', () => { + root = createRoot(container) + + act(() => { + root?.render( + + + + ) + }) + + expect(harness.disposeCalls).toBeGreaterThanOrEqual(1) + expect(harness.syncCalls).toBe(2) + expect(harness.watchedPtyIds).toEqual(new Set([FIRST_PTY_ID, OLD_SECOND_PTY_ID])) + }) + + it('resynchronizes a parked split when only its detached PTY and layout change', () => { + root = createRoot(container) + act(() => { + root?.render() + }) + expect(harness.watchedPtyIds).toContain(OLD_SECOND_PTY_ID) + + act(() => { + useAppStore.setState({ + ptyIdsByTabId: { [TAB_ID]: [FIRST_PTY_ID, NEW_SECOND_PTY_ID] }, + terminalLayoutsByTabId: { [TAB_ID]: splitLayout(NEW_SECOND_PTY_ID) } + }) + }) + + expect(harness.syncCalls).toBe(2) + expect(harness.watchedPtyIds).toEqual(new Set([FIRST_PTY_ID, NEW_SECOND_PTY_ID])) + + act(() => { + useAppStore.setState({ + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ active', 2: 'new split title' } } + }) + }) + expect(harness.syncCalls).toBe(2) + + act(() => { + useAppStore.setState({ + terminalLayoutsByTabId: { + [TAB_ID]: { ...splitLayout(NEW_SECOND_PTY_ID), activeLeafId: SECOND_LEAF_ID } + } + }) + }) + expect(harness.syncCalls).toBe(3) + + captureParkedTerminalPaneCandidates(TAB_ID, WORKTREE_ID, [ + { ptyId: FIRST_PTY_ID, paneId: 10, leafId: FIRST_LEAF_ID, drivesTabTitle: false }, + { ptyId: NEW_SECOND_PTY_ID, paneId: 11, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + act(() => { + root?.render() + }) + expect(harness.syncCalls).toBe(4) + }) + + it('does not scan unparked worktrees on an unrelated store write', () => { + const capturedPaneGet = vi.spyOn(capturedPanesByTabId, 'get') + const ptyIdsByTabId = new Proxy({} as Record, { + get(target, property, receiver) { + if (typeof property === 'string') { + harness.reconciliationPtyReads += 1 + } + return Reflect.get(target, property, receiver) + } + }) + useAppStore.setState({ ptyIdsByTabId }) + root = createRoot(container) + act(() => { + root?.render( + <> + + + + ) + }) + harness.reconciliationPtyReads = 0 + const syncCallsAfterMount = harness.syncCalls + + act(() => { + useAppStore.setState({ sortEpoch: 1 }) + }) + + expect(harness.reconciliationPtyReads).toBe(0) + expect(capturedPaneGet).not.toHaveBeenCalled() + expect(harness.syncCalls).toBe(syncCallsAfterMount) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts new file mode 100644 index 00000000000..1ee7ba8c2d0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-parked-terminal-watcher-synchronization.ts @@ -0,0 +1,167 @@ +import { useEffect, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + disposeParkedTerminalWatchersForWorktree, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' +import { capturedPanesByTabId } from './terminal-parked-watcher-registry' + +type TerminalParkingAssignment = { + groupId: string + isActiveInGroup: boolean +} + +const EMPTY_PTY_IDS: readonly string[] = [] + +type WatcherReconciliationStoreInputs = readonly ( + | readonly string[] + | TerminalLayoutSnapshot + | string + | null +)[] + +const EMPTY_WATCHER_RECONCILIATION_INPUTS: WatcherReconciliationStoreInputs = Object.freeze([]) + +export function getTerminalParkingInputsKey(terminalTabs: readonly TerminalTab[]): string { + return JSON.stringify(terminalTabs.map((tab) => [tab.id, tab.ptyId, tab.pendingActivationSpawn])) +} + +export function getTerminalParkingAssignmentsKey( + assignments: ReadonlyMap +): string { + return JSON.stringify( + Array.from(assignments, ([tabId, assignment]) => [ + tabId, + assignment.groupId, + assignment.isActiveInGroup + ]) + ) +} + +function getWatcherSynchronizationKey(args: { + worktreeId: string + inputsKey: string + assignmentsKey: string + parkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null + reconciliationKey: string +}): string { + return JSON.stringify([ + args.worktreeId, + args.inputsKey, + args.assignmentsKey, + Array.from(args.parkedTabIds), + Array.from(args.activationDeferredMountTabIds ?? []).sort(), + args.reconciliationKey + ]) +} + +function getCapturedPaneKey(terminalTabs: readonly TerminalTab[]): unknown[] { + return terminalTabs.map((tab) => { + const capture = capturedPanesByTabId.get(tab.id) + return [ + tab.id, + capture?.worktreeId ?? null, + capture?.panes.map((pane) => [pane.ptyId, pane.paneId, pane.leafId, pane.drivesTabTitle]) ?? + [] + ] + }) +} + +function getWatcherReconciliationKey( + terminalTabs: readonly TerminalTab[], + inputs: WatcherReconciliationStoreInputs +): string { + if (inputs.length === 0) { + return '' + } + const tabs = terminalTabs.map((tab, index) => { + const offset = index * 3 + const ptyIds = inputs[offset] as readonly string[] + const layout = inputs[offset + 1] as TerminalLayoutSnapshot | null + const titleSlotKey = inputs[offset + 2] as string + return [ + tab.id, + ptyIds, + layout?.root ?? null, + layout?.activeLeafId ?? null, + layout?.ptyIdsByLeafId ?? null, + titleSlotKey + ] + }) + return JSON.stringify([tabs, getCapturedPaneKey(terminalTabs)]) +} + +export function useParkedTerminalWatcherSynchronization(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + inputsKey: string + assignmentsKey: string + parkedTabIds: ReadonlySet + activationDeferredMountTabIds?: ReadonlySet | null +}): void { + const { + worktreeId, + terminalTabs, + inputsKey, + assignmentsKey, + parkedTabIds, + activationDeferredMountTabIds + } = args + const reconciliationStoreInputs = useAppStore( + useShallow((state) => + // Why: an empty committed park set has no live watcher state for store writes to reconcile. + parkedTabIds.size === 0 + ? EMPTY_WATCHER_RECONCILIATION_INPUTS + : terminalTabs.flatMap((tab) => [ + state.ptyIdsByTabId[tab.id] ?? EMPTY_PTY_IDS, + state.terminalLayoutsByTabId[tab.id] ?? null, + Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}).join(',') + ]) + ) + ) as WatcherReconciliationStoreInputs + const reconciliationKey = getWatcherReconciliationKey(terminalTabs, reconciliationStoreInputs) + const synchronizationKey = getWatcherSynchronizationKey({ ...args, reconciliationKey }) + const synchronizationKeyRef = useRef(null) + + useEffect( + () => () => { + synchronizationKeyRef.current = null + disposeParkedTerminalWatchersForWorktree(worktreeId) + }, + [worktreeId] + ) + + useEffect(() => { + if (synchronizationKeyRef.current === synchronizationKey) { + return + } + syncParkedTerminalTabWatchers({ + worktreeId, + tabs: terminalTabs, + parkedTabIds, + // Why: activation-deferred tabs have no prior pane-owned title slot. + restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined + }) + // Why: capture cleanup mutates the registry during commit; store its post-commit key. + synchronizationKeyRef.current = getWatcherSynchronizationKey({ + worktreeId, + inputsKey, + assignmentsKey, + parkedTabIds, + activationDeferredMountTabIds, + reconciliationKey: getWatcherReconciliationKey(terminalTabs, reconciliationStoreInputs) + }) + }, [ + activationDeferredMountTabIds, + assignmentsKey, + inputsKey, + parkedTabIds, + reconciliationStoreInputs, + synchronizationKey, + terminalTabs, + worktreeId + ]) +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts index 888f82237ee..2e1c97c5450 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.test.ts @@ -6,7 +6,9 @@ import type { TerminalTab } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ storeState: { pendingStartupByTabId: {} as Record, + ptyIdsByTabId: {} as Record, runtimeStatusByEnvironmentId: new Map(), + runtimePaneTitlesByTabId: {} as Record>, settings: {} as Record, terminalLayoutsByTabId: {} as Record }>, sleepingAgentSessionsByPaneKey: {} as Record< diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index 6a7a0e1a498..f33023c9499 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -31,11 +31,12 @@ import { selectEvictionExemptTerminalTabLayoutKey } from './terminal-eviction-exempt-tabs' import { selectSleepingRecordParkExemptTabIds } from './sleeping-record-park-exemption' +import { canWatcherCoverParkedTerminalTab } from './terminal-parked-tab-watchers' import { - canWatcherCoverParkedTerminalTab, - disposeParkedTerminalWatchersForWorktree, - syncParkedTerminalTabWatchers -} from './terminal-parked-tab-watchers' + getTerminalParkingAssignmentsKey, + getTerminalParkingInputsKey, + useParkedTerminalWatcherSynchronization +} from './use-parked-terminal-watcher-synchronization' type TerminalOverlayTabAssignment = { groupId: string @@ -86,6 +87,14 @@ export function useTerminalTabColdParking(args: { activityTerminalPortals, activationDeferredMountTabIds } = args + const terminalParkingInputsKey = getTerminalParkingInputsKey(terminalTabs) + const terminalParkingAssignmentsKey = getTerminalParkingAssignmentsKey(assignments) + const terminalParkingTabsDependency = coldParkTerminalPanes + ? terminalParkingInputsKey + : terminalTabs + const terminalParkingAssignmentsDependency = coldParkTerminalPanes + ? terminalParkingAssignmentsKey + : assignments const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) const terminalParkingEnabled = useAppStore( (state) => state.settings?.terminalHiddenViewParking !== false @@ -247,17 +256,18 @@ export function useTerminalTabColdParking(args: { timers.set(tabId, timer) } } + // eslint-disable-next-line react-hooks/exhaustive-deps -- semantic keys own the tab and assignment dependencies. }, [ activityTerminalPortals, - assignments, isWorktreeActive, pendingStartupByTabId, - pairedRuntimeParkingEnvironmentIds, + runtimeStatusByEnvironmentId, shouldMeasureHiddenWorktree, terminalParkingEnabled, terminalSshParkingEnabled, terminalTabParkingRevision, - terminalTabs, + terminalParkingAssignmentsDependency, + terminalParkingTabsDependency, worktreeId ]) @@ -357,18 +367,14 @@ export function useTerminalTabColdParking(args: { // panes — watcher disposal therefore lands before any PTY data IPC can // reach a freshly remounted pane, and watcher start lands after the parked // pane's unmount capture. - useEffect(() => { - syncParkedTerminalTabWatchers({ - worktreeId, - tabs: terminalTabs, - parkedTabIds: parkedTerminalTabIds, - // Why: activation-deferred tabs have no prior pane-owned title slot; - // pull main's title-only snapshot when their watcher starts. - restoreTitleOnStartTabIds: activationDeferredMountTabIds ?? undefined - }) - }, [activationDeferredMountTabIds, parkedTerminalTabIds, terminalTabs, worktreeId]) - - useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) + useParkedTerminalWatcherSynchronization({ + worktreeId, + terminalTabs, + assignmentsKey: terminalParkingAssignmentsKey, + inputsKey: terminalParkingInputsKey, + parkedTabIds: parkedTerminalTabIds, + activationDeferredMountTabIds + }) return parkedTerminalTabIds } diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts index a8747ec81ff..4b7557ba057 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.test.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.test.ts @@ -183,6 +183,57 @@ describe('closeTerminalTab', () => { isWebTerminalSurfaceTabIdMock.mockReturnValue(false) }) + it('retires exact resume authority when explicit cleanup arrives after the tab disappeared', () => { + const closeTab = vi.fn() + const onClosed = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab', { + hostCloseReason: 'cleanup', + localPtyTeardownOwnedExternally: true, + onClosed + }) + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { + reason: 'cleanup', + localPtyTeardownOwnedExternally: true + }) + expect(onClosed).toHaveBeenCalledOnce() + }) + + it('retires exact resume authority when a user close arrives after the tab disappeared', () => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('retired-worker-tab') + + expect(closeTab).toHaveBeenCalledWith('retired-worker-tab', { reason: 'user' }) + }) + + it.each([ + ['local PTY exit', { reason: 'pty-exit' as const }], + ['paired host PTY exit', { hostCloseReason: 'pty-exit' as const }] + ])('preserves missing-tab recovery authority for %s', (_label, options) => { + const closeTab = vi.fn() + getStateMock.mockReturnValue({ + tabsByWorktree: {}, + unifiedTabsByWorktree: {}, + closeTab + }) + + closeTerminalTab('unexpectedly-lost-tab', options) + + expect(closeTab).not.toHaveBeenCalled() + }) + it('delegates host-backed terminal closes to the paired runtime', () => { const closeTab = vi.fn() isWebRuntimeSessionActiveMock.mockReturnValue(true) diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 55934e7ae88..8216696d217 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -65,6 +65,19 @@ export function closeTerminalTab( ) const target = resolveTerminalCloseTarget(state, tabId, precomputedCloseState) if (!target) { + const closeReason = options?.reason ?? options?.hostCloseReason ?? 'user' + if (closeReason !== 'pty-exit') { + // Why: late explicit cleanup must still revoke tab-scoped resume authority after PTY exit removed the row. + state.closeTab(tabId, { + reason: closeReason, + ...(options?.localPtyTeardownOwnedExternally + ? { localPtyTeardownOwnedExternally: true } + : {}), + ...(options?.precomputedRetirementPlan + ? { precomputedRetirementPlan: options.precomputedRetirementPlan } + : {}) + }) + } options?.onClosed?.() return } diff --git a/src/renderer/src/components/ui/checkbox.tsx b/src/renderer/src/components/ui/checkbox.tsx index b5ae01c3da6..2e9e53e55e7 100644 --- a/src/renderer/src/components/ui/checkbox.tsx +++ b/src/renderer/src/components/ui/checkbox.tsx @@ -1,15 +1,20 @@ 'use client' import * as React from 'react' -import { CheckIcon } from 'lucide-react' +import { CheckIcon, MinusIcon } from 'lucide-react' import { Checkbox as CheckboxPrimitive } from 'radix-ui' import { cn } from '@/lib/utils' -function Checkbox({ className, ...props }: React.ComponentProps) { +function Checkbox({ + className, + checked, + ...props +}: React.ComponentProps) { return ( - + {checked === 'indeterminate' ? ( + + ) : ( + + )} ) diff --git a/src/renderer/src/components/ui/team-multi-combobox.test.ts b/src/renderer/src/components/ui/team-multi-combobox.test.ts deleted file mode 100644 index 9f231371c29..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { LinearTeam } from '../../../../shared/types' -import { - TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES, - filterTeamMultiComboboxTeams, - isTeamMultiComboboxQueryTooLarge -} from './team-multi-combobox' - -function team(overrides: Partial): LinearTeam { - return { - id: 'team-1', - key: 'ENG', - name: 'Engineering', - workspaceId: 'workspace-1', - workspaceName: 'Workspace', - ...overrides - } -} - -describe('filterTeamMultiComboboxTeams', () => { - it('returns all teams for empty queries', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, '')).toBe(teams) - expect(filterTeamMultiComboboxTeams(teams, ' ')).toBe(teams) - }) - - it('matches team names and keys case-insensitively', () => { - const teams = [team({ id: 'eng' }), team({ id: 'ops', key: 'OPS', name: 'Operations' })] - - expect(filterTeamMultiComboboxTeams(teams, 'ops')).toEqual([teams[1]]) - expect(filterTeamMultiComboboxTeams(teams, 'ENGINEER')).toEqual([teams[0]]) - }) - - it('rejects oversized pasted queries before reading teams', () => { - const oversizedQuery = 'secret-team-combobox'.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES) - const teams = [ - { - id: 'secret', - workspaceId: 'workspace-1', - get name(): string { - throw new Error('oversized team combobox queries must not scan names') - }, - get key(): string { - throw new Error('oversized team combobox queries must not scan keys') - } - } - ] as LinearTeam[] - - expect(isTeamMultiComboboxQueryTooLarge(oversizedQuery)).toBe(true) - expect(filterTeamMultiComboboxTeams(teams, oversizedQuery)).toEqual([]) - }) - - it('rejects oversized whitespace before trimming', () => { - const teams = [team({ id: 'eng' })] - - expect( - filterTeamMultiComboboxTeams(teams, ' '.repeat(TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES + 1)) - ).toEqual([]) - }) -}) diff --git a/src/renderer/src/components/ui/team-multi-combobox.tsx b/src/renderer/src/components/ui/team-multi-combobox.tsx deleted file mode 100644 index e1795c4801a..00000000000 --- a/src/renderer/src/components/ui/team-multi-combobox.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import React, { useCallback, useMemo, useState } from 'react' -import { Check, ChevronsUpDown } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandInput, - CommandItem, - CommandList -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import type { LinearTeam } from '../../../../shared/types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import { translate } from '@/i18n/i18n' - -type TeamMultiComboboxProps = { - teams: LinearTeam[] - selected: ReadonlySet - onChange: (next: ReadonlySet) => void - onSelectAll: () => void - triggerClassName?: string -} - -export const TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES = 2 * 1024 - -export function isTeamMultiComboboxQueryTooLarge( - query: string, - maxBytes = TEAM_MULTI_COMBOBOX_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function filterTeamMultiComboboxTeams( - teams: readonly LinearTeam[], - query: string -): readonly LinearTeam[] { - if (isTeamMultiComboboxQueryTooLarge(query)) { - return [] - } - const trimmedQuery = query.trim() - if (!trimmedQuery) { - return teams - } - const lower = trimmedQuery.toLowerCase() - return teams.filter( - (team) => team.name.toLowerCase().includes(lower) || team.key.toLowerCase().includes(lower) - ) -} - -function renderTriggerLabel(teams: LinearTeam[], selected: ReadonlySet): React.JSX.Element { - if (teams.length === 0) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - if (selected.size === teams.length) { - return ( - - {translate('auto.components.ui.team.multi.combobox.301f2a796e', 'All teams')} - - ) - } - const selectedTeams = teams.filter((t) => selected.has(t.id)) - const [first, second, ...rest] = selectedTeams - return ( - - {first ? {first.key} : null} - {second ? , {second.key} : null} - {rest.length > 0 ? +{rest.length} : null} - - ) -} - -export default function TeamMultiCombobox({ - teams, - selected, - onChange, - onSelectAll, - triggerClassName -}: TeamMultiComboboxProps): React.JSX.Element { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [commandValue, setCommandValue] = useState('') - - const filteredTeams = useMemo(() => filterTeamMultiComboboxTeams(teams, query), [teams, query]) - - const allSelected = selected.size === teams.length && teams.length > 0 - - const handleOpenChange = useCallback((nextOpen: boolean) => { - setOpen(nextOpen) - if (!nextOpen) { - setQuery('') - } - }, []) - - const toggle = useCallback( - (teamId: string) => { - const next = new Set(selected) - if (next.has(teamId)) { - if (next.size <= 1) { - return - } - next.delete(teamId) - } else { - next.add(teamId) - } - onChange(next) - }, - [onChange, selected] - ) - - const handleSelectAll = useCallback(() => { - if (allSelected) { - const first = teams[0] - if (!first) { - return - } - onChange(new Set([first.id])) - return - } - onSelectAll() - }, [allSelected, onChange, onSelectAll, teams]) - - return ( - - - - - - - -
- -
- - - {translate( - 'auto.components.ui.team.multi.combobox.de83523bf9', - 'No teams match your search.' - )} - - {filteredTeams.map((team) => { - const isSelected = selected.has(team.id) - const isLastSelected = isSelected && selected.size <= 1 - return ( - toggle(team.id)} - disabled={isLastSelected} - className="items-center gap-2 px-3 py-1.5 text-xs" - > - -
- - {team.name} - - {team.key} - - -
-
- ) - })} -
-
-
-
- ) -} diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts index 9b785d3ed71..2503a9a68fc 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -14,6 +14,12 @@ vi.mock('sonner', () => ({ } })) +vi.mock('../sidebar/preserved-branch-batch-toast', () => ({ + showPreservedBranchBatchToast: vi.fn() +})) + +import { showPreservedBranchBatchToast } from '../sidebar/preserved-branch-batch-toast' + async function settleBackgroundRemoval(): Promise { for (let index = 0; index < 10; index += 1) { await Promise.resolve() @@ -131,6 +137,54 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { }) }) + it('reports all preserved branches in one cleanup result', async () => { + const first = makeCandidate() + const second = makeCandidate({ + worktreeId: 'repo-1::/repo/beta', + displayName: 'beta', + branch: 'beta', + path: '/repo/beta' + }) + const firstBranch = { + worktreeId: first.worktreeId, + branchName: 'feature/alpha', + expectedHead: 'alpha-head' + } + const secondBranch = { + worktreeId: second.worktreeId, + branchName: 'feature/beta', + expectedHead: 'beta-head' + } + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [first, second], + removeCandidates: vi + .fn() + .mockResolvedValueOnce({ + removedIds: [first.worktreeId], + failures: [], + preservedBranches: [firstBranch] + }) + .mockResolvedValueOnce({ + removedIds: [second.worktreeId], + failures: [], + preservedBranches: [secondBranch] + }), + onProgress: vi.fn(), + onResult + }) + await settleBackgroundRemoval() + + expect(onResult).toHaveBeenCalledWith({ + removedIds: [first.worktreeId, second.worktreeId], + failures: [], + preservedBranches: [firstBranch, secondBranch] + }) + expect(showPreservedBranchBatchToast).toHaveBeenCalledWith(2, [firstBranch, secondBranch]) + expect(toast.success).not.toHaveBeenCalledWith('Removed workspaces: 2') + }) + it('removes nested candidates before their parent workspace', async () => { const parent = makeCandidate({ worktreeId: 'repo-1::/repo/parent', diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index d9a04bf8af7..4c686fb28c7 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -75,6 +75,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const count = candidates.length const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const preservedBranches: NonNullable = [] const failedCandidates: WorkspaceCleanupCandidate[] = [] const lateSettlementTrackers: WorkspaceCleanupLateSettlementTracker[] = [] // Why: rows past the removal deadline are still removing; their skip fallout @@ -200,6 +201,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ pendingSettlementFailures.delete(timeoutFailure) provisionallyBlocked.delete(candidate) removedIds.push(...lateResult.removedIds) + preservedBranches.push(...(lateResult.preservedBranches ?? [])) reportFailures(lateResult.failures) if (lateResult.failures.length === 0) { removeArrayEntry(failedCandidates, candidate) @@ -212,6 +214,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ } const result = outcome.result removedIds.push(...result.removedIds) + preservedBranches.push(...(result.preservedBranches ?? [])) reportFailures(result.failures) if (result.failures.length > 0) { failedCandidates.push(candidate) @@ -244,7 +247,11 @@ export function startWorkspaceCleanupBackgroundRemoval({ } }) ) - const result = { removedIds, failures } + const result: WorkspaceCleanupRemoveResult = { + removedIds, + failures, + ...(preservedBranches.length > 0 ? { preservedBranches } : {}) + } try { onResult?.(result) } catch (callbackError) { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts index 74197632016..93604bc9f63 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-toasts.ts @@ -4,12 +4,15 @@ import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' import { translate } from '@/i18n/i18n' +import { showPreservedBranchBatchToast } from '../sidebar/preserved-branch-batch-toast' export function showWorkspaceCleanupRemovalResultToasts( result: WorkspaceCleanupRemoveResult, pendingSettlementFailures?: ReadonlySet ): void { - if (result.removedIds.length > 0) { + if (result.preservedBranches && result.preservedBranches.length > 0) { + showPreservedBranchBatchToast(result.removedIds.length, result.preservedBranches) + } else if (result.removedIds.length > 0) { toast.success( translate( 'auto.components.workspace.cleanup.backgroundRemoval.removed', diff --git a/src/renderer/src/hooks/ipc-events-test-harness.ts b/src/renderer/src/hooks/ipc-events-test-harness.ts index 8bd4eea5fca..e2226460a7b 100644 --- a/src/renderer/src/hooks/ipc-events-test-harness.ts +++ b/src/renderer/src/hooks/ipc-events-test-harness.ts @@ -125,6 +125,7 @@ export type IpcEventsHarness = { /** Fire a main-process digit chord (zero-based index). */ jumpToWorktreeIndex: (index: number) => void jumpToTabIndex: (index: number) => void + navigationUpdate: (event: { browserPageId: string; url: string; title: string }) => void /** Standard (non-palette) target of a workspace digit chord. */ activateAndRevealWorkspace: ReturnType } @@ -146,6 +147,9 @@ export async function loadIpcEventsHarness( const activateAndRevealWorkspace = vi.fn() let createTerminalListener: ((request: CreateTerminalRequest) => void) | null = null let requestTerminalCreateListener: ((request: RequestTerminalCreateRequest) => void) | null = null + let navigationUpdateListener: + | ((event: { browserPageId: string; url: string; title: string }) => void) + | null = null const indexJumpListeners = new Map void>() vi.resetModules() @@ -243,6 +247,14 @@ export async function loadIpcEventsHarness( onStatus: () => () => {}, onClearDismissal: () => () => {} }, + browser: createApiNamespaceStub({ + onNavigationUpdate: ( + listener: (event: { browserPageId: string; url: string; title: string }) => void + ) => { + navigationUpdateListener = listener + return () => {} + } + }), mobile: createApiNamespaceStub({ consumePendingUnpairedDeviceAuthFailure: () => Promise.resolve(false) }), @@ -270,6 +282,12 @@ export async function loadIpcEventsHarness( replyTerminalCreate, jumpToWorktreeIndex: (index) => fireIndexJump(indexJumpListeners, 'worktree', index), jumpToTabIndex: (index) => fireIndexJump(indexJumpListeners, 'tab', index), + navigationUpdate: (event) => { + if (typeof navigationUpdateListener !== 'function') { + throw new Error('Expected the browser navigation listener to be registered') + } + navigationUpdateListener(event) + }, activateAndRevealWorkspace } } diff --git a/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts b/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts index 8b20968441c..eacc02488ba 100644 --- a/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts +++ b/src/renderer/src/hooks/runtime-project-refresh-scheduler.test.ts @@ -5,7 +5,7 @@ import { } from './runtime-project-refresh-scheduler' describe('refreshRuntimeProjectWorktrees', () => { - it('pins same-ID repo refreshes to the event runtime', async () => { + it('deduplicates same-host repo IDs and pins the refresh to the event runtime', async () => { const fetchWorktrees = vi.fn().mockResolvedValue(true) await refreshRuntimeProjectWorktrees( @@ -14,11 +14,8 @@ describe('refreshRuntimeProjectWorktrees', () => { fetchWorktrees ) - expect(fetchWorktrees).toHaveBeenCalledTimes(2) - expect(fetchWorktrees).toHaveBeenNthCalledWith(1, 'same-repo', { - executionHostId: 'runtime:env-1' - }) - expect(fetchWorktrees).toHaveBeenNthCalledWith(2, 'same-repo', { + expect(fetchWorktrees).toHaveBeenCalledTimes(1) + expect(fetchWorktrees).toHaveBeenCalledWith('same-repo', { executionHostId: 'runtime:env-1' }) }) diff --git a/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts b/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts index e02305d20eb..54cc3569784 100644 --- a/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts +++ b/src/renderer/src/hooks/runtime-project-refresh-scheduler.ts @@ -35,16 +35,17 @@ export async function refreshRuntimeProjectWorktrees( ): Promise { let nextIndex = 0 const failures: { repoId: string; error: unknown }[] = [] - const workerCount = Math.min(concurrency, repos.length) + const repoIds = [...new Set(repos.map((repo) => repo.id))] + const workerCount = Math.min(concurrency, repoIds.length) const executionHostId = toRuntimeExecutionHostId(environmentId) // Why: one coalesced event can represent many repos; bound probes without dropping host identity. await Promise.all( Array.from({ length: workerCount }, async () => { - while (nextIndex < repos.length) { + while (nextIndex < repoIds.length) { const index = nextIndex nextIndex += 1 - const repoId = repos[index].id + const repoId = repoIds[index] try { await fetchWorktrees(repoId, { executionHostId }) } catch (error) { diff --git a/src/renderer/src/hooks/useGitHubSlugMetadata.ts b/src/renderer/src/hooks/useGitHubSlugMetadata.ts index 4635fe85250..3da1c66787d 100644 --- a/src/renderer/src/hooks/useGitHubSlugMetadata.ts +++ b/src/renderer/src/hooks/useGitHubSlugMetadata.ts @@ -1,31 +1,13 @@ -// Why: when the dialog opens for a Project row whose repo differs from the -// active workspace, label/assignee lookups must target the row's repo via -// slug-addressed IPCs (`listLabelsBySlug` / `listAssignableUsersBySlug`), -// not via the workspace path. These hooks live in their own module so the -// existing repoPath-keyed hooks stay focused on the local-workspace flow -// and so this file remains under the lint line cap. -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: slug metadata hooks clear stale rows and track loading while async provider cache requests are in flight. */ -import { useEffect, useRef, useState } from 'react' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import type { GitHubAssignableUser, GlobalSettings } from '../../../shared/types' import type { ListAssignableUsersBySlugResult, ListLabelsBySlugResult } from '../../../shared/github-project-types' -import { - clearMetadataRequestStore, - createMetadataRequestStore, - getFreshMetadata, - loadMetadata -} from './metadata-request-cache' +import { clearMetadataRequestStore, createMetadataRequestStore } from './metadata-request-cache' import { githubRepoIdentityKey } from '../../../shared/github-repository-identity-key' import { githubProjectHost } from '../../../shared/github-project-identity' - -type MetadataState = { - data: T - loading: boolean - error: string | null -} +import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' const slugLabelStore = createMetadataRequestStore() const slugAssigneeStore = createMetadataRequestStore() @@ -40,86 +22,41 @@ export function useRepoLabelsBySlug( repo: string | null, settings?: Pick | null, host?: string -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) - // Why: parent selectors can pass a fresh settings object each render; keying - // the effect on the primitive env id keeps a failure's setState from re-running - // the effect and re-issuing the fetch in a render-paced loop (same class as - // the seedKey stabilization below). +): MetadataListState { const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) + const selectedOwner = owner ?? '' + const selectedRepo = repo ?? '' + const repositoryKey = owner && repo ? githubRepoIdentityKey({ owner, repo, host }) : null + const cacheKey = + repositoryKey && target.kind === 'environment' + ? `runtime:${target.environmentId}:${repositoryKey}` + : repositoryKey - useEffect(() => { - if (!owner || !repo) { - return - } - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) - const repositoryKey = githubRepoIdentityKey({ owner, repo, host }) - const key = - target.kind === 'environment' - ? `runtime:${target.environmentId}:${repositoryKey}` - : repositoryKey - - const cached = getFreshMetadata(slugLabelStore, key) - if (cached) { - if (activeKeyRef.current !== key) { - setState({ data: cached.data, loading: false, error: null }) - } - activeKeyRef.current = key - return - } - - if (activeKeyRef.current === key) { - return - } - activeKeyRef.current = key - const requestKey = key - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(slugLabelStore, key, () => + return useMetadataListRequest({ + cacheKey, + store: slugLabelStore, + errorFallback: 'Failed to load labels', + load: () => (target.kind === 'environment' ? callRuntimeRpc( target, 'github.project.listLabelsBySlug', - { owner, repo, host: githubProjectHost(host) }, + { owner: selectedOwner, repo: selectedRepo, host: githubProjectHost(host) }, { timeoutMs: 30_000 } ) - : window.api.gh.listLabelsBySlug({ owner, repo, host: githubProjectHost(host) }) + : window.api.gh.listLabelsBySlug({ + owner: selectedOwner, + repo: selectedRepo, + host: githubProjectHost(host) + }) ).then((res) => { if (!res.ok) { throw new Error(res.error.message) } return res.labels }) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load labels' - })) - }) - }, [owner, repo, host, activeRuntimeEnvironmentId]) - - return state + }) } export function useRepoAssigneesBySlug( @@ -128,61 +65,30 @@ export function useRepoAssigneesBySlug( seedLogins?: string[], settings?: Pick | null, host?: string -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) - // Why: seedLogins is a new array reference each parent render. Stabilize on - // the joined-string identity so the effect doesn't re-fire on every render - // — this is the assignee popover refetch-storm fix. +): MetadataListState { const seedKey = (seedLogins ?? []).slice().sort().join(',') - // Why: see useRepoLabelsBySlug — primitive env id keeps failure setState from - // re-arming the effect through a fresh settings object identity. const activeRuntimeEnvironmentId = settings?.activeRuntimeEnvironmentId ?? null + const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) + const selectedOwner = owner ?? '' + const selectedRepo = repo ?? '' + const repositoryKey = owner && repo ? githubRepoIdentityKey({ owner, repo, host }) : null + const cacheKey = repositoryKey + ? target.kind === 'environment' + ? `runtime:${target.environmentId}:${repositoryKey}#${seedKey}` + : `${repositoryKey}#${seedKey}` + : null + const args = { + owner: selectedOwner, + repo: selectedRepo, + host: githubProjectHost(host), + ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) + } - useEffect(() => { - if (!owner || !repo) { - return - } - const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId }) - const repositoryKey = githubRepoIdentityKey({ owner, repo, host }) - const key = - target.kind === 'environment' - ? `runtime:${target.environmentId}:${repositoryKey}#${seedKey}` - : `${repositoryKey}#${seedKey}` - - const cached = getFreshMetadata(slugAssigneeStore, key) - if (cached) { - // Why: see useRepoLabelsBySlug — avoid cached no-op writes when only - // the settings object identity changed. - if (activeKeyRef.current !== key) { - setState({ data: cached.data, loading: false, error: null }) - } - activeKeyRef.current = key - return - } - - if (activeKeyRef.current === key) { - return - } - activeKeyRef.current = key - const requestKey = key - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - const args = { - owner, - repo, - host: githubProjectHost(host), - ...(seedKey ? { seedLogins: seedKey.split(',') } : {}) - } - loadMetadata(slugAssigneeStore, key, () => + return useMetadataListRequest({ + cacheKey, + store: slugAssigneeStore, + errorFallback: 'Failed to load assignees', + load: () => (target.kind === 'environment' ? callRuntimeRpc( target, @@ -197,25 +103,5 @@ export function useRepoAssigneesBySlug( } return res.users }) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load assignees' - })) - }) - }, [owner, repo, host, seedKey, activeRuntimeEnvironmentId]) - - return state + }) } diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index d4bf4241392..1019ca6bad1 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -108,6 +108,45 @@ describe('getRuntimeProjectRefreshEnvironmentIds', () => { }) }) +describe('browser navigation updates', () => { + it('commits CDP navigation URLs to the render-time cache before updating the store', async () => { + let liveUrlDuringStoreWrite: string | null = null + let readLiveUrl = (_browserPageId: string): string | null => null + const setBrowserPageUrl = vi.fn((browserPageId: string) => { + liveUrlDuringStoreWrite = readLiveUrl(browserPageId) + }) + const updateBrowserPageState = vi.fn() + const storeState = createHarnessStoreState({ + tabsByWorktree: {}, + setBrowserPageUrl, + updateBrowserPageState + }) + const harness = await loadIpcEventsHarness(storeState) + const { clearLiveBrowserUrl, getLiveBrowserUrl } = + await import('@/components/browser-pane/browser-runtime') + readLiveUrl = getLiveBrowserUrl + harness.useIpcEvents() + + harness.navigationUpdate({ + browserPageId: 'page-1', + url: 'https://kagi.com/search?token=secret&q=next', + title: 'Next' + }) + + expect(liveUrlDuringStoreWrite).toBe('https://kagi.com/search?q=next') + expect(getLiveBrowserUrl('page-1')).toBe('https://kagi.com/search?q=next') + expect(setBrowserPageUrl).toHaveBeenCalledWith( + 'page-1', + 'https://kagi.com/search?token=secret&q=next' + ) + expect(updateBrowserPageState).toHaveBeenCalledWith('page-1', { + title: 'Next', + loading: false + }) + clearLiveBrowserUrl('page-1') + }) +}) + function expectWorktreeRouting(worktreeId: string): unknown { return expect.objectContaining({ worktreeId }) } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 70adba7776f..66861de31ce 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -86,6 +86,7 @@ import { setDriverForBrowserPage } from '@/lib/pane-manager/browser-mobile-driver-state' import { destroyPersistentWebview } from '@/components/browser-pane/webview-registry' +import { rememberLiveBrowserUrl } from '@/components/browser-pane/browser-runtime' import { acquireBrowserAutomationVisibility, releaseBrowserAutomationVisibility @@ -156,6 +157,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' import { translate } from '@/i18n/i18n' +import { redactKagiSessionToken } from '../../../shared/browser-url' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' import { getConnectionIdFromState } from '@/lib/connection-context' @@ -2086,6 +2088,7 @@ export function useIpcEvents(): void { return } const store = useAppStore.getState() + rememberLiveBrowserUrl(browserPageId, redactKagiSessionToken(url)) store.setBrowserPageUrl(browserPageId, url) store.updateBrowserPageState(browserPageId, { title, loading: false }) }) diff --git a/src/renderer/src/hooks/useIssueMetadata.test.tsx b/src/renderer/src/hooks/useIssueMetadata.test.tsx index c0b79d220d6..c633d543daa 100644 --- a/src/renderer/src/hooks/useIssueMetadata.test.tsx +++ b/src/renderer/src/hooks/useIssueMetadata.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { clearLinearMetadataCache, + useRepoLabels, useTeamLabels, useTeamMembers, useTeamStates, @@ -17,6 +18,9 @@ const linearMocks = vi.hoisted(() => ({ linearTeamMembers: vi.fn() })) +const runtimeMocks = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) +const githubMocks = vi.hoisted(() => ({ listLabels: vi.fn() })) + vi.mock('@/runtime/runtime-linear-client', () => ({ linearTeamStates: linearMocks.linearTeamStates, linearTeamLabels: linearMocks.linearTeamLabels, @@ -24,7 +28,7 @@ vi.mock('@/runtime/runtime-linear-client', () => ({ })) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn(), + callRuntimeRpc: runtimeMocks.callRuntimeRpc, getActiveRuntimeTarget: (settings?: { activeRuntimeEnvironmentId?: string | null } | null) => settings?.activeRuntimeEnvironmentId ? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId } @@ -33,6 +37,13 @@ vi.mock('@/runtime/runtime-rpc-client', () => ({ const roots: Root[] = [] +function installWindowApi(): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { gh: { listLabels: githubMocks.listLabels } } + }) +} + async function flushEffects(): Promise { await act(async () => { await Promise.resolve() @@ -50,12 +61,15 @@ function renderProbe(element: React.ReactNode): void { }) } -describe('useIssueMetadata Linear hooks', () => { +describe('useIssueMetadata hooks', () => { beforeEach(() => { clearLinearMetadataCache() linearMocks.linearTeamStates.mockReset() linearMocks.linearTeamLabels.mockReset() linearMocks.linearTeamMembers.mockReset() + runtimeMocks.callRuntimeRpc.mockReset() + githubMocks.listLabels.mockReset() + installWindowApi() }) afterEach(() => { @@ -65,6 +79,51 @@ describe('useIssueMetadata Linear hooks', () => { document.body.replaceChildren() }) + it('routes repo-id-only folder metadata through local IPC', async () => { + let labels: string[] = [] + githubMocks.listLabels.mockResolvedValue(['folder']) + + function LabelsProbe(): null { + labels = useRepoLabels(null, 'folder-repo-id').data + return null + } + + renderProbe() + await flushEffects() + + expect(labels).toEqual(['folder']) + expect(githubMocks.listLabels).toHaveBeenCalledExactlyOnceWith({ + repoPath: '', + repoId: 'folder-repo-id' + }) + expect(runtimeMocks.callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('prefers an explicit remote environment and repo id', async () => { + let labels: string[] = [] + runtimeMocks.callRuntimeRpc.mockResolvedValue(['remote']) + + function LabelsProbe(): null { + labels = useRepoLabels('/local/repo', 'remote-repo-id', { + runtimeEnvironmentId: ' env-explicit ', + activeRuntimeEnvironmentId: 'env-active' + }).data + return null + } + + renderProbe() + await flushEffects() + + expect(labels).toEqual(['remote']) + expect(runtimeMocks.callRuntimeRpc).toHaveBeenCalledExactlyOnceWith( + { kind: 'environment', environmentId: 'env-explicit' }, + 'github.listLabels', + { repo: 'remote-repo-id' }, + { timeoutMs: 15_000 } + ) + expect(githubMocks.listLabels).not.toHaveBeenCalled() + }) + it('does not loop when cached team-state metadata is read with a fresh settings object', async () => { let renders = 0 let states: unknown[] = [] diff --git a/src/renderer/src/hooks/useIssueMetadata.ts b/src/renderer/src/hooks/useIssueMetadata.ts index b694c5491dc..8d8a44599ef 100644 --- a/src/renderer/src/hooks/useIssueMetadata.ts +++ b/src/renderer/src/hooks/useIssueMetadata.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: repo metadata hooks share TTL caches and -Linear/GitHub cache invalidation entrypoints used by the issue dialog. */ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: issue metadata hooks clear stale rows and track loading while async provider cache requests are in flight. */ import { useEffect, useMemo, useRef, useState } from 'react' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { @@ -24,20 +21,13 @@ import { loadMetadata, type MetadataRequestStore } from './metadata-request-cache' - -type MetadataState = { - data: T - loading: boolean - error: string | null -} +import { useMetadataListRequest, type MetadataListState } from './useMetadataListRequest' type GitHubMetadataOptions = { runtimeEnvironmentId?: string | null activeRuntimeEnvironmentId?: string | null } -// ─── GitHub ──────────────────────────────────────────────── - const ghLabelStore = createMetadataRequestStore() const ghAssigneeStore = createMetadataRequestStore() @@ -45,44 +35,22 @@ export function useRepoLabels( repoPath: string | null, repoId?: string | null, options?: GitHubMetadataOptions -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) +): MetadataListState { + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + const repoSelector = repoId ?? repoPath ?? '' + const cacheKey = + repoPath || repoId + ? runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}:${repoSelector}` + : repoSelector + : null - useEffect(() => { - if (!repoPath && !repoId) { - return - } - const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null - const repoSelector = repoId ?? repoPath ?? '' - // Why: SSH/runtime metadata must not reuse host-path cache entries; the same - // repo id may resolve through a different credential/runtime boundary. - const cacheKey = runtimeEnvironmentId - ? `runtime:${runtimeEnvironmentId}:${repoSelector}` - : repoSelector - const cached = getFreshMetadata(ghLabelStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(ghLabelStore, cacheKey, () => + return useMetadataListRequest({ + cacheKey, + store: ghLabelStore, + errorFallback: 'Failed to load labels', + load: () => runtimeEnvironmentId ? callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, @@ -93,71 +61,29 @@ export function useRepoLabels( : window.api.gh .listLabels({ repoPath: repoPath ?? '', repoId: repoId ?? undefined }) .then((labels) => labels as string[]) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load labels' - })) - }) - }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) - - return state + }) } export function useRepoAssignees( repoPath: string | null, repoId?: string | null, options?: GitHubMetadataOptions -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null - }) - const activeKeyRef = useRef(null) +): MetadataListState { + const runtimeEnvironmentId = + options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null + const repoSelector = repoId ?? repoPath ?? '' + const cacheKey = + repoPath || repoId + ? runtimeEnvironmentId + ? `runtime:${runtimeEnvironmentId}:${repoSelector}` + : repoSelector + : null - useEffect(() => { - if (!repoPath && !repoId) { - return - } - const runtimeEnvironmentId = - options?.runtimeEnvironmentId?.trim() || options?.activeRuntimeEnvironmentId?.trim() || null - const repoSelector = repoId ?? repoPath ?? '' - // Why: SSH/runtime metadata must not reuse host-path cache entries; the same - // repo id may resolve through a different credential/runtime boundary. - const cacheKey = runtimeEnvironmentId - ? `runtime:${runtimeEnvironmentId}:${repoSelector}` - : repoSelector - const cached = getFreshMetadata(ghAssigneeStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(ghAssigneeStore, cacheKey, () => + return useMetadataListRequest({ + cacheKey, + store: ghAssigneeStore, + errorFallback: 'Failed to load assignees', + load: () => runtimeEnvironmentId ? callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, @@ -168,31 +94,9 @@ export function useRepoAssignees( : window.api.gh .listAssignableUsers({ repoPath: repoPath ?? '', repoId: repoId ?? undefined }) .then((users) => users as GitHubAssignableUser[]) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load assignees' - })) - }) - }, [repoPath, repoId, options?.runtimeEnvironmentId, options?.activeRuntimeEnvironmentId]) - - return state + }) } -// ─── Linear ──────────────────────────────────────────────── - const linearStateStore = createMetadataRequestStore() const linearLabelStore = createMetadataRequestStore() const linearMemberStore = createMetadataRequestStore() @@ -221,205 +125,44 @@ export function useTeamStates( teamId: string | null, settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null +): MetadataListState { + const selectedTeamId = teamId ?? '' + return useMetadataListRequest({ + cacheKey: selectedTeamId ? linearMetadataCacheKey(selectedTeamId, settings, workspaceId) : null, + store: linearStateStore, + load: () => loadTeamStates(settings, selectedTeamId, workspaceId), + errorFallback: 'Failed to load states' }) - const activeKeyRef = useRef(null) - // Why: parents can pass a fresh settings object each render; keying the effect - // on the derived cache key keeps a failure's setState from re-arming the fetch - // in a render-paced loop. The ref carries the latest settings for the call. - const settingsRef = useRef(settings) - settingsRef.current = settings - const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null - - useEffect(() => { - if (!teamId || !cacheKey) { - return - } - - const cached = getFreshMetadata(linearStateStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(linearStateStore, cacheKey, () => - linearTeamStates(settingsRef.current, teamId, workspaceId).then( - (states) => states as LinearWorkflowState[] - ) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load states' - })) - }) - }, [cacheKey, teamId, workspaceId]) - - return state } export function useTeamLabels( teamId: string | null, settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null +): MetadataListState { + const selectedTeamId = teamId ?? '' + return useMetadataListRequest({ + cacheKey: selectedTeamId ? linearMetadataCacheKey(selectedTeamId, settings, workspaceId) : null, + store: linearLabelStore, + load: () => loadTeamLabels(settings, selectedTeamId, workspaceId), + errorFallback: 'Failed to load labels' }) - const activeKeyRef = useRef(null) - // Why: see useTeamStates — cache-key deps + latest-settings ref stop the - // failure-setState render loop. - const settingsRef = useRef(settings) - settingsRef.current = settings - const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null - - useEffect(() => { - if (!teamId || !cacheKey) { - return - } - - const cached = getFreshMetadata(linearLabelStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(linearLabelStore, cacheKey, () => - linearTeamLabels(settingsRef.current, teamId, workspaceId).then( - (labels) => labels as LinearLabel[] - ) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load labels' - })) - }) - }, [cacheKey, teamId, workspaceId]) - - return state } export function useTeamMembers( teamId: string | null, settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { - const [state, setState] = useState>({ - data: [], - loading: false, - error: null +): MetadataListState { + const selectedTeamId = teamId ?? '' + return useMetadataListRequest({ + cacheKey: selectedTeamId ? linearMetadataCacheKey(selectedTeamId, settings, workspaceId) : null, + store: linearMemberStore, + load: () => loadTeamMembers(settings, selectedTeamId, workspaceId), + errorFallback: 'Failed to load members' }) - const activeKeyRef = useRef(null) - // Why: see useTeamStates — cache-key deps + latest-settings ref stop the - // failure-setState render loop. - const settingsRef = useRef(settings) - settingsRef.current = settings - const cacheKey = teamId ? linearMetadataCacheKey(teamId, settings, workspaceId) : null - - useEffect(() => { - if (!teamId || !cacheKey) { - return - } - - const cached = getFreshMetadata(linearMemberStore, cacheKey) - if (cached) { - if (activeKeyRef.current !== cacheKey) { - setState({ data: cached.data, loading: false, error: null }) - activeKeyRef.current = cacheKey - } - return - } - - activeKeyRef.current = cacheKey - const requestKey = cacheKey - setState((s) => ({ - ...s, - data: s.data.length ? ([] as typeof s.data) : s.data, - loading: true, - error: null - })) - loadMetadata(linearMemberStore, cacheKey, () => - linearTeamMembers(settingsRef.current, teamId, workspaceId).then( - (members) => members as LinearMember[] - ) - ) - .then((data) => { - if (activeKeyRef.current !== requestKey) { - return - } - setState({ data, loading: false, error: null }) - }) - .catch((err) => { - if (activeKeyRef.current !== requestKey) { - return - } - activeKeyRef.current = null - setState((s) => ({ - ...s, - loading: false, - error: err instanceof Error ? err.message : 'Failed to load members' - })) - }) - }, [cacheKey, teamId, workspaceId]) - - return state } -/** - * Load Linear team metadata for every selected team and union by id (#8739). - * Reuses the same per-team cache stores as the single-team hooks. - */ function useTeamsMetadataList( teamIds: readonly string[], settings: RuntimeLinearSettings | undefined, @@ -431,8 +174,8 @@ function useTeamsMetadataList( workspaceId: string | null | undefined ) => Promise, errorFallback: string -): MetadataState { - const [state, setState] = useState>({ +): MetadataListState { + const [state, setState] = useState>({ data: [], loading: false, error: null @@ -448,7 +191,6 @@ function useTeamsMetadataList( [teamIdsKey] ) - // Why: recompute each render (like useTeamStates cacheKey) so runtime target changes re-key. const requestKey = stableTeamIds.length === 0 ? null @@ -464,22 +206,12 @@ function useTeamsMetadataList( } activeKeyRef.current = requestKey - const capturedKey = requestKey - const teams = stableTeamIds - - // Fast path: every team still fresh in cache → union synchronously. - const cachedGroups: T[][] = [] - let allCached = true - for (const teamId of teams) { - const cacheKey = linearMetadataCacheKey(teamId, settingsRef.current, workspaceId) - const cached = getFreshMetadata(store, cacheKey) - if (!cached) { - allCached = false - break - } - cachedGroups.push(cached.data) - } - if (allCached) { + const cachedGroups = stableTeamIds.map( + (teamId) => + getFreshMetadata(store, linearMetadataCacheKey(teamId, settingsRef.current, workspaceId)) + ?.data + ) + if (cachedGroups.every((group): group is T[] => group !== undefined)) { setState({ data: unionLinearMetadataById(cachedGroups), loading: false, error: null }) return } @@ -492,7 +224,7 @@ function useTeamsMetadataList( })) void Promise.all( - teams.map((teamId) => { + stableTeamIds.map((teamId) => { const cacheKey = linearMetadataCacheKey(teamId, settingsRef.current, workspaceId) return loadMetadata(store, cacheKey, () => loadTeam(settingsRef.current, teamId, workspaceId) @@ -500,7 +232,7 @@ function useTeamsMetadataList( }) ) .then((groups) => { - if (activeKeyRef.current !== capturedKey) { + if (activeKeyRef.current !== requestKey) { return } setState({ @@ -510,7 +242,7 @@ function useTeamsMetadataList( }) }) .catch((err) => { - if (activeKeyRef.current !== capturedKey) { + if (activeKeyRef.current !== requestKey) { return } activeKeyRef.current = null @@ -546,12 +278,11 @@ const loadTeamMembers = ( ): Promise => linearTeamMembers(settings, teamId, workspaceId).then((members) => members as LinearMember[]) -/** Union of workflow states for every selected Linear team (multi-team filters). */ export function useTeamsStates( teamIds: readonly string[], settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { +): MetadataListState { return useTeamsMetadataList( teamIds, settings, @@ -562,12 +293,11 @@ export function useTeamsStates( ) } -/** Union of labels for every selected Linear team (multi-team filters). */ export function useTeamsLabels( teamIds: readonly string[], settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { +): MetadataListState { return useTeamsMetadataList( teamIds, settings, @@ -578,12 +308,11 @@ export function useTeamsLabels( ) } -/** Union of members for every selected Linear team (multi-team filters). */ export function useTeamsMembers( teamIds: readonly string[], settings?: RuntimeLinearSettings, workspaceId?: string | null -): MetadataState { +): MetadataListState { return useTeamsMetadataList( teamIds, settings, diff --git a/src/renderer/src/hooks/useMetadataListRequest.test.tsx b/src/renderer/src/hooks/useMetadataListRequest.test.tsx new file mode 100644 index 00000000000..3e0c6a68055 --- /dev/null +++ b/src/renderer/src/hooks/useMetadataListRequest.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { createMetadataRequestStore } from './metadata-request-cache' +import { useMetadataListRequest } from './useMetadataListRequest' + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { + resolve = settle + }) + return { promise, resolve } +} + +afterEach(cleanup) + +describe('useMetadataListRequest', () => { + it('treats only null as disabled', async () => { + const store = createMetadataRequestStore() + let loads = 0 + const view = renderHook(() => + useMetadataListRequest({ + cacheKey: '', + store, + errorFallback: 'Failed to load metadata', + load: async () => { + loads += 1 + return ['loaded'] + } + }) + ) + + await act(() => Promise.resolve()) + expect(loads).toBe(1) + expect(view.result.current.data).toEqual(['loaded']) + }) + + it('ignores stale responses and retains the result while disabled', async () => { + const store = createMetadataRequestStore() + const first = deferred() + const second = deferred() + const view = renderHook( + ({ cacheKey }: { cacheKey: string | null }) => + useMetadataListRequest({ + cacheKey, + store, + errorFallback: 'Failed to load metadata', + load: () => (cacheKey === 'first' ? first.promise : second.promise) + }), + { initialProps: { cacheKey: 'first' as string | null } } + ) + + view.rerender({ cacheKey: 'second' }) + await act(async () => { + second.resolve(['second']) + await second.promise + }) + expect(view.result.current.data).toEqual(['second']) + + view.rerender({ cacheKey: null }) + await act(async () => { + first.resolve(['first']) + await first.promise + }) + expect(view.result.current).toEqual({ data: ['second'], loading: false, error: null }) + }) +}) diff --git a/src/renderer/src/hooks/useMetadataListRequest.ts b/src/renderer/src/hooks/useMetadataListRequest.ts new file mode 100644 index 00000000000..186b620d6cd --- /dev/null +++ b/src/renderer/src/hooks/useMetadataListRequest.ts @@ -0,0 +1,72 @@ +import { useEffect, useEffectEvent, useRef, useState } from 'react' +import { getFreshMetadata, loadMetadata, type MetadataRequestStore } from './metadata-request-cache' + +export type MetadataListState = { + data: T[] + loading: boolean + error: string | null +} + +type MetadataListRequest = { + cacheKey: string | null + store: MetadataRequestStore + load: () => Promise + errorFallback: string +} + +export function useMetadataListRequest({ + cacheKey, + store, + load, + errorFallback +}: MetadataListRequest): MetadataListState { + const [state, setState] = useState>({ + data: [], + loading: false, + error: null + }) + const activeKeyRef = useRef(null) + const loadLatest = useEffectEvent(load) + + useEffect(() => { + if (cacheKey === null) { + return + } + + const cached = getFreshMetadata(store, cacheKey) + if (cached) { + if (activeKeyRef.current !== cacheKey) { + setState({ data: cached.data, loading: false, error: null }) + } + activeKeyRef.current = cacheKey + return + } + activeKeyRef.current = cacheKey + const requestKey = cacheKey + setState((current) => ({ + ...current, + data: current.data.length ? [] : current.data, + loading: true, + error: null + })) + void loadMetadata(store, cacheKey, () => loadLatest()) + .then((data) => { + if (activeKeyRef.current === requestKey) { + setState({ data, loading: false, error: null }) + } + }) + .catch((error: unknown) => { + if (activeKeyRef.current !== requestKey) { + return + } + activeKeyRef.current = null + setState((current) => ({ + ...current, + loading: false, + error: error instanceof Error ? error.message : errorFallback + })) + }) + }, [cacheKey, errorFallback, store]) + + return state +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1f5f127f338..3acc9517287 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2719,15 +2719,6 @@ }, "sheet": { "1189e9fe0a": "Close" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "No teams match your search.", - "301f2a796e": "All teams", - "18ec58881e": "Search teams..." - } - } } }, "terminal": { @@ -4920,7 +4911,8 @@ "ae57cbf6e4": "Failed to delete workspace", "7488ed8711": "View", "2b20ce87b3": "Force Delete", - "4f3876c0f5": "Force delete failed" + "4f3876c0f5": "Force delete failed", + "workspaceListChanged": "Workspace list changed" }, "toast": { "1d0fa5c0a5": "Failed to delete workspace {{value0}}", @@ -5343,6 +5335,45 @@ "connectName": "Connect to SSH host {{value0}}", "authFailedTooltip": "{{value0}} · authentication failed", "failedTooltip": "{{value0}} · connection failed" + }, + "preserved": { + "branch": { + "batch": { + "toast": { + "a3cdd9d9e6": "Git kept {{count}} local branches because they may contain unmerged commits. Kept branches do not retain workspace folders; their commits remain in the repository. Orca may continue freeing workspace disk space in the background.", + "a3cdd9d9e6_one": "Git kept {{count}} local branch because it may contain unmerged commits. Kept branches do not retain workspace folders; their commits remain in the repository. Orca may continue freeing workspace disk space in the background.", + "a3cdd9d9e6_other": "Git kept {{count}} local branches because they may contain unmerged commits. Kept branches do not retain workspace folders; their commits remain in the repository. Orca may continue freeing workspace disk space in the background.", + "6310412304": "Review {{count}} Branches", + "6310412304_one": "Review {{count}} Branch", + "6310412304_other": "Review {{count}} Branches", + "cea24c2b7d": "{{count}} workspaces removed", + "cea24c2b7d_one": "{{count}} workspace removed", + "cea24c2b7d_other": "{{count}} workspaces removed", + "0e0379f24a": "{{count}} branches kept", + "0e0379f24a_one": "{{count}} branch kept", + "0e0379f24a_other": "{{count}} branches kept", + "4cf75caab7": "{{value0}}, {{value1}}", + "e61d78054f": "Deleting local branches: {{value0}}", + "1e1a5f6763": "Local branches deleted: {{value0}}", + "43d9395605": "{{value0}} deleted, {{value1}} not deleted", + "d42f1f14e0": "Retry {{count}} Branches", + "d42f1f14e0_one": "Retry {{count}} Branch", + "d42f1f14e0_other": "Retry {{count}} Branches" + } + } + } + }, + "PreservedBranchBatchReviewDialog": { + "c4bf8e7eaf": "Review kept branches", + "f21976c9a8": "Select the local branches you want to force delete. Unselected branches stay in their repositories.", + "38c947f7c5": "Select all", + "9602129d38": "{{value0}} of {{value1}} selected", + "ee39e872d5": "May be unmerged", + "676db406fd": "Head unavailable", + "285e1e4882": "Cancel", + "a0f9863597": "Force Delete {{count}} Branches", + "a0f9863597_one": "Force Delete {{count}} Branch", + "a0f9863597_other": "Force Delete {{count}} Branches" } }, "shared": { @@ -11976,17 +12007,6 @@ "yoloPermissionsInfo": "Agent permission info", "yoloPermissionsTooltip": "Skip permission checks for agents for less interruptions" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents can message each other, take tasks, and coordinate handoffs.", - "399cf885c0": "Agent Orchestration", - "c5292c409d": "Agents can inspect app windows and operate local apps when you ask.", - "1ecfb490ac": "Computer Use", - "01426f3a23": "Agents can navigate sites, inspect pages, and work through browser tasks.", - "ea85d9e628": "Agent Browser Use", - "linearTicketsTitle": "Linear agent skill", - "linearTicketsDescription": "Agents can use linked Linear tasks for richer ticket-aware handoffs.", - "linearTicketsSetupSummary": "Recommended for Linear workspaces; does not affect Linear connection setup." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Press Enter to run the command and confirm npx if asked. You can also set this up later in Settings.", "47fc6cc6dc": "Skill setup command", @@ -12072,37 +12092,6 @@ "9f47f345a4": "It won't take long!", "e4726b2d50": "Skip onboarding?" }, - "OnboardingTourStep": { - "3f9586c043": "Take the tour", - "60c5576353": "Exit tour" - }, - "RepoStep": { - "e8fdb36338": "Scanning repositories. Click to stop.", - "b7c4da0504": "SSH? Set hosts up in Settings", - "c33b190ca3": "Host paths only", - "7b679207e4": "Workspace", - "24c7c8696c": "Clone into host path", - "7932e95f68": "Clone", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "Paste an HTTPS or SSH URL.", - "132425a3e3": "Clone a repo", - "6558d50c69": "Want to import many repos at once? Select the parent folder.", - "831524961f": "Choose any local directory, git repo or not.", - "f4e9c8dcf8": "Browse for a folder", - "e8214aa632": "Open as Folder", - "3863747c56": "Add Git Project", - "2ebbc26343": "/home/user/project", - "466108ab89": "Enter a path that exists on the selected host.", - "8cab104e3c": "Open a project on this host", - "2d20200346": "Import repositories", - "27ca610db1": "Back", - "cecd6593fa": "Scanned folder:", - "c7af322fc3": "Stop scanning", - "c3d9d44ca2": "Stop scan", - "cf23006ba7": "Selected host", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}Found {{value1}} {{value2}} in this folder." - }, "ThemeStep": { "a4b254779d": "macOS Option key", "6c51398942": "Mouse", @@ -12130,15 +12119,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "Could not open SSH settings", - "fd74e7558e": "Clone failed", "52acfbef51": "Could not save progress" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Enable capabilities" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Uses PowerShell 7+ when available, with Windows PowerShell as fallback.", @@ -12239,10 +12223,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hosts are ready for this project.", - "placeholder": "Choose host" - }, "ProjectCombobox": { "empty": "No projects match your search.", "addProject": "Add a new project", @@ -12644,43 +12624,6 @@ "cda56c5915": "notes / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "Thinking...", - "ef8b164dd1": "review src/auth", - "952d3ddd9a": "session started", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "Running 12 tests", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "Splittable terminal", - "2a7cfc82c8": "Linked to GH #1842", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "Start", - "c1f28c03b2": "Worktree picker truncates", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "GitHub & Linear tasks", - "5171768676": "orchestrating 3 agents", - "cebc7769cd": "redesign auth flow", - "e44269e97d": "Agent orchestration", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "refactor billing webhook", - "9c812e0d7c": "speed up CI pipeline", - "3c4adfd821": "fix login race condition", - "56a0271428": "Isolated workspaces", - "ef737dcee1": "GitHub & Linear tasks", - "ac51c061e2": "codex", - "47f16ecf34": "Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.", - "70aa182266": "Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.", - "f10c14dd9d": "Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.", - "5d6ee181b6": "Open any workspace to return to its terminal, then split panes for tests, logs, and agents." - }, "FeatureWallBody": { "25ec5356d6": "Setup" }, @@ -12924,11 +12867,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "working" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "notes" @@ -14362,7 +14300,17 @@ "usageUnavailable": "Usage unavailable", "noRunUsageYet": "No run usage yet", "noWorkspace": "No workspace", - "latestSavedOutput": "Latest saved output" + "latestSavedOutput": "Latest saved output", + "backToList": "All automations", + "tableName": "Name", + "tableProject": "Project", + "tableStatus": "Status", + "tableActions": "Actions", + "newAutomation": "New Automation", + "rowActions": "Automation actions" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "Loading automations" }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...", @@ -14479,9 +14427,12 @@ "AutomationListSearchField": { "tooLong": "Search text is too long — list is unfiltered", "label": "Search automations", - "placeholder": "Search by name, project, or prompt", + "placeholder": "Search...", "tooLongShort": "Too long", "clear": "Clear search" + }, + "AutomationListLocalRows": { + "c92c9463c6": "Automation actions" } }, "agent": { @@ -14743,80 +14694,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca sign-in unavailable" - }, - "account": "Account", - "reconnect": "Reconnect profile", - "connect": "Connect profile", - "organization": "Organization", - "create": { - "profile": { - "for": { - "org": "Create profile for org" - } - } - }, - "signout": "Sign out", - "16e3681072": "New local profile", - "e3b91a3d90": "Create an empty profile for separate projects and worktrees.", - "f322e1f4d6": "Profile name", - "cfa59f8ad1": "Create and Switch", - "b4f9d1125d": "Local", - "4815f7d163": "Switch profile", - "d00d853e2a": "Manage profiles", - "c106c674fe": "New local profile", - "org": { - "members": "Organization members" - }, - "signInWaiting": "Waiting for sign-in…", - "signInAgain": "Sign in again", - "signIn": "Sign in to Orca", - "accountTitle": "Orca account", - "accountSignedIn": "Signed in", - "accountSignInRequired": "Sign-in required", - "accountSignedOut": "Signed out" - }, - "management": { - "04e7bd2a23": "Transfer", - "128c7dfe64": "Copy to", - "df8b7d876b": "Move to", - "9aa26347b3": "Project moved", - "816ce624b6": "Project copied", - "2c45bda8d3": "Manage profiles", - "2db945e4a0": "Copy or move projects from the active profile to another local profile.", - "8668cb2946": "No projects in this profile.", - "93034915ab": "Create another profile before copying projects.", - "projectCountSingular": "{{count}} project", - "projectCountPlural": "{{count}} projects" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "Move project?", - "description": "Move {{repoName}} to {{targetName}}. Orca removes it from {{activeProfileName}}, keeps files in place, and relaunches into {{targetName}}.", - "action": "Move project" - }, - "copy": { - "title": "Copy project?", - "description": "Copy {{repoName}} to {{targetName}}. Both profiles will point at the same files with separate Orca metadata.", - "action": "Copy project" - }, - "live": { - "work": "Live work in this project" - }, - "cancel": "Cancel", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" - } - } - }, "signout": { "confirm": { "title": "Sign out of Orca?", @@ -14824,79 +14701,6 @@ "cancel": "Cancel", "action": "Sign out" } - }, - "switch": { - "confirm": { - "target": "the selected profile", - "title": "Switch profiles?", - "description": "Switching to {{targetName}} will relaunch Orca and reload the workspace for {{activeProfileName}}.", - "live": { - "work": "Live work in this profile" - }, - "cancel": "Cancel", - "switch": "Switch profile", - "terminalSingular": "{{count}} live terminal tab", - "terminalPlural": "{{count}} live terminal tabs", - "agentSingular": "{{count}} active agent", - "agentPlural": "{{count}} active agents", - "browserSingular": "{{count}} browser workspace", - "browserPlural": "{{count}} browser workspaces" - } - }, - "org": { - "members": { - "role": { - "label": "Role", - "owner": "Owner", - "admin": "Admin", - "member": "Member" - }, - "not": { - "signed": { - "in": "They haven't signed in to Orca yet." - } - }, - "remove": { - "confirm": "Remove", - "cancel": "Cancel", - "label": "Remove teammate" - }, - "title": "Organization members", - "load": { - "error": "Couldn't load organization members." - }, - "retry": "Try again", - "pending": { - "title": "Pending invites", - "revoke": "Revoke" - }, - "invite": { - "title": "Invite", - "placeholder": "teammate@example.com", - "role": "Invite role", - "submit": "Send" - }, - "error": { - "forbidden": "Your role can't do that.", - "conflict": "Already a member or invited.", - "remove": { - "self": "You can't remove yourself." - }, - "own": { - "role": "You can't change your own role." - }, - "not": { - "found": "That teammate is no longer in this organization." - }, - "reconnect": "Reconnect your profile to manage members.", - "unconfigured": "Organization management is unavailable in this build.", - "failed": "Something went wrong. Please try again." - }, - "subtitle": { - "named": "People in {{orgName}} who can collaborate on Orca.", - "default": "People in your organization who can collaborate on Orca." - } - } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index c0a5ccf5910..0739b34a160 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "Cerrar" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "Ningún equipo coincide con tu búsqueda.", - "301f2a796e": "Todos los equipos", - "18ec58881e": "Buscar equipos..." - } - } } }, "terminal": { @@ -3991,7 +3982,7 @@ "e643b30398": "Proyecto agregado en host SSH" }, "AddRepoCreateStep": { - "0ae45b8238": "mi-proyecto", + "0ae45b8238": "my-project", "a8149a3a5a": "Nombre", "11fd2a7db8": "Repositorio Git", "5e97f0c4b9": "Proyecto creado", @@ -6060,7 +6051,7 @@ "a6c2816115": "y", "b8a7efb3f6": "ORCA_BITBUCKET_EMAIL", "8489c0aa49": "Bitbucket", - "e74de656ce": "inicio de sesión de autenticación glab", + "e74de656ce": "glab auth login", "05e5245af7": "La CLI de GitLab está instalada pero no autenticada. Ejecute este comando en una terminal:", "a83cac5726": "Instalar la CLI de GitLab", "35a3379372": "Instale la CLI de GitLab para habilitar solicitudes de fusión, problemas y canalizaciones.", @@ -6068,7 +6059,7 @@ "a3326f6f1b": "glabro", "027440e1cb": "Fusionar solicitudes, problemas, todos y canalizaciones a través de", "513abfe47d": "GitLab", - "51000487c4": "inicio de sesión de autenticación gh", + "51000487c4": "gh auth login", "09285e9fe6": "La CLI de GitHub está instalada pero no autenticada. Ejecute este comando en una terminal:", "399cf46867": "Instalar la CLI de GitHub", "c0c8575e05": "Instale la CLI de GitHub para habilitar solicitudes de extracción, problemas y comprobaciones.", @@ -6475,12 +6466,12 @@ "07ba35bc68": "Revisa la indentación bajo `scripts:`. Las claves de hooks deben usar dos espacios y las líneas de comandos deben usar cuatro.", "787ca433ef": "Define solo las claves admitidas: `scripts`, `setup`, `archive` e `issueCommand`.", "ecc73d9125": "Compara tu archivo con la plantilla funcional de abajo y copia esa estructura si hace falta.", - "925f9e0dc4": "texto-primer plano", + "925f9e0dc4": "text-foreground", "0cc712b823": "El archivo de configuración principal existe en la raíz del repositorio, pero Orca aún no pudo analizar las definiciones de hooks admitidas.", - "c90b858573": "texto-ámbar-700 oscuro:texto-ámbar-300", + "c90b858573": "text-amber-700 dark:text-amber-300", "aba825233f": "El archivo contiene claves de configuración que esta versión de Orca no reconoce. Es posible que necesites actualizar Orca o revisar el archivo en busca de errores tipográficos.", "ca424ff135": "Los valores predeterminados compartidos de hooks y automatización de Issues están definidos en el repositorio y disponibles para todos los que lo usan.", - "32f417fe17": "texto-esmeralda-700 oscuro:texto-esmeralda-300", + "32f417fe17": "text-emerald-700 dark:text-emerald-300", "8bfe65fc60": "Usar comandos locales", "8d6c56bff8": "Ejecutar ambos", "0fa21e19ec": "Nombre del workspace, normalmente basado en el nombre de la rama.", @@ -6524,7 +6515,7 @@ "2b7d27b93c": "Usar {{value0}} como color del repositorio", "fde066a63b": "Las cargas PNG deben tener un tamaño de 256 KB o menos.", "cc1286e263": "Favicon", - "03ca1a4e9b": "ejemplo.com", + "03ca1a4e9b": "example.com", "381b4844fd": "Subir PNG", "7da623abcc": "Se usa de forma predeterminada: GitHub siempre proporciona uno, incluso cuando el propietario no configuró una imagen personalizada.", "39da8a10bf": "Usar avatar de GitHub", @@ -10951,7 +10942,7 @@ "9fffaf17c1": "Alternar barra lateral derecha ({{value0}})", "b37ff4a89a": "puertos", "9f83375839": "cheques", - "6306b48afd": "control de fuente", + "6306b48afd": "source-control", "ef182dcb12": "buscar", "fc3095d2ed": "explorador", "aiVaultSessionHistory": "Agentes", @@ -11493,17 +11484,6 @@ "yoloPermissionsInfo": "Información de permisos del agente", "yoloPermissionsTooltip": "Omite las comprobaciones de permisos de los agentes para reducir interrupciones" }, - "FeatureSetupChecklist": { - "77f74946f5": "Los agentes pueden enviarse mensajes entre sí, tomar tareas y coordinar handoffs.", - "399cf885c0": "Orquestación de agentes", - "c5292c409d": "Los agentes pueden inspeccionar ventanas de apps y operar apps locales cuando se lo pidas.", - "1ecfb490ac": "Uso de la computadora", - "01426f3a23": "Los agentes pueden navegar por sitios, inspeccionar páginas y trabajar en tareas del navegador.", - "ea85d9e628": "Uso del navegador por agentes", - "linearTicketsTitle": "Skill de agente de Linear", - "linearTicketsDescription": "Los agentes pueden usar tareas de Linear enlazadas para handoffs con más contexto de ticket.", - "linearTicketsSetupSummary": "Recomendado para espacios de trabajo de Linear; no afecta la configuración de conexión de Linear." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Pulsa Enter para ejecutar el comando y confirma npx si se te solicita. También puedes configurarlo más tarde en Configuración.", "47fc6cc6dc": "Comando de configuración de skills", @@ -11589,37 +11569,6 @@ "9f47f345a4": "¡No tardará mucho!", "e4726b2d50": "¿Saltar la incorporación?" }, - "OnboardingTourStep": { - "3f9586c043": "Haz el recorrido", - "60c5576353": "Salir del recorrido" - }, - "RepoStep": { - "e8fdb36338": "Escaneo de repositorios. Haga clic para detener.", - "b7c4da0504": "¿SSH? Configura hosts en Configuración", - "c33b190ca3": "Solo rutas del host", - "7b679207e4": "Espacio de trabajo", - "24c7c8696c": "Clonar en ruta del host", - "7932e95f68": "Clonar", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "Pegue una URL HTTPS o SSH.", - "132425a3e3": "Clonar un repositorio", - "6558d50c69": "¿Quieres importar muchos repositorios a la vez? Seleccione la carpeta principal.", - "831524961f": "Elija cualquier directorio local, repositorio git o no.", - "f4e9c8dcf8": "Buscar una carpeta", - "e8214aa632": "Abrir como carpeta", - "3863747c56": "Agregar proyecto Git", - "2ebbc26343": "/home/user/project", - "466108ab89": "Introduce una ruta que exista en el host seleccionado.", - "8cab104e3c": "Abrir un proyecto en este host", - "2d20200346": "Importar repositorios", - "27ca610db1": "Atrás", - "cecd6593fa": "Carpeta escaneada:", - "c7af322fc3": "Dejar de escanear", - "c3d9d44ca2": "Detener escaneo", - "cf23006ba7": "Host seleccionado", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}Se encontraron {{value1}} {{value2}} en esta carpeta." - }, "ThemeStep": { "a4b254779d": "Tecla de opción de macOS", "6c51398942": "Ratón", @@ -11647,15 +11596,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "No se pudo abrir la configuración de SSH", - "fd74e7558e": "Clon falló", "52acfbef51": "No se pudo guardar el progreso" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "Activar capacidades" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "Usa PowerShell 7+ cuando está disponible, con Windows PowerShell como alternativa.", @@ -11756,10 +11700,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "No hay hosts listos para este proyecto.", - "placeholder": "Elegir host" - }, "ProjectCombobox": { "empty": "Ningún proyecto coincide con tu búsqueda.", "addProject": "Add a new project", @@ -12135,43 +12075,6 @@ "cda56c5915": "notes / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "Pensando...", - "ef8b164dd1": "revisar src/auth", - "952d3ddd9a": "sesión iniciada", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "Ejecutando 12 pruebas", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "Terminal dividible", - "2a7cfc82c8": "Vinculado a GH #1842", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "Comenzar", - "c1f28c03b2": "El selector de worktree se trunca", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "Tareas de GitHub y Linear", - "5171768676": "orquestando 3 agentes", - "cebc7769cd": "rediseñar el flujo de autenticación", - "e44269e97d": "Orquestación de agentes", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "refactorizar el webhook de facturación", - "9c812e0d7c": "acelerar el proceso de CI", - "3c4adfd821": "arreglar la condición de carrera de inicio de sesión", - "56a0271428": "Espacios de trabajo aislados", - "ef737dcee1": "Tareas de GitHub y Linear", - "ac51c061e2": "codex", - "47f16ecf34": "Avanza en varias cosas a la vez. Cada workspace mantiene juntos su rama, terminal y actividad de agentes.", - "70aa182266": "Delega un objetivo y sigue con lo tuyo. Un agente coordinador se despliega y publica PRs en paralelo.", - "f10c14dd9d": "Evita cambiar de pestaña. Elige desde tu backlog de GitHub o Linear e inicia un workspace con un clic.", - "5d6ee181b6": "Abre cualquier workspace para volver a su terminal y luego divide paneles para tests, logs y agentes." - }, "FeatureWallBody": { "25ec5356d6": "Configuración" }, @@ -12415,13 +12318,8 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "laboral" - }, "ReviewAnimatedVisual": { - "8df4d52b68": "pr-vista", + "8df4d52b68": "pr-view", "8ab622e4d6": "notas" }, "FeatureWallBrowserAction": { @@ -12697,7 +12595,7 @@ "19c45cfdc0": "Expandir todo", "982d14bfa5": "Abrir todos los cambios", "3d909843bb": "Abrir diff de rama", - "8368d256ec": "rama combinada", + "8368d256ec": "combined-branch", "8f68ad9ca9": "Mostrar {{value0}} AI {{value1}}", "724a13568d": "en {{value0}}", "6094135eec": "frente a {{value0}}", @@ -12889,7 +12787,7 @@ "8f4d2c1a9b": "Redimensionar tabla de contenido" }, "MarkdownTemplatePicker": { - "22cd94426f": "sin título.md", + "22cd94426f": "untitled.md", "6e2e6c04ad": "Markdown en blanco", "df667919ca": "No hay plantillas que coincidan.", "22fd4890ad": "Buscar plantillas...", @@ -13816,7 +13714,27 @@ "a21f6c33ad": "Origen de automatización actualizado.", "53f06f0ad5": "Reintentar origen", "pendingAutomationMissing": "La automatización ya no está disponible.", - "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible." + "pendingAutomationRunMissing": "El historial de ejecuciones ya no está disponible.", + "noSearchMatches": "Ninguna automatización coincide con tu búsqueda.", + "paused": "Pausada", + "runCount": "{{count}} ejecuciones", + "runCount_one": "{{count}} ejecución", + "runCount_other": "{{count}} ejecuciones", + "projectDefaultBaseRef": "predeterminado del proyecto", + "createFromBaseRef": "Crear desde {{baseRef}}", + "missingWorkspace": "Espacio de trabajo ausente", + "runUsageSummary": "{{cost}} est. · {{tokens}} tokens", + "usageUnavailable": "Uso no disponible", + "noRunUsageYet": "Aún no hay uso de ejecución", + "noWorkspace": "Sin espacio de trabajo", + "latestSavedOutput": "Última salida guardada", + "backToList": "Todas las automatizaciones", + "tableName": "Nombre", + "tableProject": "Proyecto", + "tableStatus": "Estado", + "tableActions": "Acciones", + "newAutomation": "Nueva automatización", + "rowActions": "Acciones de automatización" }, "CreateFromPicker": { "f061f49e3f": "Buscar ramas del repositorio...", @@ -13929,6 +13847,19 @@ "5a7863909c": "Ejecutar la configuración para cada espacio de trabajo nuevo", "18f000ad4e": "Avanzado", "874b72195b": "Cuando esta automatización crea un workspace, lo prepara igual que al crear un worktree manualmente: ejecuta la configuración del proyecto y abre sus pestañas de terminal." + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "Cargando automatizaciones" + }, + "AutomationListSearchField": { + "tooLong": "El texto de búsqueda es demasiado largo — la lista no está filtrada", + "label": "Buscar automatizaciones", + "placeholder": "Buscar...", + "tooLongShort": "Demasiado largo", + "clear": "Borrar búsqueda" + }, + "AutomationListLocalRows": { + "c92c9463c6": "Acciones de automatización" } }, "agent": { @@ -14181,80 +14112,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Inicio de sesión en Orca no disponible" - }, - "account": "Cuenta", - "reconnect": "Reconectar perfil", - "connect": "Conectar perfil", - "organization": "Organización", - "create": { - "profile": { - "for": { - "org": "Crear perfil para organización" - } - } - }, - "signout": "Cerrar sesión", - "16e3681072": "Nuevo perfil local", - "e3b91a3d90": "Crear un perfil vacío para proyectos y worktrees separados.", - "f322e1f4d6": "Nombre del perfil", - "cfa59f8ad1": "Crear y cambiar", - "b4f9d1125d": "Local", - "4815f7d163": "Cambiar perfil", - "d00d853e2a": "Gestionar perfiles", - "c106c674fe": "Nuevo perfil local", - "org": { - "members": "Miembros de la organización" - }, - "signInWaiting": "Esperando el inicio de sesión…", - "signInAgain": "Volver a iniciar sesión", - "signIn": "Iniciar sesión en Orca", - "accountTitle": "Cuenta de Orca", - "accountSignedIn": "Sesión iniciada", - "accountSignInRequired": "Inicio de sesión requerido", - "accountSignedOut": "Sesión cerrada" - }, - "management": { - "04e7bd2a23": "Transferir", - "128c7dfe64": "Copiar a", - "df8b7d876b": "Mover a", - "9aa26347b3": "Proyecto movido", - "816ce624b6": "Proyecto copiado", - "2c45bda8d3": "Gestionar perfiles", - "2db945e4a0": "Copiar o mover proyectos del perfil activo a otro perfil local.", - "8668cb2946": "No hay proyectos en este perfil.", - "93034915ab": "Cree otro perfil antes de copiar proyectos.", - "projectCountSingular": "{{count}} proyecto", - "projectCountPlural": "{{count}} proyectos" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "¿Mover proyecto?", - "description": "Mover {{repoName}} a {{targetName}}. Orca lo elimina de {{activeProfileName}}, mantiene los archivos en su lugar y relanza en {{targetName}}.", - "action": "Mover proyecto" - }, - "copy": { - "title": "¿Copiar proyecto?", - "description": "Copiar {{repoName}} a {{targetName}}. Ambos perfiles apuntarán a los mismos archivos con metadatos de Orca separados.", - "action": "Copiar proyecto" - }, - "live": { - "work": "Trabajo activo en este proyecto" - }, - "cancel": "Cancelar", - "terminalSingular": "{{count}} pestaña de terminal activa", - "terminalPlural": "{{count}} pestañas de terminal activas", - "agentSingular": "{{count}} agente activo", - "agentPlural": "{{count}} agentes activos", - "browserSingular": "{{count}} espacio de trabajo del navegador", - "browserPlural": "{{count}} espacios de trabajo del navegador" - } - } - }, "signout": { "confirm": { "title": "¿Cerrar sesión en Orca?", @@ -14262,79 +14119,6 @@ "cancel": "Cancelar", "action": "Cerrar sesión" } - }, - "switch": { - "confirm": { - "target": "el perfil seleccionado", - "title": "¿Cambiar perfiles?", - "description": "Cambiar a {{targetName}} relanzará Orca y recargará el espacio de trabajo de {{activeProfileName}}.", - "live": { - "work": "Trabajo activo en este perfil" - }, - "cancel": "Cancelar", - "switch": "Cambiar perfil", - "terminalSingular": "{{count}} pestaña de terminal activa", - "terminalPlural": "{{count}} pestañas de terminal activas", - "agentSingular": "{{count}} agente activo", - "agentPlural": "{{count}} agentes activos", - "browserSingular": "{{count}} espacio de trabajo del navegador", - "browserPlural": "{{count}} espacios de trabajo del navegador" - } - }, - "org": { - "members": { - "role": { - "label": "Rol", - "owner": "Propietario", - "admin": "Administrador", - "member": "Miembro" - }, - "not": { - "signed": { - "in": "Aún no han iniciado sesión en Orca." - } - }, - "remove": { - "confirm": "Eliminar", - "cancel": "Cancelar", - "label": "Eliminar compañero" - }, - "title": "Miembros de la organización", - "load": { - "error": "No se pudieron cargar los miembros de la organización." - }, - "retry": "Intentar de nuevo", - "pending": { - "title": "Invitaciones pendientes", - "revoke": "Revocar" - }, - "invite": { - "title": "Invitar", - "placeholder": "teammate@example.com", - "role": "Rol de invitación", - "submit": "Enviar" - }, - "error": { - "forbidden": "Tu rol no puede hacer eso.", - "conflict": "Ya es miembro o fue invitado.", - "remove": { - "self": "No puedes eliminarte a ti mismo." - }, - "own": { - "role": "No puedes cambiar tu propio rol." - }, - "not": { - "found": "Ese compañero ya no está en esta organización." - }, - "reconnect": "Reconecta tu perfil para gestionar miembros.", - "unconfigured": "La gestión de organización no está disponible en esta compilación.", - "failed": "Algo salió mal. Por favor, intenta de nuevo." - }, - "subtitle": { - "named": "Personas en {{orgName}} que pueden colaborar en Orca.", - "default": "Personas en tu organización que pueden colaborar en Orca." - } - } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 16118042ddc..fde6cee2a6f 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "閉じる" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "検索に一致するチームはありません。", - "301f2a796e": "全チーム", - "18ec58881e": "チームを検索…" - } - } } }, "terminal": { @@ -11493,17 +11484,6 @@ "yoloPermissionsInfo": "Agent 権限情報", "yoloPermissionsTooltip": "Agent の権限チェックをスキップして割り込みを減らす" }, - "FeatureSetupChecklist": { - "77f74946f5": "Agent は相互にメッセージを送信し、タスクを実行し、引き継ぎを調整できます。", - "399cf885c0": "Agent オーケストレーション", - "c5292c409d": "Agent は、要求に応じてアプリウィンドウを検査し、ローカルアプリを操作できます。", - "1ecfb490ac": "コンピュータ操作", - "01426f3a23": "Agent はサイトを移動し、ページを検査し、ブラウザのタスクを実行できます。", - "ea85d9e628": "Agent ブラウザの使用", - "linearTicketsTitle": "Linear Agent スキル", - "linearTicketsDescription": "Agent はリンクされた Linear タスクを使って、チケットを踏まえたより詳しい引き継ぎができます。", - "linearTicketsSetupSummary": "Linear ワークスペースに推奨。Linear 接続設定には影響しません。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter キーを押してコマンドを実行し、要求されたら npx を確認します。これは、後で [設定] で設定することもできます。", "47fc6cc6dc": "スキルセットアップコマンド", @@ -11589,37 +11569,6 @@ "9f47f345a4": "それほど時間はかかりません!", "e4726b2d50": "オンボーディングをスキップしますか?" }, - "OnboardingTourStep": { - "3f9586c043": "ツアーに参加する", - "60c5576353": "出口ツアー" - }, - "RepoStep": { - "e8fdb36338": "リポジトリをスキャンしています。クリックして停止します。", - "b7c4da0504": "SSH? 「設定」でホストをセットアップする", - "c33b190ca3": "ホストパスのみ", - "7b679207e4": "ワークスペース", - "24c7c8696c": "ホストパスにクローンを作成します", - "7932e95f68": "クローン", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "HTTPS または SSH URL を貼り付けます。", - "132425a3e3": "リポジトリのクローンを作成する", - "6558d50c69": "一度に多くのリポジトリをインポートしたいですか?親フォルダーを選択します。", - "831524961f": "git repo かどうかに関係なく、任意のローカルディレクトリを選択します。", - "f4e9c8dcf8": "フォルダーを参照する", - "e8214aa632": "フォルダーとして開く", - "3863747c56": "Git プロジェクトの追加", - "2ebbc26343": "/home/user/project", - "466108ab89": "ホスト上に存在するパスを入力します。", - "8cab104e3c": "ホストプロジェクトを開く", - "2d20200346": "リポジトリのインポート", - "27ca610db1": "戻る", - "cecd6593fa": "スキャンしたフォルダー:", - "c7af322fc3": "スキャンを停止する", - "c3d9d44ca2": "スキャンの停止", - "cf23006ba7": "選択したホスト", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}このフォルダー内で {{value1}} {{value2}} が見つかりました。" - }, "ThemeStep": { "a4b254779d": "macOS の Option キー", "6c51398942": "マウス", @@ -11647,15 +11596,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "SSH 設定を開けませんでした", - "fd74e7558e": "クローン作成に失敗しました", "52acfbef51": "進行状況を保存できませんでした" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "CLI とスキルをインストール" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "利用可能な場合は PowerShell 7+ を使い、代わりに Windows PowerShell を使用します。", @@ -11756,10 +11700,6 @@ "jiraSelectBindFailed": "この Jira Issue をリンクできませんでした。該当するサイトを選択するか Jira に再接続してから、もう一度お試しください。", "emoji": "絵文字" }, - "ProjectHostSetupCombobox": { - "empty": "このプロジェクトで準備が整ったホストはありません。", - "placeholder": "ホストを選択" - }, "ProjectCombobox": { "empty": "検索条件に一致するプロジェクトはありません。", "addProject": "新規プロジェクトを追加", @@ -12135,43 +12075,6 @@ "cda56c5915": "メモ / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "思考中…", - "ef8b164dd1": "review src/auth", - "952d3ddd9a": "セッションが開始されました", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "12 個のテストを実行する", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "分割可能なターミナル", - "2a7cfc82c8": "GH #1842 にリンク", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "開始", - "c1f28c03b2": "ワークツリーピッカーが切り捨てる", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "GitHub と Linear タスク", - "5171768676": "3 つの Agent を調整する", - "cebc7769cd": "認証フローを再設計する", - "e44269e97d": "Agent のオーケストレーション", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "請求 Webhook をリファクタリングする", - "9c812e0d7c": "CIパイプラインを高速化する", - "3c4adfd821": "ログイン競合状態を修正", - "56a0271428": "隔離されたワークスペース", - "ef737dcee1": "GitHub と Linear タスク", - "ac51c061e2": "codex", - "47f16ecf34": "複数の作業を同時に進めましょう。各ワークスペースはブランチ、ターミナル、Agent の活動をまとめます。", - "70aa182266": "目標を任せて離れます。コーディネーター Agent が作業を分担し、並列で PR を出します。", - "f10c14dd9d": "タブ切り替えは不要。GitHub または Linear のバックログから選んで、ワンクリックでワークスペースを開始します。", - "5d6ee181b6": "ワークスペースを開いてターミナルに戻り、テスト、ログ、Agent 用にペインを分割します。" - }, "FeatureWallBody": { "25ec5356d6": "設定" }, @@ -12415,11 +12318,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "実行中" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "メモ" @@ -13816,7 +13714,27 @@ "a21f6c33ad": "オートメーションのソースを更新しました。", "53f06f0ad5": "ソースを再試行", "pendingAutomationMissing": "自動化は利用できなくなりました。", - "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。" + "pendingAutomationRunMissing": "実行履歴は利用できなくなりました。", + "noSearchMatches": "検索に一致する自動化がありません。", + "paused": "一時停止中", + "runCount": "{{count}} 回の実行", + "runCount_one": "{{count}} 回の実行", + "runCount_other": "{{count}} 回の実行", + "projectDefaultBaseRef": "プロジェクトのデフォルト", + "createFromBaseRef": "{{baseRef}} から作成", + "missingWorkspace": "ワークスペースがありません", + "runUsageSummary": "約 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "使用量を利用できません", + "noRunUsageYet": "まだ実行の使用量がありません", + "noWorkspace": "ワークスペースなし", + "latestSavedOutput": "最新の保存済み出力", + "backToList": "すべての自動化", + "tableName": "名前", + "tableProject": "プロジェクト", + "tableStatus": "ステータス", + "tableActions": "操作", + "newAutomation": "新しい自動化", + "rowActions": "自動化の操作" }, "CreateFromPicker": { "f061f49e3f": "リポジトリブランチを検索…", @@ -13929,6 +13847,19 @@ "5a7863909c": "新規ワークスペースごとにセットアップを実行", "18f000ad4e": "詳細設定", "874b72195b": "このオートメーションがワークスペースを作成するときに、手動でワークツリーを作成する場合と同じ準備を行います。プロジェクトのセットアップを実行し、ターミナルタブを開きます。" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "自動化を読み込み中" + }, + "AutomationListSearchField": { + "tooLong": "検索テキストが長すぎます — リストは未フィルタです", + "label": "自動化を検索", + "placeholder": "検索...", + "tooLongShort": "長すぎます", + "clear": "検索をクリア" + }, + "AutomationListLocalRows": { + "c92c9463c6": "自動化の操作" } }, "agent": { @@ -14181,80 +14112,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca へのサインインは利用できません" - }, - "reconnect": "プロファイルを再接続", - "connect": "プロファイルを接続", - "organization": "組織", - "create": { - "profile": { - "for": { - "org": "組織用のプロファイルを作成" - } - } - }, - "signout": "サインアウト", - "16e3681072": "新規ローカルプロファイル", - "e3b91a3d90": "プロジェクトとワークツリーを分けるための空のプロファイルを作成します。", - "f322e1f4d6": "プロファイル名", - "cfa59f8ad1": "作成して切り替え", - "b4f9d1125d": "ローカル", - "4815f7d163": "プロファイルを切り替え", - "d00d853e2a": "プロファイルを管理", - "c106c674fe": "新規ローカルプロファイル", - "account": "アカウント", - "org": { - "members": "組織メンバー" - }, - "signInWaiting": "サインインを待機中…", - "signInAgain": "もう一度サインイン", - "signIn": "Orca にサインイン", - "accountTitle": "Orca アカウント", - "accountSignedIn": "サインイン済み", - "accountSignInRequired": "サインインが必要です", - "accountSignedOut": "サインアウト済み" - }, - "management": { - "04e7bd2a23": "転送", - "128c7dfe64": "コピー先", - "df8b7d876b": "移動先", - "9aa26347b3": "プロジェクトを移動しました", - "816ce624b6": "プロジェクトをコピーしました", - "2c45bda8d3": "プロファイルを管理", - "2db945e4a0": "アクティブなプロファイルから別のローカルプロファイルへプロジェクトをコピーまたは移動します。", - "8668cb2946": "このプロファイルにはプロジェクトがありません。", - "93034915ab": "プロジェクトをコピーする前に、別のプロファイルを作成してください。", - "projectCountSingular": "{{count}} 件のプロジェクト", - "projectCountPlural": "{{count}} 件のプロジェクト" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "プロジェクトを移動しますか?", - "description": "{{repoName}}を{{targetName}}に移動します。Orca は{{activeProfileName}}から削除し、ファイルはそのまま保持し、{{targetName}}に再起動します。", - "action": "プロジェクトを移動" - }, - "copy": { - "title": "プロジェクトをコピーしますか?", - "description": "{{repoName}}を{{targetName}}にコピーします。両方のプロファイルが同じファイルを別の Orca メタデータで参照します。", - "action": "プロジェクトをコピー" - }, - "live": { - "work": "このプロジェクトのアクティブな作業" - }, - "cancel": "キャンセル", - "terminalSingular": "{{count}}個のアクティブなターミナルタブ", - "terminalPlural": "{{count}}個のアクティブなターミナルタブ", - "agentSingular": "{{count}}個のアクティブな Agent", - "agentPlural": "{{count}}個のアクティブな Agent", - "browserSingular": "{{count}}個のブラウザワークスペース", - "browserPlural": "{{count}}個のブラウザワークスペース" - } - } - }, "signout": { "confirm": { "title": "Orca からログアウトしますか?", @@ -14262,79 +14119,6 @@ "cancel": "キャンセル", "action": "サインアウト" } - }, - "switch": { - "confirm": { - "target": "選択されたプロファイル", - "title": "プロファイルを切り替えますか?", - "description": "{{targetName}}に切り替えると、Orca が再起動され、{{activeProfileName}}のワークスペースが再読み込みされます。", - "live": { - "work": "このプロファイルのアクティブな作業" - }, - "cancel": "キャンセル", - "switch": "プロファイルを切り替え", - "terminalSingular": "{{count}}個のアクティブなターミナルタブ", - "terminalPlural": "{{count}}個のアクティブなターミナルタブ", - "agentSingular": "{{count}}個のアクティブな Agent", - "agentPlural": "{{count}}個のアクティブな Agent", - "browserSingular": "{{count}}個のブラウザワークスペース", - "browserPlural": "{{count}}個のブラウザワークスペース" - } - }, - "org": { - "members": { - "role": { - "label": "役割", - "owner": "オーナー", - "admin": "管理者", - "member": "メンバー" - }, - "not": { - "signed": { - "in": "まだ Orca にサインインしていません。" - } - }, - "remove": { - "confirm": "削除", - "cancel": "キャンセル", - "label": "チームメンバーを削除" - }, - "title": "組織メンバー", - "load": { - "error": "組織メンバーを読み込めませんでした。" - }, - "retry": "再試行", - "pending": { - "title": "保留中の招待", - "revoke": "取り消し" - }, - "invite": { - "title": "招待", - "placeholder": "teammate@example.com", - "role": "招待役割", - "submit": "送信" - }, - "error": { - "forbidden": "この役割では実行できません。", - "conflict": "既にメンバーまたは招待済みです。", - "remove": { - "self": "自分自身を削除することはできません。" - }, - "own": { - "role": "自分の役割を変更することはできません。" - }, - "not": { - "found": "そのチームメンバーはこの組織にいません。" - }, - "reconnect": "メンバーを管理するには、プロファイルを再接続してください。", - "unconfigured": "このビルドでは組織管理が利用できません。", - "failed": "問題が発生しました。もう一度お試しください。" - }, - "subtitle": { - "named": "{{orgName}} で Orca を共同利用できるメンバーです。", - "default": "組織内で Orca を共同利用できるメンバーです。" - } - } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 82b49d649d0..0966c9bf3f3 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2604,15 +2604,6 @@ }, "sheet": { "1189e9fe0a": "닫기" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "검색어와 일치하는 팀이 없습니다.", - "301f2a796e": "모든 팀", - "18ec58881e": "팀 검색..." - } - } } }, "terminal": { @@ -2774,7 +2765,7 @@ "agent": { "row": { "ad991ece5c": "Agent 창을 더 이상 사용할 수 없습니다.", - "090d607412": "오래된 agent 행-{{value0}}" + "090d607412": "stale-agent-row-{{value0}}" } } }, @@ -6053,7 +6044,7 @@ "a3326f6f1b": "glab", "027440e1cb": "MR, 이슈, 할 일, 파이프라인을 다음 도구로 사용합니다.", "513abfe47d": "GitLab", - "51000487c4": "gh 인증 로그인", + "51000487c4": "gh auth login", "09285e9fe6": "GitHub CLI가 설치되어 있지만 인증되지 않았습니다. terminal에서 다음 명령을 실행하세요.", "399cf46867": "GitHub CLI 설치", "c0c8575e05": "PR, 이슈 및 검사를 활성화하려면 GitHub CLI를 설치하세요.", @@ -8275,7 +8266,7 @@ "compareAgainstUpstreamDescription": "소스 제어가 커밋된 변경 사항 비교에 기본으로 사용할 기준을 선택합니다. 브랜치 업스트림은 현재 브랜치를 자동으로 따라가며, 업스트림이 없으면 저장소 기본 브랜치로 돌아갑니다. 비교 기준은 해당 worktree의 Git 패널에서 worktree별로 변경할 수 있습니다. Pull Request 및 리베이스 대상은 변경되지 않습니다.", "compareBase": "비교 기준", "currentBranch": "현재 브랜치", - "upstream": "업스트림", + "upstream": "upstream", "localChanges": "로컬 변경 사항", "originMaster": "origin/master", "committedChanges": "커밋된 변경 사항", @@ -10951,7 +10942,7 @@ "9fffaf17c1": "오른쪽 사이드바 표시/숨기기({{value0}})", "b37ff4a89a": "포트", "9f83375839": "검사", - "6306b48afd": "소스 제어", + "6306b48afd": "source-control", "ef182dcb12": "검색", "fc3095d2ed": "탐색기", "aiVaultSessionHistory": "에이전트", @@ -11493,17 +11484,6 @@ "yoloPermissionsInfo": "Agent 권한 정보", "yoloPermissionsTooltip": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다." }, - "FeatureSetupChecklist": { - "77f74946f5": "Agents는 서로 메시지를 보내고, 작업을 수행하고, 핸드오프를 조정할 수 있습니다.", - "399cf885c0": "Agent 오케스트레이션", - "c5292c409d": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다.", - "1ecfb490ac": "컴퓨터 사용", - "01426f3a23": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다.", - "ea85d9e628": "Agent 브라우저 사용", - "linearTicketsTitle": "Linear agent 스킬", - "linearTicketsDescription": "Agents는 연결된 Linear 작업을 사용해 티켓 맥락을 반영한 더 풍부한 인계를 할 수 있습니다.", - "linearTicketsSetupSummary": "Linear 워크스페이스에 권장됩니다. Linear 연결 설정에는 영향을 주지 않습니다." - }, "FeatureSetupInlineTerminal": { "789b59936e": "Enter를 눌러 명령을 실행하고 요청 시 npx를 확인합니다. 나중에 설정에서 설정할 수도 있습니다.", "47fc6cc6dc": "스킬 설정 명령어", @@ -11589,37 +11569,6 @@ "9f47f345a4": "오래 걸리지 않을 거예요!", "e4726b2d50": "온보딩을 건너뛰시겠습니까?" }, - "OnboardingTourStep": { - "3f9586c043": "둘러보기", - "60c5576353": "투어 종료" - }, - "RepoStep": { - "e8fdb36338": "repos를 스캔하는 중입니다. 중지하려면 클릭하세요.", - "b7c4da0504": "SSH? 설정에서 호스트를 설정하세요", - "c33b190ca3": "호스트 경로만", - "7b679207e4": "워크스페이스", - "24c7c8696c": "서버 경로로 복제", - "7932e95f68": "클론", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "HTTPS 또는 SSH URL을 붙여넣습니다.", - "132425a3e3": "repo 복제", - "6558d50c69": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요.", - "831524961f": "로컬 디렉터리(git repo 여부)를 선택하세요.", - "f4e9c8dcf8": "폴더 찾아보기", - "e8214aa632": "폴더로 열기", - "3863747c56": "Git 프로젝트 추가", - "2ebbc26343": "/home/user/project", - "466108ab89": "선택한 호스트에 존재하는 경로를 입력하세요.", - "8cab104e3c": "서버 프로젝트 열기", - "2d20200346": "repos 가져오기", - "27ca610db1": "뒤로", - "cecd6593fa": "스캔한 폴더:", - "c7af322fc3": "스캔 중지", - "c3d9d44ca2": "스캔 중지", - "cf23006ba7": "선택한 호스트", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}이 폴더에서 {{value1}} {{value2}}을(를) 찾았습니다." - }, "ThemeStep": { "a4b254779d": "macOS 옵션 키", "6c51398942": "마우스", @@ -11647,15 +11596,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "SSH 설정을 열 수 없습니다.", - "fd74e7558e": "복제 실패", "52acfbef51": "진행 상황을 저장할 수 없습니다." } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "기능 활성화" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "사용할 수 있으면 PowerShell 7+를 사용하고, 없으면 Windows PowerShell을 사용합니다.", @@ -11756,10 +11700,6 @@ "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "이 프로젝트에 준비된 호스트가 없습니다.", - "placeholder": "호스트 선택" - }, "ProjectCombobox": { "empty": "검색과 일치하는 프로젝트가 없습니다.", "addProject": "새 프로젝트 추가", @@ -12135,43 +12075,6 @@ "cda56c5915": "메모 / launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "생각 중...", - "ef8b164dd1": "src/auth 리뷰", - "952d3ddd9a": "세션이 시작되었습니다", - "771d8881c2": "claude", - "6ed43cb0e0": "dashboard.spec.ts", - "24fedd5a52": "login.spec.ts", - "8279e9d95b": "12개 테스트 실행", - "6218a9014d": "pnpm playwright test", - "04d54d50ec": "orca · zsh", - "1aa8a9a24a": "분할 가능한 terminal", - "2a7cfc82c8": "GH #1842와 연결됨", - "3822d8d14b": "fix/worktree-picker-truncates", - "d54aefe09e": "LIN-329", - "40bbd92ef4": "시작", - "c1f28c03b2": "작업 트리 선택 도구가 잘립니다.", - "fc0cc0b267": "GH #1842", - "0688842445": "GH #1799", - "bee6b4088d": "GitHub 및 Linear 작업", - "5171768676": "agents 3개 조율", - "cebc7769cd": "인증 흐름 재설계", - "e44269e97d": "Agent 오케스트레이션", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "청구 웹훅 리팩터링", - "9c812e0d7c": "CI 파이프라인 속도 향상", - "3c4adfd821": "로그인 경쟁 조건 수정", - "56a0271428": "격리된 워크스페이스", - "ef737dcee1": "GitHub 및 Linear 작업", - "ac51c061e2": "codex", - "47f16ecf34": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다.", - "70aa182266": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다.", - "f10c14dd9d": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요.", - "5d6ee181b6": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요." - }, "FeatureWallBody": { "25ec5356d6": "설정" }, @@ -12198,7 +12101,7 @@ "00078a6134": "설정에서 보기", "14327073cc": "저장", "88469e926b": "설정 스크립트", - "5c5b65044e": "pnpm 설치", + "5c5b65044e": "pnpm install", "a7463915b6": "설정 스크립트를 저장하지 못했습니다.", "6299297dac": "설정 스크립트가 저장되었습니다.", "f0bbf7da77": "사용해 보세요", @@ -12415,11 +12318,6 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "작업 중" - }, "ReviewAnimatedVisual": { "8df4d52b68": "pr-view", "8ab622e4d6": "메모" @@ -12697,7 +12595,7 @@ "19c45cfdc0": "모두 확장", "982d14bfa5": "commit 되지 않은 Diff 열기", "3d909843bb": "브랜치 diff 열기", - "8368d256ec": "결합 브랜치", + "8368d256ec": "combined-branch", "8f68ad9ca9": "{{value0}} AI {{value1}} 표시", "724a13568d": "{{value0}}에서", "6094135eec": "대 {{value0}}", @@ -12889,7 +12787,7 @@ "8f4d2c1a9b": "목차 크기 조절" }, "MarkdownTemplatePicker": { - "22cd94426f": "제목없음.md", + "22cd94426f": "untitled.md", "6e2e6c04ad": "빈 Markdown", "df667919ca": "일치하는 템플릿이 없습니다.", "22fd4890ad": "템플릿 검색...", @@ -13827,7 +13725,27 @@ "a21f6c33ad": "자동화 소스를 새로 고쳤습니다.", "53f06f0ad5": "소스 다시 시도", "pendingAutomationMissing": "자동화를 더 이상 사용할 수 없습니다.", - "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다." + "pendingAutomationRunMissing": "실행 기록을 더 이상 사용할 수 없습니다.", + "noSearchMatches": "검색과 일치하는 자동화가 없습니다.", + "paused": "일시 중지됨", + "runCount": "실행 {{count}}회", + "runCount_one": "실행 {{count}}회", + "runCount_other": "실행 {{count}}회", + "projectDefaultBaseRef": "프로젝트 기본값", + "createFromBaseRef": "{{baseRef}}에서 만들기", + "missingWorkspace": "워크스페이스 없음", + "runUsageSummary": "약 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "사용량을 사용할 수 없음", + "noRunUsageYet": "아직 실행 사용량 없음", + "noWorkspace": "워크스페이스 없음", + "latestSavedOutput": "최근 저장된 출력", + "backToList": "모든 자동화", + "tableName": "이름", + "tableProject": "프로젝트", + "tableStatus": "상태", + "tableActions": "작업", + "newAutomation": "새 자동화", + "rowActions": "자동화 작업" }, "CreateFromPicker": { "f061f49e3f": "repo 브랜치 검색...", @@ -13940,6 +13858,19 @@ "5a7863909c": "새 워크스페이스마다 설정 실행", "18f000ad4e": "고급", "874b72195b": "이 자동화가 워크스페이스를 만들 때, 워크트리를 직접 만들 때와 같은 방식으로 준비합니다 — 프로젝트 설정을 실행하고 터미널 탭을 엽니다." + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "자동화 로딩 중" + }, + "AutomationListSearchField": { + "tooLong": "검색 텍스트가 너무 깁니다 — 목록이 필터링되지 않음", + "label": "자동화 검색", + "placeholder": "검색...", + "tooLongShort": "너무 김", + "clear": "검색 지우기" + }, + "AutomationListLocalRows": { + "c92c9463c6": "자동화 작업" } }, "agent": { @@ -14192,80 +14123,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca 로그인 사용 불가" - }, - "reconnect": "프로필 다시 연결", - "connect": "프로필 연결", - "organization": "조직", - "create": { - "profile": { - "for": { - "org": "조직용 프로필 생성" - } - } - }, - "signout": "로그아웃", - "16e3681072": "새 로컬 프로필", - "e3b91a3d90": "프로젝트와 워크트리를 분리할 빈 프로필을 만듭니다.", - "f322e1f4d6": "프로필 이름", - "cfa59f8ad1": "만들고 전환", - "b4f9d1125d": "로컬", - "4815f7d163": "프로필 전환", - "d00d853e2a": "프로필 관리", - "c106c674fe": "새 로컬 프로필", - "account": "계정", - "org": { - "members": "조직 구성원" - }, - "signInWaiting": "로그인 대기 중…", - "signInAgain": "다시 로그인", - "signIn": "Orca에 로그인", - "accountTitle": "Orca 계정", - "accountSignedIn": "로그인됨", - "accountSignInRequired": "로그인 필요", - "accountSignedOut": "로그아웃됨" - }, - "management": { - "04e7bd2a23": "전송", - "128c7dfe64": "복사 대상", - "df8b7d876b": "이동 대상", - "9aa26347b3": "프로젝트를 이동했습니다", - "816ce624b6": "프로젝트를 복사했습니다", - "2c45bda8d3": "프로필 관리", - "2db945e4a0": "활성 프로필의 프로젝트를 다른 로컬 프로필로 복사하거나 이동합니다.", - "8668cb2946": "이 프로필에는 프로젝트가 없습니다.", - "93034915ab": "프로젝트를 복사하려면 먼저 다른 프로필을 만드세요.", - "projectCountSingular": "{{count}}개의 프로젝트", - "projectCountPlural": "{{count}}개의 프로젝트" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "프로젝트를 이동하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 이동합니다. Orca가 해당 프로젝트를 {{activeProfileName}}에서 제거한 뒤 파일은 유지하고 {{targetName}}(으)로 다시 시작합니다.", - "action": "프로젝트 이동" - }, - "copy": { - "title": "프로젝트를 복사하시겠습니까?", - "description": "{{repoName}}을(를) {{targetName}}(으)로 복사합니다. 두 프로필 모두 동일한 파일을 별도의 Orca 메타데이터와 함께 가리킵니다.", - "action": "프로젝트 복사" - }, - "live": { - "work": "이 프로젝트의 활성 작업" - }, - "cancel": "취소", - "terminalSingular": "활성 터미널 탭 {{count}}개", - "terminalPlural": "활성 터미널 탭 {{count}}개", - "agentSingular": "활성 에이전트 {{count}}개", - "agentPlural": "활성 에이전트 {{count}}개", - "browserSingular": "브라우저 작업 공간 {{count}}개", - "browserPlural": "브라우저 작업 공간 {{count}}개" - } - } - }, "signout": { "confirm": { "title": "Orca에서 로그아웃할까요?", @@ -14273,79 +14130,6 @@ "cancel": "취소", "action": "로그아웃" } - }, - "switch": { - "confirm": { - "target": "선택된 프로필", - "title": "프로필을 전환하시겠습니까?", - "description": "{{targetName}}(으)로 전환하면 Orca가 다시 시작되고 {{activeProfileName}}의 작업 공간이 다시 로드됩니다.", - "live": { - "work": "이 프로필의 활성 작업" - }, - "cancel": "취소", - "switch": "프로필 전환", - "terminalSingular": "활성 터미널 탭 {{count}}개", - "terminalPlural": "활성 터미널 탭 {{count}}개", - "agentSingular": "활성 에이전트 {{count}}개", - "agentPlural": "활성 에이전트 {{count}}개", - "browserSingular": "브라우저 작업 공간 {{count}}개", - "browserPlural": "브라우저 작업 공간 {{count}}개" - } - }, - "org": { - "members": { - "role": { - "label": "역할", - "owner": "소유자", - "admin": "관리자", - "member": "구성원" - }, - "not": { - "signed": { - "in": "아직 Orca에 로그인하지 않았습니다." - } - }, - "remove": { - "confirm": "제거", - "cancel": "취소", - "label": "팀원 제거" - }, - "title": "조직 구성원", - "load": { - "error": "조직 구성원을 로드할 수 없습니다." - }, - "retry": "다시 시도", - "pending": { - "title": "대기 중인 초대", - "revoke": "취소" - }, - "invite": { - "title": "초대", - "placeholder": "teammate@example.com", - "role": "초대 역할", - "submit": "보내기" - }, - "error": { - "forbidden": "이 작업을 수행할 권한이 없습니다.", - "conflict": "이미 구성원이거나 초대되었습니다.", - "remove": { - "self": "자신을 제거할 수 없습니다." - }, - "own": { - "role": "자신의 역할을 변경할 수 없습니다." - }, - "not": { - "found": "해당 팀원은 이 조직에 없습니다." - }, - "reconnect": "구성원을 관리하려면 프로필을 다시 연결하세요.", - "unconfigured": "이 빌드에서 조직 관리를 사용할 수 없습니다.", - "failed": "문제가 발생했습니다. 다시 시도해 주세요." - }, - "subtitle": { - "named": "{{orgName}}에서 Orca에 협업할 수 있는 사람들.", - "default": "조직에서 Orca에 협업할 수 있는 사람들." - } - } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ccf7f6cc57f..4290c07cca4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -547,7 +547,7 @@ "notification": { "b0536028c9": "打开快捷键", "141ad6c004": "处理终端快捷方式", - "0ab0cd001a": "大小 4 文本静音前景" + "0ab0cd001a": "size-4 text-muted-foreground" } } } @@ -2212,7 +2212,7 @@ "IssueSourceSelector": { "d6aeb2012b": "显示议题来自", "787c970baf": "议题来源", - "643d7e9496": "上游", + "643d7e9496": "upstream", "51d1608920": "origin", "cdc9bd64fa": "紧凑", "30b2c9df91": "上游" @@ -2616,15 +2616,6 @@ }, "sheet": { "1189e9fe0a": "关闭" - }, - "team": { - "multi": { - "combobox": { - "de83523bf9": "没有团队符合您的搜索。", - "301f2a796e": "所有团队", - "18ec58881e": "搜索团队..." - } - } } }, "terminal": { @@ -3827,7 +3818,7 @@ "c7f9b3f0c1": "关", "8b12c0850a": "保存", "de8fce5854": "取消", - "ddbcaef7be": "src/渲染器包/ui", + "ddbcaef7be": "src/renderer packages/ui", "0e9ad9c798": "目录", "064c1e2d12": "渲染器用户界面", "b3a500c623": "名称", @@ -3984,7 +3975,7 @@ "e643b30398": "添加了远程项目" }, "AddRepoCreateStep": { - "0ae45b8238": "我的项目", + "0ae45b8238": "my-project", "a8149a3a5a": "名称", "11fd2a7db8": "Git 存储库", "5e97f0c4b9": "项目已创建", @@ -5463,7 +5454,7 @@ "0de9fda203": "放弃", "c71770c455": "{basePrompt}", "f19a56498d": ";您的分支前缀设置仍然适用。", - "800edb1e54": "修复登录流程", + "800edb1e54": "fix-login-flow", "5d569f5199": "。 Orca 仅生成最后一段,例如", "570817d126": "和", "56580dcf60": "。您还可以参考", @@ -5487,7 +5478,7 @@ "63121132c0": "放弃", "4416b25d29": "优先使用任务中的领域名词,避免工单 ID,并保持名称便于评审人理解。", "39278f4411": ";您的分支前缀设置仍然适用。", - "ebb942a2ec": "修复登录流程", + "ebb942a2ec": "fix-login-flow", "af2d9a2cc6": "。 Orca 仅生成最后一段,例如", "182d419b97": "内置分支名称提示", "2f5dc661fe": "附加到 Orca 的", @@ -5506,7 +5497,7 @@ "ade9a5bb03": ") 来确定结果范围。", "b468f46726": "upstream/main", "80f7c82303": ")或完整的参考(例如", - "915ad97875": "上游", + "915ad97875": "upstream", "a5c16712c1": "已检测多个遥控器。输入远程名称(例如", "9a14ec7400": "选择下面的一个基础分支", "086ce7f369": "跟随主分支 ({{value0}})", @@ -6057,7 +6048,7 @@ "a6c2816115": "和", "b8a7efb3f6": "ORCA_BITBUCKET_EMAIL", "8489c0aa49": "Bitbucket", - "e74de656ce": "glab 身份验证登录", + "e74de656ce": "glab auth login", "05e5245af7": "GitLab CLI 已安装但未经过身份验证。在终端中运行此命令:", "a83cac5726": "安装 GitLab CLI", "35a3379372": "安装 GitLab CLI 以启用合并请求、议题和管道。", @@ -6065,7 +6056,7 @@ "a3326f6f1b": "光亮的", "027440e1cb": "通过合并请求、议题、待办事项和管道", "513abfe47d": "GitLab", - "51000487c4": "gh 验证登录", + "51000487c4": "gh auth login", "09285e9fe6": "GitHub CLI 已安装但未经过身份验证。在终端中运行此命令:", "399cf46867": "安装 GitHub CLI", "c0c8575e05": "安装 GitHub CLI 以启用PR、议题和检查。", @@ -6431,7 +6422,7 @@ "RepositoryHooksSection": { "af49e2a19e": "该文件存在,但 Orca 找不到有效的“scripts”或“issueCommand”定义。", "3397879bee": "并且本地命令存在,选择哪个运行。", - "39da2ae12f": "Orca.yaml", + "39da2ae12f": "orca.yaml", "ac9038d2cc": "当两者", "32fec28f5b": "命令源", "bbbd6e0bc4": "命令源 & orca.yaml", @@ -6472,12 +6463,12 @@ "07ba35bc68": "检查 `scripts:` 下的缩进。钩子键应使用两个空格,命令行应使用四个空格。", "787ca433ef": "仅定义支持的键:`scripts`、`setup`、`archive` 和 `issueCommand`。", "ecc73d9125": "将你的文件与下面可用的模板进行比较,并在需要时复制该结构。", - "925f9e0dc4": "文本前景", + "925f9e0dc4": "text-foreground", "0cc712b823": "核心配置文件存在于仓库根目录中,但 Orca 尚无法解析支持的钩子定义。", - "c90b858573": "文本-​​Amber-700 深色:文本-Amber-300", + "c90b858573": "text-amber-700 dark:text-amber-300", "aba825233f": "该文件包含此版本的 Orca 无法识别的配置密钥。您可能需要更新 Orca,或检查文件是否有拼写错误。", "ca424ff135": "共享挂钩和议题自动化默认值在仓库中定义,可供所有使用它的人使用。", - "32f417fe17": "文本-​​emerald-700 深色:文本-emerald-300", + "32f417fe17": "text-emerald-700 dark:text-emerald-300", "8bfe65fc60": "使用本地命令", "8d6c56bff8": "运行两者", "0fa21e19ec": "工作区的名称,通常基于分支名称。", @@ -6998,7 +6989,7 @@ "a05bc9183f": "保存预设", "2d7d45e991": "取消", "c240a16f25": "使用仓库相对路径,例如packages/web 或apps/api。", - "fde7ff2cc3": "包/网络共享/ui", + "fde7ff2cc3": "packages/web shared/ui", "caf33029cc": "目录", "3b6f1abd3e": "例如仅限网络", "a6fcdd9e3c": "名称", @@ -8287,7 +8278,7 @@ "compareAgainstUpstreamDescription": "选择源代码管理在比较已提交变更时默认使用的基准。分支上游会自动跟随当前分支;如果没有上游,则回退到仓库默认分支。你仍然可以在对应 worktree 的 Git 面板中按 worktree 更改对比基准。Pull Request 和变基目标不会改变。", "compareBase": "对比基准", "currentBranch": "当前分支", - "upstream": "上游", + "upstream": "upstream", "localChanges": "本地变更", "originMaster": "origin/master", "committedChanges": "已提交的更改", @@ -8413,7 +8404,7 @@ "ac0a985873": "模拟器技能", "9353854ff3": "Orca 模拟器", "d4b7833894": "Orca CLI", - "84e5706975": "服务SIM卡", + "84e5706975": "serve-sim", "7c5a8a2bee": "Xcode", "bec7231663": "平板电脑", "49727355a3": "iPhone", @@ -8613,7 +8604,7 @@ "repository": { "search": { "bc7e504b8e": ".orca/issue-command", - "603c68b68c": "Orca.yaml", + "603c68b68c": "orca.yaml", "9dc60d7f6d": "GitHub", "ec70364df2": "工作流程", "66b584bd6c": "issue 命令", @@ -10971,7 +10962,7 @@ "9fffaf17c1": "切换右侧边栏 ({{value0}})", "b37ff4a89a": "端口", "9f83375839": "检查", - "6306b48afd": "源代码控制", + "6306b48afd": "source-control", "ef182dcb12": "搜索", "fc3095d2ed": "资源管理器", "aiVaultSessionHistory": "智能体", @@ -11513,17 +11504,6 @@ "yoloPermissionsInfo": "智能体权限信息", "yoloPermissionsTooltip": "跳过智能体权限检查,减少打断" }, - "FeatureSetupChecklist": { - "77f74946f5": "智能体可互相通信、领取任务并协调交接。", - "399cf885c0": "智能体编排", - "c5292c409d": "按你的要求,智能体可查看应用窗口并操作本地应用。", - "1ecfb490ac": "计算机控制", - "01426f3a23": "智能体可浏览网站、检查页面并完成浏览器任务。", - "ea85d9e628": "智能体浏览器操控", - "linearTicketsTitle": "Linear 智能体技能", - "linearTicketsDescription": "智能体可使用已链接的 Linear 任务,完成更了解 ticket 背景的交接。", - "linearTicketsSetupSummary": "推荐用于 Linear 工作区;不会影响 Linear 连接设置。" - }, "FeatureSetupInlineTerminal": { "789b59936e": "按 Enter 运行命令并在询问时确认 npx。您也可以稍后在“设置”中进行设置。", "47fc6cc6dc": "技能设置命令", @@ -11609,37 +11589,6 @@ "9f47f345a4": "不会花很长时间的!", "e4726b2d50": "跳过入门引导?" }, - "OnboardingTourStep": { - "3f9586c043": "开始导览", - "60c5576353": "退出导览" - }, - "RepoStep": { - "e8fdb36338": "扫描存储库。单击停止。", - "b7c4da0504": "SSH?在“设置”中设置主机", - "c33b190ca3": "仅服务器路径", - "7b679207e4": "工作区", - "24c7c8696c": "克隆到服务器路径", - "7932e95f68": "克隆", - "955134915e": "git@github.com:org/repo.git", - "288d8444b7": "粘贴 HTTPS 或 SSH URL。", - "132425a3e3": "克隆一个仓库", - "6558d50c69": "想要一次导入多个仓库?选择父文件夹。", - "831524961f": "选择任何本地目录,无论是否为 git 仓库。", - "f4e9c8dcf8": "浏览文件夹", - "e8214aa632": "作为文件夹打开", - "3863747c56": "添加 Git 项目", - "2ebbc26343": "/home/user/project", - "466108ab89": "输入运行时服务器上存在的路径。", - "8cab104e3c": "打开服务器项目", - "2d20200346": "导入存储库", - "27ca610db1": "返回", - "cecd6593fa": "扫描的文件夹:", - "c7af322fc3": "停止扫描", - "c3d9d44ca2": "停止扫描", - "cf23006ba7": "运行时服务器", - "7ec3f48820": "/home/user", - "2e6438dd34": "{{value0}}在此文件夹中找到{{value1}} {{value2}}。" - }, "ThemeStep": { "a4b254779d": "macOS Option 键", "6c51398942": "鼠标", @@ -11667,15 +11616,10 @@ "use": { "onboarding": { "flow": { - "dce4bdce5b": "无法打开 SSH 设置", - "fd74e7558e": "克隆失败", "52acfbef51": "无法保存进度" } } }, - "AgentFeatureSetupStep": { - "97dcdc010f": "安装 CLI 和技能" - }, "WindowsTerminalStep": { "powerShell": "PowerShell", "powerShellPwsh": "在可用时使用 PowerShell 7+,否则回退到 Windows PowerShell。", @@ -11776,10 +11720,6 @@ "jiraSelectBindFailed": "无法关联此 Jira 议题。请选择匹配的站点或重新连接 Jira,然后重试。", "emoji": "Emoji" }, - "ProjectHostSetupCombobox": { - "empty": "此项目没有已就绪的主机。", - "placeholder": "选择主机" - }, "ProjectCombobox": { "empty": "没有匹配搜索的项目。", "addProject": "添加新项目", @@ -12106,7 +12046,7 @@ "0f8481e1a7": "发送给 Claude", "3d2352f94b": "描述一下变化……", "d8856b604a": "div.pricing-grid > div.card.starter:nth-of-type(1) > a.cta", - "7da6eed7bf": "本地主机:3000", + "7da6eed7bf": "localhost:3000", "0a2bd01c02": "新浏览器选项卡", "04096318ab": "终端 1", "eb88125c6f": "✓ 已验证 — 免费试用仍然有效。", @@ -12155,43 +12095,6 @@ "cda56c5915": "笔记/launch-plan.md", "e16479c1c5": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }" }, - "FeatureTourPreview": { - "1170621527": ">", - "304ad0dfc1": "思维...", - "ef8b164dd1": "审查 src/auth", - "952d3ddd9a": "会话开始", - "771d8881c2": "claude", - "6ed43cb0e0": "仪表板规格.ts", - "24fedd5a52": "登录规范", - "8279e9d95b": "运行 12 项测试", - "6218a9014d": "pnpm剧作家测试", - "04d54d50ec": "Orca·zsh", - "1aa8a9a24a": "可拆分终端", - "2a7cfc82c8": "链接到 GH #1842", - "3822d8d14b": "修复/worktree-picker-截断", - "d54aefe09e": "林-329", - "40bbd92ef4": "开始", - "c1f28c03b2": "工作树选择器截断", - "fc0cc0b267": "GH #1842", - "0688842445": "GH#1799", - "bee6b4088d": "GitHub 和 Linear 任务", - "5171768676": "协调3个智能体", - "cebc7769cd": "重新设计身份验证流程", - "e44269e97d": "智能体编排", - "ec4a73f5e6": "PR 3/3", - "cfdfd4d6b4": "PR 2/3", - "b1f17bcc74": "PR 1/3", - "e38112b289": "重构计费 Webhook", - "9c812e0d7c": "加速 CI 管道", - "3c4adfd821": "修复登录竞争条件", - "56a0271428": "独立的工作区", - "ef737dcee1": "GitHub 和 Linear 任务", - "ac51c061e2": "codex", - "47f16ecf34": "同时推进多项工作。每个工作区都会把分支、终端和智能体活动放在一起。", - "70aa182266": "交给一个目标后就可以离开。协调智能体会分发工作并并行交付 PR。", - "f10c14dd9d": "跳过来回切换标签页。从 GitHub 或 Linear 待办中挑选任务,一键启动工作区。", - "5d6ee181b6": "打开任意工作区即可回到它的终端,然后拆分窗格运行测试、日志和智能体。" - }, "FeatureWallBody": { "25ec5356d6": "设置" }, @@ -12308,9 +12211,9 @@ "623881d72e": "checkout.spec.ts", "5c5cbd783f": "(1.2秒)", "3261c6853b": "› 可以登录", - "defe550fe2": "登录规范", + "defe550fe2": "login.spec.ts", "0b20782e0f": "使用 4 名工作人员运行 12 项测试", - "4371cc9931": "pnpm剧作家测试", + "4371cc9931": "pnpm playwright test", "16877e038d": "向下拆分", "a2b114dad0": "向右拆分 ·", "0bc9ad0cd1": "同一窗格:", @@ -12435,13 +12338,8 @@ } } }, - "FeatureTourWorkspaceCard": { - "a23ed9da7f": "codex", - "2f33dc932b": "claude", - "9220794f6a": "处理中" - }, "ReviewAnimatedVisual": { - "8df4d52b68": "PR视图", + "8df4d52b68": "pr-view", "8ab622e4d6": "笔记" }, "FeatureWallBrowserAction": { @@ -12717,7 +12615,7 @@ "19c45cfdc0": "全部展开", "982d14bfa5": "打开未提交的差异", "3d909843bb": "打开分支差异", - "8368d256ec": "组合分支", + "8368d256ec": "combined-branch", "8f68ad9ca9": "显示 {{value0}} AI {{value1}}", "724a13568d": "在 {{value0}}", "6094135eec": "与 {{value0}}", @@ -12852,7 +12750,7 @@ "10ed04a685": "笔记本单元在本机上从笔记本文件夹执行本地 Python。仅运行您信任的文件中的单元。", "9e06ae5d36": "运行笔记本代码?", "d6f37a640b": "空笔记本", - "8c3b21369a": "nb格式", + "8c3b21369a": "nbformat", "329764e9fc": "BETA", "15ec40a735": "保存笔记本", "07e7d96612": "细胞", @@ -12909,7 +12807,7 @@ "8f4d2c1a9b": "调整目录大小" }, "MarkdownTemplatePicker": { - "22cd94426f": "无标题.md", + "22cd94426f": "untitled.md", "6e2e6c04ad": "空白 Markdown", "df667919ca": "没有匹配的模板。", "22fd4890ad": "搜索模板...", @@ -13836,7 +13734,27 @@ "a21f6c33ad": "自动化来源已刷新。", "53f06f0ad5": "重试来源", "pendingAutomationMissing": "自动化已不可用。", - "pendingAutomationRunMissing": "运行历史已不可用。" + "pendingAutomationRunMissing": "运行历史已不可用。", + "noSearchMatches": "没有匹配的自动化。", + "paused": "已暂停", + "runCount": "{{count}} 次运行", + "runCount_one": "{{count}} 次运行", + "runCount_other": "{{count}} 次运行", + "projectDefaultBaseRef": "项目默认", + "createFromBaseRef": "从 {{baseRef}} 创建", + "missingWorkspace": "缺少工作区", + "runUsageSummary": "约 {{cost}} · {{tokens}} tokens", + "usageUnavailable": "用量不可用", + "noRunUsageYet": "尚无运行用量", + "noWorkspace": "无工作区", + "latestSavedOutput": "最近保存的输出", + "backToList": "全部自动化", + "tableName": "名称", + "tableProject": "项目", + "tableStatus": "状态", + "tableActions": "操作", + "newAutomation": "新建自动化", + "rowActions": "自动化操作" }, "CreateFromPicker": { "f061f49e3f": "搜索仓库分支...", @@ -13949,6 +13867,19 @@ "5a7863909c": "为每个新工作区运行设置", "18f000ad4e": "高级", "874b72195b": "当此自动化创建工作区时,使用与手动创建工作树相同的方式准备——运行项目的设置并打开其终端标签页。" + }, + "AutomationsPageSkeleton": { + "55527b7bcf": "正在加载自动化" + }, + "AutomationListSearchField": { + "tooLong": "搜索文本过长 — 列表未筛选", + "label": "搜索自动化", + "placeholder": "搜索...", + "tooLongShort": "过长", + "clear": "清除搜索" + }, + "AutomationListLocalRows": { + "c92c9463c6": "自动化操作" } }, "agent": { @@ -14201,80 +14132,6 @@ }, "orca": { "profiles": { - "switcher": { - "cloud": { - "unavailable": "Orca 登录不可用" - }, - "reconnect": "重新连接配置文件", - "connect": "连接配置文件", - "organization": "组织", - "create": { - "profile": { - "for": { - "org": "为组织创建配置文件" - } - } - }, - "signout": "退出登录", - "16e3681072": "新建本地配置文件", - "e3b91a3d90": "创建一个空配置文件,用于分开管理项目和工作树。", - "f322e1f4d6": "配置文件名称", - "cfa59f8ad1": "创建并切换", - "b4f9d1125d": "本地", - "4815f7d163": "切换配置文件", - "d00d853e2a": "管理配置文件", - "c106c674fe": "新建本地配置文件", - "account": "帐户", - "org": { - "members": "组织成员" - }, - "signInWaiting": "正在等待登录…", - "signInAgain": "重新登录", - "signIn": "登录 Orca", - "accountTitle": "Orca 帐户", - "accountSignedIn": "已登录", - "accountSignInRequired": "需要登录", - "accountSignedOut": "已退出登录" - }, - "management": { - "04e7bd2a23": "转移", - "128c7dfe64": "复制到", - "df8b7d876b": "移动到", - "9aa26347b3": "项目已移动", - "816ce624b6": "项目已复制", - "2c45bda8d3": "管理配置文件", - "2db945e4a0": "将项目从当前配置文件复制或移动到另一个本地配置文件。", - "8668cb2946": "此配置文件中没有项目。", - "93034915ab": "复制项目前,请先创建另一个配置文件。", - "projectCountSingular": "{{count}} 个项目", - "projectCountPlural": "{{count}} 个项目" - }, - "project": { - "transfer": { - "confirm": { - "move": { - "title": "移动项目?", - "description": "将 {{repoName}} 移动到 {{targetName}}。Orca 会将其从 {{activeProfileName}} 中移除,保留文件原位,并重新启动进入 {{targetName}}。", - "action": "移动项目" - }, - "copy": { - "title": "复制项目?", - "description": "将 {{repoName}} 复制到 {{targetName}}。两个配置文件将指向相同的文件,但使用各自独立的 Orca 元数据。", - "action": "复制项目" - }, - "live": { - "work": "此项目中正在进行的工作" - }, - "cancel": "取消", - "terminalSingular": "{{count}} 个正在运行的终端选项卡", - "terminalPlural": "{{count}} 个正在运行的终端选项卡", - "agentSingular": "{{count}} 个活动的智能体", - "agentPlural": "{{count}} 个活动的智能体", - "browserSingular": "{{count}} 个浏览器工作区", - "browserPlural": "{{count}} 个浏览器工作区" - } - } - }, "signout": { "confirm": { "title": "退出 Orca?", @@ -14282,79 +14139,6 @@ "cancel": "取消", "action": "退出登录" } - }, - "switch": { - "confirm": { - "target": "所选配置文件", - "title": "切换配置文件?", - "description": "切换到 {{targetName}} 将重新启动 Orca 并重新加载 {{activeProfileName}} 的工作区。", - "live": { - "work": "此配置文件中正在进行的工作" - }, - "cancel": "取消", - "switch": "切换配置文件", - "terminalSingular": "{{count}} 个正在运行的终端选项卡", - "terminalPlural": "{{count}} 个正在运行的终端选项卡", - "agentSingular": "{{count}} 个活动的智能体", - "agentPlural": "{{count}} 个活动的智能体", - "browserSingular": "{{count}} 个浏览器工作区", - "browserPlural": "{{count}} 个浏览器工作区" - } - }, - "org": { - "members": { - "role": { - "label": "角色", - "owner": "所有者", - "admin": "管理员", - "member": "成员" - }, - "not": { - "signed": { - "in": "他们尚未登录 Orca。" - } - }, - "remove": { - "confirm": "移除", - "cancel": "取消", - "label": "移除成员" - }, - "title": "组织成员", - "load": { - "error": "无法加载组织成员。" - }, - "retry": "重试", - "pending": { - "title": "待处理的邀请", - "revoke": "撤销" - }, - "invite": { - "title": "邀请", - "placeholder": "teammate@example.com", - "role": "邀请角色", - "submit": "发送" - }, - "error": { - "forbidden": "你的角色无法执行此操作。", - "conflict": "已是成员或已被邀请。", - "remove": { - "self": "无法移除你自己。" - }, - "own": { - "role": "无法更改你自己的角色。" - }, - "not": { - "found": "该成员已不在此组织中。" - }, - "reconnect": "请重新连接你的配置文件以管理成员。", - "unconfigured": "此版本不支持组织管理。", - "failed": "出现问题。请重试。" - }, - "subtitle": { - "named": "{{orgName}} 中可以协作使用 Orca 的成员。", - "default": "你的组织中可以协作使用 Orca 的成员。" - } - } } } }, diff --git a/src/renderer/src/i18n/technical-literal-catalog-values.test.ts b/src/renderer/src/i18n/technical-literal-catalog-values.test.ts new file mode 100644 index 00000000000..5cf9963175b --- /dev/null +++ b/src/renderer/src/i18n/technical-literal-catalog-values.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import en from './locales/en.json' +import es from './locales/es.json' +import ko from './locales/ko.json' +import zh from './locales/zh.json' + +const repairedEntries = [ + ['es', 'auto.components.sidebar.AddRepoCreateStep.0ae45b8238'], + ['es', 'auto.components.settings.IntegrationsPane.e74de656ce'], + ['es', 'auto.components.settings.IntegrationsPane.51000487c4'], + ['es', 'auto.components.settings.RepositoryHooksSection.925f9e0dc4'], + ['es', 'auto.components.settings.RepositoryHooksSection.c90b858573'], + ['es', 'auto.components.settings.RepositoryHooksSection.32f417fe17'], + ['es', 'auto.components.settings.RepositoryIconPicker.03ca1a4e9b'], + ['es', 'auto.components.right.sidebar.index.6306b48afd'], + ['es', 'auto.components.feature.wall.ReviewAnimatedVisual.8df4d52b68'], + ['es', 'auto.components.editor.CombinedDiffViewer.8368d256ec'], + ['es', 'auto.components.editor.MarkdownTemplatePicker.22cd94426f'], + ['ko', 'auto.components.terminal.pane.stale.agent.row.090d607412'], + ['ko', 'auto.components.settings.IntegrationsPane.51000487c4'], + ['ko', 'auto.components.settings.git.search.upstream'], + ['ko', 'auto.components.right.sidebar.index.6306b48afd'], + ['ko', 'auto.components.feature.wall.FeatureWallSetupWorkflowActions.5c5b65044e'], + ['ko', 'auto.components.editor.CombinedDiffViewer.8368d256ec'], + ['ko', 'auto.components.editor.MarkdownTemplatePicker.22cd94426f'], + ['zh', 'auto.lib.terminal.shortcut.capture.notification.0ab0cd001a'], + ['zh', 'auto.components.github.IssueSourceSelector.643d7e9496'], + ['zh', 'auto.components.sparse.SparseCheckoutPresetSelect.ddbcaef7be'], + ['zh', 'auto.components.sidebar.AddRepoCreateStep.0ae45b8238'], + ['zh', 'auto.components.settings.AutoRenameBranchFromWorkSetting.800edb1e54'], + ['zh', 'auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec'], + ['zh', 'auto.components.settings.BaseRefPicker.915ad97875'], + ['zh', 'auto.components.settings.IntegrationsPane.e74de656ce'], + ['zh', 'auto.components.settings.IntegrationsPane.51000487c4'], + ['zh', 'auto.components.settings.RepositoryHooksSection.39da2ae12f'], + ['zh', 'auto.components.settings.RepositoryHooksSection.925f9e0dc4'], + ['zh', 'auto.components.settings.RepositoryHooksSection.c90b858573'], + ['zh', 'auto.components.settings.RepositoryHooksSection.32f417fe17'], + ['zh', 'auto.components.settings.SparsePresetSettingsSection.fde7ff2cc3'], + ['zh', 'auto.components.settings.git.search.upstream'], + ['zh', 'auto.components.settings.mobile.emulator.search.84e5706975'], + ['zh', 'auto.components.settings.repository.search.603c68b68c'], + ['zh', 'auto.components.right.sidebar.index.6306b48afd'], + ['zh', 'auto.components.feature.wall.BrowserAnimatedVisual.7da6eed7bf'], + ['zh', 'auto.components.feature.wall.WorkbenchAnimatedVisual.defe550fe2'], + ['zh', 'auto.components.feature.wall.WorkbenchAnimatedVisual.4371cc9931'], + ['zh', 'auto.components.feature.wall.ReviewAnimatedVisual.8df4d52b68'], + ['zh', 'auto.components.editor.CombinedDiffViewer.8368d256ec'], + ['zh', 'auto.components.editor.IpynbViewer.8c3b21369a'], + ['zh', 'auto.components.editor.MarkdownTemplatePicker.22cd94426f'] +] as const + +const catalogs = { es, ko, zh } as const + +function readValue(catalog: object, key: string): unknown { + return key.split('.').reduce((value, part) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return undefined + } + return (value as Record)[part] + }, catalog) +} + +describe('technical literal catalog repairs (#13121)', () => { + it('keeps every repaired value identical to English', () => { + expect(repairedEntries).toHaveLength(43) + + for (const [locale, key] of repairedEntries) { + expect(readValue(catalogs[locale], key), `${locale}:${key}`).toBe(readValue(en, key)) + } + }) +}) diff --git a/src/renderer/src/lib/agent-draft-paste-content.ts b/src/renderer/src/lib/agent-draft-paste-content.ts index 9f31770be7e..b6c381dc5a9 100644 --- a/src/renderer/src/lib/agent-draft-paste-content.ts +++ b/src/renderer/src/lib/agent-draft-paste-content.ts @@ -1,5 +1,6 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' import type { GlobalSettings } from '../../../shared/types' +import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, @@ -189,19 +190,6 @@ function getSanitizedUtf8ByteLengthForCodePoint(codePoint: number): number { ) } -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - async function writeAgentDraftPtyInput( settings: Pick | null | undefined, ptyId: string, diff --git a/src/renderer/src/lib/comment-body-submit-state.ts b/src/renderer/src/lib/comment-body-submit-state.ts index be3cc7ddb2e..4a7066f6408 100644 --- a/src/renderer/src/lib/comment-body-submit-state.ts +++ b/src/renderer/src/lib/comment-body-submit-state.ts @@ -1,3 +1,5 @@ +import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits' + export const COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES = 64 * 1024 export type CommentBodySubmitState = @@ -7,19 +9,6 @@ export type CommentBodySubmitState = type CommentBodyPresence = 'empty' | 'present' | 'too-large-leading-whitespace' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - function getCommentBodyPresence( body: string, maxScanBytes = COMMENT_BODY_NONBLANK_SCAN_MAX_BYTES @@ -29,7 +18,7 @@ function getCommentBodyPresence( for (let index = 0; index < body.length; index += 1) { const codePoint = body.codePointAt(index) ?? 0 const codeUnitLength = codePoint > 0xffff ? 2 : 1 - scannedBytes += getCodePointUtf8ByteLength(codePoint) + scannedBytes += getUtf8ByteLengthForCodePoint(codePoint) if (scannedBytes > maxScanBytes) { return 'too-large-leading-whitespace' } diff --git a/src/renderer/src/lib/flatten-retained-slice.test.ts b/src/renderer/src/lib/flatten-retained-slice.test.ts new file mode 100644 index 00000000000..2eedfc5c6ad --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' + +import { flattenRetainedSlice } from './flatten-retained-slice' + +// Why 1 MB: comfortably above V8's SlicedString threshold, so a raw slice really does keep the +// parent alive and the difference between flattened and not is unmistakable in heapUsed. +const PARENT_CHARS = 1024 * 1024 +const TAIL_CHARS = 512 +const PARENTS = 8 + +function collect(): number { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + gc() + gc() + return process.memoryUsage().heapUsed +} + +// Distinct leading chars stop V8 sharing one backing store across the parents. +function makeParent(index: number, filler: string): string { + return String.fromCharCode(0x41 + index) + filler.repeat(PARENT_CHARS - 1) +} + +function retainedBytesForTails(filler: string, transform: (value: string) => string): number { + const parents = Array.from({ length: PARENTS }, (_unused, index) => makeParent(index, filler)) + const baseline = collect() + const tails = parents.map((parent) => transform(parent.slice(parent.length - TAIL_CHARS))) + // Drop the parents; only the tails stay reachable. + parents.length = 0 + const retained = collect() - baseline + expect(tails).toHaveLength(PARENTS) + expect(tails.every((tail) => tail.length === TAIL_CHARS)).toBe(true) + return retained +} + +describe('flattenRetainedSlice', () => { + it('preserves content exactly', () => { + expect(flattenRetainedSlice('')).toBe('') + expect(flattenRetainedSlice('a')).toBe('a') + const source = 'hello world, 漢字, , \u{1f600}' + expect(flattenRetainedSlice(source.slice(3))).toBe(source.slice(3)) + }) + + it.each([ + ['one-byte', 'a'], + ['two-byte', '漢'] + ])('drops the %s parent a raw slice would pin', (_label, filler) => { + const raw = retainedBytesForTails(filler, (value) => value) + const flattened = retainedBytesForTails(filler, flattenRetainedSlice) + + // A raw slice pins all 8 parents; flattening must keep only the tails. + expect(raw).toBeGreaterThan(PARENTS * PARENT_CHARS * 0.5) + expect(flattened).toBeLessThan(PARENTS * PARENT_CHARS * 0.05) + }) +}) diff --git a/src/renderer/src/lib/flatten-retained-slice.ts b/src/renderer/src/lib/flatten-retained-slice.ts new file mode 100644 index 00000000000..45585c3a75e --- /dev/null +++ b/src/renderer/src/lib/flatten-retained-slice.ts @@ -0,0 +1,4 @@ +// V8 slices can retain their full parent; force a standalone copy for values that outlive it. +export function flattenRetainedSlice(value: string): string { + return value.length === 0 ? value : `${value} `.slice(0, -1) +} diff --git a/src/renderer/src/lib/github-work-item-background-create.test.ts b/src/renderer/src/lib/github-work-item-background-create.test.ts deleted file mode 100644 index ef7adfdb2b9..00000000000 --- a/src/renderer/src/lib/github-work-item-background-create.test.ts +++ /dev/null @@ -1,878 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import type { RuntimeStatus } from '../../../shared/runtime-types' -import type { GitHubWorkItem, Repo } from '../../../shared/types' -import { - MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - RUNTIME_PROTOCOL_VERSION -} from '../../../shared/protocol-version' - -vi.mock('@/lib/tui-agent-startup', () => ({ - buildAgentDraftLaunchPlan: vi.fn(() => null), - buildAgentStartupPlan: vi.fn(() => ({ - agent: 'codex', - launchCommand: 'codex', - expectedProcess: 'codex', - followupPrompt: null, - launchConfig: { kind: 'shell', command: 'codex' } - })) -})) - -vi.mock('@/lib/telemetry', () => ({ - tuiAgentToAgentKind: (agent: string) => agent -})) - -import { buildAgentDraftLaunchPlan, buildAgentStartupPlan } from '@/lib/tui-agent-startup' -import { createGitHubWorkItemWorkspaceInBackground } from './github-work-item-background-create' - -const repo: Repo = { - id: 'repo-1', - path: '/repo', - displayName: 'orca', - badgeColor: 'blue', - addedAt: 1 -} - -function makeRuntimeStatus(overrides: Partial = {}): RuntimeStatus { - return { - runtimeId: 'runtime-1', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - liveLeafCount: 0, - runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - ...overrides - } -} - -function makeIssue(overrides: Partial = {}): GitHubWorkItem { - return { - id: 'I_42', - repoId: 'repo-1', - type: 'issue', - number: 42, - title: 'Make issue workspace creation async', - url: 'https://github.com/stablyai/orca/issues/42', - state: 'open', - author: null, - labels: [], - assignees: [], - createdAt: '2026-06-22T00:00:00Z', - updatedAt: '2026-06-22T00:00:00Z', - commentsCount: 0, - ...overrides - } as GitHubWorkItem -} - -function makeStore(overrides: Partial> = {}) { - return { ...baseStore(), ...overrides } -} - -function baseStore() { - return { - activeView: 'tasks' as const, - repos: [repo], - pendingWorktreeCreations: {}, - sshConnectionStates: new Map(), - runtimeStatusByEnvironmentId: new Map(), - settings: { - activeRuntimeEnvironmentId: null, - defaultTuiAgent: 'codex' as const, - disabledTuiAgents: [] - }, - ensureDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRemoteDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRuntimeDetectedAgents: vi.fn().mockResolvedValue([]) - } -} - -function makeDeps(store = makeStore()) { - return { - getStore: () => store, - getActiveView: vi.fn(() => store.activeView), - hasPendingCreate: vi.fn(() => true), - isPendingCreateActive: vi.fn(() => true), - resolveSetupDecision: vi.fn().mockResolvedValue({ kind: 'decided', decision: 'inherit' }), - resolvePrStartPoint: vi.fn(), - confirmHooks: vi.fn().mockResolvedValue('run'), - readIssueCommand: vi.fn().mockResolvedValue({ - effectiveContent: null, - localContent: null, - sharedContent: null, - localFilePath: '', - source: 'none' - }), - beginBackgroundCreate: vi.fn(() => 'creation-1'), - continueBackgroundCreate: vi.fn(() => true), - activatePendingCreate: vi.fn(), - removePendingCreate: vi.fn(), - setActiveView: vi.fn(), - toastError: vi.fn() - } -} - -describe('createGitHubWorkItemWorkspaceInBackground', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('starts a background issue workspace without opening the composer', async () => { - const deps = makeDeps() - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - telemetrySource: 'sidebar', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'background-started' }) - expect(openModalFallback).not.toHaveBeenCalled() - expect(deps.beginBackgroundCreate).toHaveBeenCalledWith( - expect.objectContaining({ - repoId: 'repo-1', - name: 'issue-42-make-issue-workspace', - displayName: 'Issue 42 Make Issue Workspace', - linkedIssue: 42, - telemetrySource: 'sidebar', - setupDecision: 'inherit', - agent: null - }) - ) - expect(deps.continueBackgroundCreate).toHaveBeenCalledWith( - 'creation-1', - expect.objectContaining({ - repoId: 'repo-1', - name: 'issue-42-make-issue-workspace', - displayName: 'Issue 42 Make Issue Workspace', - linkedIssue: 42, - telemetrySource: 'sidebar', - setupDecision: 'inherit', - agent: null, - startupPlan: null, - quickPrompt: '', - quickTelemetry: null - }), - { revealCreationSurface: false } - ) - }) - - it('does not resolve a PR start point for a stale PR-typed issue URL', async () => { - const deps = makeDeps() - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ - type: 'pr', - number: 6933, - title: 'The board columns are displayed backwards', - url: 'https://github.com/stablyai/orca/issues/6933', - branchName: 'fix-issue-6933', - baseRefName: 'main', - isCrossRepository: true - }), - repoId: 'repo-1', - telemetrySource: 'sidebar', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.resolvePrStartPoint).not.toHaveBeenCalled() - const beginCalls = deps.beginBackgroundCreate.mock.calls as unknown as [ - WorktreeCreationRequest - ][] - expect(beginCalls[0]?.[0].linkedIssue).toBe(6933) - expect(beginCalls[0]?.[0].linkedPR).toBeUndefined() - const continueCalls = deps.continueBackgroundCreate.mock.calls as unknown as [ - string, - WorktreeCreationRequest - ][] - const request = continueCalls[0]?.[1] - expect(request?.linkedIssue).toBe(6933) - expect(request?.linkedPR).toBeUndefined() - expect(request?.baseBranch).toBeUndefined() - expect(request?.pushTarget).toBeUndefined() - expect(request?.branchNameOverride).toBeUndefined() - expect(request?.compareBaseRef).toBeUndefined() - }) - - it('shows the pending workspace before async preflight resolves', async () => { - let resolveSetupDecision: ((value: { kind: 'decided'; decision: 'inherit' }) => void) | null = - null - const deps = makeDeps() - deps.resolveSetupDecision.mockImplementation( - () => - new Promise<{ kind: 'decided'; decision: 'inherit' }>((resolve) => { - resolveSetupDecision = resolve - }) - ) - - const pending = createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.beginBackgroundCreate).toHaveBeenCalledTimes(1) - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - - expect(resolveSetupDecision).not.toBeNull() - resolveSetupDecision!({ kind: 'decided', decision: 'inherit' }) - await pending - - expect(deps.continueBackgroundCreate).toHaveBeenCalledTimes(1) - }) - - it('reuses an existing pending issue create instead of staging a duplicate', async () => { - const pendingRequest: WorktreeCreationRequest = { - repoId: 'repo-1', - name: 'issue-42-make-issue-workspace', - setupDecision: 'inherit', - linkedIssue: 42, - agent: null, - pendingFirstAgentMessageRename: false, - note: '', - startupPlan: null, - quickPrompt: '', - quickTelemetry: null - } - const deps = makeDeps( - makeStore({ - pendingWorktreeCreations: { - 'creation-existing': { - creationId: 'creation-existing', - phase: 'preparing', - status: 'creating', - indeterminate: false, - loaderVisible: true, - request: pendingRequest - } - } - }) - ) - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(result).toEqual({ kind: 'background-started' }) - expect(deps.activatePendingCreate).toHaveBeenCalledWith('creation-existing') - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('falls back without staging when the SSH repo is disconnected', async () => { - const sshRepo: Repo = { - ...repo, - connectionId: 'devbox' - } - const deps = makeDeps( - makeStore({ - repos: [sshRepo], - sshConnectionStates: new Map([['devbox', { status: 'disconnected' }]]) - }) - ) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'host-unavailable' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.confirmHooks).not.toHaveBeenCalled() - }) - - it('falls back without staging when the runtime repo is unreachable', async () => { - const runtimeRepo: Repo = { - ...repo, - executionHostId: 'runtime:env-1' - } - const deps = makeDeps( - makeStore({ - repos: [runtimeRepo], - runtimeStatusByEnvironmentId: new Map([['env-1', { status: null, checkedAt: 1 }]]) - }) - ) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'host-unavailable' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.confirmHooks).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('falls back without staging when the runtime host platform is unknown', async () => { - const runtimeRepo: Repo = { - ...repo, - executionHostId: 'runtime:env-1' - } - const deps = makeDeps( - makeStore({ - repos: [runtimeRepo], - runtimeStatusByEnvironmentId: new Map([ - ['env-1', { status: makeRuntimeStatus({ runtimeId: 'runtime-env-1' }), checkedAt: 1 }] - ]) - }) - ) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'host-unavailable' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.beginBackgroundCreate).not.toHaveBeenCalled() - expect(deps.resolveSetupDecision).not.toHaveBeenCalled() - expect(deps.confirmHooks).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('uses the preferred quick agent when one is available', async () => { - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.agent).toBe('codex') - expect(request.startupPlan?.launchCommand).toBe('codex') - expect(request.startup).toBeUndefined() - expect(request.quickTelemetry).toEqual({ - agent_kind: 'codex', - launch_source: 'new_workspace_composer', - request_kind: 'new' - }) - expect(buildAgentStartupPlan).toHaveBeenCalled() - }) - - it('uses runtime-owned detection and indeterminate progress for runtime repos', async () => { - const runtimeRepo: Repo = { - ...repo, - executionHostId: 'runtime:env-1' - } - const store = makeStore({ - repos: [runtimeRepo], - runtimeStatusByEnvironmentId: new Map([ - [ - 'env-1', - { - status: makeRuntimeStatus({ runtimeId: 'runtime-env-1', hostPlatform: 'win32' }), - checkedAt: 1 - } - ] - ]), - ensureDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRemoteDetectedAgents: vi.fn().mockResolvedValue([]), - ensureRuntimeDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(store.ensureRuntimeDetectedAgents).toHaveBeenCalledWith('env-1') - expect(store.ensureDetectedAgents).not.toHaveBeenCalled() - expect(store.ensureRemoteDetectedAgents).not.toHaveBeenCalled() - expect(deps.beginBackgroundCreate).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeCreateProgressMode: 'indeterminate', - workspaceRunContext: expect.objectContaining({ - hostId: 'runtime:env-1', - repoId: 'repo-1' - }) - }) - ) - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.worktreeCreateProgressMode).toBe('indeterminate') - expect(request.agent).toBe('codex') - expect(buildAgentStartupPlan).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'win32' }) - ) - }) - - it('falls back before creating when the selected agent startup plan cannot be built', async () => { - vi.mocked(buildAgentStartupPlan).mockReturnValueOnce(null) - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'agent-startup' }) - expect(deps.toastError).toHaveBeenCalledWith('Could not build the agent launch command.') - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).toHaveBeenCalledWith('tasks') - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('stops before opening the composer when the staged create is cancelled during setup preflight', async () => { - let resolveSetupDecision: ((value: { kind: 'needs-modal' }) => void) | null = null - const deps = makeDeps() - deps.resolveSetupDecision.mockImplementation( - () => - new Promise<{ kind: 'needs-modal' }>((resolve) => { - resolveSetupDecision = resolve - }) - ) - const openModalFallback = vi.fn() - - const pending = createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - deps.hasPendingCreate.mockReturnValue(false) - expect(resolveSetupDecision).not.toBeNull() - resolveSetupDecision!({ kind: 'needs-modal' }) - - expect(await pending).toEqual({ kind: 'background-started' }) - expect(openModalFallback).not.toHaveBeenCalled() - expect(deps.removePendingCreate).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('stops before hook trust and agent detection when the staged create is cancelled', async () => { - let resolveSetupDecision: ((value: { kind: 'decided'; decision: 'inherit' }) => void) | null = - null - const deps = makeDeps() - deps.resolveSetupDecision.mockImplementation( - () => - new Promise<{ kind: 'decided'; decision: 'inherit' }>((resolve) => { - resolveSetupDecision = resolve - }) - ) - - const pending = createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - deps.hasPendingCreate.mockReturnValue(false) - expect(resolveSetupDecision).not.toBeNull() - resolveSetupDecision!({ kind: 'decided', decision: 'inherit' }) - - expect(await pending).toEqual({ kind: 'background-started' }) - expect(deps.confirmHooks).not.toHaveBeenCalled() - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('falls back to the composer when setup policy requires an explicit choice', async () => { - const deps = makeDeps() - deps.resolveSetupDecision.mockResolvedValueOnce({ kind: 'needs-modal' }) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'setup-ask' }) - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).toHaveBeenCalledWith('tasks') - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('leaves the current view alone on fallback after the user leaves the staged create', async () => { - const deps = makeDeps() - deps.resolveSetupDecision.mockResolvedValueOnce({ kind: 'needs-modal' }) - deps.isPendingCreateActive.mockReturnValueOnce(false) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).not.toHaveBeenCalled() - }) - - it('falls back to the composer when PR start point cannot be resolved', async () => { - const deps = makeDeps() - deps.resolvePrStartPoint.mockRejectedValueOnce(new Error('No PR head')) - const openModalFallback = vi.fn() - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), - repoId: 'repo-1', - openModalFallback - }, - deps - ) - - expect(result).toEqual({ kind: 'fallback', reason: 'pr-start-point' }) - expect(deps.toastError).toHaveBeenCalledWith('No PR head') - expect(openModalFallback).toHaveBeenCalledTimes(1) - expect(deps.removePendingCreate).toHaveBeenCalledWith('creation-1') - expect(deps.setActiveView).toHaveBeenCalledWith('tasks') - expect(deps.continueBackgroundCreate).not.toHaveBeenCalled() - }) - - it('includes resolved PR start-point data in the background request', async () => { - const deps = makeDeps() - deps.resolvePrStartPoint.mockResolvedValueOnce({ - baseBranch: 'feature/from-pr', - pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, - branchNameOverride: 'feature/from-pr', - compareBaseRef: 'main' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ - type: 'pr', - number: 6934, - url: 'https://github.com/stablyai/orca/pull/6934', - branchName: 'fix-issue-6933', - baseRefName: 'main', - isCrossRepository: true - }), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.resolvePrStartPoint).toHaveBeenCalledWith( - 'repo-1', - 6934, - expect.anything(), - expect.objectContaining({ - type: 'pr', - number: 6934, - branchName: 'fix-issue-6933', - baseRefName: 'main', - isCrossRepository: true - }) - ) - expect(deps.continueBackgroundCreate).toHaveBeenCalledWith( - 'creation-1', - expect.objectContaining({ - linkedPR: 6934, - baseBranch: 'feature/from-pr', - pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, - branchNameOverride: 'feature/from-pr', - compareBaseRef: 'main' - }), - { revealCreationSurface: false } - ) - }) - - it('prefers native draft startup when the agent supports it', async () => { - vi.mocked(buildAgentDraftLaunchPlan).mockReturnValueOnce({ - agent: 'codex', - launchCommand: 'codex --prompt-file', - expectedProcess: 'codex', - launchConfig: { agentCommand: 'codex', agentArgs: '--prompt-file', agentEnv: {} } - }) - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.startupPlan?.launchCommand).toBe('codex --prompt-file') - expect(request.startup?.command).toBe('codex --prompt-file') - expect(buildAgentStartupPlan).not.toHaveBeenCalled() - }) - - it('attaches the rendered issue command when the repo configured one and trust runs', async () => { - const deps = makeDeps() - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'gh issue view {{issue}} --repo {{artifact_url}}', - localContent: null, - sharedContent: 'gh issue view {{issue}} --repo {{artifact_url}}', - localFilePath: '', - source: 'shared' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'setup') - expect(deps.confirmHooks).toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand?.command).toBe( - 'gh issue view 42 --repo https://github.com/stablyai/orca/issues/42' - ) - }) - - it('omits the issue command when the repo configured none', async () => { - const deps = makeDeps() - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - }) - - it('skips the issue command when setup trust was declined', async () => { - const deps = makeDeps() - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: 'echo {{issue}}', - sharedContent: null, - localFilePath: '/repo/.orca/issue-command', - source: 'local' - }) - // Why: the setup confirmHooks call resolves 'skip', which mirrors the - // composer suppressing the issue command when setup trust is declined. - deps.confirmHooks.mockResolvedValue('skip') - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - // Why: a declined setup trust short-circuits before the (up-to-15s) read, - // so workspace creation is never stalled to fetch a command we will drop. - expect(deps.readIssueCommand).not.toHaveBeenCalled() - }) - - it('does not run the issue command for PR items', async () => { - const deps = makeDeps() - deps.resolvePrStartPoint.mockResolvedValueOnce({ - baseBranch: 'feature/from-pr', - pushTarget: { remote: 'origin', branch: 'feature/from-pr' }, - branchNameOverride: 'feature/from-pr', - compareBaseRef: 'main' - }) - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: 'echo {{issue}}', - sharedContent: null, - localFilePath: '/repo/.orca/issue-command', - source: 'local' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue({ type: 'pr', number: 7, url: 'https://github.com/stablyai/orca/pull/7' }), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - expect(deps.confirmHooks).not.toHaveBeenCalledWith(expect.anything(), 'repo-1', 'issueCommand') - }) - - it('fails closed when reading the issue command rejects', async () => { - const deps = makeDeps() - deps.readIssueCommand.mockRejectedValueOnce(new Error('runtime offline')) - - const result = await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - expect(result).toEqual({ kind: 'background-started' }) - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - expect(request.issueCommand).toBeUndefined() - }) - - it('re-reads the store for each trust check so mid-flow trust updates are honored', async () => { - const snapshots = [makeStore(), makeStore(), makeStore()] - let getStoreCall = 0 - const deps = makeDeps(snapshots[0]) - deps.getStore = vi.fn( - () => snapshots[Math.min(getStoreCall++, snapshots.length - 1)] ?? snapshots[0] - ) - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: null, - sharedContent: 'echo {{issue}}', - localFilePath: '', - source: 'shared' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - // Why: an "Always trust" stamped by the setup prompt only exists in a fresh - // snapshot. Each trust check must read the store at call time instead of - // reusing the snapshot captured when the flow began. - const setupCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'setup') - const issueCall = deps.confirmHooks.mock.calls.find((call) => call[2] === 'issueCommand') - expect(setupCall?.[0]).toBeDefined() - expect(issueCall?.[0]).toBeDefined() - expect(setupCall?.[0]).not.toBe(snapshots[0]) - expect(issueCall?.[0]).not.toBe(snapshots[0]) - expect(issueCall?.[0]).not.toBe(setupCall?.[0]) - }) - - it('keeps the seeded agent prompt as the bare issue URL (not Complete )', async () => { - const store = makeStore({ - ensureDetectedAgents: vi.fn().mockResolvedValue(['codex']) - }) - const deps = makeDeps(store) - deps.readIssueCommand.mockResolvedValueOnce({ - effectiveContent: 'echo {{issue}}', - localContent: 'echo {{issue}}', - sharedContent: null, - localFilePath: '/repo/.orca/issue-command', - source: 'local' - }) - - await createGitHubWorkItemWorkspaceInBackground( - { - item: makeIssue(), - repoId: 'repo-1', - openModalFallback: vi.fn() - }, - deps - ) - - const continueCall = deps.continueBackgroundCreate.mock.calls[0] as unknown[] | undefined - expect(continueCall).toBeDefined() - const request = continueCall?.[1] as WorktreeCreationRequest - // Why: Brennan's confirmed scope keeps the quick-start prompt the bare link; - // the issue command runs as a side-pane split, not as the agent prompt. - expect(request.startupPlan?.draftPrompt).toBe('https://github.com/stablyai/orca/issues/42') - expect(request.startupPlan?.draftPrompt ?? '').not.toContain('Complete') - expect(request.quickPrompt).not.toContain('Complete') - // The issue command itself still threads through as a side-pane split. - expect(request.issueCommand?.command).toBe('echo 42') - }) -}) diff --git a/src/renderer/src/lib/github-work-item-background-create.ts b/src/renderer/src/lib/github-work-item-background-create.ts deleted file mode 100644 index 8f08c1b30cc..00000000000 --- a/src/renderer/src/lib/github-work-item-background-create.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { toast } from 'sonner' - -import { useAppStore } from '@/store' -import { - beginBackgroundWorktreePreparation, - continueBackgroundWorktreeCreation -} from '@/lib/worktree-creation-flow' -import { - buildGitHubWorkItemBackendStartup, - buildGitHubWorkItemStartupPlan, - buildInitialGitHubWorkItemRequest, - type GitHubWorkItemBackgroundStoreSnapshot, - resolvePreferredQuickAgentForGitHubWorkItem -} from '@/lib/github-work-item-background-request' -import type { WorktreeCreationRequest } from '@/lib/pending-worktree-creation' -import { - resolveDirectPrStartPoint, - resolveDirectSetupDecision -} from '@/lib/launch-work-item-direct-preflight' -import { agentLaunchCommandErrorMessage } from '@/lib/launch-work-item-direct-messages' -import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' -import { renderIssueCommandTemplate } from '@/lib/new-workspace' -import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' -import { readRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' -import { isGitRepoKind } from '../../../shared/repo-kind' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' -import { evaluateRuntimeCompat } from '../../../shared/protocol-compat' -import { - MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, - RUNTIME_PROTOCOL_VERSION -} from '../../../shared/protocol-version' -import type { GitHubWorkItem, SetupDecision, Repo } from '../../../shared/types' -import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' -import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' - -export type BackgroundGitHubWorkItemCreateResult = - | { kind: 'background-started' } - | { kind: 'error' } - | { - kind: 'fallback' - reason: 'repo-missing' | 'host-unavailable' | 'setup-ask' | 'pr-start-point' | 'agent-startup' - } - -type AppActiveView = ReturnType['activeView'] - -type BackgroundGitHubWorkItemCreateDeps = { - getStore: () => GitHubWorkItemBackgroundStoreSnapshot - getActiveView: () => AppActiveView - hasPendingCreate: (creationId: string) => boolean - isPendingCreateActive: (creationId: string) => boolean - resolveSetupDecision: typeof resolveDirectSetupDecision - resolvePrStartPoint: typeof resolveDirectPrStartPoint - confirmHooks: ( - store: GitHubWorkItemBackgroundStoreSnapshot, - repoId: string, - scope: 'setup' | 'issueCommand' - ) => ReturnType - readIssueCommand: typeof readRuntimeIssueCommand - beginBackgroundCreate: typeof beginBackgroundWorktreePreparation - continueBackgroundCreate: typeof continueBackgroundWorktreeCreation - activatePendingCreate: (creationId: string) => void - removePendingCreate: (creationId: string) => void - setActiveView: (view: AppActiveView) => void - toastError: (message: string) => void -} - -export type BackgroundGitHubWorkItemCreateArgs = { - item: GitHubWorkItem - repoId: string - taskSourceContext?: TaskSourceContext | null - workspaceRunContext?: WorkspaceRunContext | null - telemetrySource?: WorktreeCreationRequest['telemetrySource'] - openModalFallback: () => void -} - -const DEFAULT_DEPS: BackgroundGitHubWorkItemCreateDeps = { - getStore: () => useAppStore.getState(), - getActiveView: () => useAppStore.getState().activeView, - hasPendingCreate: (creationId) => - useAppStore.getState().pendingWorktreeCreations[creationId] != null, - isPendingCreateActive: (creationId) => - useAppStore.getState().activePendingCreationId === creationId, - resolveSetupDecision: resolveDirectSetupDecision, - resolvePrStartPoint: resolveDirectPrStartPoint, - confirmHooks: (store, repoId, scope: 'setup' | 'issueCommand') => - ensureHooksConfirmed(store as ReturnType, repoId, scope), - readIssueCommand: readRuntimeIssueCommand, - beginBackgroundCreate: beginBackgroundWorktreePreparation, - continueBackgroundCreate: continueBackgroundWorktreeCreation, - activatePendingCreate: (creationId) => { - const store = useAppStore.getState() - store.setActivePendingWorktreeCreation(creationId) - store.setActiveView('terminal') - store.setSidebarOpen(true) - }, - removePendingCreate: (creationId) => - useAppStore.getState().removePendingWorktreeCreation(creationId), - setActiveView: (view) => useAppStore.getState().setActiveView(view), - toastError: (message) => toast.error(message) -} - -function findPendingGitHubWorkItemCreate( - store: GitHubWorkItemBackgroundStoreSnapshot, - request: WorktreeCreationRequest -): string | null { - if (!request.linkedIssue && !request.linkedPR) { - return null - } - const match = Object.values(store.pendingWorktreeCreations).find((entry) => { - const pending = entry.request - return ( - pending.repoId === request.repoId && - pending.linkedIssue === request.linkedIssue && - pending.linkedPR === request.linkedPR - ) - }) - return match?.creationId ?? null -} - -function repoHostUnavailable(store: GitHubWorkItemBackgroundStoreSnapshot, repo: Repo): boolean { - const host = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (host?.kind === 'ssh') { - return store.sshConnectionStates.get(host.targetId)?.status !== 'connected' - } - if (host?.kind !== 'runtime') { - return false - } - const status = store.runtimeStatusByEnvironmentId.get(host.environmentId)?.status - if (!status) { - return true - } - if (!status.hostPlatform) { - return true - } - const compatibility = evaluateRuntimeCompat({ - clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, - minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, - serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion, - serverMinCompatibleClientProtocolVersion: - status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion - }) - return compatibility.kind === 'blocked' -} - -function abandonStagedCreate( - creationId: string, - restoreView: AppActiveView, - deps: BackgroundGitHubWorkItemCreateDeps -): void { - // Why: fallback paths abandon the temporary creation surface, so return to the - // flow that launched it unless the user already activated something else. - const shouldRestoreView = deps.isPendingCreateActive(creationId) - deps.removePendingCreate(creationId) - if (shouldRestoreView) { - deps.setActiveView(restoreView) - } -} - -export async function createGitHubWorkItemWorkspaceInBackground( - args: BackgroundGitHubWorkItemCreateArgs, - deps: BackgroundGitHubWorkItemCreateDeps = DEFAULT_DEPS -): Promise { - const store = deps.getStore() - const repo = store.repos.find((candidate) => candidate.id === args.repoId) - if (!repo) { - args.openModalFallback() - return { kind: 'fallback', reason: 'repo-missing' } - } - - const initialRequest = buildInitialGitHubWorkItemRequest(args, repo) - const existingPendingCreateId = findPendingGitHubWorkItemCreate(store, initialRequest) - if (existingPendingCreateId) { - deps.activatePendingCreate(existingPendingCreateId) - return { kind: 'background-started' } - } - // Why: disconnected hosts make hook and agent probes fall back to skip/no-agent; - // keep the old composer gate so Retry cannot reuse degraded preflight values. - if (repoHostUnavailable(store, repo)) { - args.openModalFallback() - return { kind: 'fallback', reason: 'host-unavailable' } - } - - const restoreView = deps.getActiveView() - const creationId = deps.beginBackgroundCreate(initialRequest) - const itemIdentity = resolveGitHubWorkItemIdentity(args.item) - - try { - const repoOwnerSettings = getSettingsForRepoRuntimeOwner(store, args.repoId) - const setupResolution = await deps.resolveSetupDecision(args.repoId, repo, repoOwnerSettings) - // Why: once the staged row disappears, the user already cancelled or moved - // on, so every later preflight await must exit without reopening UI. - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - if (setupResolution.kind === 'needs-modal') { - abandonStagedCreate(creationId, restoreView, deps) - args.openModalFallback() - return { kind: 'fallback', reason: 'setup-ask' } - } - - let baseBranch: string | undefined - let pushTarget: WorktreeCreationRequest['pushTarget'] - let branchNameOverride: string | undefined - let compareBaseRef: string | undefined - if (itemIdentity.type === 'pr' && itemIdentity.number) { - try { - const result = await deps.resolvePrStartPoint( - args.repoId, - itemIdentity.number, - repoOwnerSettings, - args.item - ) - baseBranch = result.baseBranch - pushTarget = result.pushTarget - branchNameOverride = result.branchNameOverride - compareBaseRef = result.compareBaseRef - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - } catch (error) { - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - deps.toastError(error instanceof Error ? error.message : 'Unable to resolve pull request.') - abandonStagedCreate(creationId, restoreView, deps) - args.openModalFallback() - return { kind: 'fallback', reason: 'pr-start-point' } - } - } - - // Why: trust prompts are serialized app-wide, so read the store fresh at - // each check — an "Always trust" stamped by an earlier prompt (including - // this flow's own setup prompt) must short-circuit instead of re-prompting. - const trustDecision = await deps.confirmHooks(deps.getStore(), args.repoId, 'setup') - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - const setupDecision: SetupDecision = - trustDecision === 'skip' ? 'skip' : setupResolution.decision - const agent = await resolvePreferredQuickAgentForGitHubWorkItem(store, repo) - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - const { startupPlan, quickPrompt, launchDraftPrompt, quickTelemetry } = - buildGitHubWorkItemStartupPlan({ - agent, - item: args.item, - repo, - store - }) - if (agent && !startupPlan) { - deps.toastError(agentLaunchCommandErrorMessage()) - abandonStagedCreate(creationId, restoreView, deps) - args.openModalFallback() - return { kind: 'fallback', reason: 'agent-startup' } - } - const backendStartup = buildGitHubWorkItemBackendStartup(agent, startupPlan, quickTelemetry) - - // Why: mirror the composer's trust-gated issue-command split. Only GitHub - // issues (numeric issue number) on git repos run it; PRs/Linear/folders never - // do. Reuse the setup trust decision: a 'skip' there also skips the command. - let issueCommand: WorktreeCreationRequest['issueCommand'] - // Why: a declined setup trust also skips the issue command, so short-circuit - // before the (up-to-15s) read rather than reading just to drop the result. - if ( - trustDecision !== 'skip' && - isGitRepoKind(repo) && - args.item.type === 'issue' && - typeof args.item.number === 'number' - ) { - // Why: read failures fail closed (no command), so create still proceeds. - let effectiveContent = '' - try { - const issueCommandRead = await deps.readIssueCommand(repoOwnerSettings, args.repoId) - effectiveContent = (issueCommandRead.effectiveContent ?? '').trim() - } catch { - effectiveContent = '' - } - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - if (effectiveContent.length > 0) { - const issueCommandTrust = await deps.confirmHooks( - deps.getStore(), - args.repoId, - 'issueCommand' - ) - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - if (issueCommandTrust === 'run') { - issueCommand = { - command: renderIssueCommandTemplate(effectiveContent, { - issueNumber: args.item.number, - artifactUrl: args.item.url ?? null - }) - } - } - } - } - - const request: WorktreeCreationRequest = { - ...initialRequest, - ...(baseBranch ? { baseBranch } : {}), - ...(compareBaseRef ? { compareBaseRef } : {}), - setupDecision, - ...(pushTarget ? { pushTarget } : {}), - agent, - ...(branchNameOverride ? { branchNameOverride } : {}), - ...(backendStartup ? { startup: backendStartup } : {}), - ...(issueCommand ? { issueCommand } : {}), - startupPlan, - quickPrompt, - ...(launchDraftPrompt ? { launchDraftPrompt } : {}), - quickTelemetry - } - - deps.continueBackgroundCreate(creationId, request, { revealCreationSurface: false }) - return { kind: 'background-started' } - } catch (error) { - if (!deps.hasPendingCreate(creationId)) { - return { kind: 'background-started' } - } - abandonStagedCreate(creationId, restoreView, deps) - deps.toastError(error instanceof Error ? error.message : 'Unable to prepare workspace.') - return { kind: 'error' } - } -} diff --git a/src/renderer/src/lib/github-work-item-background-request.ts b/src/renderer/src/lib/github-work-item-background-request.ts deleted file mode 100644 index 60ebed0477a..00000000000 --- a/src/renderer/src/lib/github-work-item-background-request.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { useAppStore } from '@/store' -import { - buildAgentDraftLaunchPlan, - buildAgentStartupPlan, - type AgentStartupPlan -} from '@/lib/tui-agent-startup' -import { resolveQuickCreateLinkedWorkItemPrompt } from '@/lib/linked-work-item-context' -import { pickQuickWorkspaceAgent } from '@/lib/quick-workspace-agent-selection' -import type { - PendingWorktreeCreation, - WorktreeCreationRequest -} from '@/lib/pending-worktree-creation' -import { CLIENT_PLATFORM, getWorkspaceIntentName, getWorkspaceSeedName } from '@/lib/new-workspace' -import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' -import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' -import { repoIsRemote } from '../../../shared/agent-launch-remote' -import { resolveGitHubWorkItemIdentity } from '@/lib/github-work-item-identity' -import { buildGitHubWorkspaceSource } from '../../../shared/new-workspace/workspace-source' -import { - resolveTuiAgentLaunchArgs, - resolveTuiAgentLaunchEnv -} from '../../../shared/tui-agent-launch-defaults' -import { tuiAgentToAgentKind } from '@/lib/telemetry' -import type { GitHubWorkItem, GlobalSettings, Repo, TuiAgent } from '../../../shared/types' -import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context' -import type { AgentStartedTelemetry } from '@/lib/worktree-activation' -import { getRepoExecutionHostId, parseExecutionHostId } from '../../../shared/execution-host' -import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' -import { resolveLocalWindowsAgentStartupShell } from '../../../shared/windows-terminal-shell' - -export type GitHubWorkItemBackgroundStoreSnapshot = { - repos: readonly Repo[] - pendingWorktreeCreations: Record - sshConnectionStates: ReturnType['sshConnectionStates'] - runtimeStatusByEnvironmentId: ReturnType< - typeof useAppStore.getState - >['runtimeStatusByEnvironmentId'] - settings: - | Partial< - Pick< - GlobalSettings, - | 'activeRuntimeEnvironmentId' - | 'defaultTuiAgent' - | 'disabledTuiAgents' - | 'agentCmdOverrides' - | 'agentDefaultArgs' - | 'agentDefaultEnv' - | 'terminalWindowsShell' - > - > - | null - | undefined - ensureDetectedAgents: ReturnType['ensureDetectedAgents'] - ensureRemoteDetectedAgents: ReturnType['ensureRemoteDetectedAgents'] - ensureRuntimeDetectedAgents: ReturnType< - typeof useAppStore.getState - >['ensureRuntimeDetectedAgents'] -} - -export type BuildInitialGitHubWorkItemRequestArgs = { - item: GitHubWorkItem - repoId: string - taskSourceContext?: TaskSourceContext | null - workspaceRunContext?: WorkspaceRunContext | null - telemetrySource?: WorktreeCreationRequest['telemetrySource'] -} - -type QuickCreateLinkedWorkItemPromptResult = ReturnType< - typeof resolveQuickCreateLinkedWorkItemPrompt -> - -function resolveGitHubWorkItemPrompt(item: GitHubWorkItem): QuickCreateLinkedWorkItemPromptResult { - const resolver = resolveQuickCreateLinkedWorkItemPrompt as unknown as ( - linkedWorkItem: GitHubWorkItem, - note: string, - opts?: { cliAvailable: boolean } - ) => QuickCreateLinkedWorkItemPromptResult - return resolver(item, '', { cliAvailable: false }) -} - -export function buildGitHubWorkItemBackendStartup( - agent: TuiAgent | null, - startupPlan: AgentStartupPlan | null, - quickTelemetry: AgentStartedTelemetry | null -): WorktreeCreationRequest['startup'] { - // Why: draft/followup launches still need the renderer to finish terminal - // setup, so only self-contained startup plans can move into createWorktree. - if (!agent || !startupPlan || startupPlan.draftPrompt || startupPlan.followupPrompt) { - return undefined - } - return { - command: startupPlan.launchCommand, - ...(startupPlan.env ? { env: startupPlan.env } : {}), - launchConfig: startupPlan.launchConfig, - launchAgent: agent, - ...(startupPlan.startupCommandDelivery - ? { startupCommandDelivery: startupPlan.startupCommandDelivery } - : {}), - ...(quickTelemetry ? { telemetry: quickTelemetry } : {}) - } -} - -function getWorkspaceRunContextForRepo( - repo: Repo, - provided: WorkspaceRunContext | null | undefined -): WorkspaceRunContext | null { - if (provided) { - return provided - } - const projection = projectHostSetupProjectionFromRepos([repo]) - const project = projection.projects[0] - const setup = projection.setups[0] - if (!project || !setup) { - return null - } - return { - kind: 'workspace-run', - projectId: project.id, - hostId: getRepoExecutionHostId(repo), - projectHostSetupId: setup.id, - repoId: repo.id, - path: repo.path - } -} - -export async function resolvePreferredQuickAgentForGitHubWorkItem( - store: GitHubWorkItemBackgroundStoreSnapshot, - repo: Repo -): Promise { - const host = parseExecutionHostId(getRepoExecutionHostId(repo)) - const detectedAgents = - host?.kind === 'ssh' - ? await store.ensureRemoteDetectedAgents(host.targetId) - : host?.kind === 'runtime' - ? await store.ensureRuntimeDetectedAgents(host.environmentId) - : await store.ensureDetectedAgents() - return pickQuickWorkspaceAgent( - store.settings?.defaultTuiAgent, - detectedAgents, - store.settings?.disabledTuiAgents - ) -} - -function resolveGitHubWorkItemLaunchPlatform( - store: GitHubWorkItemBackgroundStoreSnapshot, - repo: Repo -): NodeJS.Platform { - const host = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (host?.kind === 'runtime') { - // Why: the background runtime path gates on hostPlatform before this point; - // POSIX is safer than client PowerShell if another caller violates that. - return ( - store.runtimeStatusByEnvironmentId.get(host.environmentId)?.status?.hostPlatform ?? 'linux' - ) - } - const projectRuntime = repo.connectionId - ? undefined - : getLocalRepoProjectExecutionRuntimeContext( - store as ReturnType, - repo.id, - CLIENT_PLATFORM - ) - return resolveSourceControlLaunchPlatform({ - connectionId: repo.connectionId, - worktreePath: repo.path, - projectRuntime - }) -} - -export function buildGitHubWorkItemStartupPlan(args: { - agent: TuiAgent | null - item: GitHubWorkItem - repo: Repo - store: GitHubWorkItemBackgroundStoreSnapshot -}): { - startupPlan: AgentStartupPlan | null - quickPrompt: string - /** Draft context (issue link) that reaches only the TUI input; callers thread - * it onto the creation request so completion can seed the chat composer. */ - launchDraftPrompt: string | null - quickTelemetry: AgentStartedTelemetry | null -} { - const { agent, item, repo, store } = args - if (!agent) { - return { startupPlan: null, quickPrompt: '', launchDraftPrompt: null, quickTelemetry: null } - } - const { prompt: quickPrompt, draftPrompt } = resolveGitHubWorkItemPrompt(item) - // Why: runtime-owned repos launch on their owner host, not on the client - // desktop, so startup shell quoting must use the runtime platform. - const platform = resolveGitHubWorkItemLaunchPlatform(store, repo) - // Why: SSH remotes deploy the CLI shim as plain `orca`, so the Linux-only - // `orca-ide` rename must not be applied for remote launches. - const isRemote = repoIsRemote(repo) - const shell = resolveLocalWindowsAgentStartupShell({ - platform, - isRemote, - terminalWindowsShell: store.settings?.terminalWindowsShell - }) - const draftLaunchPlan = draftPrompt - ? buildAgentDraftLaunchPlan({ - agent, - draft: draftPrompt, - cmdOverrides: store.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), - platform, - shell, - isRemote - }) - : null - const startupPlan = draftLaunchPlan - ? { - agent: draftLaunchPlan.agent, - launchCommand: draftLaunchPlan.launchCommand, - expectedProcess: draftLaunchPlan.expectedProcess, - followupPrompt: null, - launchConfig: draftLaunchPlan.launchConfig, - ...(draftLaunchPlan.startupCommandDelivery - ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } - : {}), - ...(draftLaunchPlan.env ? { env: draftLaunchPlan.env } : {}) - } - : buildAgentStartupPlan({ - agent, - prompt: quickPrompt, - cmdOverrides: store.settings?.agentCmdOverrides ?? {}, - agentArgs: resolveTuiAgentLaunchArgs(agent, store.settings?.agentDefaultArgs), - agentEnv: resolveTuiAgentLaunchEnv(agent, store.settings?.agentDefaultEnv), - platform, - shell, - isRemote, - allowEmptyPromptLaunch: true - }) - if (startupPlan && draftPrompt && !draftLaunchPlan) { - startupPlan.draftPrompt = draftPrompt - } - return { - startupPlan, - quickPrompt, - launchDraftPrompt: draftPrompt || null, - quickTelemetry: { - agent_kind: tuiAgentToAgentKind(agent), - launch_source: 'new_workspace_composer', - request_kind: 'new' - } - } -} - -function getGitHubWorkItemName(item: GitHubWorkItem): { seedName: string; displayName?: string } { - const identity = resolveGitHubWorkItemIdentity(item) - const intent = - identity.number !== null - ? getWorkspaceIntentName({ - sourceText: item.title, - workItem: { type: identity.type, number: identity.number, title: item.title } - }) - : null - return { - seedName: getWorkspaceSeedName({ - explicitName: intent?.seedName ?? '', - prompt: '', - linkedIssueNumber: identity.type === 'issue' ? identity.number : null, - linkedPR: identity.type === 'pr' ? identity.number : null - }), - ...(intent?.displayName ? { displayName: intent.displayName } : {}) - } -} - -export function buildInitialGitHubWorkItemRequest( - args: BuildInitialGitHubWorkItemRequestArgs, - repo: Repo -): WorktreeCreationRequest { - const { seedName, displayName } = getGitHubWorkItemName(args.item) - const workspaceRunContext = getWorkspaceRunContextForRepo(repo, args.workspaceRunContext) - const ownerHost = parseExecutionHostId(getRepoExecutionHostId(repo)) - const identity = resolveGitHubWorkItemIdentity(args.item) - const linkedWorkItem = - identity.number !== null - ? buildGitHubWorkspaceSource({ - type: identity.type, - number: identity.number, - title: args.item.title, - url: args.item.url, - repoId: args.repoId - }) - : null - return { - repoId: args.repoId, - worktreeCreateProgressMode: ownerHost?.kind === 'local' ? 'stepped' : 'indeterminate', - ...(args.taskSourceContext ? { taskSourceContext: args.taskSourceContext } : {}), - ...(linkedWorkItem - ? { - linkedWorkItem, - ...(args.taskSourceContext ? { linkedTaskSourceContext: args.taskSourceContext } : {}) - } - : {}), - ...(workspaceRunContext ? { workspaceRunContext } : {}), - name: seedName, - ...(displayName ? { displayName } : {}), - ...(identity.type === 'issue' && identity.number ? { linkedIssue: identity.number } : {}), - ...(identity.type === 'pr' && identity.number ? { linkedPR: identity.number } : {}), - ...(args.telemetrySource ? { telemetrySource: args.telemetrySource } : {}), - setupDecision: 'inherit', - agent: null, - pendingFirstAgentMessageRename: false, - note: '', - startupPlan: null, - quickPrompt: '', - quickTelemetry: null - } -} diff --git a/src/renderer/src/lib/input-quiet-scheduler.ts b/src/renderer/src/lib/input-quiet-scheduler.ts index 7e251ee6813..66ed6c6d90f 100644 --- a/src/renderer/src/lib/input-quiet-scheduler.ts +++ b/src/renderer/src/lib/input-quiet-scheduler.ts @@ -2,6 +2,7 @@ type InputQuietScheduleOptions = { delayMs: number quietMs: number idleTimeoutMs: number + maxWaitMs?: number } const INPUT_QUIET_EVENTS: readonly (keyof WindowEventMap)[] = [ @@ -14,7 +15,7 @@ const INPUT_QUIET_EVENTS: readonly (keyof WindowEventMap)[] = [ ] let listenersInstalled = false -let lastInputAt = 0 +let lastInputAt = Number.NEGATIVE_INFINITY function now(): number { return typeof performance !== 'undefined' ? performance.now() : Date.now() @@ -28,12 +29,22 @@ export function markInputQuietSchedulerInput(): void { recordInput() } +export function resetInputQuietSchedulerForTest(): void { + lastInputAt = Number.NEGATIVE_INFINITY +} + +export function hasInputBeenQuietFor(quietMs: number): boolean { + if (typeof window !== 'undefined') { + ensureInputQuietListeners(window) + } + return now() - lastInputAt >= quietMs +} + function ensureInputQuietListeners(targetWindow: Window): void { if (listenersInstalled) { return } listenersInstalled = true - lastInputAt = now() const options: AddEventListenerOptions = { capture: true, passive: true } for (const eventName of INPUT_QUIET_EVENTS) { targetWindow.addEventListener(eventName, recordInput, options) @@ -63,7 +74,7 @@ function cancelIdleCallback(targetWindow: Window, idleId: number): void { export function scheduleAfterInputQuiet( callback: () => void, - { delayMs, quietMs, idleTimeoutMs }: InputQuietScheduleOptions + { delayMs, quietMs, idleTimeoutMs, maxWaitMs }: InputQuietScheduleOptions ): () => void { if (typeof window === 'undefined') { const fallbackTimer = setTimeout(callback, delayMs) @@ -77,12 +88,34 @@ export function scheduleAfterInputQuiet( let delayTimer: number | null = null let quietTimer: number | null = null let idleId: number | null = null + let maxWaitTimer: number | null = null + + const clearScheduledWork = (): void => { + if (delayTimer !== null) { + targetWindow.clearTimeout(delayTimer) + delayTimer = null + } + if (quietTimer !== null) { + targetWindow.clearTimeout(quietTimer) + quietTimer = null + } + if (idleId !== null) { + cancelIdleCallback(targetWindow, idleId) + idleId = null + } + if (maxWaitTimer !== null) { + targetWindow.clearTimeout(maxWaitTimer) + maxWaitTimer = null + } + } const run = (): void => { - idleId = null - if (!cancelled) { - callback() + if (cancelled) { + return } + clearScheduledWork() + cancelled = true + callback() } const checkQuietWindow = (): void => { @@ -106,17 +139,12 @@ export function scheduleAfterInputQuiet( delayTimer = null checkQuietWindow() }, delayMs) + if (maxWaitMs !== undefined) { + maxWaitTimer = targetWindow.setTimeout(run, Math.max(0, maxWaitMs)) + } return () => { cancelled = true - if (delayTimer !== null) { - targetWindow.clearTimeout(delayTimer) - } - if (quietTimer !== null) { - targetWindow.clearTimeout(quietTimer) - } - if (idleId !== null) { - cancelIdleCallback(targetWindow, idleId) - } + clearScheduledWork() } } diff --git a/src/renderer/src/lib/pane-manager/pane-fit.test.ts b/src/renderer/src/lib/pane-manager/pane-fit.test.ts index 70e7d20526d..aa97a9a1887 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' -import { safeFit, safeFitAndThen } from './pane-fit' +import { readProposedPaneFitDimensions, safeFit, safeFitAndThen } from './pane-fit' import { applyOrDeferPaneMetricOptions } from './pane-metric-options-deferral' import { paneFitClientSizeChanged } from './pane-reveal-fit' +import { setFitOverride } from './mobile-fit-overrides' vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ recordRendererCrashBreadcrumb: vi.fn() @@ -502,4 +503,64 @@ describe('deferred metric flush inside safeFit', () => { expect(pane.terminal.options.fontSize).toBe(12) expect(pane.fitAddon.fit).toHaveBeenCalled() }) + + it('reports the post-metric grid used by the next safe fit', () => { + const terminal = { cols: 80, rows: 24, options: {} as Record } + const pane = { + id: 12, + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 500, height: 300 }) + }, + fitAddon: { + fit: vi.fn(), + proposeDimensions: vi.fn(() => + Number(terminal.options.fontSize ?? 10) >= 18 + ? { cols: 20, rows: 10 } + : { cols: 40, rows: 20 } + ) + } + } as unknown as ManagedPane + applyOrDeferPaneMetricOptions(pane, { fontSize: 18 }, false) + + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 20, rows: 10 }) + expect(pane.terminal.options.fontSize).toBe(18) + }) + + it('reports an owner override even while the pane is unmeasurable', () => { + const resize = vi.fn() + const pane = { + terminal: { cols: 80, rows: 24, options: {}, resize }, + container: { + dataset: { ptyId: 'pty-override' }, + getBoundingClientRect: () => ({ width: 0, height: 0 }) + }, + fitAddon: { proposeDimensions: vi.fn() } + } as unknown as ManagedPane + setFitOverride('pty-override', 'mobile-fit', 49, 20) + + try { + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 49, rows: 20 }) + expect(pane.fitAddon.proposeDimensions).not.toHaveBeenCalled() + expect(safeFit(pane)).toBe(false) + expect(resize).not.toHaveBeenCalled() + } finally { + setFitOverride('pty-override', 'desktop-fit', 0, 0) + } + }) + + it('flushes deferred metrics before reporting a measurable owner override', () => { + const pane = createMetricPane() + pane.container.dataset.ptyId = 'pty-metric-override' + applyOrDeferPaneMetricOptions(pane, { fontSize: 12 }, false) + setFitOverride('pty-metric-override', 'mobile-fit', 49, 20) + + try { + expect(readProposedPaneFitDimensions(pane)).toEqual({ cols: 49, rows: 20 }) + expect(pane.terminal.options.fontSize).toBe(12) + } finally { + setFitOverride('pty-metric-override', 'desktop-fit', 0, 0) + } + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts index c9807dbb10a..f7165c3aad6 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -55,6 +55,28 @@ export type SafeFitContinuationHandle = { export { readFitClientSize } from './pane-fit-client-size' +/** Returns the exact dimensions the next safe fit will use after any applicable + * deferred metrics. An owner override remains measurable while its pane is hidden. */ +export function readProposedPaneFitDimensions( + pane: ManagedPane +): { cols: number; rows: number } | null { + const ptyId = pane.container?.dataset?.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + const measurable = canMeasurePaneForFit(pane) + if (!measurable && !override) { + return null + } + // Re-check after the metric flush: larger cells can push a narrow pane below + // the fit floor even though it was measurable with the old metrics. + if (measurable && flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { + return null + } + if (override) { + return { cols: override.cols, rows: override.rows } + } + return getProposedPaneDimensions(pane) +} + function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { // Why: split reparent has its own delayed restore; restoring here can fight that timer. return !( @@ -70,10 +92,7 @@ function performSafeFit(pane: ManagedPane): boolean { return false } // Why here: metric options deferred while the pane was unmeasurable must land - // before this fit reads dimensions, or the fit pins cols/rows to stale metrics. - // Why re-check: the gate above measured with the old cell size — a large font - // jump on a narrow pane can drop it under the floor, and fit() would then pin - // the PTY at the tiny grid that floor exists to prevent. + // before this fit reads dimensions, then the fit floor must be checked again. if (flushDeferredPaneMetricOptions(pane) && !canMeasurePaneForFit(pane)) { return false } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 701482d16bb..26b19453f64 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -527,6 +527,12 @@ describe('openTerminal — addon and provider wiring', () => { deregisterCharacterJoiner: vi.fn((joinerId: number) => { events.push(`deregisterCharacterJoiner:${joinerId}`) }), + clearSelection: vi.fn(() => { + events.push('clearSelection') + }), + dispose: vi.fn(() => { + events.push('dispose') + }), unicode: unicodeProxy, buffer: { active: { cursorX: 0, cursorY: 0 } } } as unknown as ManagedPaneInternal['terminal'] @@ -607,6 +613,15 @@ describe('openTerminal — addon and provider wiring', () => { expect(pane.arabicShapingJoinerCleanup).toBeNull() }) + it('clears selection before disposing a remounted pane', () => { + const { pane, events } = createOpenTerminalHarness() + openTerminal(pane) + + disposePane(pane, new Map([[pane.id, pane]])) + + expect(events.indexOf('clearSelection')).toBeLessThan(events.indexOf('dispose')) + }) + // Why: a link streamed under a stationary pointer must re-linkify on the next // move; openTerminal wires the hover-cache reset and disposePane must detach it. it('installs the streamed-output linkifier hover reset and disposes it', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index e5695207a57..131a947312e 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -254,6 +254,12 @@ export function disposePane( } catch { /* ignore */ } + try { + // Drop renderer selection state before a recovery remount replaces the surface. + pane.terminal.clearSelection() + } catch { + /* ignore */ + } try { pane.terminal.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index c3f0c4df330..2a9d7d023ac 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -1721,4 +1721,168 @@ describe('pane terminal output scheduler', () => { vi.advanceTimersByTime(100) expect(throwing.write).toHaveBeenCalledTimes(1) }) + + describe('queue memory retention (STA-3567)', () => { + // Why 2 MB: comfortably above BACKGROUND_CHUNK_CHARS (16 K), so every drain leaves a residual slice. + const CHUNK_CHARS = 2 * 1024 * 1024 + const TERMINALS = 8 + + function collect(): number { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('global.gc unavailable - config/vitest.config.ts must pass --expose-gc') + } + gc() + gc() + return process.memoryUsage().heapUsed + } + + it('releases chunks it has already drained past', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why many medium chunks: compactConsumedChunks only splices once chunkIndex reaches 64, + // so below that the drained slots stay in the array and must be cleared individually. + const CHUNKS_PER_TERMINAL = 40 + const CHUNK = 48 * 1024 + const SINKS = 4 + + let writtenChars = 0 + const terminals = Array.from({ length: SINKS }, () => ({ + write: (data: string, callback?: () => void) => { + writtenChars += data.length + callback?.() + } + })) + + const baseline = collect() + + for (const [index, terminal] of terminals.entries()) { + for (let chunk = 0; chunk < CHUNKS_PER_TERMINAL; chunk += 1) { + writeTerminalOutput( + terminal, + String.fromCharCode(65 + index) + + String.fromCharCode(48 + (chunk % 10)) + + 'q'.repeat(CHUNK - 2), + { foreground: false, latencySensitive: false } + ) + } + } + + const debug = ( + globalThis as { + __terminalOutputSchedulerDebug?: { snapshot: () => { queuedChars: number } } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('scheduler debug API unavailable') + } + + const TAIL_CHARS = 64 * 1024 + let ticks = 0 + while (debug.snapshot().queuedChars > TAIL_CHARS && ticks < 40000) { + vi.advanceTimersByTime(4) + ticks += 1 + } + + const queuedChars = debug.snapshot().queuedChars + const retainedBytes = collect() - baseline + + // Sanity: the queues really drained down, and none hit the backlog cap. + expect(writtenChars).toBeGreaterThan(SINKS * CHUNKS_PER_TERMINAL * CHUNK * 0.9) + expect(queuedChars).toBeGreaterThan(0) + expect(queuedChars).toBeLessThanOrEqual(TAIL_CHARS) + + // The defect: ~39 drained-past slots per terminal kept their strings alive uncharged. + expect(retainedBytes).toBeLessThan(2 * 1024 * 1024) + }) + + it('does not pin the parent when a producer enqueues a slice', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why a slice: agent-status-osc.ts and the restore-overlap trims hand the scheduler + // strings cut from a much larger buffer. The queue must own its copy rather than + // trusting every producer to flatten first (STA-3567 review round 2). + const KEEP_CHARS = 64 * 1024 + const sinks = Array.from({ length: TERMINALS }, () => ({ + write: (_data: string, callback?: () => void) => callback?.() + })) + + const baseline = collect() + + for (const [index, sink] of sinks.entries()) { + const parent = String.fromCharCode(65 + index) + 'q'.repeat(CHUNK_CHARS - 1) + writeTerminalOutput(sink, parent.slice(parent.length - KEEP_CHARS), { + foreground: false, + latencySensitive: false + }) + } + + const retainedBytes = collect() - baseline + + // 8 x 64 KB of real payload must not keep 8 x 2 MB of parents alive. + expect(retainedBytes).toBeLessThan(4 * 1024 * 1024) + }) + + it('drops the parent chunk once only a small tail is still queued', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + + // Why a hand-rolled write: vi.fn() retains every argument in mock.calls, which would + // dominate the measurement with the very bytes the queue is supposed to have released. + let writtenChars = 0 + const makeSink = (): { write: (data: string, callback?: () => void) => void } => ({ + write: (data: string, callback?: () => void) => { + writtenChars += data.length + callback?.() + } + }) + const terminals = Array.from({ length: TERMINALS }, makeSink) + + // Why baseline BEFORE enqueueing: the queue owns a copy of every chunk, so a baseline + // taken after enqueue already contains the parents this test must prove get released, + // and the assertion would hold even with residual flattening disabled. + const baseline = collect() + + for (const [index, terminal] of terminals.entries()) { + // Built and dropped inline so only the queue's own copy stays reachable. + writeTerminalOutput( + terminal, + String.fromCharCode(65 + index) + 'q'.repeat(CHUNK_CHARS - 1), + { foreground: false, latencySensitive: false } + ) + } + + const debug = ( + globalThis as { + __terminalOutputSchedulerDebug?: { snapshot: () => { queuedChars: number } } + } + ).__terminalOutputSchedulerDebug + if (!debug) { + throw new Error('scheduler debug API unavailable') + } + + // Why stop early: the leak is what a PARTIALLY drained queue pins. Draining to empty + // frees the chunks either way, so a full drain cannot observe the defect. + const TAIL_CHARS = 64 * 1024 + let ticks = 0 + while (debug.snapshot().queuedChars > TAIL_CHARS && ticks < 20000) { + vi.advanceTimersByTime(4) + ticks += 1 + } + + const queuedChars = debug.snapshot().queuedChars + const retainedBytes = collect() - baseline + + // Sanity: nearly everything drained, but a real tail is still queued - otherwise + // there is no residual slice to hold a parent chunk and the measurement is meaningless. + expect(writtenChars).toBeGreaterThan(TERMINALS * CHUNK_CHARS * 0.9) + expect(queuedChars).toBeGreaterThan(0) + expect(queuedChars).toBeLessThanOrEqual(TAIL_CHARS) + + // The defect: those few queued KB pinned 8 x 2 MB of chunks (~16 MB). + expect(retainedBytes).toBeLessThan(4 * 1024 * 1024) + }) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 8acb768e635..db826781812 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -7,6 +7,7 @@ import { } from './pane-terminal-foreground-render-settle' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { flattenRetainedSlice } from '@/lib/flatten-retained-slice' import { discardInFlightTerminalOutputAckCredits, registerTerminalOutputAckCredits @@ -49,6 +50,8 @@ type WriteTerminalOutputOptions = { type QueueChunk = { data: string + // Tracks the backing data still reachable through this queue slot. + retainedChars: number foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean @@ -631,6 +634,16 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { data += chunk.data remaining -= chunk.data.length entry.queuedChars -= chunk.data.length + // Clear drained slots before the 64-chunk compaction can release them. + entry.chunks[entry.chunkIndex] = { + data: '', + retainedChars: 0, + foreground: chunk.foreground, + forceForegroundRefresh: false, + followupForegroundRefresh: false, + shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + stripTransientCursorShows: false + } entry.chunkIndex += 1 if (chunk.onParsed) { parsedCallbacks.push(chunk.onParsed) @@ -642,9 +655,13 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { } data += chunk.data.slice(0, remaining) + const residual = chunk.data.slice(remaining) + // Geometric flattening bounds retained parents while keeping total copy work linear. + const flatten = residual.length * 2 <= chunk.retainedChars entry.chunks[entry.chunkIndex] = { ...chunk, - data: chunk.data.slice(remaining) + data: flatten ? flattenRetainedSlice(residual) : residual, + retainedChars: flatten ? residual.length : chunk.retainedChars } entry.queuedChars -= remaining remaining = 0 @@ -719,8 +736,11 @@ function enqueueChunk( ackCredit?: () => void } ): void { + // Own queued data so producer slices cannot pin larger PTY or restore buffers. + const owned = flattenRetainedSlice(data) entry.chunks.push({ - data, + data: owned, + retainedChars: owned.length, foreground: options?.foreground === true, forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, @@ -783,6 +803,7 @@ function replaceBacklogWithWarning( entry.chunks = [ { data: warning, + retainedChars: warning.length, foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, diff --git a/src/renderer/src/lib/paste-payload-metadata.ts b/src/renderer/src/lib/paste-payload-metadata.ts index be63b7bc558..f28e49b24f2 100644 --- a/src/renderer/src/lib/paste-payload-metadata.ts +++ b/src/renderer/src/lib/paste-payload-metadata.ts @@ -1,4 +1,5 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' +import { getUtf8ByteLengthForCodePoint } from '../../../shared/utf8-byte-limits' export type PastePayloadMetadata = { byteLength: number @@ -138,16 +139,3 @@ function isPasteControlSequenceCodePoint(codePoint: number): boolean { codePoint === 0x7f ) } - -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} diff --git a/src/renderer/src/lib/pr-bot-author-overrides.ts b/src/renderer/src/lib/pr-bot-author-overrides.ts index cf8ade2fea7..714dd0b6c01 100644 --- a/src/renderer/src/lib/pr-bot-author-overrides.ts +++ b/src/renderer/src/lib/pr-bot-author-overrides.ts @@ -2,8 +2,11 @@ import { useMemo } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' -import { createBotAuthorOverrideSet, normalizePRCommentAuthorLogin } from './pr-comment-audience' -import { MAX_PR_BOT_AUTHOR_OVERRIDES } from '../../../shared/pr-bot-author-overrides' +import { + createBotAuthorOverrideSet, + MAX_PR_BOT_AUTHOR_OVERRIDES, + normalizePRCommentAuthorLogin +} from '../../../shared/pr-bot-author-overrides' let overrideUpdateQueue = Promise.resolve() diff --git a/src/renderer/src/lib/pr-comment-action-state.test.ts b/src/renderer/src/lib/pr-comment-action-state.test.ts index 628d8c5ba2a..48f99ab1e8c 100644 --- a/src/renderer/src/lib/pr-comment-action-state.test.ts +++ b/src/renderer/src/lib/pr-comment-action-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { PRComment } from '../../../shared/types' -import { groupPRComments } from './pr-comment-groups' +import { groupPRComments } from '../../../shared/pr-comment-groups' import { getPRCommentGroupActionState, isPRCommentGroupQueueableForAI, diff --git a/src/renderer/src/lib/pr-comment-action-state.ts b/src/renderer/src/lib/pr-comment-action-state.ts index e75d7c09cba..9073c82a86f 100644 --- a/src/renderer/src/lib/pr-comment-action-state.ts +++ b/src/renderer/src/lib/pr-comment-action-state.ts @@ -1,5 +1,8 @@ -import type { PRCommentGroup } from './pr-comment-groups' -import { getPRCommentGroupId, getPRCommentGroupRoot } from './pr-comment-groups' +import { + getPRCommentGroupId, + getPRCommentGroupRoot, + type PRCommentGroup +} from '../../../shared/pr-comment-groups' /** How a comment group should read in the PR sidebar triage UI. */ export type PRCommentGroupActionState = 'open' | 'conversation' | 'resolved' diff --git a/src/renderer/src/lib/pr-comment-audience-labels.ts b/src/renderer/src/lib/pr-comment-audience-labels.ts new file mode 100644 index 00000000000..50e307cc1c7 --- /dev/null +++ b/src/renderer/src/lib/pr-comment-audience-labels.ts @@ -0,0 +1,22 @@ +import type { PRCommentAudienceFilter } from '../../../shared/pr-comment-audience' +import { createLocalizedCatalog } from '@/i18n/localized-catalog' +import { translate } from '@/i18n/i18n' + +export const getPrCommentAudienceFilters = createLocalizedCatalog( + (): { value: PRCommentAudienceFilter; label: string }[] => [ + { value: 'all', label: translate('auto.lib.pr.comment.audience.27ce73211c', 'All') }, + { value: 'human', label: translate('auto.lib.pr.comment.audience.a7150a17bc', 'Humans') }, + { value: 'bot', label: translate('auto.lib.pr.comment.audience.64deee36a9', 'Bots') } + ] +) + +export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { + switch (filter) { + case 'bot': + return translate('auto.lib.pr.comment.audience.empty.bot', 'No bot comments.') + case 'human': + return translate('auto.lib.pr.comment.audience.empty.human', 'No human comments.') + case 'all': + return translate('auto.lib.pr.comment.audience.empty.all', 'No comments yet.') + } +} diff --git a/src/renderer/src/lib/pr-comment-audience.ts b/src/renderer/src/lib/pr-comment-audience.ts deleted file mode 100644 index dc731095277..00000000000 --- a/src/renderer/src/lib/pr-comment-audience.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { PRComment } from '../../../shared/types' -import { - createBotAuthorOverrideSet, - normalizePRCommentAuthorLogin -} from '../../../shared/pr-bot-author-overrides' -import { createLocalizedCatalog } from '@/i18n/localized-catalog' -import { translate } from '@/i18n/i18n' - -export { createBotAuthorOverrideSet, normalizePRCommentAuthorLogin } - -export type PRCommentAudienceFilter = 'all' | 'human' | 'bot' - -export const getPrCommentAudienceFilters = createLocalizedCatalog( - (): { value: PRCommentAudienceFilter; label: string }[] => [ - { value: 'all', label: translate('auto.lib.pr.comment.audience.27ce73211c', 'All') }, - { value: 'human', label: translate('auto.lib.pr.comment.audience.a7150a17bc', 'Humans') }, - { value: 'bot', label: translate('auto.lib.pr.comment.audience.64deee36a9', 'Bots') } - ] -) - -const BOT_LOGIN_SUFFIX = '[bot]' -const AUTOMATION_LOGIN_PATTERNS = [ - /bot$/i, - /-bot$/i, - /\bbot\b/i, - /automation/i, - /actions/i, - /renovate/i, - /dependabot/i -] -// Why: some AI/code-review services use regular GitHub user accounts rather -// than GitHub Apps, so GitHub metadata can report them as users. Keep this -// central so the Tasks dialog and PR sidebar classify comments identically. -const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [ - 'chatgpt-codex-connector', - 'codex-connector', - 'qodo', - 'coderabbit', - 'codium', - 'sonarcloud', - 'sonarqube', - 'sourcery-ai', - 'deepsource', - 'snyk', - 'codecov', - 'greptile', - 'ellipsis', - 'graphite-app', - 'reviewer-gpt', - '-reviewer' -] - -export function isBotPRComment( - comment: PRComment, - botAuthorOverrides?: ReadonlySet -): boolean { - const author = comment.author.trim() - const normalized = normalizePRCommentAuthorLogin(author) - if (botAuthorOverrides?.has(normalized)) { - return true - } - if (comment.isBot === true) { - return true - } - if (normalized.endsWith(BOT_LOGIN_SUFFIX)) { - return true - } - if (KNOWN_AUTOMATION_LOGIN_SUBSTRINGS.some((needle) => normalized.includes(needle))) { - return true - } - return AUTOMATION_LOGIN_PATTERNS.some((pattern) => pattern.test(author)) -} - -export function getPRCommentAudienceCounts( - comments: PRComment[], - botAuthorOverrides?: ReadonlySet -): Record { - const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length - return { - all: comments.length, - human: comments.length - bot, - bot - } -} - -export function filterPRCommentsByAudience( - comments: PRComment[], - filter: PRCommentAudienceFilter, - botAuthorOverrides?: ReadonlySet -): PRComment[] { - if (filter === 'bot') { - return comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)) - } - if (filter === 'human') { - return comments.filter((comment) => !isBotPRComment(comment, botAuthorOverrides)) - } - return comments -} - -export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { - switch (filter) { - case 'bot': - return translate('auto.lib.pr.comment.audience.empty.bot', 'No bot comments.') - case 'human': - return translate('auto.lib.pr.comment.audience.empty.human', 'No human comments.') - case 'all': - return translate('auto.lib.pr.comment.audience.empty.all', 'No comments yet.') - } -} diff --git a/src/renderer/src/lib/pr-comment-groups.ts b/src/renderer/src/lib/pr-comment-groups.ts deleted file mode 100644 index d9fcfddf2b3..00000000000 --- a/src/renderer/src/lib/pr-comment-groups.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { PRComment } from '../../../shared/types' - -export const PR_COMMENT_RESOLVED_CONTAINER_CLASS = 'opacity-50' -export const PR_COMMENT_RESOLVED_AUTHOR_CLASS = 'text-muted-foreground' -export const PR_COMMENT_OPEN_AUTHOR_CLASS = 'text-foreground' - -export type PRCommentGroup = - | { kind: 'standalone'; comment: PRComment } - | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } - -export function groupPRComments(comments: PRComment[]): PRCommentGroup[] { - const threadMap = new Map() - const groupsByFirstComment = new Map() - - for (const comment of comments) { - if (!comment.threadId) { - groupsByFirstComment.set(comment, { kind: 'standalone', comment }) - continue - } - const existing = threadMap.get(comment.threadId) - if (existing) { - existing.replies.push(comment) - continue - } - threadMap.set(comment.threadId, { root: comment, replies: [] }) - } - - const emitted = new Set() - const groups: PRCommentGroup[] = [] - for (const comment of comments) { - if (!comment.threadId) { - const group = groupsByFirstComment.get(comment) - if (group) { - groups.push(group) - } - continue - } - if (emitted.has(comment.threadId)) { - continue - } - emitted.add(comment.threadId) - const thread = threadMap.get(comment.threadId) - if (thread) { - groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) - } - } - return groups -} - -export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] { - return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment] -} - -export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { - return group.kind === 'thread' ? group.root : group.comment -} - -export function getPRCommentGroupCount(group: PRCommentGroup): number { - return getPRCommentGroupComments(group).length -} - -export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { - return getPRCommentGroupRoot(group).isResolved === true -} - -export function getPRCommentGroupId(group: PRCommentGroup): string { - return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` -} diff --git a/src/renderer/src/lib/pr-comment-resolution-classes.ts b/src/renderer/src/lib/pr-comment-resolution-classes.ts new file mode 100644 index 00000000000..2caa89efd1a --- /dev/null +++ b/src/renderer/src/lib/pr-comment-resolution-classes.ts @@ -0,0 +1,3 @@ +export const PR_COMMENT_RESOLVED_CONTAINER_CLASS = 'opacity-50' +export const PR_COMMENT_RESOLVED_AUTHOR_CLASS = 'text-muted-foreground' +export const PR_COMMENT_OPEN_AUTHOR_CLASS = 'text-foreground' diff --git a/src/renderer/src/lib/preserved-branch-cleanup.ts b/src/renderer/src/lib/preserved-branch-cleanup.ts new file mode 100644 index 00000000000..b13830391aa --- /dev/null +++ b/src/renderer/src/lib/preserved-branch-cleanup.ts @@ -0,0 +1,5 @@ +export { + preservedBranchCleanupKey, + preservedBranchCleanupScopeKey, + type PreservedBranchCleanup +} from '../../../shared/preserved-branch-cleanup' diff --git a/src/renderer/src/lib/primary-selection-paste.ts b/src/renderer/src/lib/primary-selection-paste.ts index 93e237378d0..9dcf2c73097 100644 --- a/src/renderer/src/lib/primary-selection-paste.ts +++ b/src/renderer/src/lib/primary-selection-paste.ts @@ -1,4 +1,5 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../shared/utf8-byte-limits' import { isPrimarySelectionTextControl } from './primary-selection-capture' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, @@ -101,39 +102,6 @@ function isContentEditablePasteTargetAvailable( return target.isConnected && target.isContentEditable && (canContinue?.(target) ?? true) } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function getContentEditableInsertionRange(target: HTMLElement): Range | null { const selection = target.ownerDocument.getSelection() if (!selection || selection.rangeCount === 0) { @@ -180,7 +148,7 @@ async function pasteLargeTextIntoContentEditable( } return false } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) range = insertContentEditableChunk(target, range, text.slice(textIndex, nextIndex)) textIndex = nextIndex if (textIndex < text.length) { diff --git a/src/renderer/src/lib/terminal-quick-command-project-scope.test.ts b/src/renderer/src/lib/terminal-quick-command-project-scope.test.ts new file mode 100644 index 00000000000..bfde748ee85 --- /dev/null +++ b/src/renderer/src/lib/terminal-quick-command-project-scope.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' +import type { ProjectHostSetup, Repo, TerminalQuickCommand } from '../../../shared/types' +import { terminalQuickCommandMatchesWorkspaceProject } from './terminal-quick-command-project-scope' + +type ScopeSetup = Pick + +const LOCAL_REPO_ID = '32a0226d-9f33-42e8-8b7b-24867dea06d4' +const WINDOWS_REPO_ID = 'a0a2b4a4-1bff-494c-b005-d77918abc6a7' + +function command(repoId: string): TerminalQuickCommand { + return { + id: 'test-orca', + label: 'Test Orca', + action: 'terminal-command', + command: 'pnpm test', + appendEnter: true, + scope: { type: 'repo', repoId } + } +} + +function setup( + hostId: ExecutionHostId, + repoId: string, + projectId = 'github:stablyai/orca' +): ScopeSetup { + return { hostId, projectId, repoId } +} + +describe('terminalQuickCommandMatchesWorkspaceProject', () => { + it.each(['runtime:windows-2', 'ssh:windows-2'])( + 'shows a local repo command in the same project on %s', + (targetHostId) => { + const projectHostSetups = [ + setup('local', LOCAL_REPO_ID), + setup(targetHostId, WINDOWS_REPO_ID) + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId, + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + } + ) + + it('matches the user topology from host-fetched repo catalogs', () => { + const repo = (id: string, path: string, executionHostId?: ExecutionHostId): Repo => ({ + id, + path, + displayName: 'orca', + badgeColor: '#737373', + addedAt: 100, + kind: 'git', + gitRemoteIdentity: { + canonicalKey: 'github.com/stablyai/orca', + remoteName: 'origin', + remoteUrl: 'git@github.com:stablyai/orca.git' + }, + ...(executionHostId ? { executionHostId } : {}) + }) + const projectHostSetups = projectHostSetupProjectionFromRepos([ + repo(LOCAL_REPO_ID, '/Users/alice/orca'), + repo(WINDOWS_REPO_ID, 'C:\\Users\\alice\\orca', 'runtime:windows-2') + ]).setups + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + }) + + it('does not show a repo command for a different known project', () => { + const projectHostSetups = [ + setup('local', LOCAL_REPO_ID), + setup('runtime:windows-2', WINDOWS_REPO_ID, 'github:stablyai/other') + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(false) + }) + + it('does not trust colliding cross-host repo ids when both projects are known', () => { + const projectHostSetups = [ + setup('local', LOCAL_REPO_ID), + setup('runtime:windows-2', LOCAL_REPO_ID, 'github:stablyai/other') + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: LOCAL_REPO_ID + }) + ).toBe(false) + }) + + it('does not infer a missing target setup from another host repo id', () => { + const projectHostSetups = [setup('local', LOCAL_REPO_ID), setup('ssh:builder', WINDOWS_REPO_ID)] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(false) + }) + + it('preserves exact repo-id matching when legacy project metadata is unavailable', () => { + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups: [], + targetHostId: 'runtime:legacy', + targetRepoId: LOCAL_REPO_ID + }) + ).toBe(true) + }) + + it('resolves a settings-owned command whose repo executes through SSH', () => { + const projectHostSetups = [ + setup('ssh:builder', LOCAL_REPO_ID), + setup('runtime:windows-2', WINDOWS_REPO_ID) + ] + + expect( + terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), { + commandHostId: 'local', + projectHostSetups, + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + }) + + it('keeps a remote-host repo command visible on its own repo', () => { + expect( + terminalQuickCommandMatchesWorkspaceProject(command(WINDOWS_REPO_ID), { + commandHostId: 'runtime:windows-2', + projectHostSetups: [], + targetHostId: 'runtime:windows-2', + targetRepoId: WINDOWS_REPO_ID + }) + ).toBe(true) + }) + + it('keeps global commands visible without a repo and repo commands hidden', () => { + const globalCommand: TerminalQuickCommand = { + ...command(LOCAL_REPO_ID), + scope: { type: 'global' } + } + const context = { + commandHostId: 'local' as const, + projectHostSetups: [], + targetHostId: 'runtime:folder' as const, + targetRepoId: null + } + + expect(terminalQuickCommandMatchesWorkspaceProject(globalCommand, context)).toBe(true) + expect(terminalQuickCommandMatchesWorkspaceProject(command(LOCAL_REPO_ID), context)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/terminal-quick-command-project-scope.ts b/src/renderer/src/lib/terminal-quick-command-project-scope.ts new file mode 100644 index 00000000000..7b48a7b3ada --- /dev/null +++ b/src/renderer/src/lib/terminal-quick-command-project-scope.ts @@ -0,0 +1,73 @@ +import type { ExecutionHostId } from '../../../shared/execution-host' +import { getTerminalQuickCommandScope } from '../../../shared/terminal-quick-commands' +import type { ProjectHostSetup, TerminalQuickCommand } from '../../../shared/types' + +type TerminalQuickCommandProjectContext = { + commandHostId: ExecutionHostId + projectHostSetups: readonly Pick[] + targetHostId: ExecutionHostId + targetRepoId: string | null +} + +type ProjectResolution = + | { kind: 'resolved'; projectId: string } + | { kind: 'ambiguous' } + | { kind: 'unknown' } + +function resolveProject( + setups: TerminalQuickCommandProjectContext['projectHostSetups'], + hostId: ExecutionHostId, + repoId: string, + allowAnyHostFallback: boolean +): ProjectResolution { + const hostMatches = setups.filter((setup) => setup.hostId === hostId && setup.repoId === repoId) + // Why: commands belong to a settings host, while its repo can execute through that host's SSH. + const candidates = + hostMatches.length > 0 || !allowAnyHostFallback + ? hostMatches + : setups.filter((s) => s.repoId === repoId) + const projectIds = new Set(candidates.map((setup) => setup.projectId)) + if (projectIds.size === 0) { + return { kind: 'unknown' } + } + if (projectIds.size > 1) { + return { kind: 'ambiguous' } + } + return { kind: 'resolved', projectId: [...projectIds][0] } +} + +export function terminalQuickCommandMatchesWorkspaceProject( + command: TerminalQuickCommand, + context: TerminalQuickCommandProjectContext +): boolean { + const scope = getTerminalQuickCommandScope(command) + if (scope.type === 'global') { + return true + } + if (context.targetRepoId === null) { + return false + } + if (context.commandHostId === context.targetHostId && scope.repoId === context.targetRepoId) { + return true + } + + const commandProject = resolveProject( + context.projectHostSetups, + context.commandHostId, + scope.repoId, + true + ) + const targetProject = resolveProject( + context.projectHostSetups, + context.targetHostId, + context.targetRepoId, + false + ) + if (commandProject.kind === 'ambiguous' || targetProject.kind === 'ambiguous') { + return false + } + if (commandProject.kind === 'resolved' && targetProject.kind === 'resolved') { + return commandProject.projectId === targetProject.projectId + } + return scope.repoId === context.targetRepoId +} diff --git a/src/renderer/src/lib/text-control-paste.test.ts b/src/renderer/src/lib/text-control-paste.test.ts index f5001465e36..942bd92c69b 100644 --- a/src/renderer/src/lib/text-control-paste.test.ts +++ b/src/renderer/src/lib/text-control-paste.test.ts @@ -3,7 +3,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PASTE_PAYLOAD_CORPUS } from './paste-payload-corpus' import { - getTextControlPasteByteLength, measureTextControlPasteByteLength, pasteTextIntoTextControl, shouldHandleTextControlPaste @@ -251,7 +250,7 @@ describe('text control paste', () => { }) it('uses utf-8 byte thresholds for non-ascii text', () => { - expect(getTextControlPasteByteLength('a😀é')).toBe(7) + expect(measureTextControlPasteByteLength('a😀é').byteLength).toBe(7) expect(shouldHandleTextControlPaste('😀😀', { directMaxBytes: 7 })).toBe(true) expect(shouldHandleTextControlPaste('abc', { directMaxBytes: 7 })).toBe(false) expect(shouldHandleTextControlPaste('', { directMaxBytes: 0 })).toBe(false) @@ -263,6 +262,8 @@ describe('text control paste', () => { }) expect(measurement).toEqual({ byteLength: 8, exceededLimit: true }) - expect(measurement.byteLength).toBeLessThan(getTextControlPasteByteLength('😀'.repeat(100))) + expect(measurement.byteLength).toBeLessThan( + measureTextControlPasteByteLength('😀'.repeat(100)).byteLength + ) }) }) diff --git a/src/renderer/src/lib/text-control-paste.ts b/src/renderer/src/lib/text-control-paste.ts index a1733044a9f..a3a19028953 100644 --- a/src/renderer/src/lib/text-control-paste.ts +++ b/src/renderer/src/lib/text-control-paste.ts @@ -1,4 +1,5 @@ import { yieldToEventLoop } from '../../../shared/event-loop-yield' +import { getUtf8ChunkEndIndex } from '../../../shared/utf8-byte-limits' import { createTextControlCancelledResult, createTextControlPastedResult, @@ -34,19 +35,6 @@ export type { TextControlPasteSource } from './text-control-paste-model' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function measureTextControlPasteByteLength( text: string, options: { stopAfterBytes?: number } = {} @@ -55,10 +43,6 @@ export function measureTextControlPasteByteLength( return { byteLength, exceededLimit } } -export function getTextControlPasteByteLength(text: string): number { - return measureTextControlPasteByteLength(text).byteLength -} - export async function measureTextControlPasteByteLengthWithYield( text: string, options: { @@ -129,26 +113,6 @@ export function shouldHandleTextControlPaste( return measurement.exceededLimit || measurement.byteLength > directMaxBytes } -function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number): number { - let byteLength = 0 - let index = startIndex - - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextByteLength = getCodePointUtf8ByteLength(codePoint) - - if (byteLength > 0 && byteLength + nextByteLength > maxBytes) { - break - } - - byteLength += nextByteLength - index += codeUnitLength - } - - return index -} - function dispatchTextControlInputEvent( target: HTMLInputElement | HTMLTextAreaElement, data: string | null, @@ -283,7 +247,7 @@ export async function pasteTextIntoTextControl( }) } - const nextIndex = getNextChunkBoundary(text, textIndex, chunkMaxBytes) + const nextIndex = getUtf8ChunkEndIndex(text, textIndex, chunkMaxBytes) const chunk = text.slice(textIndex, nextIndex) const caret = target.selectionStart ?? target.value.length target.setRangeText(chunk, caret, caret, 'end') diff --git a/src/renderer/src/runtime/runtime-file-search-bounds.ts b/src/renderer/src/runtime/runtime-file-search-bounds.ts index 1301fbe83e4..b7823f783fd 100644 --- a/src/renderer/src/runtime/runtime-file-search-bounds.ts +++ b/src/renderer/src/runtime/runtime-file-search-bounds.ts @@ -1,4 +1,5 @@ import type { SearchOptions, SearchResult } from '../../../shared/types' +import { isUtf8ByteLengthWithinLimit } from '../../../shared/utf8-byte-limits' export const RUNTIME_FILE_SEARCH_TEXT_MAX_BYTES = 8 * 1024 @@ -8,35 +9,11 @@ export function createEmptyRuntimeFileSearchResult(): SearchResult { return { files: [], totalMatches: 0, truncated: false } } -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function isRuntimeFileSearchTextWithinLimit( text: string, maxBytes = RUNTIME_FILE_SEARCH_TEXT_MAX_BYTES ): boolean { - let byteLength = 0 - for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return false - } - if (codePoint > 0xffff) { - index += 1 - } - } - return true + return isUtf8ByteLengthWithinLimit(text, maxBytes) } export function getRuntimeFileSearchRejectedField( diff --git a/src/renderer/src/runtime/runtime-provider-search-bounds.ts b/src/renderer/src/runtime/runtime-provider-search-bounds.ts index d41441344c1..b231cca356d 100644 --- a/src/renderer/src/runtime/runtime-provider-search-bounds.ts +++ b/src/renderer/src/runtime/runtime-provider-search-bounds.ts @@ -1,17 +1,6 @@ -export const RUNTIME_PROVIDER_SEARCH_QUERY_MAX_BYTES = 8 * 1024 +import { isUtf8ByteLengthWithinLimit } from '../../../shared/utf8-byte-limits' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} +export const RUNTIME_PROVIDER_SEARCH_QUERY_MAX_BYTES = 8 * 1024 export function isRuntimeProviderSearchQueryWithinLimit( query: string | null | undefined, @@ -20,16 +9,5 @@ export function isRuntimeProviderSearchQueryWithinLimit( if (query === null || query === undefined) { return true } - let byteLength = 0 - for (let index = 0; index < query.length; index += 1) { - const codePoint = query.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return false - } - if (codePoint > 0xffff) { - index += 1 - } - } - return true + return isUtf8ByteLengthWithinLimit(query, maxBytes) } diff --git a/src/renderer/src/runtime/runtime-repo-search-bounds.ts b/src/renderer/src/runtime/runtime-repo-search-bounds.ts index 36480657568..9717f3fe906 100644 --- a/src/renderer/src/runtime/runtime-repo-search-bounds.ts +++ b/src/renderer/src/runtime/runtime-repo-search-bounds.ts @@ -1,32 +1,10 @@ -export const RUNTIME_REPO_REF_SEARCH_QUERY_MAX_BYTES = 2 * 1024 +import { isUtf8ByteLengthWithinLimit } from '../../../shared/utf8-byte-limits' -function getCodePointUtf8ByteLength(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} +export const RUNTIME_REPO_REF_SEARCH_QUERY_MAX_BYTES = 2 * 1024 export function isRuntimeRepoRefSearchQueryWithinLimit( query: string, maxBytes = RUNTIME_REPO_REF_SEARCH_QUERY_MAX_BYTES ): boolean { - let byteLength = 0 - for (let index = 0; index < query.length; index += 1) { - const codePoint = query.codePointAt(index) ?? 0 - byteLength += getCodePointUtf8ByteLength(codePoint) - if (byteLength > maxBytes) { - return false - } - if (codePoint > 0xffff) { - index += 1 - } - } - return true + return isUtf8ByteLengthWithinLimit(query, maxBytes) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts new file mode 100644 index 00000000000..317065d7c81 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync-mirror-identity.test.ts @@ -0,0 +1,389 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { createStore } from 'zustand/vanilla' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import type { BrowserCertificateFailure } from '../../../shared/types' +import { + applyWebSessionTabsSnapshot, + applyWebSessionTabsSnapshots, + applyFreshWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from './web-session-tabs-sync' + +const ENVIRONMENT_ID = 'web-env-1' +const NOW = 1_700_000_000_000 +const WORKTREE_A = 'repo::/worktree-a' +const WORKTREE_B = 'repo::/worktree-b' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const HOST_TAB_ID = 'host-tab-1' +const MIRRORED_TAB_ID = toWebTerminalSurfaceTabId(HOST_TAB_ID) + +const CERTIFICATE_FAILURE: BrowserCertificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'host-browser-page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 +} + +function makeState(overrides: Partial = {}): WebSessionTabsSyncState { + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + activeWorktreeId: null, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: {}, + sortEpoch: 0, + ...overrides + } +} + +function makeSnapshot( + worktree: string, + tabs: RuntimeMobileSessionTabsResult['tabs'], + activeTabType: RuntimeMobileSessionTabsResult['activeTabType'] = 'terminal' +): RuntimeMobileSessionTabsResult { + return { + worktree, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: `group:${worktree}`, + activeTabId: tabs.find((tab) => tab.isActive)?.id ?? null, + activeTabType, + tabs + } +} + +function makeTerminalSnapshot( + options: { + worktree?: string + title?: string + terminal?: string | null + } = {} +): RuntimeMobileSessionTabsResult { + const worktree = options.worktree ?? WORKTREE_A + const terminal = options.terminal === undefined ? 'terminal-1' : options.terminal + return makeSnapshot(worktree, [ + { + type: 'terminal', + id: `${HOST_TAB_ID}::${LEAF_ID}`, + parentTabId: HOST_TAB_ID, + leafId: LEAF_ID, + title: options.title ?? 'host shell', + isActive: true, + ...(terminal === null + ? { status: 'pending-handle' as const, terminal: null } + : { status: 'ready' as const, terminal }) + } + ]) +} + +function makeBrowserSnapshot( + options: { + worktree?: string + pageId?: string + tabId?: string + workspaceId?: string + title?: string + url?: string + loading?: boolean + certificateFailure?: BrowserCertificateFailure | null + } = {} +): RuntimeMobileSessionTabsResult { + const worktree = options.worktree ?? WORKTREE_A + const pageId = options.pageId ?? 'host-browser-page-1' + const certificateFailure = + options.certificateFailure === undefined + ? { ...CERTIFICATE_FAILURE, browserPageId: pageId } + : options.certificateFailure + ? { ...options.certificateFailure } + : null + return makeSnapshot( + worktree, + [ + { + type: 'browser', + id: options.tabId ?? 'host-browser-tab', + browserWorkspaceId: options.workspaceId ?? 'host-browser-workspace', + browserPageId: pageId, + title: options.title ?? 'Example Domain', + url: options.url ?? 'https://example.com/', + loading: options.loading ?? false, + canGoBack: false, + canGoForward: false, + certificateFailure, + isActive: true + } + ], + 'browser' + ) +} + +function applySnapshot( + state: WebSessionTabsSyncState, + snapshot: RuntimeMobileSessionTabsResult, + now = NOW +): WebSessionTabsSyncState { + const patch = applyWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, now) + return patch === state ? state : { ...state, ...patch } +} + +describe('remote mirror resource identity', () => { + beforeEach(() => { + resetWebSessionTabsSnapshotFreshnessForTests() + }) + + it('keeps PTY arrays and layouts stable on an exact terminal replay', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const ptyIds = state.ptyIdsByTabId + const layouts = state.terminalLayoutsByTabId + + const replayed = applyWebSessionTabsSnapshot( + state, + makeTerminalSnapshot(), + ENVIRONMENT_ID, + NOW + 1 + ) + + expect(replayed).toBe(state) + expect(state.ptyIdsByTabId).toBe(ptyIds) + expect(state.terminalLayoutsByTabId).toBe(layouts) + }) + + it('updates terminal metadata without replacing PTY arrays or layouts', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const patch = applyWebSessionTabsSnapshot( + state, + makeTerminalSnapshot({ title: 'renamed shell' }), + ENVIRONMENT_ID, + NOW + 1 + ) + const next = { ...state, ...patch } + + expect(next.tabsByWorktree[WORKTREE_A]?.[0]?.title).toBe('renamed shell') + expect(next.ptyIdsByTabId).toBe(state.ptyIdsByTabId) + expect(next.terminalLayoutsByTabId).toBe(state.terminalLayoutsByTabId) + }) + + it('clears unread state on replay without replacing PTY arrays or layouts', () => { + const hydrated = applySnapshot(makeState(), makeTerminalSnapshot()) + const state = { + ...hydrated, + unreadTerminalTabs: { [MIRRORED_TAB_ID]: true as const } + } + const next = applySnapshot(state, makeTerminalSnapshot(), NOW + 1) + + expect(next.unreadTerminalTabs).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.ptyIdsByTabId).toBe(state.ptyIdsByTabId) + expect(next.terminalLayoutsByTabId).toBe(state.terminalLayoutsByTabId) + }) + + it('replaces PTY arrays and layouts when the host rotates the handle', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const next = applySnapshot(state, makeTerminalSnapshot({ terminal: 'terminal-2' }), NOW + 1) + + expect(next.ptyIdsByTabId).not.toBe(state.ptyIdsByTabId) + expect(next.terminalLayoutsByTabId).not.toBe(state.terminalLayoutsByTabId) + expect(next.ptyIdsByTabId[MIRRORED_TAB_ID]).toEqual(['remote:web-env-1@@terminal-2']) + expect(next.terminalLayoutsByTabId[MIRRORED_TAB_ID]?.ptyIdsByLeafId).toEqual({ + [LEAF_ID]: 'remote:web-env-1@@terminal-2' + }) + }) + + it('removes the PTY key when a ready terminal becomes pending', () => { + const state = applySnapshot(makeState(), makeTerminalSnapshot()) + const next = applySnapshot(state, makeTerminalSnapshot({ terminal: null }), NOW + 1) + + expect(next.ptyIdsByTabId).not.toBe(state.ptyIdsByTabId) + expect(next.ptyIdsByTabId).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.terminalLayoutsByTabId[MIRRORED_TAB_ID]?.ptyIdsByLeafId).toEqual({}) + }) + + it('cleans up terminal resources and unread state when the host omits the tab', () => { + const hydrated = applySnapshot(makeState(), makeTerminalSnapshot()) + const state = { + ...hydrated, + unreadTerminalTabs: { [MIRRORED_TAB_ID]: true as const } + } + const next = applySnapshot(state, makeSnapshot(WORKTREE_A, [], null), NOW + 1) + + expect(next.ptyIdsByTabId).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.terminalLayoutsByTabId).not.toHaveProperty(MIRRORED_TAB_ID) + expect(next.unreadTerminalTabs).not.toHaveProperty(MIRRORED_TAB_ID) + }) + + it('keeps browser pages, handles, and certificate failures stable on exact replay', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + + const replayed = applyWebSessionTabsSnapshot( + state, + makeBrowserSnapshot(), + ENVIRONMENT_ID, + NOW + 1 + ) + + expect(replayed).toBe(state) + }) + + it('updates browser metadata without replacing handles or certificate failures', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + const next = applySnapshot( + state, + makeBrowserSnapshot({ + title: 'Changed title', + url: 'https://example.com/changed', + loading: true + }), + NOW + 1 + ) + + expect(next.browserPagesByWorkspace).not.toBe(state.browserPagesByWorkspace) + expect(next.remoteBrowserPageHandlesByPageId).toBe(state.remoteBrowserPageHandlesByPageId) + expect(next.browserCertificateFailuresByPageId).toBe(state.browserCertificateFailuresByPageId) + }) + + it('clears a same-page certificate failure without replacing its page or handle', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + const next = applySnapshot(state, makeBrowserSnapshot({ certificateFailure: null }), NOW + 1) + + expect(next.browserPagesByWorkspace).toBe(state.browserPagesByWorkspace) + expect(next.remoteBrowserPageHandlesByPageId).toBe(state.remoteBrowserPageHandlesByPageId) + expect(next.browserCertificateFailuresByPageId).not.toBe( + state.browserCertificateFailuresByPageId + ) + expect(next.browserCertificateFailuresByPageId).not.toHaveProperty('host-browser-page-1') + }) + + it('cleans up replaced browser page handles and certificate failures', () => { + const state = applySnapshot(makeState(), makeBrowserSnapshot()) + const next = applySnapshot( + state, + makeBrowserSnapshot({ + pageId: 'host-browser-page-2', + certificateFailure: null + }), + NOW + 1 + ) + + expect(next.browserPagesByWorkspace['host-browser-workspace']?.map((page) => page.id)).toEqual([ + 'host-browser-page-2' + ]) + expect(next.remoteBrowserPageHandlesByPageId).not.toHaveProperty('host-browser-page-1') + expect(next.remoteBrowserPageHandlesByPageId['host-browser-page-2']).toEqual({ + environmentId: ENVIRONMENT_ID, + remotePageId: 'host-browser-page-2' + }) + expect(next.browserCertificateFailuresByPageId).not.toHaveProperty('host-browser-page-1') + }) + + it('keeps resource maps stable across an unchanged multi-worktree browser batch', () => { + const snapshots = [ + makeBrowserSnapshot({ worktree: WORKTREE_A }), + makeBrowserSnapshot({ + worktree: WORKTREE_B, + tabId: 'host-browser-tab-2', + workspaceId: 'host-browser-workspace-2', + pageId: 'host-browser-page-2' + }) + ] + const initial = applyWebSessionTabsSnapshots(makeState(), snapshots, ENVIRONMENT_ID, NOW) + const state = { ...makeState(), ...initial } + + const replayed = applyWebSessionTabsSnapshots(state, snapshots, ENVIRONMENT_ID, NOW + 1) + + expect(replayed).toBe(state) + }) + + it('preserves unchanged browser resources when a sibling worktree replaces its page', () => { + const worktreeBSnapshot = makeBrowserSnapshot({ + worktree: WORKTREE_B, + tabId: 'host-browser-tab-2', + workspaceId: 'host-browser-workspace-2', + pageId: 'host-browser-page-2' + }) + const initial = applyWebSessionTabsSnapshots( + makeState(), + [makeBrowserSnapshot(), worktreeBSnapshot], + ENVIRONMENT_ID, + NOW + ) + const state = { ...makeState(), ...initial } + const pages = state.browserPagesByWorkspace['host-browser-workspace-2'] + const page = pages?.[0] + const handle = state.remoteBrowserPageHandlesByPageId['host-browser-page-2'] + const certificate = state.browserCertificateFailuresByPageId['host-browser-page-2'] + + const patch = applyWebSessionTabsSnapshots( + state, + [ + makeBrowserSnapshot({ pageId: 'host-browser-page-3', certificateFailure: null }), + worktreeBSnapshot + ], + ENVIRONMENT_ID, + NOW + 1 + ) + const next = { ...state, ...patch } + + expect(next.browserPagesByWorkspace['host-browser-workspace-2']).toBe(pages) + expect(next.browserPagesByWorkspace['host-browser-workspace-2']?.[0]).toBe(page) + expect(next.remoteBrowserPageHandlesByPageId['host-browser-page-2']).toBe(handle) + expect(next.browserCertificateFailuresByPageId['host-browser-page-2']).toBe(certificate) + }) + + it('emits no store updates for 128 accepted identical resource frames', () => { + const terminal = makeTerminalSnapshot() + const browser = makeBrowserSnapshot() + const snapshot = { + ...browser, + tabs: [...terminal.tabs.map((tab) => ({ ...tab, isActive: false })), ...browser.tabs] + } + const store = createStore(() => makeState()) + let notifications = 0 + store.subscribe(() => { + notifications += 1 + }) + store.setState((state) => + applyFreshWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, NOW) + ) + notifications = 0 + const initial = store.getState() + + for (let snapshotVersion = 2; snapshotVersion <= 129; snapshotVersion += 1) { + store.setState((state) => + applyFreshWebSessionTabsSnapshot( + state, + { ...snapshot, snapshotVersion }, + ENVIRONMENT_ID, + NOW + snapshotVersion + ) + ) + } + + expect(notifications).toBe(0) + expect(store.getState()).toBe(initial) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts index 412911617df..d33c6f80b5d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-remote-status-title-flap.test.ts @@ -59,8 +59,9 @@ import { resetRendererOwnedAgentStatusPanesForTests } from '../components/terminal-pane/renderer-owned-agent-status-registry' import { - applyFreshWebSessionTabsSnapshot, - resetWebSessionTabsSnapshotFreshnessForTests + applyWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + shouldApplyWebSessionTabsSnapshot } from './web-session-tabs-sync' // Why: web-session-tabs-sync imports the app-level store singleton; this @@ -148,9 +149,9 @@ function applyHostSnapshot( now: number ): void { const state = store.getState() - const patch = applyFreshWebSessionTabsSnapshot(state, snapshot, ENV, now) // The freshness gate must accept every republished frame (monotonic version). - expect(patch).not.toBe(state) + expect(shouldApplyWebSessionTabsSnapshot(snapshot, ENV)).toBe(true) + const patch = applyWebSessionTabsSnapshot(state, snapshot, ENV, now) store.setState(patch as Partial) } diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index 01836b7955a..89e0b6fda2b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -643,7 +643,8 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 1, - hostMappings: 1 + hostMappings: 1, + hostMappingWorktrees: 1 }) applyFreshWebSessionTabsSnapshot( @@ -664,7 +665,8 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 0, - hostMappings: 0 + hostMappings: 0, + hostMappingWorktrees: 0 }) }) @@ -720,17 +722,89 @@ describe('applyWebSessionTabsSnapshot', () => { expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 2, - hostMappings: 2 + hostMappings: 2, + hostMappingWorktrees: 2 }) clearWebSessionTabsTrackingForEnvironment(ENV) expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ freshness: 1, - hostMappings: 1 + hostMappings: 1, + hostMappingWorktrees: 1 }) }) + it('clears one worktree mapping without dropping a sibling in the same environment', () => { + // Why: POSIX paths may contain ':', so this sibling's worktree id is prefixed by WT's — the case a prefix scan wiped. + const secondWorktree = `${WT}:2` + const terminalSnapshot = makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'host shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1' + } + ]) + const secondSnapshot = makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-2::${SECOND_LEAF_ID}`, + title: 'second shell', + parentTabId: 'host-tab-2', + leafId: SECOND_LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-2' + } + ], + { worktree: secondWorktree } + ) + applyFreshWebSessionTabsSnapshot(makeState(), terminalSnapshot, ENV, NOW) + applyFreshWebSessionTabsSnapshot(makeState(), secondSnapshot, ENV, NOW) + + applyFreshWebSessionTabsSnapshot( + makeState(), + { + ...makeSnapshot([], { + publicationEpoch: 'removed-epoch', + snapshotVersion: 0, + activeGroupId: null, + activeTabId: null, + activeTabType: null + }), + removed: true + } as RuntimeMobileSessionTabsResult, + ENV, + NOW + 1 + ) + + expect(_getWebSessionTabsTrackingCountsForTest()).toEqual({ + freshness: 1, + hostMappings: 1, + hostMappingWorktrees: 1 + }) + expect( + resolveHostSessionTabIdForWebSessionTab(makeState(), { + environmentId: ENV, + worktreeId: WT, + tabId: toWebTerminalSurfaceTabId('host-tab-1') + }) + ).toBeNull() + expect( + resolveHostSessionTabIdForWebSessionTab(makeState(), { + environmentId: ENV, + worktreeId: secondWorktree, + tabId: toWebTerminalSurfaceTabId('host-tab-2') + }) + ).toBe('host-tab-2') + }) + it('keeps a provisional Claude tab when the host Claude surface is unrelated', () => { const staleLocalAgentTab: TerminalTab = { id: 'local-agent-tab', @@ -2273,6 +2347,157 @@ describe('applyWebSessionTabsSnapshot', () => { ) }) + it('keeps an empty snapshot batch as an identity no-op', () => { + const state = makeState() + expect(applyWebSessionTabsSnapshots(state, [], ENV, NOW)).toBe(state) + }) + + it('matches sequential reconciliation across duplicate-worktree mixed snapshots', () => { + const secondWorktree = 'repo::/other-worktree' + const snapshots: RuntimeMobileSessionTabsResult[] = [ + makeSnapshot([ + { + type: 'terminal', + id: HOST_SURFACE_ID, + title: 'first agent', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-1', + agentStatus: { + state: 'working', + prompt: 'first task', + updatedAt: NOW, + stateStartedAt: NOW, + agentType: 'codex', + paneKey: HOST_SURFACE_ID, + stateHistory: [] + } + } + ]), + makeSnapshot( + [ + { + type: 'browser', + id: 'host-browser-unified', + title: 'Example Domain', + browserWorkspaceId: 'host-browser-workspace', + browserPageId: 'host-browser-page', + url: 'https://example.com/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + ], + { + worktree: secondWorktree, + activeGroupId: 'host-group-2', + activeTabId: 'host-browser-unified', + activeTabType: 'browser' + } + ), + makeSnapshot([], { + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null + }), + makeSnapshot( + [ + { + type: 'terminal', + id: `host-tab-3::${THIRD_LEAF_ID}`, + title: 'replacement agent', + parentTabId: 'host-tab-3', + leafId: THIRD_LEAF_ID, + isActive: true, + status: 'ready', + terminal: 'terminal-3', + agentStatus: { + state: 'waiting', + prompt: 'replacement question', + updatedAt: NOW + 1, + stateStartedAt: NOW + 1, + agentType: 'codex', + paneKey: `host-tab-3::${THIRD_LEAF_ID}`, + stateHistory: [] + } + } + ], + { snapshotVersion: 3 } + ), + makeSnapshot( + [ + { + type: 'markdown', + id: 'host-readme-unified', + title: 'README.md', + filePath: '/repo/README.md', + relativePath: 'README.md', + language: 'markdown', + mode: 'edit', + isDirty: false, + isActive: true, + sourceFileId: '/repo/README.md', + sourceFilePath: '/repo/README.md', + sourceRelativePath: 'README.md', + documentVersion: 'file:/repo/README.md' + } + ], + { + worktree: secondWorktree, + snapshotVersion: 2, + activeGroupId: 'host-group-2', + activeTabId: 'host-readme-unified', + activeTabType: 'markdown' + } + ) + ] + const provisionalTab: TerminalTab = { + id: 'host-tab-1', + ptyId: null, + worktreeId: WT, + title: 'Codex', + defaultTitle: 'Codex', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW, + launchAgent: 'codex' + } + const initial = makeState({ + activeWorktreeId: null, + tabsByWorktree: { [WT]: [provisionalTab] }, + pendingStartupByTabId: { + [provisionalTab.id]: { command: 'codex' } + }, + automaticAgentResumeClaimsByTabId: { + [provisionalTab.id]: { + worktreeId: WT, + launchAgent: 'codex', + providerSession: { key: 'session_id', id: 'session-a' } + } + } + }) + const initialCopy = structuredClone(initial) + let sequential = initial + for (const snapshot of snapshots) { + const patch = applyWebSessionTabsSnapshot(sequential, snapshot, ENV, NOW) + if (patch !== sequential) { + sequential = { ...sequential, ...patch } + } + } + + resetWebSessionTabsSnapshotFreshnessForTests() + const batchPatch = applyWebSessionTabsSnapshots(initial, snapshots, ENV, NOW) + const batched = { ...initial, ...batchPatch } + + expect(batched).toEqual(sequential) + expect(initial).toEqual(initialCopy) + }) + it('replaces temporary web-created tabs once the host publishes the same PTY', () => { const localTab: TerminalTab = { id: 'local-web-tab', @@ -2877,28 +3102,30 @@ describe('applyWebSessionTabsSnapshot', () => { } } + const state = makeState({ + activeTabId: mirroredTabId, + activeTabIdByWorktree: { [WT]: mirroredTabId }, + activeTabType: 'terminal', + activeTabTypeByWorktree: { [WT]: 'terminal' }, + tabsByWorktree: { + [WT]: [ + { + id: mirroredTabId, + ptyId: 'remote:web-env-1@@terminal-2', + worktreeId: WT, + title: 'right pane', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + terminalLayoutsByTabId: { [mirroredTabId]: currentLayout } + }) + const patch = applyWebSessionTabsSnapshot( - makeState({ - activeTabId: mirroredTabId, - activeTabIdByWorktree: { [WT]: mirroredTabId }, - activeTabType: 'terminal', - activeTabTypeByWorktree: { [WT]: 'terminal' }, - tabsByWorktree: { - [WT]: [ - { - id: mirroredTabId, - ptyId: 'remote:web-env-1@@terminal-2', - worktreeId: WT, - title: 'right pane', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: NOW - } - ] - }, - terminalLayoutsByTabId: { [mirroredTabId]: currentLayout } - }), + state, makeSnapshot([ { type: 'terminal', @@ -2938,7 +3165,11 @@ describe('applyWebSessionTabsSnapshot', () => { ptyId: 'remote:web-env-1@@terminal-2', title: 'right pane' }) - expect(patch.terminalLayoutsByTabId?.[mirroredTabId]?.activeLeafId).toBe(SECOND_LEAF_ID) + // No layout patch at all, and the effective layout keeps the local active leaf. + expect(patch.terminalLayoutsByTabId).toBeUndefined() + expect({ ...state, ...patch }.terminalLayoutsByTabId[mirroredTabId]?.activeLeafId).toBe( + SECOND_LEAF_ID + ) }) it('removes a null-pty pending activation tab when the host publishes the initial terminal', () => { @@ -3315,10 +3546,8 @@ describe('applyWebSessionTabsSnapshot', () => { title: 'Example Domain' } ]) - expect(patch.remoteBrowserPageHandlesByPageId?.[page.id]).toEqual({ - environmentId: ENV, - remotePageId: 'host-browser-page' - }) + // Absent key, not a missing handle: the seeded { ENV, 'host-browser-page' } handle matched. + expect(patch.remoteBrowserPageHandlesByPageId).toBeUndefined() expect(patch.unifiedTabsByWorktree?.[WT]?.map((tab) => tab.id)).toEqual([ 'local-browser-unified' ]) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 37ab1feedd2..aa102eb11a5 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -105,6 +105,10 @@ const latestSessionTabsSnapshotByWorktree = new Map() const replayableSessionTabsSnapshotByWorktree = new Map() const lastHostTerminalTabCountByWorktree = new Map() const hostSessionTabIdByLocalKey = new Map() +const hostSessionTabMappingKeysByEnvironmentAndWorktree = new Map< + string, + Map> +>() type TerminalSurface = RuntimeMobileSessionTerminalClientTab type ReadyTerminalSurface = RuntimeMobileSessionTerminalClientTab & { status: 'ready' } @@ -165,6 +169,33 @@ export type WebSessionTabsSyncState = Pick< > & Partial> +type WebSessionTabsBatchRecordKey = + | 'activeBrowserTabIdByWorktree' + | 'activeFileIdByWorktree' + | 'activeGroupIdByWorktree' + | 'activeTabIdByWorktree' + | 'activeTabTypeByWorktree' + | 'agentStatusByPaneKey' + | 'automaticAgentResumeClaimsByTabId' + | 'browserCertificateFailuresByPageId' + | 'browserPagesByWorkspace' + | 'browserTabsByWorktree' + | 'groupsByWorktree' + | 'layoutByWorktree' + | 'pendingStartupByTabId' + | 'ptyIdsByTabId' + | 'remoteBrowserPageHandlesByPageId' + | 'tabBarOrderByWorktree' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'unifiedTabsByWorktree' + | 'unreadTerminalTabs' + +type WebSessionTabsBatchContext = { + agentPaneKeysByTabId: Map> | null + changedRecords: Set +} + function isSessionTabsListAllResult(value: unknown): value is SessionTabsListAllResult { return ( Boolean(value) && @@ -331,15 +362,23 @@ export function resetWebSessionTabsSnapshotFreshnessForTests(): void { replayableSessionTabsSnapshotByWorktree.clear() lastHostTerminalTabCountByWorktree.clear() hostSessionTabIdByLocalKey.clear() + hostSessionTabMappingKeysByEnvironmentAndWorktree.clear() } export function _getWebSessionTabsTrackingCountsForTest(): { freshness: number hostMappings: number + hostMappingWorktrees: number } { + let hostMappingWorktrees = 0 + for (const mappingKeysByWorktree of hostSessionTabMappingKeysByEnvironmentAndWorktree.values()) { + hostMappingWorktrees += mappingKeysByWorktree.size + } return { freshness: latestSessionTabsSnapshotByWorktree.size, - hostMappings: hostSessionTabIdByLocalKey.size + hostMappings: hostSessionTabIdByLocalKey.size, + // Why: the mapping index is a parallel structure, so leak tests must see it drain alongside the flat map. + hostMappingWorktrees } } @@ -352,12 +391,7 @@ function clearWebSessionTabsTrackingForWorktree(environmentId: string, worktreeI clearWebSessionReorderIntentsForWorktree({ environmentId }, worktreeId) clearWebSessionCloseIntentsForWorktree({ environmentId }, worktreeId) clearWebAgentSessionHandoffsForWorktree(environmentId, worktreeId) - const keyPrefix = `${environmentId}:${worktreeId}:` - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) - } - } + clearHostSessionTabIdMappings(environmentId, worktreeId) } export function clearWebSessionTabsTrackingForEnvironment(environmentId: string): void { @@ -381,10 +415,15 @@ export function clearWebSessionTabsTrackingForEnvironment(environmentId: string) lastHostTerminalTabCountByWorktree.delete(key) } } - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(trimmedEnvironmentId) + if (mappingKeysByWorktree) { + for (const mappingKeys of mappingKeysByWorktree.values()) { + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } } + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(trimmedEnvironmentId) } clearWebAgentSessionHandoffsForEnvironment(trimmedEnvironmentId) clearAllWebRuntimeWakeTerminalRespawn() @@ -398,6 +437,35 @@ function hostSessionTabMappingKey(args: { return `${args.environmentId}:${args.worktreeId}:${args.tabId}` } +function clearHostSessionTabIdMappings(environmentId: string, worktreeId: string): void { + const mappingKeysByWorktree = hostSessionTabMappingKeysByEnvironmentAndWorktree.get(environmentId) + const mappingKeys = mappingKeysByWorktree?.get(worktreeId) + if (!mappingKeys) { + return + } + for (const mappingKey of mappingKeys) { + hostSessionTabIdByLocalKey.delete(mappingKey) + } + mappingKeysByWorktree?.delete(worktreeId) + if (mappingKeysByWorktree?.size === 0) { + hostSessionTabMappingKeysByEnvironmentAndWorktree.delete(environmentId) + } +} + +function setHostSessionTabIdMapping( + args: { environmentId: string; worktreeId: string; tabId: string }, + hostTabId: string +): void { + const mappingKey = hostSessionTabMappingKey(args) + hostSessionTabIdByLocalKey.set(mappingKey, hostTabId) + const mappingKeysByWorktree = + hostSessionTabMappingKeysByEnvironmentAndWorktree.get(args.environmentId) ?? new Map() + const mappingKeys = mappingKeysByWorktree.get(args.worktreeId) ?? new Set() + mappingKeys.add(mappingKey) + mappingKeysByWorktree.set(args.worktreeId, mappingKeys) + hostSessionTabMappingKeysByEnvironmentAndWorktree.set(args.environmentId, mappingKeysByWorktree) +} + export function resolveHostSessionTabIdForWebSessionTab( _state: WebSessionTabsSyncState, args: { @@ -735,13 +803,60 @@ function isFencedClientAgentStatus( return isClientOwnedAgentStatus(paneKey, existing) && isAgentStatusFresh(existing, now) } +function batchAgentPaneKeysForTabs( + state: WebSessionTabsSyncState, + tabIds: ReadonlySet, + batchContext?: WebSessionTabsBatchContext +): string[] { + if (!batchContext) { + return Object.keys(state.agentStatusByPaneKey) + } + if (!batchContext.agentPaneKeysByTabId) { + batchContext.agentPaneKeysByTabId = new Map() + for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + continue + } + const paneKeys = batchContext.agentPaneKeysByTabId.get(tabId) ?? new Set() + paneKeys.add(paneKey) + batchContext.agentPaneKeysByTabId.set(tabId, paneKeys) + } + } + return [...tabIds].flatMap((tabId) => [...(batchContext.agentPaneKeysByTabId?.get(tabId) ?? [])]) +} + +function updateBatchAgentPaneKey( + paneKey: string, + present: boolean, + batchContext?: WebSessionTabsBatchContext +): void { + const tabId = parsePaneKey(paneKey)?.tabId + const index = batchContext?.agentPaneKeysByTabId + if (!tabId || !index) { + return + } + if (present) { + const paneKeys = index.get(tabId) ?? new Set() + paneKeys.add(paneKey) + index.set(tabId, paneKeys) + return + } + const paneKeys = index.get(tabId) + paneKeys?.delete(paneKey) + if (paneKeys?.size === 0) { + index.delete(tabId) + } +} + /** Generates a state patch for mirrored agent statuses, merging host entries with client overrides. */ function buildMirroredAgentStatusPatch( state: WebSessionTabsSyncState, currentTerminalTabs: readonly TerminalTab[], terminalSurfaceTabs: readonly TerminalSurface[], mirroredTerminalTabs: readonly MirroredTerminalTab[], - now: number + now: number, + batchContext?: WebSessionTabsBatchContext ): Pick | null { const mirroredTabIds = new Set() for (const tab of currentTerminalTabs) { @@ -812,7 +927,7 @@ function buildMirroredAgentStatusPatch( let aggregateRelevantChange = false let sortRelevantChange = false - for (const paneKey of Object.keys(state.agentStatusByPaneKey)) { + for (const paneKey of batchAgentPaneKeysForTabs(state, mirroredTabIds, batchContext)) { if (!isMirroredAgentPaneKeyForTabs(paneKey, mirroredTabIds)) { continue } @@ -830,9 +945,14 @@ function buildMirroredAgentStatusPatch( continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey } + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) } delete nextAgentStatusByPaneKey[paneKey] + updateBatchAgentPaneKey(paneKey, false, batchContext) changed = true aggregateRelevantChange = true sortRelevantChange = true @@ -844,9 +964,14 @@ function buildMirroredAgentStatusPatch( continue } if (nextAgentStatusByPaneKey === state.agentStatusByPaneKey) { - nextAgentStatusByPaneKey = { ...state.agentStatusByPaneKey } + nextAgentStatusByPaneKey = writableWebSessionTabsRecord( + state, + 'agentStatusByPaneKey', + batchContext + ) } nextAgentStatusByPaneKey[paneKey] = entry + updateBatchAgentPaneKey(paneKey, true, batchContext) changed = true const entryAttributionChanged = existing?.worktreeId !== entry.worktreeId || existing?.tabId !== entry.tabId @@ -1075,7 +1200,7 @@ function buildMirroredBrowserTabs( const createdAt = existing?.page.createdAt ?? now + sortOffset + index const groupId = hostGroupIdByTabId.get(tab.id) ?? fallbackGroupId const title = tab.title.trim() || 'Browser' - const page: BrowserPage = { + const nextPage: BrowserPage = { id: pageId, workspaceId, worktreeId: snapshot.worktree, @@ -1090,6 +1215,9 @@ function buildMirroredBrowserTabs( browserRuntimeEnvironmentId: environmentId, viewportPresetId: existing?.page.viewportPresetId ?? null } + // Why: reuse hinges on browserPageEqual comparing workspaceId — the removed-workspace + // page-list cleanup gates on page.workspaceId matching this entry's workspace.id. + const page = existing && browserPageEqual(existing.page, nextPage) ? existing.page : nextPage const workspace: BrowserWorkspace = { id: workspaceId, worktreeId: snapshot.worktree, @@ -1285,34 +1413,20 @@ function updateHostSessionTabIdMappings(args: { browserTabs: readonly MirroredBrowserTab[] editorTabs: readonly MirroredEditorTab[] }): void { - const keyPrefix = `${args.environmentId}:${args.worktreeId}:` - for (const key of hostSessionTabIdByLocalKey.keys()) { - if (key.startsWith(keyPrefix)) { - hostSessionTabIdByLocalKey.delete(key) - } - } + clearHostSessionTabIdMappings(args.environmentId, args.worktreeId) const mirroredTerminalIds = new Set(args.terminalTabs.map((tab) => tab.id)) for (const surface of args.terminalSurfaces) { const localId = toWebTerminalSurfaceTabId(surface.parentTabId) if (mirroredTerminalIds.has(localId)) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: localId }), - surface.parentTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: localId }, surface.parentTabId) } } for (const entry of args.browserTabs) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), - entry.hostTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } for (const entry of args.editorTabs) { - hostSessionTabIdByLocalKey.set( - hostSessionTabMappingKey({ ...args, tabId: entry.unifiedTab.id }), - entry.hostTabId - ) + setHostSessionTabIdMapping({ ...args, tabId: entry.unifiedTab.id }, entry.hostTabId) } } @@ -1500,20 +1614,47 @@ function pushRecentTabId(recent: string[] | undefined, tabId: string): string[] return [...base.filter((id) => id !== tabId), tabId] } +function writableWebSessionTabsRecord( + state: WebSessionTabsSyncState, + recordKey: K, + batchContext?: WebSessionTabsBatchContext +): NonNullable { + const record = (state[recordKey] ?? {}) as NonNullable + if (!batchContext) { + return { ...record } as NonNullable + } + // Why: one batch owns its record copies, so later snapshots can update them without recopying every workspace. + if (batchContext.changedRecords.has(recordKey)) { + return record + } + const next = { ...record } as NonNullable + const mutableState = state as unknown as Record< + WebSessionTabsBatchRecordKey, + Record + > + mutableState[recordKey] = next as Record + batchContext.changedRecords.add(recordKey) + return next +} + function withWorktreeEntry( - record: Record, + state: WebSessionTabsSyncState, + recordKey: WebSessionTabsBatchRecordKey, key: string, value: T | null, - equal: (a: T | undefined, b: T | null) => boolean + equal: (a: T | undefined, b: T | null) => boolean, + batchContext?: WebSessionTabsBatchContext, + deleteNull = true ): Record { + const record = (state[recordKey] ?? {}) as Record if (equal(record[key], value)) { return record } - const next = { ...record } - if (value === null) { + const next = writableWebSessionTabsRecord(state, recordKey, batchContext) as Record + if (value === null && deleteNull) { delete next[key] } else { - next[key] = value + next[key] = value as T } return next } @@ -1763,11 +1904,12 @@ function findCurrentVisibleUnifiedTabId(args: { return null } -export function applyWebSessionTabsSnapshot( +function applyWebSessionTabsSnapshotWithContext( state: WebSessionTabsSyncState, rawSnapshot: RuntimeMobileSessionTabsResult, environmentId: string, - now = Date.now() + now = Date.now(), + batchContext?: WebSessionTabsBatchContext ): WebSessionTabsSyncState | Partial { const worktreeId = rawSnapshot.worktree if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { @@ -1892,6 +2034,9 @@ export function applyWebSessionTabsSnapshot( const removedTerminalIds = new Set( currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) ) + const removedTerminalResourceIds = [...removedTerminalIds].filter( + (tabId) => !mirroredTerminalIds.has(tabId) + ) for (const provisionalTabId of exactProvisionalHandoffs) { clearWebAgentSessionHandoff({ environmentId, worktreeId, provisionalTabId }) } @@ -2259,28 +2404,42 @@ export function applyWebSessionTabsSnapshot( })() let nextPtyIdsByTabId = state.ptyIdsByTabId - for (const removedId of removedTerminalIds) { + for (const removedId of removedTerminalResourceIds) { if (nextPtyIdsByTabId[removedId]) { nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId delete nextPtyIdsByTabId[removedId] } } for (const { tab, ptyIds } of mirroredTerminalTabs) { + if (ptyIds.length === 0) { + if (nextPtyIdsByTabId[tab.id]) { + nextPtyIdsByTabId = + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId + delete nextPtyIdsByTabId[tab.id] + } + continue + } const current = nextPtyIdsByTabId[tab.id] ?? [] if (!sameStringArray(current, ptyIds)) { nextPtyIdsByTabId = - nextPtyIdsByTabId === state.ptyIdsByTabId ? { ...state.ptyIdsByTabId } : nextPtyIdsByTabId + nextPtyIdsByTabId === state.ptyIdsByTabId + ? writableWebSessionTabsRecord(state, 'ptyIdsByTabId', batchContext) + : nextPtyIdsByTabId nextPtyIdsByTabId[tab.id] = ptyIds } } let nextTerminalLayoutsByTabId = state.terminalLayoutsByTabId - for (const removedId of removedTerminalIds) { + for (const removedId of removedTerminalResourceIds) { if (nextTerminalLayoutsByTabId[removedId]) { nextTerminalLayoutsByTabId = nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? { ...state.terminalLayoutsByTabId } + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) : nextTerminalLayoutsByTabId delete nextTerminalLayoutsByTabId[removedId] } @@ -2289,7 +2448,7 @@ export function applyWebSessionTabsSnapshot( if (!terminalLayoutEqual(nextTerminalLayoutsByTabId[tab.id], layout)) { nextTerminalLayoutsByTabId = nextTerminalLayoutsByTabId === state.terminalLayoutsByTabId - ? { ...state.terminalLayoutsByTabId } + ? writableWebSessionTabsRecord(state, 'terminalLayoutsByTabId', batchContext) : nextTerminalLayoutsByTabId nextTerminalLayoutsByTabId[tab.id] = layout } @@ -2300,7 +2459,7 @@ export function applyWebSessionTabsSnapshot( if (nextUnreadTerminalTabs[removedId]) { nextUnreadTerminalTabs = nextUnreadTerminalTabs === state.unreadTerminalTabs - ? { ...state.unreadTerminalTabs } + ? writableWebSessionTabsRecord(state, 'unreadTerminalTabs', batchContext) : nextUnreadTerminalTabs delete nextUnreadTerminalTabs[removedId] } @@ -2314,14 +2473,14 @@ export function applyWebSessionTabsSnapshot( if (nextPendingStartupByTabId[removedId]) { nextPendingStartupByTabId = nextPendingStartupByTabId === pendingStartupByTabId - ? { ...pendingStartupByTabId } + ? writableWebSessionTabsRecord(state, 'pendingStartupByTabId', batchContext) : nextPendingStartupByTabId delete nextPendingStartupByTabId[removedId] } if (nextAutomaticAgentResumeClaimsByTabId[removedId]) { nextAutomaticAgentResumeClaimsByTabId = nextAutomaticAgentResumeClaimsByTabId === automaticAgentResumeClaimsByTabId - ? { ...automaticAgentResumeClaimsByTabId } + ? writableWebSessionTabsRecord(state, 'automaticAgentResumeClaimsByTabId', batchContext) : nextAutomaticAgentResumeClaimsByTabId delete nextAutomaticAgentResumeClaimsByTabId[removedId] } @@ -2330,29 +2489,52 @@ export function applyWebSessionTabsSnapshot( let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId - for (const removedWorkspaceId of removedBrowserWorkspaceIds) { - const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] - if (nextBrowserPagesByWorkspace[removedWorkspaceId]) { - nextBrowserPagesByWorkspace = - nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? { ...state.browserPagesByWorkspace } - : nextBrowserPagesByWorkspace - delete nextBrowserPagesByWorkspace[removedWorkspaceId] - } - for (const page of pages) { - if (nextBrowserCertificateFailuresByPageId[page.id]) { - nextBrowserCertificateFailuresByPageId = - nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? { ...state.browserCertificateFailuresByPageId } - : nextBrowserCertificateFailuresByPageId - delete nextBrowserCertificateFailuresByPageId[page.id] + if (removedBrowserWorkspaceIds.size > 0) { + const nextBrowserWorkspaceIds = new Set(nextBrowserTabs?.map((tab) => tab.id) ?? []) + const nextBrowserPageIds = new Set(mirroredBrowserTabs.map((entry) => entry.page.id)) + for (const workspace of retainedBrowserTabs) { + for (const page of state.browserPagesByWorkspace[workspace.id] ?? []) { + nextBrowserPageIds.add(page.id) } - if (nextRemoteBrowserPageHandlesByPageId[page.id]) { - nextRemoteBrowserPageHandlesByPageId = - nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? { ...state.remoteBrowserPageHandlesByPageId } - : nextRemoteBrowserPageHandlesByPageId - delete nextRemoteBrowserPageHandlesByPageId[page.id] + } + for (const removedWorkspaceId of removedBrowserWorkspaceIds) { + const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] + if ( + !nextBrowserWorkspaceIds.has(removedWorkspaceId) && + nextBrowserPagesByWorkspace[removedWorkspaceId] + ) { + nextBrowserPagesByWorkspace = + nextBrowserPagesByWorkspace === state.browserPagesByWorkspace + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) + : nextBrowserPagesByWorkspace + delete nextBrowserPagesByWorkspace[removedWorkspaceId] + } + for (const page of pages) { + if (nextBrowserPageIds.has(page.id)) { + continue + } + if (nextBrowserCertificateFailuresByPageId[page.id]) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? writableWebSessionTabsRecord( + state, + 'browserCertificateFailuresByPageId', + batchContext + ) + : nextBrowserCertificateFailuresByPageId + delete nextBrowserCertificateFailuresByPageId[page.id] + } + if (nextRemoteBrowserPageHandlesByPageId[page.id]) { + nextRemoteBrowserPageHandlesByPageId = + nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId + ? writableWebSessionTabsRecord( + state, + 'remoteBrowserPageHandlesByPageId', + batchContext + ) + : nextRemoteBrowserPageHandlesByPageId + delete nextRemoteBrowserPageHandlesByPageId[page.id] + } } } } @@ -2361,7 +2543,7 @@ export function applyWebSessionTabsSnapshot( if (!sameBrowserPages(current, [page])) { nextBrowserPagesByWorkspace = nextBrowserPagesByWorkspace === state.browserPagesByWorkspace - ? { ...state.browserPagesByWorkspace } + ? writableWebSessionTabsRecord(state, 'browserPagesByWorkspace', batchContext) : nextBrowserPagesByWorkspace nextBrowserPagesByWorkspace[page.workspaceId] = [page] } @@ -2372,7 +2554,7 @@ export function applyWebSessionTabsSnapshot( ) { nextRemoteBrowserPageHandlesByPageId = nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId - ? { ...state.remoteBrowserPageHandlesByPageId } + ? writableWebSessionTabsRecord(state, 'remoteBrowserPageHandlesByPageId', batchContext) : nextRemoteBrowserPageHandlesByPageId nextRemoteBrowserPageHandlesByPageId[page.id] = { environmentId, @@ -2387,7 +2569,7 @@ export function applyWebSessionTabsSnapshot( ) { nextBrowserCertificateFailuresByPageId = nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId - ? { ...state.browserCertificateFailuresByPageId } + ? writableWebSessionTabsRecord(state, 'browserCertificateFailuresByPageId', batchContext) : nextBrowserCertificateFailuresByPageId if (certificateFailure) { nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure @@ -2398,28 +2580,36 @@ export function applyWebSessionTabsSnapshot( } const nextTabsByWorktree = withWorktreeEntry( - state.tabsByWorktree, + state, + 'tabsByWorktree', worktreeId, nextTerminalTabs, - sameTerminalTabs + sameTerminalTabs, + batchContext ) const nextBrowserTabsByWorktree = withWorktreeEntry( - state.browserTabsByWorktree, + state, + 'browserTabsByWorktree', worktreeId, nextBrowserTabs, - sameBrowserTabs + sameBrowserTabs, + batchContext ) const nextUnifiedTabsByWorktree = withWorktreeEntry( - state.unifiedTabsByWorktree, + state, + 'unifiedTabsByWorktree', worktreeId, nextUnifiedTabs, - sameUnifiedTabs + sameUnifiedTabs, + batchContext ) const nextGroupsByWorktree = withWorktreeEntry( - state.groupsByWorktree, + state, + 'groupsByWorktree', worktreeId, nextGroups, - sameGroups + sameGroups, + batchContext ) const nextActiveGroupId = // Why: status/title snapshots carry the host's last active tab; a client that already switched panes keeps its local group focus. @@ -2429,7 +2619,14 @@ export function applyWebSessionTabsSnapshot( null const nextActiveGroupIdByWorktree = nextGroups && state.activeGroupIdByWorktree[worktreeId] !== nextActiveGroupId - ? { ...state.activeGroupIdByWorktree, [worktreeId]: nextActiveGroupId ?? targetGroupId } + ? withWorktreeEntry( + state, + 'activeGroupIdByWorktree', + worktreeId, + nextActiveGroupId ?? targetGroupId, + (current, next) => current === next, + batchContext + ) : state.activeGroupIdByWorktree const nextLayoutByWorktree = (() => { if (!nextGroups) { @@ -2467,28 +2664,58 @@ export function applyWebSessionTabsSnapshot( if (tabGroupLayoutEqual(state.layoutByWorktree[worktreeId], fallbackLayout)) { return state.layoutByWorktree } - return { - ...state.layoutByWorktree, - [worktreeId]: fallbackLayout - } + return withWorktreeEntry( + state, + 'layoutByWorktree', + worktreeId, + fallbackLayout, + (current, next) => current === next, + batchContext + ) })() const nextTabBarOrderByWorktree = withWorktreeEntry( - state.tabBarOrderByWorktree, + state, + 'tabBarOrderByWorktree', worktreeId, nextTabBarOrder.length > 0 ? nextTabBarOrder : null, - (a, b) => sameStringArray(a ?? [], b ?? []) + (a, b) => sameStringArray(a ?? [], b ?? []), + batchContext ) const nextActiveTabIdByWorktree = (state.activeTabIdByWorktree[worktreeId] ?? null) !== nextActiveTerminalId - ? { ...state.activeTabIdByWorktree, [worktreeId]: nextActiveTerminalId } + ? withWorktreeEntry( + state, + 'activeTabIdByWorktree', + worktreeId, + nextActiveTerminalId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeTabIdByWorktree const nextActiveBrowserTabIdByWorktree = (state.activeBrowserTabIdByWorktree[worktreeId] ?? null) !== nextActiveBrowserWorkspaceId - ? { ...state.activeBrowserTabIdByWorktree, [worktreeId]: nextActiveBrowserWorkspaceId } + ? withWorktreeEntry( + state, + 'activeBrowserTabIdByWorktree', + worktreeId, + nextActiveBrowserWorkspaceId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeBrowserTabIdByWorktree const nextActiveFileIdByWorktree = (state.activeFileIdByWorktree[worktreeId] ?? null) !== nextActiveEditorFileId - ? { ...state.activeFileIdByWorktree, [worktreeId]: nextActiveEditorFileId } + ? withWorktreeEntry( + state, + 'activeFileIdByWorktree', + worktreeId, + nextActiveEditorFileId, + (current, next) => (current ?? null) === next, + batchContext, + false + ) : state.activeFileIdByWorktree const isActiveWorktree = state.activeWorktreeId === worktreeId const focusIntentVisibleTabType = @@ -2564,14 +2791,22 @@ export function applyWebSessionTabsSnapshot( const nextActiveTabType = isActiveWorktree ? nextVisibleTabType : state.activeTabType const nextActiveTabTypeByWorktree = state.activeTabTypeByWorktree[worktreeId] !== nextVisibleTabType - ? { ...state.activeTabTypeByWorktree, [worktreeId]: nextVisibleTabType } + ? withWorktreeEntry( + state, + 'activeTabTypeByWorktree', + worktreeId, + nextVisibleTabType, + (current, next) => current === next, + batchContext + ) : state.activeTabTypeByWorktree const agentStatusPatch = buildMirroredAgentStatusPatch( state, currentTerminalTabs, terminalSurfaceTabs, mirroredTerminalTabs, - now + now, + batchContext ) const patch: Partial = { @@ -2641,21 +2876,45 @@ export function applyWebSessionTabsSnapshot( return Object.keys(patch).length === 0 ? state : patch } +export function applyWebSessionTabsSnapshot( + state: WebSessionTabsSyncState, + rawSnapshot: RuntimeMobileSessionTabsResult, + environmentId: string, + now = Date.now() +): WebSessionTabsSyncState | Partial { + return applyWebSessionTabsSnapshotWithContext(state, rawSnapshot, environmentId, now) +} + export function applyWebSessionTabsSnapshots( state: WebSessionTabsSyncState, snapshots: readonly RuntimeMobileSessionTabsResult[], environmentId: string, now = Date.now() ): WebSessionTabsSyncState | Partial { - let nextState = state + const nextState = { ...state } + const batchContext: WebSessionTabsBatchContext = { + agentPaneKeysByTabId: null, + changedRecords: new Set() + } let mergedPatch: Partial = {} for (const snapshot of snapshots) { - const patch = applyWebSessionTabsSnapshot(nextState, snapshot, environmentId, now) + const patch = applyWebSessionTabsSnapshotWithContext( + nextState, + snapshot, + environmentId, + now, + batchContext + ) if (patch === nextState) { continue } mergedPatch = { ...mergedPatch, ...patch } - nextState = { ...nextState, ...patch } + Object.assign(nextState, patch) + } + const mutableMergedPatch = mergedPatch as Record + const mutableNextState = nextState as unknown as Record + for (const recordKey of batchContext.changedRecords) { + mutableMergedPatch[recordKey] = mutableNextState[recordKey] } return Object.keys(mergedPatch).length === 0 ? state : mergedPatch } diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index 5687dfc9776..e0e2a5e88a6 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -17,9 +17,11 @@ import { createEditorSlice } from './slices/editor' import { createStatsSlice } from './slices/stats' import { createMemorySlice } from './slices/memory' import { createWorkspaceSpaceSlice } from './slices/workspace-space' -import { createClaudeUsageSlice } from './slices/claude-usage' -import { createCodexUsageSlice } from './slices/codex-usage' -import { createOpenCodeUsageSlice } from './slices/opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './slices/usage-provider-slices' import { createBrowserSlice } from './slices/browser' import { createRateLimitSlice } from './slices/rate-limits' import { createSshSlice } from './slices/ssh' diff --git a/src/renderer/src/store/slices/claude-usage.ts b/src/renderer/src/store/slices/claude-usage.ts deleted file mode 100644 index b9e6d0c74fc..00000000000 --- a/src/renderer/src/store/slices/claude-usage.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { - ClaudeUsageBreakdownRow, - ClaudeUsageDailyPoint, - ClaudeUsageRange, - ClaudeUsageScanState, - ClaudeUsageScope, - ClaudeUsageSessionRow, - ClaudeUsageSnapshot, - ClaudeUsageSummary -} from '../../../../shared/claude-usage-types' -import type { AppState } from '../types' - -export type ClaudeUsageSlice = { - claudeUsageScope: ClaudeUsageScope - claudeUsageRange: ClaudeUsageRange - claudeUsageScanState: ClaudeUsageScanState | null - claudeUsageSummary: ClaudeUsageSummary | null - claudeUsageDaily: ClaudeUsageDailyPoint[] - claudeUsageModelBreakdown: ClaudeUsageBreakdownRow[] - claudeUsageProjectBreakdown: ClaudeUsageBreakdownRow[] - claudeUsageRecentSessions: ClaudeUsageSessionRow[] - setClaudeUsageEnabled: (enabled: boolean) => Promise - setClaudeUsageScope: (scope: ClaudeUsageScope) => Promise - setClaudeUsageRange: (range: ClaudeUsageRange) => Promise - fetchClaudeUsage: (opts?: { forceRefresh?: boolean }) => Promise - enableClaudeUsage: () => Promise - refreshClaudeUsage: () => Promise -} - -export const createClaudeUsageSlice: StateCreator = ( - set, - get -) => ({ - claudeUsageScope: 'orca', - claudeUsageRange: '30d', - claudeUsageScanState: null, - claudeUsageSummary: null, - claudeUsageDaily: [], - claudeUsageModelBreakdown: [], - claudeUsageProjectBreakdown: [], - claudeUsageRecentSessions: [], - - setClaudeUsageEnabled: async (enabled) => { - try { - const nextScanState = (await window.api.claudeUsage.setEnabled({ - enabled - })) as ClaudeUsageScanState | undefined - // Why: the web client (paired runtime) does not bridge the desktop-only - // usage IPC; its preload fallback resolves this call to `undefined`. Bail - // so the toggle no-ops instead of seeding an empty scan state and then - // crashing on the follow-up fetch. - if (!nextScanState) { - return - } - set({ - // Why: every enable should look like a fresh scan cycle in the UI. - // Reusing the last completed timestamp makes repeated toggles skip the - // loading skeleton and briefly render an empty analytics pane. - claudeUsageScanState: enabled - ? { - ...nextScanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : nextScanState, - claudeUsageSummary: null, - claudeUsageDaily: [], - claudeUsageModelBreakdown: [], - claudeUsageProjectBreakdown: [], - claudeUsageRecentSessions: [] - }) - if (enabled) { - await get().fetchClaudeUsage({ forceRefresh: true }) - } - } catch (error) { - console.error('Failed to update Claude usage setting:', error) - } - }, - - setClaudeUsageScope: async (scope) => { - set({ claudeUsageScope: scope }) - await get().fetchClaudeUsage() - }, - - setClaudeUsageRange: async (range) => { - set({ claudeUsageRange: range }) - await get().fetchClaudeUsage() - }, - - fetchClaudeUsage: async (opts) => { - try { - const scanState = (await window.api.claudeUsage.getScanState()) as - | ClaudeUsageScanState - | undefined - // Why: in the web client the usage IPC is unavailable and the preload - // fallback resolves to `undefined`; reading `scanState.enabled` below would - // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an - // absent scan state as "usage unavailable" and stop. - if (!scanState) { - return - } - const currentScanState = get().claudeUsageScanState - const shouldPreserveLoadingState = - opts?.forceRefresh === true && - currentScanState?.enabled === true && - get().claudeUsageSummary === null - set({ - claudeUsageScanState: shouldPreserveLoadingState - ? { - ...scanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : scanState - }) - if (!scanState.enabled) { - return - } - - const { claudeUsageScope, claudeUsageRange } = get() - const snapshot = (await window.api.claudeUsage.getSnapshot({ - scope: claudeUsageScope, - range: claudeUsageRange, - limit: 10 - })) as ClaudeUsageSnapshot - const hasCachedSnapshot = - snapshot.scanState.lastScanCompletedAt !== null || snapshot.scanState.hasAnyClaudeData - - if (hasCachedSnapshot) { - set({ - claudeUsageScanState: - opts?.forceRefresh === true - ? { ...snapshot.scanState, isScanning: true } - : snapshot.scanState, - claudeUsageSummary: snapshot.summary, - claudeUsageDaily: snapshot.daily, - claudeUsageModelBreakdown: snapshot.modelBreakdown, - claudeUsageProjectBreakdown: snapshot.projectBreakdown, - claudeUsageRecentSessions: snapshot.recentSessions - }) - } else { - set({ - claudeUsageScanState: { - ...scanState, - isScanning: true, - lastScanError: null - } - }) - } - - await window.api.claudeUsage.refresh({ - force: opts?.forceRefresh ?? false - }) - const { claudeUsageScope: refreshedScope, claudeUsageRange: refreshedRange } = get() - const refreshedSnapshot = (await window.api.claudeUsage.getSnapshot({ - scope: refreshedScope, - range: refreshedRange, - limit: 10 - })) as ClaudeUsageSnapshot - - set({ - claudeUsageScanState: refreshedSnapshot.scanState, - claudeUsageSummary: refreshedSnapshot.summary, - claudeUsageDaily: refreshedSnapshot.daily, - claudeUsageModelBreakdown: refreshedSnapshot.modelBreakdown, - claudeUsageProjectBreakdown: refreshedSnapshot.projectBreakdown, - claudeUsageRecentSessions: refreshedSnapshot.recentSessions - }) - } catch (error) { - console.error('Failed to fetch Claude usage:', error) - } - }, - - enableClaudeUsage: async () => { - await get().setClaudeUsageEnabled(true) - }, - - refreshClaudeUsage: async () => { - await get().fetchClaudeUsage({ forceRefresh: true }) - } -}) diff --git a/src/renderer/src/store/slices/codex-usage.ts b/src/renderer/src/store/slices/codex-usage.ts deleted file mode 100644 index fa90bc0eab1..00000000000 --- a/src/renderer/src/store/slices/codex-usage.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { - CodexUsageBreakdownRow, - CodexUsageDailyPoint, - CodexUsageRange, - CodexUsageScanState, - CodexUsageScope, - CodexUsageSessionRow, - CodexUsageSnapshot, - CodexUsageSummary -} from '../../../../shared/codex-usage-types' -import type { AppState } from '../types' - -export type CodexUsageSlice = { - codexUsageScope: CodexUsageScope - codexUsageRange: CodexUsageRange - codexUsageScanState: CodexUsageScanState | null - codexUsageSummary: CodexUsageSummary | null - codexUsageDaily: CodexUsageDailyPoint[] - codexUsageModelBreakdown: CodexUsageBreakdownRow[] - codexUsageProjectBreakdown: CodexUsageBreakdownRow[] - codexUsageRecentSessions: CodexUsageSessionRow[] - setCodexUsageEnabled: (enabled: boolean) => Promise - setCodexUsageScope: (scope: CodexUsageScope) => Promise - setCodexUsageRange: (range: CodexUsageRange) => Promise - fetchCodexUsage: (opts?: { forceRefresh?: boolean }) => Promise - enableCodexUsage: () => Promise - refreshCodexUsage: () => Promise -} - -export const createCodexUsageSlice: StateCreator = ( - set, - get -) => ({ - codexUsageScope: 'orca', - codexUsageRange: '30d', - codexUsageScanState: null, - codexUsageSummary: null, - codexUsageDaily: [], - codexUsageModelBreakdown: [], - codexUsageProjectBreakdown: [], - codexUsageRecentSessions: [], - - setCodexUsageEnabled: async (enabled) => { - try { - const nextScanState = (await window.api.codexUsage.setEnabled({ - enabled - })) as CodexUsageScanState | undefined - // Why: the web client (paired runtime) does not bridge the desktop-only - // usage IPC; its preload fallback resolves this call to `undefined`. Bail - // so the toggle no-ops instead of seeding an empty scan state and then - // crashing on the follow-up fetch. - if (!nextScanState) { - return - } - set({ - codexUsageScanState: enabled - ? { - ...nextScanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : nextScanState, - codexUsageSummary: null, - codexUsageDaily: [], - codexUsageModelBreakdown: [], - codexUsageProjectBreakdown: [], - codexUsageRecentSessions: [] - }) - if (enabled) { - await get().fetchCodexUsage({ forceRefresh: true }) - } - } catch (error) { - console.error('Failed to update Codex usage setting:', error) - } - }, - - setCodexUsageScope: async (scope) => { - set({ codexUsageScope: scope }) - await get().fetchCodexUsage() - }, - - setCodexUsageRange: async (range) => { - set({ codexUsageRange: range }) - await get().fetchCodexUsage() - }, - - fetchCodexUsage: async (opts) => { - try { - const scanState = (await window.api.codexUsage.getScanState()) as - | CodexUsageScanState - | undefined - // Why: in the web client the usage IPC is unavailable and the preload - // fallback resolves to `undefined`; reading `scanState.enabled` below would - // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an - // absent scan state as "usage unavailable" and stop. - if (!scanState) { - return - } - const currentScanState = get().codexUsageScanState - const shouldPreserveLoadingState = - opts?.forceRefresh === true && - currentScanState?.enabled === true && - get().codexUsageSummary === null - set({ - codexUsageScanState: shouldPreserveLoadingState - ? { - ...scanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : scanState - }) - if (!scanState.enabled) { - return - } - - const { codexUsageScope, codexUsageRange } = get() - const snapshot = (await window.api.codexUsage.getSnapshot({ - scope: codexUsageScope, - range: codexUsageRange, - limit: 10 - })) as CodexUsageSnapshot - const hasCachedSnapshot = - snapshot.scanState.lastScanCompletedAt !== null || snapshot.scanState.hasAnyCodexData - - if (hasCachedSnapshot) { - set({ - codexUsageScanState: - opts?.forceRefresh === true - ? { ...snapshot.scanState, isScanning: true } - : snapshot.scanState, - codexUsageSummary: snapshot.summary, - codexUsageDaily: snapshot.daily, - codexUsageModelBreakdown: snapshot.modelBreakdown, - codexUsageProjectBreakdown: snapshot.projectBreakdown, - codexUsageRecentSessions: snapshot.recentSessions - }) - } else { - set({ - codexUsageScanState: { - ...scanState, - isScanning: true, - lastScanError: null - } - }) - } - - await window.api.codexUsage.refresh({ - force: opts?.forceRefresh ?? false - }) - const { codexUsageScope: refreshedScope, codexUsageRange: refreshedRange } = get() - const refreshedSnapshot = (await window.api.codexUsage.getSnapshot({ - scope: refreshedScope, - range: refreshedRange, - limit: 10 - })) as CodexUsageSnapshot - - set({ - codexUsageScanState: refreshedSnapshot.scanState, - codexUsageSummary: refreshedSnapshot.summary, - codexUsageDaily: refreshedSnapshot.daily, - codexUsageModelBreakdown: refreshedSnapshot.modelBreakdown, - codexUsageProjectBreakdown: refreshedSnapshot.projectBreakdown, - codexUsageRecentSessions: refreshedSnapshot.recentSessions - }) - } catch (error) { - console.error('Failed to fetch Codex usage:', error) - } - }, - - enableCodexUsage: async () => { - await get().setCodexUsageEnabled(true) - }, - - refreshCodexUsage: async () => { - await get().fetchCodexUsage({ forceRefresh: true }) - } -}) diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 49c0ae51b65..c6908d4ad6a 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -122,9 +122,11 @@ import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' -import { createClaudeUsageSlice } from './claude-usage' -import { createCodexUsageSlice } from './codex-usage' -import { createOpenCodeUsageSlice } from './opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './usage-provider-slices' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' diff --git a/src/renderer/src/store/slices/github-repo-lookup-index.test.ts b/src/renderer/src/store/slices/github-repo-lookup-index.test.ts new file mode 100644 index 00000000000..41918b28c07 --- /dev/null +++ b/src/renderer/src/store/slices/github-repo-lookup-index.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../../shared/types' +import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' + +function makeRepo(id: string, path: string): Repo { + return { id, path, displayName: id, badgeColor: 'blue', addedAt: 1, kind: 'git' } +} + +describe('getGitHubRepoLookupIndex', () => { + it('retains the first duplicate after indexing later rows', () => { + const first = makeRepo('duplicate', '/first') + const second = makeRepo('duplicate', '/second') + const later = makeRepo('later', '/later') + const lookup = getGitHubRepoLookupIndex([first, second, later]) + + expect(lookup.findById('duplicate')).toBe(first) + expect(lookup.findById('later')).toBe(later) + expect(lookup.findById('duplicate')).toBe(first) + }) + + it('matches Array.find ordering for combined ID and path lookups', () => { + const pathMatch = makeRepo('other', '/target') + const idMatch = makeRepo('target', '/other') + const lookup = getGitHubRepoLookupIndex([pathMatch, idMatch]) + + expect(lookup.findByIdOrPath('target', '/target')).toBe(pathMatch) + }) + + it('scans each repo once across repeated misses', () => { + let idReads = 0 + const repos = Array.from({ length: 32 }, (_, index) => { + const repo = makeRepo(`repo-${index}`, `/repo-${index}`) + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + idReads += 1 + return `repo-${index}` + } + }) + }) + const lookup = getGitHubRepoLookupIndex(repos) + + expect(lookup.findById('missing-first')).toBeUndefined() + expect(lookup.findById('missing-second')).toBeUndefined() + expect(idReads).toBe(repos.length) + }) + + it('stops after a sparse first-row match', () => { + let idReads = 0 + const repos = Array.from({ length: 32 }, (_, index) => { + const repo = makeRepo(`repo-${index}`, `/repo-${index}`) + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + idReads += 1 + return `repo-${index}` + } + }) + }) + + expect(getGitHubRepoLookupIndex(repos).findById('repo-0')).toBe(repos[0]) + expect(idReads).toBe(1) + }) +}) diff --git a/src/renderer/src/store/slices/github-repo-lookup-index.ts b/src/renderer/src/store/slices/github-repo-lookup-index.ts new file mode 100644 index 00000000000..0c21a2fbd50 --- /dev/null +++ b/src/renderer/src/store/slices/github-repo-lookup-index.ts @@ -0,0 +1,72 @@ +import type { Repo } from '../../../../shared/types' + +type IndexedRepo = { + index: number + repo: Repo +} + +export type GitHubRepoLookupIndex = { + findById: (repoId: string) => Repo | undefined + findByPath: (repoPath: string) => Repo | undefined + findByIdOrPath: (repoId: string | undefined, repoPath: string) => Repo | undefined +} + +// Why: repo identity/path updates replace this array, while weak ownership avoids retaining superseded snapshots. +const lookupByRepos = new WeakMap() +const EMPTY_REPOS: readonly Repo[] = [] + +export function getGitHubRepoLookupIndex( + repos: readonly Repo[] | undefined +): GitHubRepoLookupIndex { + const repoRows = repos ?? EMPTY_REPOS + const cached = lookupByRepos.get(repoRows) + if (cached) { + return cached + } + + const byId = new Map() + const byPath = new Map() + let indexedCount = 0 + + const scanUntil = (matches: (repoId: string, repoPath: string) => boolean): Repo | undefined => { + while (indexedCount < repoRows.length) { + const index = indexedCount + const repo = repoRows[index] + indexedCount += 1 + const repoId = repo.id + const repoPath = repo.path + if (!byId.has(repoId)) { + byId.set(repoId, { index, repo }) + } + if (!byPath.has(repoPath)) { + byPath.set(repoPath, { index, repo }) + } + if (matches(repoId, repoPath)) { + return repo + } + } + return undefined + } + + const lookup: GitHubRepoLookupIndex = { + findById: (repoId) => byId.get(repoId)?.repo ?? scanUntil((id) => id === repoId), + findByPath: (repoPath) => + byPath.get(repoPath)?.repo ?? scanUntil((_id, path) => path === repoPath), + findByIdOrPath: (repoId, repoPath) => { + if (!repoId) { + return lookup.findByPath(repoPath) + } + const idMatch = byId.get(repoId) + const pathMatch = byPath.get(repoPath) + if (idMatch || pathMatch) { + if (!pathMatch || (idMatch && idMatch.index < pathMatch.index)) { + return idMatch?.repo + } + return pathMatch.repo + } + return scanUntil((id, path) => id === repoId || path === repoPath) + } + } + lookupByRepos.set(repoRows, lookup) + return lookup +} diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index 1040bdee41f..f77b8577a94 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -5551,6 +5551,447 @@ describe('createGitHubSlice.refreshGitHubForWorktreeIfStale', () => { describe('createGitHubSlice.refreshAllGitHub', () => { beforeEach(() => { vi.clearAllMocks() + resetRemoteRuntimeMocks() + }) + + it('publishes no store update when the cache sweep changes nothing', () => { + const store = createTestStore() + store.setState({ + repos: [{ id: 'repo-1', path: '/repo', name: 'repo', kind: 'git' }], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + worktreesByRepo: { 'repo-1': [makePRRefreshWorktree()] } + } as unknown as Partial) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().refreshAllGitHub() + unsubscribe() + + expect(publications).toBe(0) + }) + + it('still clears populated comments when no workspace refresh is needed', () => { + const store = createTestStore() + store.setState({ + commentsCache: { cached: { data: [], fetchedAt: 1 } }, + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false + } as unknown as Partial) + let publications = 0 + const unsubscribe = store.subscribe(() => { + publications += 1 + }) + + store.getState().refreshAllGitHub() + unsubscribe() + + expect(store.getState().commentsCache).toEqual({}) + expect(publications).toBe(1) + }) + + it('skips repo and worktree identity reads when no GitHub decoration is visible', () => { + const store = createTestStore() + const repoId = 'repo-idle' + let repoIdentityReads = 0 + let worktreeRepoIdentityReads = 0 + const repo = { id: repoId, path: '/idle', name: 'idle', kind: 'git' as const } + const worktree = makePRRefreshWorktree({ + id: 'wt-idle', + repoId, + path: '/idle/worktrees/idle', + branch: 'feature/idle' + }) + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + Object.defineProperty(worktree, 'repoId', { + configurable: true, + enumerable: true, + get: () => { + worktreeRepoIdentityReads += 1 + return repoId + } + }) + store.setState({ + repos: [repo], + groupBy: 'repo', + worktreeCardProperties: ['comment'], + rightSidebarOpen: false, + commentsCache: { cached: { data: [], fetchedAt: 1 } }, + worktreesByRepo: { [repoId]: [worktree] } + } as unknown as Partial) + repoIdentityReads = 0 + worktreeRepoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(store.getState().commentsCache).toEqual({}) + expect(repoIdentityReads).toBe(0) + expect(worktreeRepoIdentityReads).toBe(0) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + expect(mockApi.gh.issue).not.toHaveBeenCalled() + }) + + it('bounds stale PR repo identity reads to a constant amount per repo and worktree', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repoIds = Array.from({ length: repoCount }, (_, index) => `repo-${index}`) + const repos = repoIds.map((repoId) => { + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repoIds.map((repoId, index) => [ + repoId, + [ + makePRRefreshWorktree({ + id: `wt-${index}`, + repoId, + path: `/${repoId}/worktrees/feature`, + branch: `feature/${index}`, + lastActivityAt: index + }) + ] + ]) + ) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 5) + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledTimes(5) + }) + + it('stops repo indexing after a sparse enabled match', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `repo-${index}` + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo: { + 'repo-0': [ + makePRRefreshWorktree({ + id: 'wt-sparse', + repoId: 'repo-0', + path: '/repo-0/worktrees/feature', + branch: 'feature/sparse' + }) + ] + } + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(5) + expect(mockApi.gh.enqueuePRRefresh).toHaveBeenCalledOnce() + }) + + it('does not restart repo indexing for repeated missing IDs', () => { + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `repo-${index}` + const repo = { id: repoId, path: `/${repoId}`, name: repoId, kind: 'git' as const } + return Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + }) + const missingWorktrees = Array.from({ length: repoCount }, (_, index) => + makePRRefreshWorktree({ + id: `wt-missing-${index}`, + repoId: `missing-${index}`, + branch: `feature/missing-${index}` + }) + ) + store.setState({ + repos, + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + worktreesByRepo: { missing: missingWorktrees } + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount) + expect(mockApi.gh.enqueuePRRefresh).not.toHaveBeenCalled() + }) + + it('keeps runtime PR dispatch identity reads linear in PR-status grouping', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-linear-pr', + ok: true, + result: makePR({ number: 12 }), + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + let worktreeIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `runtime-repo-${index}` + const repoPath = `/runtime/repo-${index}` + const repo = { + id: repoId, + path: repoPath, + name: repoId, + kind: 'git' as const, + executionHostId: 'runtime:env-1' + } + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + return Object.defineProperty(repo, 'path', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoPath + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repos.map((repo, index) => { + const worktreeId = `runtime-wt-${index}` + const worktree = makePRRefreshWorktree({ + id: worktreeId, + repoId: repo.id, + path: `${repo.path}/worktrees/feature`, + branch: `feature/${index}`, + linkedPR: 12, + lastActivityAt: index + }) + Object.defineProperty(worktree, 'id', { + configurable: true, + enumerable: true, + get: () => { + worktreeIdentityReads += 1 + return worktreeId + } + }) + return [repo.id, [worktree]] + }) + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos, + groupBy: 'pr-status', + worktreeCardProperties: ['comment'], + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + worktreeIdentityReads = 0 + + store.getState().refreshAllGitHub() + + await vi.waitFor(() => expect(Object.keys(store.getState().prCache)).toHaveLength(repoCount)) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(repoCount) + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 20) + expect(worktreeIdentityReads).toBeLessThanOrEqual(repoCount * 5) + }) + + it('keeps runtime issue dispatch repo identity reads linear', async () => { + runtimeEnvironmentCall.mockResolvedValue({ + id: 'rpc-linear-issue', + ok: true, + result: null, + _meta: { runtimeId: 'remote-runtime' } + }) + const store = createTestStore() + const repoCount = 128 + let repoIdentityReads = 0 + const repos = Array.from({ length: repoCount }, (_, index) => { + const repoId = `issue-repo-${index}` + const repoPath = `/runtime/issues-${index}` + const repo = { + id: repoId, + path: repoPath, + name: repoId, + kind: 'git' as const, + executionHostId: 'runtime:env-1' + } + Object.defineProperty(repo, 'id', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoId + } + }) + return Object.defineProperty(repo, 'path', { + configurable: true, + enumerable: true, + get: () => { + repoIdentityReads += 1 + return repoPath + } + }) + }) + const worktreesByRepo = Object.fromEntries( + repos.map((repo, index) => [ + repo.id, + [ + makePRRefreshWorktree({ + id: `issue-wt-${index}`, + repoId: repo.id, + path: `${repo.path}/worktrees/feature`, + linkedIssue: index + 1 + }) + ] + ]) + ) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as AppState['settings'], + repos, + groupBy: 'repo', + worktreeCardProperties: ['issue'], + worktreesByRepo + } as unknown as Partial) + repoIdentityReads = 0 + + store.getState().refreshAllGitHub() + + await vi.waitFor(() => expect(Object.keys(store.getState().issueCache)).toHaveLength(repoCount)) + expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(repoCount) + expect(repoIdentityReads).toBeLessThanOrEqual(repoCount * 15) + }) + + it('keeps the first repo owner when duplicate IDs span hosts', () => { + const store = createTestStore() + const repoId = 'duplicate-repo' + const firstRepo = { + id: repoId, + path: '/first', + name: 'first', + kind: 'git' as const, + connectionId: 'first', + executionHostId: 'ssh:first' + } + const secondRepo = { + id: repoId, + path: '/second', + name: 'second', + kind: 'git' as const, + connectionId: 'second', + executionHostId: 'ssh:second' + } + const laterRepo = { + id: 'later-repo', + path: '/later', + name: 'later', + kind: 'git' as const + } + store.setState({ + repos: [firstRepo, secondRepo, laterRepo], + groupBy: 'repo', + worktreeCardProperties: ['pr'], + rightSidebarOpen: false, + sshConnectionStates: new Map([ + ['first', { status: 'connected' }], + ['second', { status: 'connected' }] + ]), + worktreesByRepo: { + first: [ + makePRRefreshWorktree({ + id: 'wt-duplicate-first', + repoId, + path: '/first/worktrees/feature', + branch: 'feature/duplicate-first' + }) + ], + middle: [ + makePRRefreshWorktree({ + id: 'wt-later', + repoId: laterRepo.id, + path: '/later/worktrees/feature', + branch: 'feature/later' + }) + ], + last: [ + makePRRefreshWorktree({ + id: 'wt-duplicate-last', + repoId, + path: '/first/worktrees/last', + branch: 'feature/duplicate-last' + }) + ] + } + } as unknown as Partial) + + store.getState().refreshAllGitHub() + + const duplicateCandidates = mockApi.gh.enqueuePRRefresh.mock.calls + .map(([call]) => call.candidate) + .filter((candidate) => candidate.repoId === repoId) + expect(duplicateCandidates).toHaveLength(2) + expect(duplicateCandidates).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + repoPath: '/first', + connectionId: 'first', + executionHostId: 'ssh:first' + }), + expect.objectContaining({ + repoPath: '/first', + connectionId: 'first', + executionHostId: 'ssh:first' + }) + ]) + ) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() }) it('refreshes stale PR data when source control is the visible PR surface', () => { diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 6a214736378..7ab87e09731 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -77,6 +77,7 @@ import { import { normalizeGitHubPRForBranchOutcome } from '../../../../shared/github-pr-for-branch-outcome' import { restoreReactionOnSubject, setReactionOnSubject } from '@/lib/pr-comment-reactions' import { withGitHubCheckDetailsTimeout } from '@/runtime/github-check-details-timeout' +import { getGitHubRepoLookupIndex } from './github-repo-lookup-index' // ─── ProjectV2 cache types ──────────────────────────────────────────── // Why: separate from CacheEntry — project-view has a single GraphQL source (no issue/PR fallback) and a distinct error union. @@ -126,7 +127,7 @@ function getRuntimeRepoTarget( if (target.kind !== 'environment') { return null } - const repo = state.repos.find((candidate) => candidate.path === repoPath) + const repo = getGitHubRepoLookupIndex(state.repos).findByPath(repoPath) return repo ? { target, repo } : null } @@ -250,9 +251,9 @@ function findRepoForGitHubOwner( repoId: string | undefined, repoPath: string ): Repo | undefined { - return (state.repos ?? []).find((candidate) => - repoId ? candidate.id === repoId || candidate.path === repoPath : candidate.path === repoPath - ) + return state.repos + ? getGitHubRepoLookupIndex(state.repos).findByIdOrPath(repoId, repoPath) + : undefined } function getGitHubFocusedRepoOwnerHostId( @@ -963,13 +964,7 @@ function getPRChecksCacheTtl(entry: CacheEntry | undefined): nu } function findWorktreeById(state: AppState, worktreeId: string): Worktree | null { - for (const worktrees of Object.values(state.worktreesByRepo)) { - const worktree = worktrees.find((w) => w.id === worktreeId) - if (worktree) { - return worktree - } - } - return null + return getWorktreeLookupIndex(state).byId.get(worktreeId)?.first ?? null } type WorktreeLookupEntry = { @@ -982,9 +977,12 @@ type WorktreeLookupIndex = { repoHostIdsByRepoId: Map> } +const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} +const EMPTY_WORKTREE_REPOS: AppState['repos'] = [] + function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { const byId = new Map() - for (const worktrees of Object.values(state.worktreesByRepo)) { + for (const worktrees of Object.values(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO)) { for (const worktree of worktrees) { const worktreeId = worktree.id const existing = byId.get(worktreeId) @@ -1008,11 +1006,29 @@ function buildWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { return { byId, repoHostIdsByRepoId } } +// Why: worktree/owner updates replace these snapshots, while weak ownership avoids retaining superseded state. +const worktreeLookupIndexes = new WeakMap< + AppState['worktreesByRepo'], + { repos: AppState['repos']; index: WorktreeLookupIndex } +>() + +function getWorktreeLookupIndex(state: AppState): WorktreeLookupIndex { + const worktreesByRepo = state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO + const repos = state.repos ?? EMPTY_WORKTREE_REPOS + const cached = worktreeLookupIndexes.get(worktreesByRepo) + if (cached && cached.repos === repos) { + return cached.index + } + const index = buildWorktreeLookupIndex(state) + worktreeLookupIndexes.set(worktreesByRepo, { repos, index }) + return index +} + function findUniqueWorktreeById( state: AppState, worktreeId: string, executionHostId?: string, - lookupIndex = buildWorktreeLookupIndex(state) + lookupIndex = getWorktreeLookupIndex(state) ): Worktree | null { const match = lookupIndex.byId.get(worktreeId)?.unique ?? null // Why: metadata persistence is keyed only by worktree id; an id owned by two hosts is non-unique so destructive clears fail closed. @@ -1131,9 +1147,10 @@ function shouldApplyBranchMismatchedLinkedPRClear(args: { function buildPRRefreshCandidate( state: AppState, worktree: Worktree, - repoPath?: string + repoPath?: string, + repoOverride?: Repo ): GitHubPRRefreshCandidate | null { - const repo = state.repos.find((r) => r.id === worktree.repoId) + const repo = repoOverride ?? getGitHubRepoLookupIndex(state.repos).findById(worktree.repoId) if (!repo) { return null } @@ -2990,9 +3007,10 @@ export const createGitHubSlice: StateCreator = (s }, fetchPRForBranch: async (repoPath, branch, options): Promise => { - const repo = get().repos?.find((candidate) => - options?.repoId ? candidate.id === options.repoId : candidate.path === repoPath - ) + const repoLookup = getGitHubRepoLookupIndex(get().repos) + const repo = options?.repoId + ? repoLookup.findById(options.repoId) + : repoLookup.findByPath(repoPath) const repoId = options?.repoId ?? repo?.id const requestSettings = settingsForGitHubRepoOwner(get().settings, repo) const cacheKey = prCacheKey( @@ -4421,22 +4439,33 @@ export const createGitHubSlice: StateCreator = (s refreshAllGitHub: () => { // Clear comments cache; evict stale entries to bound long-session growth across repos/branches. - set((s) => ({ - commentsCache: {}, - prCache: evictStaleEntries(s.prCache), - issueCache: evictStaleEntries(s.issueCache), - checksCache: evictStaleEntries(s.checksCache), - workItemsCache: evictStaleEntries(s.workItemsCache), - projectViewCache: evictStaleEntries(s.projectViewCache), - prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) - })) + set((s) => { + const next = { + commentsCache: Object.keys(s.commentsCache).length === 0 ? s.commentsCache : {}, + prCache: evictStaleEntries(s.prCache), + issueCache: evictStaleEntries(s.issueCache), + checksCache: evictStaleEntries(s.checksCache), + workItemsCache: evictStaleEntries(s.workItemsCache), + projectViewCache: evictStaleEntries(s.projectViewCache), + prRefreshStates: pruneExpiredPRRefreshStates(s.prRefreshStates) + } + // Why: each eviction helper returns its input untouched when nothing changed, so an + // unchanged sweep can return `s` and avoid waking every subscriber on window resume. + return next.commentsCache === s.commentsCache && + next.prCache === s.prCache && + next.issueCache === s.issueCache && + next.checksCache === s.checksCache && + next.workItemsCache === s.workItemsCache && + next.projectViewCache === s.projectViewCache && + next.prRefreshStates === s.prRefreshStates + ? s + : next + }) // Why: don't prune prRequestGenerations here — deleting a live generation makes its response look stale. // Only re-fetch PR/issue entries that are already stale — skip fresh ones const state = get() - const now = Date.now() - const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] const cardProps = state.worktreeCardProperties ?? [] const rawCardProps = cardProps as readonly string[] const shouldRefreshIssues = shouldRefreshIssueDecorations(state) @@ -4448,10 +4477,17 @@ export const createGitHubSlice: StateCreator = (s (state.settings?.experimentalNewWorktreeCardStyle === true ? cardProps.includes('status') : cardProps.includes('pr') || rawCardProps.includes('ci')) + if (!shouldRefreshPRs && !shouldRefreshIssues) { + return + } + + const now = Date.now() + const stalePRCandidates: { candidate: GitHubPRRefreshCandidate; score: number }[] = [] + const repoLookup = getGitHubRepoLookupIndex(state.repos) for (const worktrees of Object.values(state.worktreesByRepo)) { for (const wt of worktrees) { - const repo = state.repos.find((r) => r.id === wt.repoId) + const repo = repoLookup.findById(wt.repoId) if (!repo) { continue } @@ -4469,7 +4505,7 @@ export const createGitHubSlice: StateCreator = (s ) const prEntry = state.prCache[prKey] if (!prEntry || now - prEntry.fetchedAt >= CACHE_TTL) { - const candidate = buildPRRefreshCandidate(state, wt) + const candidate = buildPRRefreshCandidate(state, wt, undefined, repo) if (candidate) { stalePRCandidates.push({ candidate, diff --git a/src/renderer/src/store/slices/opencode-usage.ts b/src/renderer/src/store/slices/opencode-usage.ts deleted file mode 100644 index 87c28e7822d..00000000000 --- a/src/renderer/src/store/slices/opencode-usage.ts +++ /dev/null @@ -1,181 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { - OpenCodeUsageBreakdownRow, - OpenCodeUsageDailyPoint, - OpenCodeUsageRange, - OpenCodeUsageScanState, - OpenCodeUsageScope, - OpenCodeUsageSessionRow, - OpenCodeUsageSnapshot, - OpenCodeUsageSummary -} from '../../../../shared/opencode-usage-types' -import type { AppState } from '../types' - -export type OpenCodeUsageSlice = { - openCodeUsageScope: OpenCodeUsageScope - openCodeUsageRange: OpenCodeUsageRange - openCodeUsageScanState: OpenCodeUsageScanState | null - openCodeUsageSummary: OpenCodeUsageSummary | null - openCodeUsageDaily: OpenCodeUsageDailyPoint[] - openCodeUsageModelBreakdown: OpenCodeUsageBreakdownRow[] - openCodeUsageProjectBreakdown: OpenCodeUsageBreakdownRow[] - openCodeUsageRecentSessions: OpenCodeUsageSessionRow[] - setOpenCodeUsageEnabled: (enabled: boolean) => Promise - setOpenCodeUsageScope: (scope: OpenCodeUsageScope) => Promise - setOpenCodeUsageRange: (range: OpenCodeUsageRange) => Promise - fetchOpenCodeUsage: (opts?: { forceRefresh?: boolean }) => Promise - enableOpenCodeUsage: () => Promise - refreshOpenCodeUsage: () => Promise -} - -export const createOpenCodeUsageSlice: StateCreator = ( - set, - get -) => ({ - openCodeUsageScope: 'orca', - openCodeUsageRange: '30d', - openCodeUsageScanState: null, - openCodeUsageSummary: null, - openCodeUsageDaily: [], - openCodeUsageModelBreakdown: [], - openCodeUsageProjectBreakdown: [], - openCodeUsageRecentSessions: [], - - setOpenCodeUsageEnabled: async (enabled) => { - try { - const nextScanState = (await window.api.openCodeUsage.setEnabled({ - enabled - })) as OpenCodeUsageScanState | undefined - // Why: the web client (paired runtime) does not bridge the desktop-only - // usage IPC; its preload fallback resolves this call to `undefined`. Bail - // so the toggle no-ops instead of seeding an empty scan state and then - // crashing on the follow-up fetch. - if (!nextScanState) { - return - } - set({ - openCodeUsageScanState: enabled - ? { - ...nextScanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : nextScanState, - openCodeUsageSummary: null, - openCodeUsageDaily: [], - openCodeUsageModelBreakdown: [], - openCodeUsageProjectBreakdown: [], - openCodeUsageRecentSessions: [] - }) - if (enabled) { - await get().fetchOpenCodeUsage({ forceRefresh: true }) - } - } catch (error) { - console.error('Failed to update OpenCode usage setting:', error) - } - }, - - setOpenCodeUsageScope: async (scope) => { - set({ openCodeUsageScope: scope }) - await get().fetchOpenCodeUsage() - }, - - setOpenCodeUsageRange: async (range) => { - set({ openCodeUsageRange: range }) - await get().fetchOpenCodeUsage() - }, - - fetchOpenCodeUsage: async (opts) => { - try { - const scanState = (await window.api.openCodeUsage.getScanState()) as - | OpenCodeUsageScanState - | undefined - // Why: in the web client the usage IPC is unavailable and the preload - // fallback resolves to `undefined`; reading `scanState.enabled` below would - // throw `Cannot read properties of undefined (reading 'enabled')`. Treat an - // absent scan state as "usage unavailable" and stop. - if (!scanState) { - return - } - const currentScanState = get().openCodeUsageScanState - const shouldPreserveLoadingState = - opts?.forceRefresh === true && - currentScanState?.enabled === true && - get().openCodeUsageSummary === null - set({ - openCodeUsageScanState: shouldPreserveLoadingState - ? { - ...scanState, - isScanning: true, - lastScanCompletedAt: null, - lastScanError: null - } - : scanState - }) - if (!scanState.enabled) { - return - } - - const { openCodeUsageScope, openCodeUsageRange } = get() - const snapshot = (await window.api.openCodeUsage.getSnapshot({ - scope: openCodeUsageScope, - range: openCodeUsageRange, - limit: 10 - })) as OpenCodeUsageSnapshot - const hasCachedSnapshot = - snapshot.scanState.lastScanCompletedAt !== null || snapshot.scanState.hasAnyOpenCodeData - - if (hasCachedSnapshot) { - set({ - openCodeUsageScanState: - opts?.forceRefresh === true - ? { ...snapshot.scanState, isScanning: true } - : snapshot.scanState, - openCodeUsageSummary: snapshot.summary, - openCodeUsageDaily: snapshot.daily, - openCodeUsageModelBreakdown: snapshot.modelBreakdown, - openCodeUsageProjectBreakdown: snapshot.projectBreakdown, - openCodeUsageRecentSessions: snapshot.recentSessions - }) - } else { - set({ - openCodeUsageScanState: { - ...scanState, - isScanning: true, - lastScanError: null - } - }) - } - - await window.api.openCodeUsage.refresh({ - force: opts?.forceRefresh ?? false - }) - const { openCodeUsageScope: refreshedScope, openCodeUsageRange: refreshedRange } = get() - const refreshedSnapshot = (await window.api.openCodeUsage.getSnapshot({ - scope: refreshedScope, - range: refreshedRange, - limit: 10 - })) as OpenCodeUsageSnapshot - - set({ - openCodeUsageScanState: refreshedSnapshot.scanState, - openCodeUsageSummary: refreshedSnapshot.summary, - openCodeUsageDaily: refreshedSnapshot.daily, - openCodeUsageModelBreakdown: refreshedSnapshot.modelBreakdown, - openCodeUsageProjectBreakdown: refreshedSnapshot.projectBreakdown, - openCodeUsageRecentSessions: refreshedSnapshot.recentSessions - }) - } catch (error) { - console.error('Failed to fetch OpenCode usage:', error) - } - }, - - enableOpenCodeUsage: async () => { - await get().setOpenCodeUsageEnabled(true) - }, - - refreshOpenCodeUsage: async () => { - await get().fetchOpenCodeUsage({ forceRefresh: true }) - } -}) diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 1380c311579..06de2c2b9ae 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -218,7 +218,7 @@ describe('removeWorktree cascade', () => { expect(result).toEqual({ ok: true, - preservedBranch: { branchName: 'feature/test', head: 'def456' } + preservedBranch: { branchName: 'feature/test', head: 'def456', hostId: 'local' } }) expect(toast.warning).toHaveBeenCalledWith('Worktree deleted, branch kept', { id: 'preserved-branch:feature/test:def456', @@ -254,7 +254,7 @@ describe('removeWorktree cascade', () => { expect(result).toEqual({ ok: true, - preservedBranch: { branchName: 'feature/test', head: 'def456' } + preservedBranch: { branchName: 'feature/test', head: 'def456', hostId: 'local' } }) expect(toast.warning).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/store-test-helpers.ts b/src/renderer/src/store/slices/store-test-helpers.ts index 6a342f8c5e2..a4117e25260 100644 --- a/src/renderer/src/store/slices/store-test-helpers.ts +++ b/src/renderer/src/store/slices/store-test-helpers.ts @@ -25,9 +25,11 @@ import { createEditorSlice } from './editor' import { createStatsSlice } from './stats' import { createMemorySlice } from './memory' import { createWorkspaceSpaceSlice } from './workspace-space' -import { createClaudeUsageSlice } from './claude-usage' -import { createCodexUsageSlice } from './codex-usage' -import { createOpenCodeUsageSlice } from './opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './usage-provider-slices' import { createBrowserSlice } from './browser' import { createRateLimitSlice } from './rate-limits' import { createSshSlice } from './ssh' diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index e1b9c89e6ab..125db58b7d4 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -792,6 +792,7 @@ export type UISlice = { | 'create-worktree' | 'edit-meta' | 'delete-worktree' + | 'preserved-branch-review' | 'forget-ssh-workspace' | 'confirm-add-project-from-folder' | 'confirm-non-git-folder' diff --git a/src/renderer/src/store/slices/usage-provider-slices.ts b/src/renderer/src/store/slices/usage-provider-slices.ts new file mode 100644 index 00000000000..16855b08a48 --- /dev/null +++ b/src/renderer/src/store/slices/usage-provider-slices.ts @@ -0,0 +1,299 @@ +import type { StateCreator } from 'zustand' +import type { + ClaudeUsageRange, + ClaudeUsageScope, + ClaudeUsageSnapshot +} from '../../../../shared/claude-usage-types' +import type { + CodexUsageRange, + CodexUsageScope, + CodexUsageSnapshot +} from '../../../../shared/codex-usage-types' +import type { + OpenCodeUsageRange, + OpenCodeUsageScope, + OpenCodeUsageSnapshot +} from '../../../../shared/opencode-usage-types' +import type { AppState } from '../types' + +type UsageSnapshot = { + scanState: { + enabled: boolean + isScanning: boolean + lastScanCompletedAt: number | null + lastScanError: string | null + } + summary: object + daily: object[] + modelBreakdown: object[] + projectBreakdown: object[] + recentSessions: object[] +} + +type UsageShape = { + scope: Scope + range: Range + snapshot: Snapshot +} + +type UsageData> = { + scope: T['scope'] + range: T['range'] + scanState: T['snapshot']['scanState'] | null + summary: T['snapshot']['summary'] | null + daily: T['snapshot']['daily'] + modelBreakdown: T['snapshot']['modelBreakdown'] + projectBreakdown: T['snapshot']['projectBreakdown'] + recentSessions: T['snapshot']['recentSessions'] +} + +type UsageApi> = { + getScanState: () => Promise + setEnabled: (args: { enabled: boolean }) => Promise + refresh: (args?: { force?: boolean }) => Promise + getSnapshot: (args: { + scope: T['scope'] + range: T['range'] + limit?: number + }) => Promise +} + +type ProviderUsageSlice< + Prefix extends string, + Name extends string, + T extends UsageShape +> = { + [K in keyof UsageData as `${Prefix}Usage${Capitalize}`]: UsageData[K] +} & Record<`set${Name}UsageEnabled`, (enabled: boolean) => Promise> & + Record<`set${Name}UsageScope`, (scope: T['scope']) => Promise> & + Record<`set${Name}UsageRange`, (range: T['range']) => Promise> & + Record<`fetch${Name}Usage`, (opts?: { forceRefresh?: boolean }) => Promise> & + Record<`enable${Name}Usage`, () => Promise> & + Record<`refresh${Name}Usage`, () => Promise> + +type UsageProviderConfig< + Prefix extends string, + Name extends string, + T extends UsageShape +> = { + prefix: Prefix + name: Name + initialScope: T['scope'] + initialRange: T['range'] + getApi: () => UsageApi + hasCachedData: (scanState: T['snapshot']['scanState']) => boolean +} + +const usageDataFields = [ + 'scope', + 'range', + 'scanState', + 'summary', + 'daily', + 'modelBreakdown', + 'projectBreakdown', + 'recentSessions' +] as const satisfies readonly (keyof UsageData>)[] + +function usageDataKey(prefix: string, field: string): string { + return `${prefix}Usage${field[0].toUpperCase()}${field.slice(1)}` +} + +function readUsageData>( + state: AppState, + prefix: string +): UsageData { + const values = state as unknown as Record + return Object.fromEntries( + usageDataFields.map((field) => [field, values[usageDataKey(prefix, field)]]) + ) as UsageData +} + +function createUsagePatch>( + prefix: string, + patch: Partial> +): Partial { + return Object.fromEntries( + usageDataFields + .filter((field) => field in patch) + .map((field) => [usageDataKey(prefix, field), patch[field]]) + ) as Partial +} + +function createUsageProviderSlice< + Prefix extends string, + Name extends string, + T extends UsageShape +>( + config: UsageProviderConfig +): StateCreator> { + return (set, get) => { + const update = (patch: Partial>): void => + set(createUsagePatch(config.prefix, patch)) + const read = (): UsageData => readUsageData(get(), config.prefix) + + const fetchUsage = async (opts?: { forceRefresh?: boolean }): Promise => { + try { + const api = config.getApi() + const scanState = (await api.getScanState()) as T['snapshot']['scanState'] | undefined + // Desktop-only usage APIs resolve undefined in paired web clients. + if (!scanState) { + return + } + + const current = read() + const preserveLoading = + opts?.forceRefresh === true && + current.scanState?.enabled === true && + current.summary === null + update({ + scanState: preserveLoading + ? { + ...scanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : scanState + }) + if (!scanState.enabled) { + return + } + + const selection = read() + const snapshot = await api.getSnapshot({ + scope: selection.scope, + range: selection.range, + limit: 10 + }) + if ( + snapshot.scanState.lastScanCompletedAt !== null || + config.hasCachedData(snapshot.scanState) + ) { + update({ + ...snapshot, + scanState: + opts?.forceRefresh === true + ? { ...snapshot.scanState, isScanning: true } + : snapshot.scanState + }) + } else { + update({ scanState: { ...scanState, isScanning: true, lastScanError: null } }) + } + + await api.refresh({ force: opts?.forceRefresh ?? false }) + const refreshedSelection = read() + update( + await api.getSnapshot({ + scope: refreshedSelection.scope, + range: refreshedSelection.range, + limit: 10 + }) + ) + } catch (error) { + console.error(`Failed to fetch ${config.name} usage:`, error) + } + } + + const setEnabled = async (enabled: boolean): Promise => { + try { + const nextScanState = (await config.getApi().setEnabled({ enabled })) as + | T['snapshot']['scanState'] + | undefined + if (!nextScanState) { + return + } + update({ + scanState: enabled + ? { + ...nextScanState, + isScanning: true, + lastScanCompletedAt: null, + lastScanError: null + } + : nextScanState, + summary: null, + daily: [], + modelBreakdown: [], + projectBreakdown: [], + recentSessions: [] + }) + if (enabled) { + await fetchUsage({ forceRefresh: true }) + } + } catch (error) { + console.error(`Failed to update ${config.name} usage setting:`, error) + } + } + + const initialData: UsageData = { + scope: config.initialScope, + range: config.initialRange, + scanState: null, + summary: null, + daily: [], + modelBreakdown: [], + projectBreakdown: [], + recentSessions: [] + } + + return { + ...createUsagePatch(config.prefix, initialData), + [`set${config.name}UsageEnabled`]: setEnabled, + [`set${config.name}UsageScope`]: async (scope: T['scope']) => { + update({ scope }) + await fetchUsage() + }, + [`set${config.name}UsageRange`]: async (range: T['range']) => { + update({ range }) + await fetchUsage() + }, + [`fetch${config.name}Usage`]: fetchUsage, + [`enable${config.name}Usage`]: () => setEnabled(true), + [`refresh${config.name}Usage`]: () => fetchUsage({ forceRefresh: true }) + } as ProviderUsageSlice + } +} + +type ClaudeUsageShape = UsageShape +type CodexUsageShape = UsageShape +type OpenCodeUsageShape = UsageShape + +export type ClaudeUsageSlice = ProviderUsageSlice<'claude', 'Claude', ClaudeUsageShape> +export type CodexUsageSlice = ProviderUsageSlice<'codex', 'Codex', CodexUsageShape> +export type OpenCodeUsageSlice = ProviderUsageSlice<'openCode', 'OpenCode', OpenCodeUsageShape> + +export const createClaudeUsageSlice = createUsageProviderSlice< + 'claude', + 'Claude', + ClaudeUsageShape +>({ + prefix: 'claude', + name: 'Claude', + initialScope: 'orca', + initialRange: '30d', + getApi: () => window.api.claudeUsage, + hasCachedData: (state) => state.hasAnyClaudeData +}) + +export const createCodexUsageSlice = createUsageProviderSlice<'codex', 'Codex', CodexUsageShape>({ + prefix: 'codex', + name: 'Codex', + initialScope: 'orca', + initialRange: '30d', + getApi: () => window.api.codexUsage, + hasCachedData: (state) => state.hasAnyCodexData +}) + +export const createOpenCodeUsageSlice = createUsageProviderSlice< + 'openCode', + 'OpenCode', + OpenCodeUsageShape +>({ + prefix: 'openCode', + name: 'OpenCode', + initialScope: 'orca', + initialRange: '30d', + getApi: () => window.api.openCodeUsage, + hasCachedData: (state) => state.hasAnyOpenCodeData +}) diff --git a/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts b/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts index 1e4c7638cd1..7e2bea1f2ed 100644 --- a/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts +++ b/src/renderer/src/store/slices/usage-snapshot-refresh.benchmark.test.ts @@ -7,7 +7,7 @@ import type { CodexUsageSnapshot, CodexUsageSummary } from '../../../../shared/codex-usage-types' -import { createCodexUsageSlice } from './codex-usage' +import { createCodexUsageSlice } from './usage-provider-slices' type Deferred = { promise: Promise diff --git a/src/renderer/src/store/slices/usage-web-client-fallback.test.ts b/src/renderer/src/store/slices/usage-web-client-fallback.test.ts index 6dd6f6470c7..4b282d15b47 100644 --- a/src/renderer/src/store/slices/usage-web-client-fallback.test.ts +++ b/src/renderer/src/store/slices/usage-web-client-fallback.test.ts @@ -1,22 +1,15 @@ import { create } from 'zustand' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' -import { createClaudeUsageSlice } from './claude-usage' -import { createCodexUsageSlice } from './codex-usage' -import { createOpenCodeUsageSlice } from './opencode-usage' +import { + createClaudeUsageSlice, + createCodexUsageSlice, + createOpenCodeUsageSlice +} from './usage-provider-slices' -// Regression: in the web client (paired `orca serve` runtime) the desktop-only -// usage IPC is not bridged, so the preload fallback proxy resolves every -// `window.api.Usage.*` call to `undefined`. Before the guards, the -// slices read `scanState.enabled` off that `undefined` and threw -// `TypeError: Cannot read properties of undefined (reading 'enabled')` when a -// user opened Settings -> Stats & Usage and pressed "enable" for an agent. -// -// These tests stub the web-client fallback (every call -> undefined) and assert -// the slices degrade to a no-op instead of throwing. +// Paired web clients resolve unbridged desktop usage calls to undefined. function stubWebClientFallback(): void { - // Mirrors web-preload-api's createFallbackProxy: any method resolves to undefined. const undefinedAsync = vi.fn(() => Promise.resolve(undefined)) const provider = { getScanState: undefinedAsync, diff --git a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts index cae7d7c8fce..d47b0f9ff8a 100644 --- a/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup-removal-preflight.test.ts @@ -89,6 +89,38 @@ describe('workspace cleanup removal and protection', () => { expect(store.getState().workspaceCleanupScan?.candidates).toEqual([]) }) + it('returns preserved branches for the cleanup batch summary', async () => { + const candidate = makeCandidate() + installWorkspaceCleanupApi( + vi.fn(async () => ({ scannedAt: NOW, candidates: [candidate], errors: [] })) + ) + const removeWorktree = vi.fn().mockResolvedValue({ + ok: true, + preservedBranch: { branchName: 'feature/cleanup', head: 'saved-head' } + }) + const store = createCleanupTestStore(removeWorktree) + store.setState({ + workspaceCleanupScan: { scannedAt: NOW, candidates: [candidate], errors: [] } + } as Partial) + + await expect( + store.getState().removeWorkspaceCleanupCandidates([candidate.worktreeId]) + ).resolves.toEqual({ + removedIds: [candidate.worktreeId], + failures: [], + preservedBranches: [ + { + worktreeId: candidate.worktreeId, + branchName: 'feature/cleanup', + expectedHead: 'saved-head' + } + ] + }) + expect(removeWorktree).toHaveBeenCalledWith(candidate.worktreeId, false, { + suppressPreservedBranchToast: true + }) + }) + it('demotes an active suggested workspace when it was not viewed from cleanup', async () => { const [candidate] = await enrichWorkspaceCleanupCandidates( [makeCandidate()], diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index bffe5fb93d4..c9ee2805594 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -24,6 +24,7 @@ import { import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { translate } from '@/i18n/i18n' +import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup' export type WorkspaceCleanupFailure = { worktreeId: string @@ -34,6 +35,7 @@ export type WorkspaceCleanupFailure = { export type WorkspaceCleanupRemoveResult = { removedIds: string[] failures: WorkspaceCleanupFailure[] + preservedBranches?: PreservedBranchCleanup[] } export type WorkspaceCleanupRemoveOptions = { @@ -300,6 +302,7 @@ export const createWorkspaceCleanupSlice: StateCreator { const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] + const preservedBranches: PreservedBranchCleanup[] = [] const approvedCandidatesByWorktreeId = new Map( (options?.approvedCandidates ?? []).map((candidate) => [candidate.worktreeId, candidate]) ) @@ -336,6 +339,13 @@ export const createWorkspaceCleanupSlice: StateCreator 0 ? { preservedBranches } : {}) + } } }) diff --git a/src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts b/src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts new file mode 100644 index 00000000000..469ecaabb4d --- /dev/null +++ b/src/renderer/src/store/slices/worktree-catalog-reconciliation.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { catalogRowsEqual, reuseEqualCatalogRows } from './worktree-catalog-reconciliation' + +describe('reuseEqualCatalogRows', () => { + it('does not traverse a catalog already reconciled by identity', () => { + const current = [ + { + get id(): string { + throw new Error('catalog row was traversed') + } + } + ] + + expect(catalogRowsEqual(current, current)).toBe(true) + }) + + it('reuses rows with equivalent nested catalog data', () => { + const current = [ + { id: 'a', nested: { labels: ['one', 'two'] }, optional: undefined }, + { id: 'b', nested: { labels: ['three'] } } + ] + const incoming = [ + { id: 'a', nested: { labels: ['one', 'two'] } }, + { id: 'b', nested: { labels: ['three'] } } + ] + + const reconciled = reuseEqualCatalogRows(current, incoming) + + expect(reconciled).toBe(current) + expect(catalogRowsEqual(current, incoming)).toBe(true) + }) + + it('reuses unaffected rows while publishing nested changes', () => { + const current = [ + { id: 'a', nested: { value: 1 } }, + { id: 'b', nested: { value: 2 } } + ] + const incoming = [ + { id: 'a', nested: { value: 3 } }, + { id: 'b', nested: { value: 2 } } + ] + + const reconciled = reuseEqualCatalogRows(current, incoming) + + expect(reconciled).not.toBe(current) + expect(reconciled[0]).toBe(incoming[0]) + expect(reconciled[1]).toBe(current[1]) + }) + + it('does not hide host ownership changes', () => { + const current = [{ id: 'a', runtimeOwnerEnvironmentId: 'env-a' }] + const incoming = [{ id: 'a', runtimeOwnerEnvironmentId: 'env-b' }] + + expect(reuseEqualCatalogRows(current, incoming)[0]).toBe(incoming[0]) + }) + + it('reuses same-ID rows from different hosts independently', () => { + const current = [ + { id: 'repo::/same/path', hostId: 'ssh:a' }, + { id: 'repo::/same/path', hostId: 'ssh:b' } + ] + const equivalent = structuredClone(current) + + expect(reuseEqualCatalogRows(current, equivalent)).toBe(current) + + const incoming = structuredClone(current.toReversed()) + const reconciled = reuseEqualCatalogRows(current, incoming) + + expect(reconciled).not.toBe(current) + expect(reconciled.map((row) => row.hostId)).toEqual(['ssh:b', 'ssh:a']) + expect(reconciled[0]).toBe(current[1]) + expect(reconciled[1]).toBe(current[0]) + }) +}) diff --git a/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts b/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts new file mode 100644 index 00000000000..5442df285f8 --- /dev/null +++ b/src/renderer/src/store/slices/worktree-catalog-reconciliation.ts @@ -0,0 +1,70 @@ +type CatalogRow = { id: string } + +function isPlainRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null) { + return false + } + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function catalogValuesEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true + } + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) { + return false + } + return left.every((value, index) => catalogValuesEqual(value, right[index])) + } + if (!isPlainRecord(left) || !isPlainRecord(right)) { + return false + } + const keys = new Set([...Object.keys(left), ...Object.keys(right)]) + for (const key of keys) { + if (!catalogValuesEqual(left[key], right[key])) { + return false + } + } + return true +} + +export function reuseEqualCatalogRows( + current: readonly T[] | undefined, + incoming: readonly T[] +): T[] { + if (!current) { + return [...incoming] + } + const currentById = new Map() + for (const row of current) { + const candidates = currentById.get(row.id) + if (candidates) { + candidates.push(row) + } else { + currentById.set(row.id, [row]) + } + } + const reconciled = incoming.map((row) => { + const candidates = currentById.get(row.id) + const previousIndex = candidates?.findIndex((candidate) => catalogValuesEqual(candidate, row)) + return previousIndex !== undefined && previousIndex >= 0 + ? candidates!.splice(previousIndex, 1)[0] + : row + }) + return current.length === reconciled.length && + current.every((row, index) => row === reconciled[index]) + ? (current as T[]) + : reconciled +} + +export function catalogRowsEqual( + current: readonly T[] | undefined, + incoming: readonly T[] +): boolean { + if (current === incoming) { + return true + } + return reuseEqualCatalogRows(current, incoming) === current +} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 499a4865cfe..731ae7273ca 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -47,6 +47,13 @@ export type WorktreeDeleteState = { lockReason?: string | null } +type RendererRemoveWorktreeResult = Omit & { + preservedBranch?: NonNullable & { + hostId?: ExecutionHostId + runtimeEnvironmentId?: string + } +} + export type WorktreeFetchOptions = { requireAuthoritative?: boolean executionHostId?: ExecutionHostId @@ -236,13 +243,18 @@ export type WorktreeSlice = { // PTY stopped; `force` alone is set by the ordinary delete confirmation. allowUnverifiedPtyStop?: boolean } - ) => Promise<({ ok: true } & RemoveWorktreeResult) | { ok: false; error: string }> + ) => Promise<({ ok: true } & RendererRemoveWorktreeResult) | { ok: false; error: string }> markWorktreesDeleting: (worktreeIds: readonly string[]) => void markWorktreesQueuedForDeletion: (worktreeIds: readonly string[]) => void forceDeletePreservedBranch: ( worktreeId: string, branchName: string, - expectedHead: string + expectedHead: string, + options?: { + suppressToast?: boolean + hostId?: ExecutionHostId + runtimeEnvironmentId?: string + } ) => Promise<({ ok: true } & ForceDeleteWorktreeBranchResult) | { ok: false; error: string }> clearWorktreeDeleteState: (worktreeId: string) => void /** Never rejects — most callers fire-and-forget. Callers that own a surface diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 6709169daca..37c88180477 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -487,6 +487,92 @@ describe('fetchWorktrees', () => { expect(result).toBe(true) }) + it('retains catalog and row references for a cloned unchanged runtime payload', async () => { + const store = createTestStore() + const first = makeWorktree({ + id: 'repo1::/path/first', + repoId: 'repo1', + path: '/path/first', + createdAt: 123, + sparsePresetId: 'preset-1', + pushTarget: { + remoteName: 'origin', + branchName: 'feature', + remoteCreated: true + }, + cliProvenance: { + kind: 'created-by-cli', + createdAt: 123, + callerTerminalHandle: 'term-1', + startupAgent: 'codex' + } + }) + const second = makeWorktree({ + id: 'repo1::/path/second', + repoId: 'repo1', + path: '/path/second' + }) + const detected = makeDetectedResult('repo1', [first, second]) + store.setState({ + worktreesByRepo: { repo1: [first, second] }, + detectedWorktreesByRepo: { repo1: detected }, + sortEpoch: 7 + } as Partial) + mockApi.worktrees.listDetected.mockResolvedValueOnce( + structuredClone(makeDetectedResult('repo1', [first, second])) + ) + const before = store.getState() + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + + await store.getState().fetchWorktrees('repo1') + unsubscribe() + + const after = store.getState() + expect(after.worktreesByRepo).toBe(before.worktreesByRepo) + expect(after.worktreesByRepo.repo1).toBe(before.worktreesByRepo.repo1) + expect(after.detectedWorktreesByRepo).toBe(before.detectedWorktreesByRepo) + expect(after.detectedWorktreesByRepo.repo1).toBe(before.detectedWorktreesByRepo.repo1) + expect(after.sortEpoch).toBe(7) + expect(subscriber).not.toHaveBeenCalled() + }) + + it('reuses unaffected catalog rows while publishing a previously untracked field change', async () => { + const store = createTestStore() + const changed = makeWorktree({ + id: 'repo1::/path/changed', + repoId: 'repo1', + path: '/path/changed', + pushTarget: { remoteName: 'origin', branchName: 'feature', remoteCreated: false } + }) + const stable = makeWorktree({ + id: 'repo1::/path/stable', + repoId: 'repo1', + path: '/path/stable' + }) + const detected = makeDetectedResult('repo1', [changed, stable]) + store.setState({ + worktreesByRepo: { repo1: [changed, stable] }, + detectedWorktreesByRepo: { repo1: detected }, + sortEpoch: 7 + } as Partial) + mockApi.worktrees.listDetected.mockResolvedValueOnce( + makeDetectedResult('repo1', [ + { ...changed, pushTarget: { ...changed.pushTarget!, remoteCreated: true } }, + { ...stable } + ]) + ) + + await store.getState().fetchWorktrees('repo1') + + const after = store.getState() + expect(after.worktreesByRepo.repo1[0]).not.toBe(changed) + expect(after.worktreesByRepo.repo1[0].pushTarget?.remoteCreated).toBe(true) + expect(after.worktreesByRepo.repo1[1]).toBe(stable) + expect(after.detectedWorktreesByRepo.repo1.worktrees[1]).toBe(detected.worktrees[1]) + expect(after.sortEpoch).toBe(8) + }) + it('updates the repo entry and bumps sortEpoch when git reports a branch change', async () => { const store = createTestStore() const existing = makeWorktree({ @@ -6097,11 +6183,13 @@ describe('worktree remote runtime mutations', () => { method: 'worktree.rm', params: { worktree: `id:${wt.id}`, + hostId: 'runtime:env-1', force: undefined, allowUnverifiedPtyStop: false, runHooks: true }, - timeoutMs: 60_000 + timeoutMs: 60_000, + expectedEnvironmentPairingRevision: undefined }) expect(mockApi.worktrees.remove).not.toHaveBeenCalled() expect(store.getState().shutdownWorktreeTerminals).toHaveBeenCalledWith(wt.id, { @@ -6120,12 +6208,21 @@ describe('worktree remote runtime mutations', () => { hostId: 'ssh:hub-private-target', runtimeOwnerEnvironmentId: 'owner-hub' }) - runtimeEnvironmentCall.mockResolvedValue({ - id: 'rpc-rm-nested', - ok: true, - result: { removed: true }, - _meta: { runtimeId: 'runtime-owner-hub' } - }) + runtimeEnvironmentCall + .mockResolvedValueOnce({ + id: 'rpc-rm-nested', + ok: true, + result: { + preservedBranch: { branchName: 'feature/nested', head: 'saved-head' } + }, + _meta: { runtimeId: 'runtime-owner-hub' } + }) + .mockResolvedValueOnce({ + id: 'rpc-force-delete-branch', + ok: true, + result: { deleted: true }, + _meta: { runtimeId: 'runtime-owner-hub' } + }) store.setState({ settings: { activeRuntimeEnvironmentId: 'different-hub' } as never, worktreesByRepo: { 'repo-ssh': [wt] } @@ -6133,12 +6230,21 @@ describe('worktree remote runtime mutations', () => { const result = await store.getState().removeWorktree(wt.id) - expect(result).toEqual({ ok: true }) - expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + expect(result).toEqual({ + ok: true, + preservedBranch: { + branchName: 'feature/nested', + head: 'saved-head', + hostId: 'ssh:hub-private-target', + runtimeEnvironmentId: 'owner-hub' + } + }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'owner-hub', method: 'worktree.rm', params: { worktree: `id:${wt.id}`, + hostId: 'ssh:hub-private-target', force: undefined, allowUnverifiedPtyStop: false, runHooks: true @@ -6146,6 +6252,96 @@ describe('worktree remote runtime mutations', () => { timeoutMs: 60_000 }) expect(mockApi.worktrees.remove).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([]) + + const forceResult = await store + .getState() + .forceDeletePreservedBranch(wt.id, 'feature/nested', 'saved-head') + + expect(forceResult).toEqual({ ok: true, deleted: true }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(2, { + selector: 'owner-hub', + method: 'worktree.forceDeleteBranch', + params: { + worktree: `id:${wt.id}`, + branchName: 'feature/nested', + expectedHead: 'saved-head', + hostId: 'ssh:hub-private-target' + }, + timeoutMs: 15_000 + }) + }) + + it('retains separate preserved-branch cleanup routes for sequential same-id hosts', async () => { + const store = createTestStore() + const worktreeId = 'repo-shared::/same/path' + const localWorktree = makeWorktree({ + id: worktreeId, + repoId: 'repo-shared', + path: '/same/path', + hostId: 'local' + }) + const remoteWorktree = makeWorktree({ + id: worktreeId, + repoId: 'repo-shared', + path: '/same/path', + hostId: 'ssh:shared-target', + runtimeOwnerEnvironmentId: 'shared-hub' + }) + mockApi.worktrees.remove.mockResolvedValueOnce({ + preservedBranch: { branchName: 'feature/local', head: 'local-head' } + }) + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => + Promise.resolve({ + id: `rpc-${method}`, + ok: true, + result: + method === 'repo.hooksCheck' + ? { hasHooks: false, hooks: null, mayNeedUpdate: false } + : method === 'worktree.rm' + ? { + preservedBranch: { branchName: 'feature/remote', head: 'remote-head' } + } + : { deleted: true }, + _meta: { runtimeId: 'runtime-shared-hub' } + }) + ) + store.setState({ + worktreesByRepo: { 'repo-shared': [localWorktree] } + } as Partial) + + await store.getState().removeWorktree(worktreeId) + store.setState({ + worktreesByRepo: { 'repo-shared': [remoteWorktree] } + } as Partial) + await store.getState().removeWorktree(worktreeId) + + await store + .getState() + .forceDeletePreservedBranch(worktreeId, 'feature/local', 'local-head', { hostId: 'local' }) + await store.getState().forceDeletePreservedBranch(worktreeId, 'feature/remote', 'remote-head', { + hostId: 'ssh:shared-target', + runtimeEnvironmentId: 'shared-hub' + }) + + expect(mockApi.worktrees.forceDeletePreservedBranch).toHaveBeenCalledWith({ + worktreeId, + branchName: 'feature/local', + expectedHead: 'local-head', + hostId: 'local' + }) + expect(runtimeEnvironmentCall).toHaveBeenCalledWith( + expect.objectContaining({ + selector: 'shared-hub', + method: 'worktree.forceDeleteBranch', + params: { + worktree: `id:${worktreeId}`, + branchName: 'feature/remote', + expectedHead: 'remote-head', + hostId: 'ssh:shared-target' + } + }) + ) }) it('fails HUB-owned SSH removal closed when the exact id has two HUB owners', async () => { @@ -6439,6 +6635,22 @@ describe('worktree remote runtime mutations', () => { expect(mockApi.worktrees.forceDeletePreservedBranch).not.toHaveBeenCalled() }) + it('suppresses per-branch feedback for an aggregate delete', async () => { + const store = createTestStore() + const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + store.setState({ worktreesByRepo: { repo1: [wt] } } as Partial) + vi.mocked(toast.success).mockClear() + vi.mocked(toast.error).mockClear() + + const result = await store + .getState() + .forceDeletePreservedBranch(wt.id, 'feature/test', 'abc123', { suppressToast: true }) + + expect(result).toEqual({ ok: true, deleted: true }) + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).not.toHaveBeenCalled() + }) + it('fails preserved branch deletion closed for two HUB owners', async () => { const store = createTestStore() const worktreeId = 'repo-ssh::/srv/same-wt' diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 51613ebdb8a..beb0395f7f4 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -28,8 +28,6 @@ import { } from './worktree-helpers' import { projectWorktreeTabModelReconciliation } from './tabs' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' -import { areWorkspaceLinkedItemsEqual } from '../../../../shared/workspace-linked-item' -import { areTaskSourceContextsEqual } from '../../../../shared/task-source-context' import { remapClosedTerminalTabSnapshotCwds, type ClosedTerminalTabSnapshot @@ -120,6 +118,10 @@ import { type DetectedWorktreeRefreshLease } from './detected-worktree-refresh-leases' import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' +import { + preservedBranchCleanupKey, + type PreservedBranchCleanup +} from '../../../../shared/preserved-branch-cleanup' import type { HostQualifiedDetectedWorktreeResult, HostQualifiedKnownWorktreeResult, @@ -129,6 +131,7 @@ import type { } from '../../../../shared/detected-worktree-provider-contract' import type { DirectSshAuthority } from '../../../../shared/ssh-types' import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index' +import { catalogRowsEqual, reuseEqualCatalogRows } from './worktree-catalog-reconciliation' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed. @@ -143,6 +146,10 @@ const FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS = 1_000 export const WORKTREE_REFRESH_CONCURRENCY = 8 const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() +const preservedBranchRuntimeTargetByCleanupKey = new Map< + string, + { cleanup: PreservedBranchCleanup; target: ReturnType } +>() const folderWorkspaceWorktreeCache = new WeakMap() const hostedReviewPushTargetLookupsInFlight = new Set() const runtimeDetectedWorktreeRefreshesInFlight = new Map< @@ -312,107 +319,8 @@ function showLocalBaseRefRefreshToast( ) } -function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean { - if (a === b) { - return true - } - if (!a || !b || a.length !== b.length) { - return !a?.length && !b?.length - } - return a.every((v, i) => v === b[i]) -} - -function areLineageRecordsEqual( - a: WorktreeLineage | null | undefined, - b: WorktreeLineage | null | undefined -): boolean { - if (!a || !b) { - return !a && !b - } - return ( - a.worktreeId === b.worktreeId && - a.worktreeInstanceId === b.worktreeInstanceId && - a.parentWorktreeId === b.parentWorktreeId && - a.parentWorktreeInstanceId === b.parentWorktreeInstanceId && - a.origin === b.origin && - a.capture.source === b.capture.source && - a.capture.confidence === b.capture.confidence && - a.orchestrationRunId === b.orchestrationRunId && - a.taskId === b.taskId && - a.coordinatorHandle === b.coordinatorHandle && - a.createdByTerminalHandle === b.createdByTerminalHandle && - a.createdAt === b.createdAt - ) -} - function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean { - if (!current || current.length !== next.length) { - return false - } - - return current.every((worktree, index) => { - const candidate = next[index] - return ( - worktree.id === candidate.id && - worktree.instanceId === candidate.instanceId && - worktree.repoId === candidate.repoId && - worktree.projectId === candidate.projectId && - worktree.hostId === candidate.hostId && - worktree.projectHostSetupId === candidate.projectHostSetupId && - worktree.path === candidate.path && - worktree.head === candidate.head && - worktree.branch === candidate.branch && - worktree.isBare === candidate.isBare && - worktree.isMainWorktree === candidate.isMainWorktree && - worktree.isSparse === candidate.isSparse && - worktree.displayName === candidate.displayName && - worktree.comment === candidate.comment && - worktree.linkedIssue === candidate.linkedIssue && - worktree.linkedPR === candidate.linkedPR && - worktree.linkedLinearIssue === candidate.linkedLinearIssue && - (worktree.linkedLinearIssueWorkspaceId ?? null) === - (candidate.linkedLinearIssueWorkspaceId ?? null) && - (worktree.linkedLinearIssueOrganizationUrlKey ?? null) === - (candidate.linkedLinearIssueOrganizationUrlKey ?? null) && - worktree.linkedGitLabMR === candidate.linkedGitLabMR && - worktree.linkedGitLabIssue === candidate.linkedGitLabIssue && - worktree.linkedBitbucketPR === candidate.linkedBitbucketPR && - worktree.linkedAzureDevOpsPR === candidate.linkedAzureDevOpsPR && - worktree.linkedGiteaPR === candidate.linkedGiteaPR && - areWorkspaceLinkedItemsEqual(worktree.linkedWorkItem, candidate.linkedWorkItem) && - areTaskSourceContextsEqual( - worktree.linkedTaskSourceContext, - candidate.linkedTaskSourceContext - ) && - worktree.isArchived === candidate.isArchived && - worktree.isUnread === candidate.isUnread && - worktree.isPinned === candidate.isPinned && - worktree.sortOrder === candidate.sortOrder && - worktree.manualOrder === candidate.manualOrder && - worktree.lastActivityAt === candidate.lastActivityAt && - worktree.workspaceStatus === candidate.workspaceStatus && - worktree.createdWithAgent === candidate.createdWithAgent && - worktree.pendingFirstAgentMessageRename === candidate.pendingFirstAgentMessageRename && - worktree.firstAgentMessageRenameError === candidate.firstAgentMessageRenameError && - worktree.baseRef === candidate.baseRef && - worktree.pushTarget?.remoteName === candidate.pushTarget?.remoteName && - worktree.pushTarget?.branchName === candidate.pushTarget?.branchName && - worktree.pushTarget?.remoteUrl === candidate.pushTarget?.remoteUrl && - worktree.sparseBaseRef === candidate.sparseBaseRef && - arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories) && - arraysShallowEqual(worktree.priorWorktreeIds, candidate.priorWorktreeIds) && - (worktree as WorktreeWithLineage).parentWorktreeId === - (candidate as WorktreeWithLineage).parentWorktreeId && - arraysShallowEqual( - (worktree as WorktreeWithLineage).childWorktreeIds, - (candidate as WorktreeWithLineage).childWorktreeIds - ) && - areLineageRecordsEqual( - (worktree as WorktreeWithLineage).lineage, - (candidate as WorktreeWithLineage).lineage - ) - ) - }) + return catalogRowsEqual(current, next) } function areDetectedWorktreeResultsEqual( @@ -424,15 +332,7 @@ function areDetectedWorktreeResultsEqual( current.repoId === next.repoId && current.authoritative === next.authoritative && current.source === next.source && - areWorktreesEqual(current.worktrees, next.worktrees) && - current.worktrees.every((worktree, index) => { - const candidate = next.worktrees[index] - return ( - worktree.ownership === candidate.ownership && - worktree.selectedCheckout === candidate.selectedCheckout && - worktree.visible === candidate.visible - ) - }) + catalogRowsEqual(current.worktrees, next.worktrees) ) } @@ -649,7 +549,7 @@ function worktreeMatchesHost( } function mergeWorktreesForHost< - T extends { hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } + T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } >( current: readonly T[] | undefined, refreshed: readonly T[], @@ -658,13 +558,17 @@ function mergeWorktreesForHost< ): T[] { // Why: host-scoped refreshes replace that host in place so alternating local/runtime refreshes don't churn sibling row order or sortEpoch. const existing = current ?? [] + const reconciled = reuseEqualCatalogRows( + existing.filter((worktree) => worktreeMatchesHost(worktree, hostId, options)), + refreshed + ) const next: T[] = [] let inserted = false for (const worktree of existing) { if (worktreeMatchesHost(worktree, hostId, options)) { if (!inserted) { - next.push(...refreshed) + next.push(...reconciled) inserted = true } continue @@ -672,7 +576,12 @@ function mergeWorktreesForHost< next.push(worktree) } - return inserted ? next : [...next, ...refreshed] + if (!inserted) { + next.push(...reconciled) + } + return existing.length === next.length && existing.every((row, index) => row === next[index]) + ? (existing as T[]) + : next } function mergeDetectedWorktreesForHost( @@ -686,9 +595,19 @@ function mergeDetectedWorktreesForHost( refreshed.worktrees, current?.worktrees ).map((worktree) => withRepoHostOwnership(worktree, hostId, setup)) + const worktrees = mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) + if ( + current && + current.repoId === refreshed.repoId && + current.authoritative === refreshed.authoritative && + current.source === refreshed.source && + current.worktrees === worktrees + ) { + return current + } return { ...refreshed, - worktrees: mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) + worktrees } } @@ -4335,6 +4254,7 @@ export const createWorktreeSlice: StateCreator 'worktree.rm', { worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), force, allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, runHooks: !skipArchive @@ -4682,13 +4602,46 @@ export const createWorktreeSlice: StateCreator // Why: Source Control may be unmounted during deletion, so it can't be the only stale-draft cleanup path. clearSessionCommitDraftForWorktree(worktreeId) const preservedBranch = removalResult?.preservedBranch + const cleanup = preservedBranch + ? { + worktreeId, + branchName: preservedBranch.branchName, + expectedHead: preservedBranch.head, + ...(hostId ? { hostId } : {}), + ...(removalRoute?.runtimeEnvironmentId + ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } + : {}) + } + : null + if (preservedBranch) { + preservedBranchRuntimeTargetByCleanupKey.set(preservedBranchCleanupKey(cleanup!), { + cleanup: cleanup!, + target + }) + } if (preservedBranch && options?.suppressPreservedBranchToast !== true) { showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { - void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead) + void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, { + ...(hostId ? { hostId } : {}), + ...(removalRoute?.runtimeEnvironmentId + ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } + : {}) + }) }) } pruneHostedReviewLinkMutationGenerations([worktreeId]) - return preservedBranch ? { ok: true as const, preservedBranch } : { ok: true as const } + return preservedBranch && cleanup + ? { + ok: true as const, + preservedBranch: { + ...preservedBranch, + ...(cleanup.hostId ? { hostId: cleanup.hostId } : {}), + ...(cleanup.runtimeEnvironmentId + ? { runtimeEnvironmentId: cleanup.runtimeEnvironmentId } + : {}) + } + } + : { ok: true as const } } catch (err) { // Why: git refusing a non-force delete for dirty/untracked files is a handled user decision, not an app error. console.warn('Failed to remove worktree:', err) @@ -4765,32 +4718,81 @@ export const createWorktreeSlice: StateCreator }) }, - forceDeletePreservedBranch: async (worktreeId, branchName, expectedHead) => { + forceDeletePreservedBranch: async (worktreeId, branchName, expectedHead, options) => { try { - const target = getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) + const requestedCleanup: PreservedBranchCleanup = { + worktreeId, + branchName, + expectedHead, + ...(options?.hostId ? { hostId: options.hostId } : {}), + ...(options?.runtimeEnvironmentId + ? { runtimeEnvironmentId: options.runtimeEnvironmentId } + : {}) + } + const requestedCleanupKey = preservedBranchCleanupKey(requestedCleanup) + const exactRetainedTarget = preservedBranchRuntimeTargetByCleanupKey.get(requestedCleanupKey) + const matchingRetainedTargets = exactRetainedTarget + ? [exactRetainedTarget] + : [...preservedBranchRuntimeTargetByCleanupKey.values()].filter( + ({ cleanup }) => + cleanup.worktreeId === worktreeId && + cleanup.branchName === branchName && + cleanup.expectedHead === expectedHead + ) + const retainedTarget = + exactRetainedTarget ?? + (options?.hostId || options?.runtimeEnvironmentId || matchingRetainedTargets.length !== 1 + ? undefined + : matchingRetainedTargets[0]) + if ((options?.hostId || options?.runtimeEnvironmentId) && !retainedTarget) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + const cleanupHostId = options?.hostId ?? retainedTarget?.cleanup.hostId + // Why: the removed row no longer records its nested HUB owner, so retain the deletion-time route. + const target = + retainedTarget?.target ?? + getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) const result = await (target.kind === 'local' ? window.api.worktrees.forceDeletePreservedBranch({ worktreeId, branchName, - expectedHead + expectedHead, + ...(cleanupHostId ? { hostId: cleanupHostId } : {}) }) : callRuntimeRpc( target, 'worktree.forceDeleteBranch', - { worktree: toRuntimeWorktreeSelector(worktreeId), branchName, expectedHead }, + { + worktree: toRuntimeWorktreeSelector(worktreeId), + branchName, + expectedHead, + ...(cleanupHostId ? { hostId: cleanupHostId } : {}) + }, { timeoutMs: 15_000 } )) - toast.success(translate('auto.store.slices.worktrees.19db0085fb', 'Local branch deleted'), { - description: translate('auto.store.slices.worktrees.5a58e03a26', 'Deleted "{{value0}}".', { - value0: branchName + if (options?.suppressToast !== true) { + toast.success(translate('auto.store.slices.worktrees.19db0085fb', 'Local branch deleted'), { + description: translate( + 'auto.store.slices.worktrees.5a58e03a26', + 'Deleted "{{value0}}".', + { value0: branchName } + ) }) - }) + } + preservedBranchRuntimeTargetByCleanupKey.delete( + retainedTarget ? preservedBranchCleanupKey(retainedTarget.cleanup) : requestedCleanupKey + ) return { ok: true as const, ...result } } catch (err) { const error = err instanceof Error ? err.message : String(err) - toast.error(translate('auto.store.slices.worktrees.0216895fb5', 'Failed to delete branch'), { - description: error - }) + if (options?.suppressToast !== true) { + toast.error( + translate('auto.store.slices.worktrees.0216895fb5', 'Failed to delete branch'), + { + description: error + } + ) + } return { ok: false as const, error } } }, diff --git a/src/renderer/src/store/types.ts b/src/renderer/src/store/types.ts index f42325c0b21..bed37f06851 100644 --- a/src/renderer/src/store/types.ts +++ b/src/renderer/src/store/types.ts @@ -15,9 +15,11 @@ import type { EditorSlice } from './slices/editor' import type { StatsSlice } from './slices/stats' import type { MemorySlice } from './slices/memory' import type { WorkspaceSpaceSlice } from './slices/workspace-space' -import type { ClaudeUsageSlice } from './slices/claude-usage' -import type { CodexUsageSlice } from './slices/codex-usage' -import type { OpenCodeUsageSlice } from './slices/opencode-usage' +import type { + ClaudeUsageSlice, + CodexUsageSlice, + OpenCodeUsageSlice +} from './slices/usage-provider-slices' import type { BrowserSlice } from './slices/browser' import type { RateLimitSlice } from './slices/rate-limits' import type { SshSlice } from './slices/ssh' diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 77f9a40dfdb..97375f0087c 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1831,10 +1831,11 @@ function createWorktreesApi(): NonNullable['worktrees']> { targetBranch, isCrossRepository }), - remove: async ({ worktreeId, force, allowUnverifiedPtyStop, skipArchive }) => { + remove: async ({ worktreeId, hostId, force, allowUnverifiedPtyStop, skipArchive }) => { invalidateRuntimeWorktreeCaches() return callRuntimeResult('worktree.rm', { worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), force, // Why (#11960): the web client renders the same Force Delete affordances, so // dropping this field here would leave paired clients permanently wedged. @@ -1846,11 +1847,12 @@ function createWorktreesApi(): NonNullable['worktrees']> { forgetLocal: () => { throw new Error('Forgetting a workspace is unavailable in paired web clients.') }, - forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead }) => + forceDeletePreservedBranch: ({ worktreeId, branchName, expectedHead, hostId }) => callRuntimeResult('worktree.forceDeleteBranch', { worktree: toRuntimeWorktreeSelector(worktreeId), branchName, - expectedHead + expectedHead, + ...(hostId ? { hostId } : {}) }), updateMeta: async ({ worktreeId, updates }) => { const rpcUpdates = diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index a03435d0e1f..298e7a55c79 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -391,6 +391,90 @@ describe('shared agent-hook-listener', () => { expect(next?.payload.interactivePrompt).toBeUndefined() }) + it('keeps AskUserQuestion visible through a late parallel sibling completion', () => { + const questions = { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + const question = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: questions + } + }, + 'production' + ) + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PermissionRequest', + tool_name: 'AskUserQuestion', + tool_input: questions + } + }, + 'production' + ) + const siblingCompletion = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'Bash', + tool_use_id: 'tool-sibling', + tool_input: { command: 'sleep 5' } + } + }, + 'production' + ) + + expect(siblingCompletion?.payload).toMatchObject({ + state: 'waiting', + toolName: 'AskUserQuestion', + interactivePrompt: question?.payload.interactivePrompt + }) + }) + + it('clears AskUserQuestion when its own PostToolUse arrives', () => { + normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + tool_name: 'AskUserQuestion', + tool_use_id: 'tool-question' + } + }, + 'production' + ) + + expect(answered?.payload.state).toBe('working') + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) + it('does not re-assert the AskUserQuestion prompt on PostToolUse', () => { // The question was answered, so PostToolUse must clear the live card instead // of re-deriving the `{questions}` prompt from the carried tool input. @@ -3594,6 +3678,28 @@ describe('shared agent-hook-listener', () => { expect(childTool?.payload.toolName).toBe('AskUserQuestion') }) + it('keeps a child AskUserQuestion visible through its parallel sibling completion', () => { + const question = claudeEvent({ + hook_event_name: 'PreToolUse', + agent_id: 'a1', + tool_use_id: 'question-1', + tool_name: 'AskUserQuestion', + tool_input: { questions: [{ question: 'Pick', options: ['a', 'b'] }] } + }) + + const siblingCompletion = claudeEvent({ + hook_event_name: 'PostToolUse', + agent_id: 'a1', + tool_use_id: 'sibling-1', + tool_name: 'Bash', + tool_input: { command: 'sleep 5' } + }) + + expect(siblingCompletion?.payload.state).toBe('waiting') + expect(siblingCompletion?.payload.interactivePrompt).toBe(question?.payload.interactivePrompt) + expect(siblingCompletion?.payload.toolName).toBe('AskUserQuestion') + }) + it('preserves the interrupted flag across a gated working window', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long job' }) claudeEvent({ diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index c41bcb7354d..44032c03b24 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -150,6 +150,8 @@ export type ClaudeLeadTurnState = { interrupted?: true /** Subagent that induced the wait; only its next tool activity may clear it, so other children's churn can't dismiss a pending human-input card. */ waitingAgentId?: string + /** Tool call that owns the wait; late completions from parallel sibling tools must not dismiss its card. */ + waitingToolUseId?: string /** Lead state a child-induced wait displaced, restored when the wait clears; can't invent 'working' since the done-gate only downgrades done→working, never back. */ stateBeforeWait?: Pick } @@ -2620,7 +2622,7 @@ function normalizeClaudeSubagentLifecycleEvent( clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } } - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } /** Sync the Claude lead-turn record when the SERVER infers an interrupt outside the hook stream (Ctrl+C with a missed Stop); else a later child lifecycle event resurrects the cancelled pane. */ @@ -2712,8 +2714,8 @@ export function clearClaudeAnsweredQuestionWait( return effectiveState === restored.state ? restored : { state: effectiveState } } -/** Re-emit the lead's cached state on child activity — gated up to 'working' while a child works — without touching the lead's tool/prompt caches, so a live card or permission wait survives child churn. */ -function buildClaudeChildDrivenStatusPayload( +/** Re-emit the cached lead state without touching its tool/prompt caches; child churn and parallel completions must not dismiss live cards. */ +function buildClaudeCachedLeadStatusPayload( state: HookListenerState, eventName: unknown, paneKey: string, @@ -2794,10 +2796,13 @@ function normalizeClaudeEvent( return null } - // Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer. - // Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. - const isAskUserQuestion = - eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name')) + // Why: Claude normally emits PreToolUse while AskUserQuestion is blocked; newer builds can also report it as PermissionRequest. + // Treat the PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. + const eventToolName = readString(hookPayload, 'tool_name') + const isAskUserQuestionWait = + (eventName === 'PreToolUse' || eventName === 'PermissionRequest') && + isAskUserQuestionTool(eventToolName) + const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionWait // Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy; // PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352). const reportedStateName = @@ -2836,6 +2841,20 @@ function normalizeClaudeEvent( state.claudeActiveSessionCronPaneKeys.delete(paneKey) } + const eventToolUseId = readFirstString(hookPayload, ['tool_use_id', 'toolUseId']) + const previousTool = state.lastToolByPaneKey.get(paneKey) + const isParallelSiblingCompletionDuringQuestion = + eventAgentId === undefined && + previousLead?.state === 'waiting' && + isAskUserQuestionTool(previousTool?.toolName) && + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + previousLead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== previousLead.waitingToolUseId + if (isParallelSiblingCompletionDuringQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). const isWaitingInducing = reportedStateName === 'waiting' @@ -2857,7 +2876,15 @@ function normalizeClaudeEvent( if (subagentOriginId) { const lead = state.claudeLeadStateByPaneKey.get(paneKey) if (lead?.state !== 'waiting' || lead.waitingAgentId !== subagentOriginId) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) + } + const isParallelSiblingCompletionDuringChildQuestion = + (eventName === 'PostToolUse' || eventName === 'PostToolUseFailure') && + lead.waitingToolUseId !== undefined && + eventToolUseId !== undefined && + eventToolUseId !== lead.waitingToolUseId + if (isParallelSiblingCompletionDuringChildQuestion) { + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } // Why: approval granted — update the tool snapshot (drop the pending card) as the lead's own next tool event would. // Restore the stashed lead state, not this child's 'working': the lead may already be done, and the done-gate never upgrades working back to done once the roster drains. @@ -2872,7 +2899,7 @@ function normalizeClaudeEvent( // Why: lead events never carry agent_id; even a child missed by lifecycle tracking cannot own the lead turn or its background-work evidence. if (eventAgentId && !isWaitingInducing) { - return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload) + return buildClaudeCachedLeadStatusPayload(state, eventName, paneKey, hookPayload) } if (isTurnBoundary && eventAgentId === undefined) { @@ -2897,10 +2924,12 @@ function normalizeClaudeEvent( ...(previousLead.interrupted ? { interrupted: true as const } : {}) } : undefined + const waitingToolUseId = eventToolUseId ?? previousLead?.waitingToolUseId state.claudeLeadStateByPaneKey.set(paneKey, { state: reportedStateName, ...(interrupted ? { interrupted } : {}), ...(isWaitingInducing && eventAgentId ? { waitingAgentId: eventAgentId } : {}), + ...(isAskUserQuestionWait && waitingToolUseId !== undefined ? { waitingToolUseId } : {}), ...(stateBeforeWait ? { stateBeforeWait } : {}) }) diff --git a/src/shared/agent-session-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 3e2f0f57921..4e9396632c7 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -204,7 +204,7 @@ function codexEffort(includeExtraHigh: boolean): CatalogOption { launchArgs: (value) => ['-c', `model_reasoning_effort=${String(value)}`], agentArgsOverride: hasCodexEffortOverride, removeAgentArgs: removeCodexEffortOverride, - midSession: { kind: 'agent-picker', command: '/model' } + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } } } } @@ -228,12 +228,9 @@ export const CODEX_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { launchArgs: (value) => ['-m', String(value)], agentArgsOverride: (tokens) => hasFlag(tokens, ['-m', '--model']), removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['-m', '--model']), - // Codex accepts a model argument in its live /model command. - midSession: { - kind: 'command', - build: (value) => `/model ${String(value)}`, - pickerCommand: '/model' - } + // Codex classifies multi-character writes as pasted prose; type the bare + // command and let its own picker apply the account-supported model. + midSession: { kind: 'agent-picker', command: '/model', delivery: 'type' } }, unknownModelOptions: [codexEffort(true)] } diff --git a/src/shared/agent-session-option-catalog-types.ts b/src/shared/agent-session-option-catalog-types.ts index bb08339e07e..ae2af3b450f 100644 --- a/src/shared/agent-session-option-catalog-types.ts +++ b/src/shared/agent-session-option-catalog-types.ts @@ -6,6 +6,7 @@ import type { } from './native-chat-session-options' export type CatalogAgentInteractionDetection = 'claude-model-switch-confirmation' +export type CatalogCommandDelivery = 'type' export type CatalogMidSessionApply = | { @@ -15,7 +16,7 @@ export type CatalogMidSessionApply = detectAgentInteraction?: CatalogAgentInteractionDetection } | { kind: 'toggle-command'; command: string } - | { kind: 'agent-picker'; command: string } + | { kind: 'agent-picker'; command: string; delivery?: CatalogCommandDelivery } | { kind: 'unsupported' } export type CatalogOptionApply = { diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index e5e254c45de..b242b6b27c9 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -20,6 +20,7 @@ import type { SessionOptionValue } from './native-chat-session-options' export type { AgentSessionOptionCatalog, CatalogAgentInteractionDetection, + CatalogCommandDelivery, CatalogMidSessionApply, CatalogModel, CatalogOption, diff --git a/src/shared/agent-tui-command-typing.ts b/src/shared/agent-tui-command-typing.ts new file mode 100644 index 00000000000..389d7e03141 --- /dev/null +++ b/src/shared/agent-tui-command-typing.ts @@ -0,0 +1,43 @@ +import { AGENT_TUI_CLEAR_INPUT_LINE } from './agent-tui-input-clear' + +export type AgentTuiCommandWriteOutcome = 'accepted' | 'rejected' | 'unknown' + +export const AGENT_TUI_COMMAND_KEY_INTERVAL_MS = 16 + +function waitForNextKey(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const timer = setTimeout(() => finish(true), AGENT_TUI_COMMAND_KEY_INTERVAL_MS) + const finish = (completed: boolean): void => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +/** Sends one PTY write per key so slash-command TUIs do not classify it as pasted prose. */ +export async function typeAgentTuiCommand(args: { + command: string + write: (key: string) => Promise + signal?: AbortSignal +}): Promise { + const keys = [AGENT_TUI_CLEAR_INPUT_LINE, ...args.command, '\r'] + for (let index = 0; index < keys.length; index += 1) { + if (args.signal?.aborted) { + return 'rejected' + } + const outcome = await args.write(keys[index]!) + if (outcome !== 'accepted') { + return outcome + } + if (index < keys.length - 1 && !(await waitForNextKey(args.signal))) { + return 'rejected' + } + } + return 'accepted' +} diff --git a/src/shared/check-job-log-tail-slice.ts b/src/shared/check-job-log-tail-slice.ts index a5a9f483b1f..2c4e0a61e20 100644 --- a/src/shared/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -1,3 +1,5 @@ +import { clampUtf8TextTail, getUtf8ByteLength } from './utf8-byte-limits' + export const PR_CHECK_LOG_TAIL_LINES = 200 export const PR_CHECK_LOG_TAIL_RECENT_LINES = 100 export const PR_CHECK_LOG_TAIL_BYTES = 16 * 1024 @@ -10,47 +12,23 @@ export const PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR = '… earlier errors …' const ERROR_LINE_PATTERN = /(?:##\[error\]|::error::|::error\b|\berror:|FAILED|exit code|ENOENT|EACCES|panic:|AssertionError)/i -// Why: this slicer runs in main (GitHub/GitLab log fetches) and in the renderer -// (defensive re-slice of traces from older remote runtimes), so no Buffer. -function utf8ByteLength(text: string): number { - let bytes = 0 - for (const character of text) { - const codePoint = character.codePointAt(0) ?? 0 - bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4 - } - return bytes -} - function applyLogTailByteCap(text: string): string { - if (utf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { + if (getUtf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { return text } - return sliceTrailingTextByUtf8Bytes(text, PR_CHECK_LOG_TAIL_BYTES) -} - -function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { - let byteLength = 0 - const characters = Array.from(text) - for (let index = characters.length - 1; index >= 0; index -= 1) { - const characterByteLength = utf8ByteLength(characters[index] ?? '') - if (byteLength + characterByteLength > byteLimit) { - return characters.slice(index + 1).join('') - } - byteLength += characterByteLength - } - return text + return clampUtf8TextTail(text, PR_CHECK_LOG_TAIL_BYTES).text } function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]): string { const prefix = prefixLines.join('\n') - const prefixByteLength = utf8ByteLength(prefix) + const prefixByteLength = getUtf8ByteLength(prefix) if (prefixByteLength >= PR_CHECK_LOG_TAIL_BYTES) { - return sliceTrailingTextByUtf8Bytes(prefix, PR_CHECK_LOG_TAIL_BYTES) + return clampUtf8TextTail(prefix, PR_CHECK_LOG_TAIL_BYTES).text } const separator = prefix.length > 0 && recentLines.length > 0 ? '\n' : '' - const recentBudget = PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - utf8ByteLength(separator) - const recentTail = sliceTrailingTextByUtf8Bytes(recentLines.join('\n'), recentBudget) + const recentBudget = PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - getUtf8ByteLength(separator) + const recentTail = clampUtf8TextTail(recentLines.join('\n'), recentBudget).text return `${prefix}${separator}${recentTail}` } diff --git a/src/shared/clipboard-text.ts b/src/shared/clipboard-text.ts index 18425efe4cd..e8170af606b 100644 --- a/src/shared/clipboard-text.ts +++ b/src/shared/clipboard-text.ts @@ -1,4 +1,9 @@ import { yieldToEventLoop } from './event-loop-yield' +import { + getUtf8ByteLengthForCodePoint, + measureUtf8ByteLength, + type Utf8ByteLengthMeasurement +} from './utf8-byte-limits' export const CLIPBOARD_TEXT_READ_MAX_BYTES = 16 * 1024 * 1024 export const CLIPBOARD_TEXT_WRITE_MAX_BYTES = 16 * 1024 * 1024 @@ -14,28 +19,13 @@ export type WriteClipboardTextOptions = { maxBytes?: number } -export type ClipboardTextByteLengthMeasurement = { - byteLength: number - exceededLimit: boolean -} +export type ClipboardTextByteLengthMeasurement = Utf8ByteLengthMeasurement export function measureClipboardTextByteLength( text: string, options: { stopAfterBytes?: number } = {} ): ClipboardTextByteLengthMeasurement { - const stopAfterBytes = options.stopAfterBytes - let byteLength = 0 - for (let index = 0; index < text.length; index += 1) { - const codePoint = text.codePointAt(index) ?? 0 - byteLength += getUtf8ByteLengthForCodePoint(codePoint) - if (Number.isFinite(stopAfterBytes) && byteLength > (stopAfterBytes ?? 0)) { - return { byteLength, exceededLimit: true } - } - if (codePoint > 0xffff) { - index += 1 - } - } - return { byteLength, exceededLimit: false } + return measureUtf8ByteLength(text, options) } export function getClipboardTextByteLength(text: string): number { @@ -172,16 +162,3 @@ export function isClipboardTextTooLargeError(error: unknown): boolean { export function isClipboardTextWriteTooLargeError(error: unknown): boolean { return error instanceof Error && error.message.includes(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) } - -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} diff --git a/src/shared/github-project-group-sort.ts b/src/shared/github-project-group-sort.ts index fe91c78051e..923b8aacf49 100644 --- a/src/shared/github-project-group-sort.ts +++ b/src/shared/github-project-group-sort.ts @@ -29,6 +29,7 @@ const EMPTY_GROUP_KEY = '__empty__' // Array.sort's behavior implementation-defined and skips later tie-breaks. const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER +// Preserve the mobile mirror's fallback for partial ordering metadata. function getFieldValueForGrouping( row: GitHubProjectRow, field: GitHubProjectField @@ -43,8 +44,9 @@ function getFieldValueForGrouping( } } if (field.kind === 'iteration' && value.kind === 'iteration') { - const idx = field.iterations.findIndex((it) => it.id === value.iterationId) - const meta = field.iterations.find((it) => it.id === value.iterationId) + const iterations = field.iterations ?? [] + const idx = iterations.findIndex((iteration) => iteration.id === value.iterationId) + const meta = iterations.find((iteration) => iteration.id === value.iterationId) return { key: value.iterationId, label: value.title || meta?.title || 'Iteration', @@ -55,7 +57,7 @@ function getFieldValueForGrouping( } } if (field.kind === 'single-select' && value.kind === 'single-select') { - const idx = field.options.findIndex((o) => o.id === value.optionId) + const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId) return { key: value.optionId, label: value.name, @@ -158,8 +160,9 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje aValue.kind === 'single-select' && bValue.kind === 'single-select' ) { - const aIdx = field.options.findIndex((o) => o.id === aValue.optionId) - const bIdx = field.options.findIndex((o) => o.id === bValue.optionId) + const options = field.options ?? [] + const aIdx = options.findIndex((option) => option.id === aValue.optionId) + const bIdx = options.findIndex((option) => option.id === bValue.optionId) cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) } else if ( @@ -167,8 +170,9 @@ function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProje aValue.kind === 'iteration' && bValue.kind === 'iteration' ) { - const aIdx = field.iterations.findIndex((it) => it.id === aValue.iterationId) - const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId) + const iterations = field.iterations ?? [] + const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId) + const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId) cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) } else if (aValue.kind === 'number' && bValue.kind === 'number') { diff --git a/src/shared/hermes-run-output-limits.test.ts b/src/shared/hermes-run-output-limits.test.ts deleted file mode 100644 index 1f583a83ca9..00000000000 --- a/src/shared/hermes-run-output-limits.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { DatabaseSync } from 'node:sqlite' -import { - formatHermesSessionMessagesWithinLimits, - HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR, - HERMES_SESSION_RUN_METADATA_MAX_BYTES, - HERMES_SESSION_RUN_SELECT_SQL, - HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR, - hydrateHermesRunPageWithinLimits -} from './hermes-run-output-limits' - -describe('HERMES_SESSION_RUN_SELECT_SQL', () => { - it('preserves exact-limit text metadata and rejects oversized non-numeric fields in SQL', () => { - const database = new DatabaseSync(':memory:') - database.exec(`CREATE TABLE sessions ( - id TEXT PRIMARY KEY, - title TEXT, - started_at, - ended_at, - model TEXT, - message_count, - input_tokens, - output_tokens - )`) - const exactTitle = 't'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - const exactModel = 'm'.repeat(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - database - .prepare( - `INSERT INTO sessions ( - id, title, started_at, ended_at, model, message_count, input_tokens, output_tokens - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ) - .run('run-1', exactTitle, 1, 2, exactModel, 3, 4, 5) - - const exact = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') - expect(exact).toEqual({ - title: exactTitle, - started_at: 1, - ended_at: 2, - model: exactModel, - message_count: 3, - input_tokens: 4, - output_tokens: 5 - }) - - database.exec(`UPDATE sessions SET - title = title || 'overflow', - model = model || 'overflow', - started_at = zeroblob(1048576), - ended_at = zeroblob(1048576), - message_count = zeroblob(1048576), - input_tokens = zeroblob(1048576), - output_tokens = zeroblob(1048576) - WHERE id = 'run-1'`) - const bounded = database.prepare(HERMES_SESSION_RUN_SELECT_SQL).get('run-1') - - expect(Buffer.byteLength(String(bounded?.title))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - expect(Buffer.byteLength(String(bounded?.model))).toBe(HERMES_SESSION_RUN_METADATA_MAX_BYTES) - expect(bounded).toMatchObject({ - started_at: null, - ended_at: null, - message_count: null, - input_tokens: null, - output_tokens: null - }) - database.close() - }) -}) - -describe('formatHermesSessionMessagesWithinLimits', () => { - it('preserves transcript formatting below the limits', () => { - const result = formatHermesSessionMessagesWithinLimits([ - { - role: 'assistant', - tool_name: ' terminal ', - reasoning_content: ' because ', - content: ' done ' - }, - { role: null, content: ' ' } - ]) - - expect(result).toEqual({ - content: [ - '## assistant / terminal', - '', - '### Reasoning', - '', - 'because', - '', - 'done', - '', - '---', - '', - '## message', - '', - '(empty)' - ].join('\n'), - truncated: false - }) - }) - - it('keeps UTF-8 output within the byte budget and explains truncation', () => { - const result = formatHermesSessionMessagesWithinLimits( - [{ role: 'assistant', content: '🐋'.repeat(100) }], - { maxBytes: 160 } - ) - - expect(result.truncated).toBe(true) - expect(Buffer.byteLength(result.content ?? '')).toBeLessThanOrEqual(160) - expect(result.content).toContain(HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR) - expect(result.content).not.toContain('\ufffd') - }) - - it('stops pulling rows after the message limit', () => { - let rowsPulled = 0 - function* messages(): Generator> { - for (let index = 0; index < 100; index += 1) { - rowsPulled += 1 - yield { role: 'user', content: `message ${index}` } - } - } - - const result = formatHermesSessionMessagesWithinLimits(messages(), { - maxBytes: 1024, - maxMessages: 2 - }) - - expect(rowsPulled).toBe(3) - expect(result.truncated).toBe(true) - expect(result.content).toContain('message 0') - expect(result.content).toContain('message 1') - expect(result.content).not.toContain('message 2') - }) -}) - -describe('hydrateHermesRunPageWithinLimits', () => { - it('bounds concurrency, preserves order, and omits aggregate overflow', async () => { - let active = 0 - let maxActive = 0 - const refs = ['first', 'second', 'third', 'fourth'] - - const runs = (await hydrateHermesRunPageWithinLimits( - refs, - async (id) => { - active += 1 - maxActive = Math.max(maxActive, active) - await new Promise((resolve) => setTimeout(resolve, 1)) - active -= 1 - return { id, output_content: id.slice(0, 3) } - }, - { maxConcurrent: 2, maxRetainedBytes: 5 } - )) as { id: string; output_content: string | null; error?: string }[] - - expect(maxActive).toBe(2) - expect(runs.map((run) => run.id)).toEqual(refs) - expect(runs[0]?.output_content).toBe('fir') - expect(runs.slice(1)).toEqual( - refs.slice(1).map((id) => ({ - id, - output_content: null, - error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR - })) - ) - }) -}) diff --git a/src/shared/hermes-run-output-limits.ts b/src/shared/hermes-run-output-limits.ts deleted file mode 100644 index d456effe8a8..00000000000 --- a/src/shared/hermes-run-output-limits.ts +++ /dev/null @@ -1,233 +0,0 @@ -export const HERMES_SESSION_TRANSCRIPT_MAX_BYTES = 8 * 1024 * 1024 -export const HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES = 10_000 -export const HERMES_RUN_PAGE_MAX_RETAINED_BYTES = 32 * 1024 * 1024 -export const HERMES_RUN_HYDRATION_CONCURRENCY = 2 -export const HERMES_SESSION_RUN_METADATA_MAX_BYTES = 16 * 1024 -export const HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR = - 'Run output omitted because this history page exceeds the memory limit' -export const HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR = - 'Session transcript truncated because it exceeds the 8 MiB or 10,000-message history limit' - -const HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE = `[${HERMES_SESSION_TRANSCRIPT_TRUNCATED_ERROR}.]` -const HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN = 'orca_source_truncated' - -export const HERMES_SESSION_RUN_SELECT_SQL = `SELECT - CASE WHEN typeof(title) = 'text' - THEN CAST(substr(CAST(title AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) - ELSE NULL END AS title, - CASE WHEN typeof(started_at) IN ('integer', 'real') THEN started_at ELSE NULL END AS started_at, - CASE WHEN typeof(ended_at) IN ('integer', 'real') THEN ended_at ELSE NULL END AS ended_at, - CASE WHEN typeof(model) = 'text' - THEN CAST(substr(CAST(model AS BLOB), 1, ${HERMES_SESSION_RUN_METADATA_MAX_BYTES}) AS TEXT) - ELSE NULL END AS model, - CASE WHEN typeof(message_count) IN ('integer', 'real') THEN message_count ELSE NULL END AS message_count, - CASE WHEN typeof(input_tokens) IN ('integer', 'real') THEN input_tokens ELSE NULL END AS input_tokens, - CASE WHEN typeof(output_tokens) IN ('integer', 'real') THEN output_tokens ELSE NULL END AS output_tokens -FROM sessions -WHERE id = ?` - -type HermesRunPageLimits = { - maxConcurrent?: number - maxRetainedBytes?: number -} - -type HermesSessionTranscriptLimits = { - maxBytes?: number - maxMessages?: number -} - -export type FormattedHermesSessionMessages = { - content: string | null - truncated: boolean -} - -export async function hydrateHermesRunPageWithinLimits( - refs: readonly T[], - hydrate: (ref: T) => Promise, - limits: HermesRunPageLimits = {} -): Promise { - const maxConcurrent = clampFiniteLimit(limits.maxConcurrent, 1, HERMES_RUN_HYDRATION_CONCURRENCY) - const maxRetainedBytes = clampFiniteLimit( - limits.maxRetainedBytes, - 0, - HERMES_RUN_PAGE_MAX_RETAINED_BYTES - ) - const results: unknown[] = [] - let retainedBytes = 0 - for (let start = 0; start < refs.length; start += maxConcurrent) { - const batch = await Promise.all(refs.slice(start, start + maxConcurrent).map(hydrate)) - for (const run of batch) { - const runBytes = hermesRunOutputByteLength(run) - if (retainedBytes + runBytes <= maxRetainedBytes) { - results.push(run) - retainedBytes += runBytes - } else { - results.push(omitHermesRunOutput(run)) - } - } - } - return results -} - -export function formatHermesSessionMessagesWithinLimits( - messages: Iterable>, - limits: HermesSessionTranscriptLimits = {} -): FormattedHermesSessionMessages { - const maxBytes = clampFiniteLimit(limits.maxBytes, 0, HERMES_SESSION_TRANSCRIPT_MAX_BYTES) - const maxMessages = clampFiniteLimit( - limits.maxMessages, - 0, - HERMES_SESSION_TRANSCRIPT_MAX_MESSAGES - ) - const chunks: string[] = [] - let retainedBytes = 0 - let messageCount = 0 - let truncated = false - - transcript: for (const message of messages) { - if (messageCount >= maxMessages) { - truncated = true - break - } - for (const part of formatHermesSessionMessageParts(message, messageCount > 0)) { - const partBytes = Buffer.byteLength(part) - if (retainedBytes + partBytes <= maxBytes) { - chunks.push(part) - retainedBytes += partBytes - continue - } - const prefix = takeUtf8Prefix(part, maxBytes - retainedBytes) - if (prefix) { - chunks.push(prefix) - retainedBytes += Buffer.byteLength(prefix) - } - truncated = true - break transcript - } - messageCount += 1 - if (message[HERMES_SESSION_TRANSCRIPT_SOURCE_TRUNCATED_COLUMN] === 1) { - truncated = true - break - } - } - - if (truncated) { - appendTranscriptTruncationNotice(chunks, retainedBytes, maxBytes) - } - return { content: chunks.length > 0 ? chunks.join('') : null, truncated } -} - -function clampFiniteLimit(value: number | undefined, minimum: number, maximum: number): number { - if (value === undefined || !Number.isFinite(value)) { - return maximum - } - return Math.max(minimum, Math.min(maximum, Math.floor(value))) -} - -function hermesRunOutputByteLength(run: unknown): number { - if (!run || typeof run !== 'object' || Array.isArray(run)) { - return 0 - } - const content = (run as Record).output_content - return typeof content === 'string' ? Buffer.byteLength(content) : 0 -} - -function formatHermesSessionMessageParts( - message: Record, - includeSeparator: boolean -): string[] { - const role = typeof message.role === 'string' ? message.role : 'message' - const content = typeof message.content === 'string' ? message.content.trim() : '' - const toolName = typeof message.tool_name === 'string' ? message.tool_name.trim() : '' - const reasoning = - typeof message.reasoning_content === 'string' - ? message.reasoning_content.trim() - : typeof message.reasoning === 'string' - ? message.reasoning.trim() - : '' - const parts = [ - includeSeparator ? '\n\n---\n\n' : '', - `## ${role}${toolName ? ` / ${toolName}` : ''}` - ] - if (reasoning) { - parts.push('\n\n### Reasoning\n\n', reasoning) - } - parts.push('\n\n', content || '(empty)') - return parts -} - -function appendTranscriptTruncationNotice( - chunks: string[], - retainedBytes: number, - maxBytes: number -): void { - const separator = chunks.length > 0 ? '\n\n---\n\n' : '' - const notice = `${separator}${HERMES_SESSION_TRANSCRIPT_TRUNCATION_NOTICE}` - const boundedNotice = takeUtf8Prefix(notice, maxBytes) - const noticeBytes = Buffer.byteLength(boundedNotice) - const targetBytes = maxBytes - noticeBytes - trimUtf8Chunks(chunks, retainedBytes, targetBytes) - if (boundedNotice) { - chunks.push(boundedNotice) - } -} - -function trimUtf8Chunks(chunks: string[], retainedBytes: number, targetBytes: number): void { - let bytes = retainedBytes - while (bytes > targetBytes && chunks.length > 0) { - const lastIndex = chunks.length - 1 - const last = chunks[lastIndex]! - const lastBytes = Buffer.byteLength(last) - const allowedBytes = Math.max(0, lastBytes - (bytes - targetBytes)) - if (allowedBytes === 0) { - chunks.pop() - bytes -= lastBytes - continue - } - const prefix = takeUtf8Prefix(last, allowedBytes) - chunks[lastIndex] = prefix - bytes = bytes - lastBytes + Buffer.byteLength(prefix) - } -} - -function takeUtf8Prefix(value: string, maxBytes: number): string { - if (maxBytes <= 0) { - return '' - } - if (Buffer.byteLength(value) <= maxBytes) { - return value - } - let low = 0 - let high = Math.min(value.length, maxBytes) - while (low < high) { - const middle = Math.ceil((low + high) / 2) - if (Buffer.byteLength(value.slice(0, middle)) <= maxBytes) { - low = middle - } else { - high = middle - 1 - } - } - if (low > 0 && isHighSurrogate(value.charCodeAt(low - 1))) { - low -= 1 - } - return value.slice(0, low) -} - -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff -} - -function omitHermesRunOutput(run: unknown): unknown { - if (!run || typeof run !== 'object' || Array.isArray(run)) { - return { output_content: null, error: HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR } - } - const record = run as Record - const existingError = typeof record.error === 'string' && record.error ? record.error : null - return { - ...record, - output_content: null, - error: existingError - ? `${existingError}; ${HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR}` - : HERMES_RUN_PAGE_OUTPUT_OMITTED_ERROR - } -} diff --git a/src/shared/hosted-review-github.test.ts b/src/shared/hosted-review-github.test.ts index 6aadaced3f2..bbc17fa785b 100644 --- a/src/shared/hosted-review-github.test.ts +++ b/src/shared/hosted-review-github.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - hostedReviewInfoFromGitHubPRInfo, - hostedReviewSummaryFromGitHubPRInfo -} from './hosted-review-github' +import { hostedReviewInfoFromGitHubPRInfo } from './hosted-review-github' import type { PRInfo } from './types' const pr: PRInfo = { @@ -16,111 +13,7 @@ const pr: PRInfo = { headSha: 'abc123' } -describe('hostedReviewSummaryFromGitHubPRInfo', () => { - it('maps PRInfo into provider-neutral summary with host identity', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr, - owner: 'acme', - repo: 'orca', - host: 'github.acme.internal' - }) - - expect(summary.identity).toEqual({ - provider: 'github', - host: 'github.acme.internal', - owner: 'acme', - repo: 'orca', - number: 12 - }) - expect(summary.checksStatus).toBe('pending') - expect(summary.threadSummary).toBeUndefined() - }) - - it('derives unresolved thread count and failing status from enrichers', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr: { ...pr, checksStatus: 'success' }, - owner: 'acme', - repo: 'orca', - comments: [ - { - id: 1, - author: 'a', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 2, - author: 'b', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 3, - author: 'c', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't2', - isResolved: true - } - ], - checks: [{ name: 'ci', status: 'completed', conclusion: 'failure', url: null }] - }) - - expect(summary.threadSummary).toEqual({ unresolvedCount: 1, dataCompleteness: 'partial' }) - expect(summary.checksStatus).toBe('failure') - }) - - it('treats cancelled checks as failed in hosted review summaries', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr: { ...pr, checksStatus: 'success' }, - owner: 'acme', - repo: 'orca', - checks: [{ name: 'ci', status: 'completed', conclusion: 'cancelled', url: null }] - }) - - expect(summary.checksStatus).toBe('failure') - }) - - it('treats action_required checks as failed so auto-merge sees the block', () => { - const summary = hostedReviewSummaryFromGitHubPRInfo({ - pr: { ...pr, checksStatus: 'success' }, - owner: 'acme', - repo: 'orca', - checks: [{ name: 'approval', status: 'completed', conclusion: 'action_required', url: null }] - }) - - expect(summary.checksStatus).toBe('failure') - }) - - it('distinguishes loaded empty comments from unknown comments', () => { - expect( - hostedReviewSummaryFromGitHubPRInfo({ - pr, - owner: 'acme', - repo: 'orca' - }).threadSummary - ).toBeUndefined() - - expect( - hostedReviewSummaryFromGitHubPRInfo({ - pr, - owner: 'acme', - repo: 'orca', - comments: [] - }).threadSummary - ).toEqual({ unresolvedCount: 0, dataCompleteness: 'partial' }) - }) - +describe('hostedReviewInfoFromGitHubPRInfo', () => { it('maps PRInfo into sidebar hosted review metadata', () => { const githubRepository = { owner: 'upstream', repo: 'orca' } const review = hostedReviewInfoFromGitHubPRInfo({ ...pr, prRepo: githubRepository }) diff --git a/src/shared/hosted-review-github.ts b/src/shared/hosted-review-github.ts index f5aa5905b45..3cf76175bda 100644 --- a/src/shared/hosted-review-github.ts +++ b/src/shared/hosted-review-github.ts @@ -1,86 +1,5 @@ -import type { PRCheckDetail, PRComment, PRInfo } from './types' -import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' -import { derivePRCheckStatus } from './pr-check-status' - -export type HostedReviewFromGitHubPRInfoArgs = { - pr: PRInfo - owner: string - repo: string - host?: string - authorLogin?: string | null - authorIsBot?: boolean - requestedReviewerLogins?: string[] | null - comments?: PRComment[] - checks?: PRCheckDetail[] - lastViewedAt?: number -} - -function unresolvedThreadCount(comments?: PRComment[]): number | null { - if (comments === undefined) { - return null - } - const unresolved = new Set() - for (const comment of comments) { - if (!comment.threadId || comment.isResolved !== false) { - continue - } - unresolved.add(comment.threadId) - } - return unresolved.size -} - -function deriveChecksStatus( - prChecksStatus: PRInfo['checksStatus'], - checks?: PRCheckDetail[] -): PRInfo['checksStatus'] { - if (!checks) { - return prChecksStatus - } - return derivePRCheckStatus(checks) -} - -export function hostedReviewSummaryFromGitHubPRInfo( - args: HostedReviewFromGitHubPRInfoArgs -): HostedReviewQueueSummary { - const unresolvedCount = unresolvedThreadCount(args.comments) - return { - identity: { - provider: 'github', - host: args.host ?? 'github.com', - owner: args.owner, - repo: args.repo, - number: args.pr.number - }, - title: args.pr.title, - url: args.pr.url, - state: args.pr.state, - author: args.authorLogin ? { login: args.authorLogin, isBot: args.authorIsBot } : null, - updatedAt: args.pr.updatedAt, - mergeable: args.pr.mergeable, - ...(args.pr.mergeStateStatus !== undefined - ? { mergeStateStatus: args.pr.mergeStateStatus } - : {}), - checksStatus: deriveChecksStatus(args.pr.checksStatus, args.checks), - reviewDecision: - args.pr.reviewDecision === 'APPROVED' - ? 'approved' - : args.pr.reviewDecision === 'CHANGES_REQUESTED' - ? 'changes_requested' - : args.pr.reviewDecision === 'REVIEW_REQUIRED' - ? 'review_required' - : undefined, - threadSummary: - unresolvedCount === null - ? undefined - : { - unresolvedCount, - dataCompleteness: 'partial' - }, - requestedReviewerLogins: args.requestedReviewerLogins, - lastViewedAt: args.lastViewedAt, - draft: args.pr.state === 'draft' - } -} +import type { HostedReviewInfo } from './hosted-review' +import type { PRInfo } from './types' export function hostedReviewInfoFromGitHubPRInfo(pr: PRInfo): HostedReviewInfo { return { diff --git a/src/shared/hosted-review-gitlab.test.ts b/src/shared/hosted-review-gitlab.test.ts deleted file mode 100644 index b3cc6ad9c59..00000000000 --- a/src/shared/hosted-review-gitlab.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { hostedReviewSummaryFromGitLabInfo } from './hosted-review-gitlab' -import type { HostedReviewInfo } from './hosted-review' - -const review: HostedReviewInfo & { provider: 'gitlab' } = { - provider: 'gitlab', - number: 12, - title: 'Add queue badges', - state: 'open', - url: 'https://gitlab.acme.internal/group/subgroup/orca/-/merge_requests/12', - status: 'pending', - updatedAt: '2026-05-12T00:00:00.000Z', - mergeable: 'MERGEABLE' -} - -describe('hostedReviewSummaryFromGitLabInfo', () => { - it('maps nested GitLab project URLs into provider-neutral identity', () => { - const summary = hostedReviewSummaryFromGitLabInfo({ review }) - - expect(summary.identity).toEqual({ - provider: 'gitlab', - host: 'gitlab.acme.internal', - owner: 'group/subgroup', - repo: 'orca', - number: 12 - }) - expect(summary.checksStatus).toBe('pending') - expect(summary.threadSummary).toBeUndefined() - }) - - it('derives unresolved thread count and failing status from enrichers', () => { - const summary = hostedReviewSummaryFromGitLabInfo({ - review: { ...review, status: 'success' }, - comments: [ - { - id: 1, - author: 'a', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 2, - author: 'b', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't1', - isResolved: false - }, - { - id: 3, - author: 'c', - authorAvatarUrl: '', - body: '', - createdAt: '', - url: '', - threadId: 't2', - isResolved: true - } - ], - checks: [{ name: 'gitlab-ci', status: 'completed', conclusion: 'failure', url: null }] - }) - - expect(summary.threadSummary).toEqual({ unresolvedCount: 1, dataCompleteness: 'partial' }) - expect(summary.checksStatus).toBe('failure') - }) -}) diff --git a/src/shared/hosted-review-gitlab.ts b/src/shared/hosted-review-gitlab.ts deleted file mode 100644 index 3253ee9ff16..00000000000 --- a/src/shared/hosted-review-gitlab.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { HostedReviewInfo, HostedReviewQueueSummary } from './hosted-review' -import type { PRCheckDetail, PRComment } from './types' -import { derivePRCheckStatus } from './pr-check-status' - -export type HostedReviewFromGitLabInfoArgs = { - review: HostedReviewInfo & { provider: 'gitlab' } - authorLogin?: string | null - authorIsBot?: boolean - comments?: PRComment[] - checks?: PRCheckDetail[] - lastViewedAt?: number -} - -type GitLabIdentityParts = { - host: string - owner: string - repo: string -} - -function parseGitLabIdentity(url: string): GitLabIdentityParts { - try { - const parsed = new URL(url) - const segments = parsed.pathname.split('/').filter(Boolean) - const markerIndex = segments.indexOf('-') - const projectSegments = markerIndex >= 0 ? segments.slice(0, markerIndex) : segments - if (projectSegments.length >= 2) { - return { - host: parsed.host || 'gitlab.com', - owner: projectSegments.slice(0, -1).join('/'), - repo: projectSegments.at(-1) ?? 'unknown' - } - } - return { - host: parsed.host || 'gitlab.com', - owner: projectSegments[0] ?? 'unknown', - repo: projectSegments[1] ?? 'unknown' - } - } catch { - // Why: queue badges should degrade gracefully for hand-entered/self-hosted URLs. - return { host: 'gitlab.com', owner: 'unknown', repo: 'unknown' } - } -} - -function unresolvedThreadCount(comments?: PRComment[]): number | null { - if (comments === undefined) { - return null - } - const unresolved = new Set() - for (const comment of comments) { - if (!comment.threadId || comment.isResolved !== false) { - continue - } - unresolved.add(comment.threadId) - } - return unresolved.size -} - -function deriveChecksStatus( - reviewStatus: HostedReviewInfo['status'], - checks?: PRCheckDetail[] -): HostedReviewInfo['status'] { - if (!checks) { - return reviewStatus - } - return derivePRCheckStatus(checks) -} - -export function hostedReviewSummaryFromGitLabInfo( - args: HostedReviewFromGitLabInfoArgs -): HostedReviewQueueSummary { - const identity = parseGitLabIdentity(args.review.url) - const unresolvedCount = unresolvedThreadCount(args.comments) - return { - identity: { - provider: 'gitlab', - host: identity.host, - owner: identity.owner, - repo: identity.repo, - number: args.review.number - }, - title: args.review.title, - url: args.review.url, - state: args.review.state, - author: args.authorLogin ? { login: args.authorLogin, isBot: args.authorIsBot } : null, - updatedAt: args.review.updatedAt, - mergeable: args.review.mergeable, - ...(args.review.mergeStateStatus !== undefined - ? { mergeStateStatus: args.review.mergeStateStatus } - : {}), - checksStatus: deriveChecksStatus(args.review.status, args.checks), - reviewDecision: - args.review.reviewDecision === 'APPROVED' - ? 'approved' - : args.review.reviewDecision === 'CHANGES_REQUESTED' - ? 'changes_requested' - : args.review.reviewDecision === 'REVIEW_REQUIRED' - ? 'review_required' - : undefined, - threadSummary: - unresolvedCount === null - ? undefined - : { - unresolvedCount, - dataCompleteness: 'partial' - }, - lastViewedAt: args.lastViewedAt, - draft: args.review.state === 'draft' - } -} diff --git a/src/shared/hosted-review-queue.test.ts b/src/shared/hosted-review-queue.test.ts deleted file mode 100644 index fbf90768493..00000000000 --- a/src/shared/hosted-review-queue.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { HostedReviewQueueSummary } from './hosted-review' -import { - classifyHostedReview, - hostedReviewIdentityKey, - reviewNeedsResponse, - reviewReadyToMerge -} from './hosted-review-queue' - -function baseSummary(overrides: Partial = {}): HostedReviewQueueSummary { - return { - identity: { provider: 'github', host: 'github.com', owner: 'acme', repo: 'orca', number: 42 }, - title: 'Improve checks panel', - url: 'https://github.com/acme/orca/pull/42', - state: 'open', - author: { login: 'teammate' }, - updatedAt: '2026-05-10T00:00:00.000Z', - mergeable: 'MERGEABLE', - checksStatus: 'success', - threadSummary: { unresolvedCount: 0 }, - ...overrides - } -} - -describe('hostedReviewIdentityKey', () => { - it('includes provider and host for enterprise-safe keys', () => { - const dotcom = hostedReviewIdentityKey({ - provider: 'github', - host: 'github.com', - owner: 'acme', - repo: 'orca', - number: 7 - }) - const ghe = hostedReviewIdentityKey({ - provider: 'github', - host: 'github.acme.internal', - owner: 'acme', - repo: 'orca', - number: 7 - }) - expect(dotcom).not.toBe(ghe) - }) -}) - -describe('classifyHostedReview', () => { - it('classifies mine/requested/agent/teammate', () => { - expect( - classifyHostedReview(baseSummary({ author: { login: 'me' } }), { - viewer: { login: 'me' } - }).state - ).toBe('mine') - - expect( - classifyHostedReview(baseSummary({ requestedReviewerLogins: ['me'] }), { - viewer: { login: 'me' } - }).state - ).toBe('requested') - - expect( - classifyHostedReview(baseSummary({ author: { login: 'orca-ci' } }), { - agentAuthorLogins: ['orca-ci'] - }).state - ).toBe('agent') - - expect(classifyHostedReview(baseSummary()).state).toBe('teammate') - }) -}) - -describe('reviewNeedsResponse', () => { - it('returns true for unresolved threads, failed checks, conflicts, and newer remote updates', () => { - expect(reviewNeedsResponse(baseSummary({ threadSummary: { unresolvedCount: 1 } }))).toBe(true) - expect(reviewNeedsResponse(baseSummary({ checksStatus: 'failure' }))).toBe(true) - expect(reviewNeedsResponse(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(true) - expect( - reviewNeedsResponse( - baseSummary({ - updatedAt: '2026-05-11T00:00:00.000Z', - lastViewedAt: Date.parse('2026-05-10T00:00:00.000Z') - }) - ) - ).toBe(true) - }) - - it('does not mark needs-response from updatedAt alone when lastViewedAt is missing', () => { - expect(reviewNeedsResponse(baseSummary({ updatedAt: '2026-05-11T00:00:00.000Z' }))).toBe(false) - }) -}) - -describe('reviewReadyToMerge', () => { - it('rejects drafts, conflicts, failed/pending checks, unresolved threads, and unknown mergeability', () => { - expect(reviewReadyToMerge(baseSummary({ state: 'draft', draft: true }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeable: 'CONFLICTING' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ checksStatus: 'failure' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ checksStatus: 'pending' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ threadSummary: { unresolvedCount: 2 } }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ threadSummary: undefined }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeable: 'UNKNOWN' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ reviewDecision: 'review_required' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ reviewDecision: 'changes_requested' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeStateStatus: 'BEHIND' }))).toBe(false) - expect(reviewReadyToMerge(baseSummary({ mergeStateStatus: 'BLOCKED' }))).toBe(false) - }) - - it('accepts neutral checks when all other gates pass', () => { - expect(reviewReadyToMerge(baseSummary({ checksStatus: 'neutral' }))).toBe(true) - }) - - it('scopes GitHub merge-state blockers to GitHub summaries', () => { - expect( - reviewReadyToMerge( - baseSummary({ - identity: { - provider: 'gitlab', - host: 'gitlab.com', - owner: 'acme', - repo: 'orca', - number: 42 - }, - mergeStateStatus: 'BLOCKED' - }) - ) - ).toBe(true) - }) -}) diff --git a/src/shared/hosted-review-queue.ts b/src/shared/hosted-review-queue.ts deleted file mode 100644 index 037765c341b..00000000000 --- a/src/shared/hosted-review-queue.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - HostedReviewIdentity, - HostedReviewQueueClassification, - HostedReviewQueueState, - HostedReviewQueueSummary, - HostedReviewUser -} from './hosted-review' - -export type HostedReviewClassificationOptions = { - viewer?: HostedReviewUser | null - agentAuthorLogins?: string[] -} - -export function hostedReviewIdentityKey(identity: HostedReviewIdentity): string { - return `${identity.provider}::${identity.host.toLowerCase()}::${identity.owner.toLowerCase()}::${identity.repo.toLowerCase()}::${identity.number}` -} - -function hasRequestedReviewerSignal( - summary: HostedReviewQueueSummary, - viewer?: HostedReviewUser | null -): boolean { - if (!viewer?.login) { - return false - } - const requested = summary.requestedReviewerLogins - if (!requested || requested.length === 0) { - return false - } - const viewerLogin = viewer.login.toLowerCase() - return requested.some((login) => login.toLowerCase() === viewerLogin) -} - -function isAgentAuthored( - summary: HostedReviewQueueSummary, - options?: HostedReviewClassificationOptions -): boolean { - if (summary.author?.isBot) { - return true - } - const author = summary.author?.login?.toLowerCase() - if (!author) { - return false - } - if (options?.agentAuthorLogins?.some((login) => login.toLowerCase() === author)) { - return true - } - return author.endsWith('[bot]') || author.includes('bot') -} - -function getQueueState( - summary: HostedReviewQueueSummary, - options?: HostedReviewClassificationOptions -): HostedReviewQueueState { - const viewerLogin = options?.viewer?.login?.toLowerCase() ?? null - const authorLogin = summary.author?.login?.toLowerCase() ?? null - if (viewerLogin && authorLogin && viewerLogin === authorLogin) { - return 'mine' - } - if (hasRequestedReviewerSignal(summary, options?.viewer)) { - return 'requested' - } - if (isAgentAuthored(summary, options)) { - return 'agent' - } - return 'teammate' -} - -export function reviewNeedsResponse( - summary: HostedReviewQueueSummary, - viewer?: HostedReviewUser | null -): boolean { - void viewer - if (summary.state !== 'open' && summary.state !== 'draft') { - return false - } - if ((summary.threadSummary?.unresolvedCount ?? 0) > 0) { - return true - } - if (summary.checksStatus === 'failure') { - return true - } - if (summary.mergeable === 'CONFLICTING') { - return true - } - if (summary.lastViewedAt === undefined) { - return false - } - const updatedAt = Date.parse(summary.updatedAt) - return Number.isFinite(updatedAt) && updatedAt > summary.lastViewedAt -} - -export function reviewReadyToMerge(summary: HostedReviewQueueSummary): boolean { - if (summary.state !== 'open') { - return false - } - if (summary.draft) { - return false - } - if (summary.mergeable !== 'MERGEABLE') { - return false - } - if ( - summary.identity.provider === 'github' && - (summary.mergeStateStatus === 'BEHIND' || summary.mergeStateStatus === 'BLOCKED') - ) { - return false - } - if ( - summary.reviewDecision === 'review_required' || - summary.reviewDecision === 'changes_requested' - ) { - return false - } - if (summary.checksStatus !== 'success' && summary.checksStatus !== 'neutral') { - return false - } - if (summary.threadSummary?.unresolvedCount !== 0) { - return false - } - return true -} - -export function classifyHostedReview( - summary: HostedReviewQueueSummary, - options?: HostedReviewClassificationOptions -): HostedReviewQueueClassification { - const state = getQueueState(summary, options) - return { - state, - requested: hasRequestedReviewerSignal(summary, options?.viewer), - needsResponse: reviewNeedsResponse(summary, options?.viewer), - readyToMerge: reviewReadyToMerge(summary) - } -} diff --git a/src/shared/hosted-review.ts b/src/shared/hosted-review.ts index 6a8685cbc81..0d25a637e41 100644 --- a/src/shared/hosted-review.ts +++ b/src/shared/hosted-review.ts @@ -173,48 +173,4 @@ export type HostedReviewCreationEligibilityArgs = { linkedGiteaPR?: number | null } -export type HostedReviewIdentity = { - provider: HostedReviewProvider - host: string - owner: string - repo: string - number: number -} - -export type HostedReviewUser = { - login: string | null - isBot?: boolean -} - export type HostedReviewDecision = 'approved' | 'changes_requested' | 'review_required' | null - -export type HostedReviewThreadSummary = { - unresolvedCount: number | null - dataCompleteness?: 'full' | 'partial' -} - -export type HostedReviewQueueSummary = { - identity: HostedReviewIdentity - title: string - url: string - state: HostedReviewState - author: HostedReviewUser | null - updatedAt: string - lastViewedAt?: number - mergeable: PRMergeableState - mergeStateStatus?: string | null - checksStatus: CheckStatus - reviewDecision?: HostedReviewDecision - threadSummary?: HostedReviewThreadSummary - requestedReviewerLogins?: string[] | null - draft?: boolean -} - -export type HostedReviewQueueState = 'mine' | 'requested' | 'agent' | 'teammate' - -export type HostedReviewQueueClassification = { - state: HostedReviewQueueState - needsResponse: boolean - readyToMerge: boolean - requested: boolean -} diff --git a/src/shared/native-chat-session-option-commands.test.ts b/src/shared/native-chat-session-option-commands.test.ts index 2583192d34b..77205f6ba5c 100644 --- a/src/shared/native-chat-session-option-commands.test.ts +++ b/src/shared/native-chat-session-option-commands.test.ts @@ -68,7 +68,7 @@ describe('buildNativeChatSessionOptionCommand', () => { ).toBe('/fast') }) - it('builds an absolute command for live Codex model changes', () => { + it('does not turn a Codex model pick into pasted slash-command prose', () => { expect( buildNativeChatSessionOptionCommand({ optionId: 'model', @@ -79,7 +79,12 @@ describe('buildNativeChatSessionOptionCommand', () => { models: CODEX_SESSION_OPTION_CATALOG.models, record: createNativeChatSessionOptionRecord('codex') }) - ).toBe('/model gpt-5.5') + ).toBeNull() + expect(CODEX_SESSION_OPTION_CATALOG.modelApply.midSession).toEqual({ + kind: 'agent-picker', + command: '/model', + delivery: 'type' + }) }) }) diff --git a/src/shared/native-chat-session-option-snapshot.test.ts b/src/shared/native-chat-session-option-snapshot.test.ts index c6436f9aad4..961701b7415 100644 --- a/src/shared/native-chat-session-option-snapshot.test.ts +++ b/src/shared/native-chat-session-option-snapshot.test.ts @@ -207,7 +207,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { }) }) - it('exposes Codex model changes as native selectable values', () => { + it('routes Codex model changes through its typed TUI picker', () => { const snapshot = buildNativeChatSessionOptionSnapshot({ catalog: CODEX_SESSION_OPTION_CATALOG, models: CODEX_SESSION_OPTION_CATALOG.models, @@ -216,7 +216,7 @@ describe('buildNativeChatSessionOptionSnapshot', () => { modelLabel: 'Model' }) expect(snapshot[0]).toMatchObject({ settable: true }) - expect(snapshot[0]?.action).toBeUndefined() + expect(snapshot[0]?.action).toEqual({ type: 'agent-picker' }) expect(snapshot[0]?.kind).toMatchObject({ type: 'select', choices: expect.arrayContaining([{ value: 'gpt-5.5', label: 'GPT-5.5' }]) diff --git a/src/shared/owned-utf16-suffix.ts b/src/shared/owned-utf16-suffix.ts new file mode 100644 index 00000000000..19d623db60e --- /dev/null +++ b/src/shared/owned-utf16-suffix.ts @@ -0,0 +1,20 @@ +const COPY_BLOCK_CODE_UNITS = 4 * 1024 + +export function copyUtf16SuffixToOwnedString(value: string, suffixCodeUnits: number): string { + const suffixLength = Math.max(0, Math.min(value.length, suffixCodeUnits)) + const start = value.length - suffixLength + const blocks: string[] = [] + const codeUnits: number[] = [] + codeUnits.length = Math.min(COPY_BLOCK_CODE_UNITS, suffixLength) + + // Why: slice can retain an arbitrarily large V8 backing string. + for (let offset = start; offset < value.length; offset += COPY_BLOCK_CODE_UNITS) { + const blockLength = Math.min(COPY_BLOCK_CODE_UNITS, value.length - offset) + codeUnits.length = blockLength + for (let index = 0; index < blockLength; index += 1) { + codeUnits[index] = value.charCodeAt(offset + index) + } + blocks.push(String.fromCharCode(...codeUnits)) + } + return blocks.join('') +} diff --git a/src/shared/persisted-state-file-bounds.test.ts b/src/shared/persisted-state-file-bounds.test.ts deleted file mode 100644 index e6ee2de1878..00000000000 --- a/src/shared/persisted-state-file-bounds.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { createHash } from 'node:crypto' -import { - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - truncateSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { NodeFileReadTooLargeError } from './node-bounded-file-reader' -import { JsonStringifyByteLimitError } from './node-bounded-json-stringify' -import { - PersistedStateSecretCapacityError, - assertPersistedStateSecretWithinLimit, - readPersistedStateJsonFileSync, - replacePersistedStateJsonWithinLimit, - restorePersistedStateBackupSync, - stringifyPrettyPersistedStateWithinLimit, - stringifyPersistedStateWithinLimit, - updatePersistedStateHashWithJsonRange -} from './persisted-state-file-bounds' - -describe('persisted state file bounds', () => { - let root = '' - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-state-bounds-')) - }) - - afterEach(() => { - rmSync(root, { recursive: true, force: true }) - }) - - it('reads and parses a state file exactly at the byte limit', () => { - const path = join(root, 'state.json') - const json = `{"value":"${'x'.repeat(20)}"}` - writeFileSync(path, json) - - expect( - readPersistedStateJsonFileSync<{ value: string }>(path, Buffer.byteLength(json)) - ).toEqual({ - byteLength: Buffer.byteLength(json), - value: { value: 'x'.repeat(20) } - }) - }) - - it('rejects an oversized sparse state file before reading its body', () => { - const path = join(root, 'state.json') - writeFileSync(path, '') - truncateSync(path, 1025) - - expect(() => readPersistedStateJsonFileSync(path, 1024)).toThrow(NodeFileReadTooLargeError) - }) - - it('rejects structurally amplified state before parsing it', () => { - const path = join(root, 'state.json') - const json = '{"rows":[{},{}]}' - writeFileSync(path, json) - - expect(() => - readPersistedStateJsonFileSync(path, Buffer.byteLength(json), { - structuralTokens: 7, - nestingDepth: 3 - }) - ).toThrow('JSON structure') - }) - - it('matches native compact JSON exactly at the output boundary', () => { - const state = { quote: '"', unicode: '🐋', nested: [1, true, null] } - const native = JSON.stringify(state) - - expect(stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ - byteLength: Buffer.byteLength(native), - serialized: native - }) - expect(() => stringifyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1)).toThrow( - JsonStringifyByteLimitError - ) - }) - - it('matches native pretty JSON and enforces its whitespace-inclusive boundary', () => { - const state = { nested: { value: 'x' }, list: [1, 2] } - const native = JSON.stringify(state, null, 2) - - expect(stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native))).toEqual({ - byteLength: Buffer.byteLength(native), - serialized: native - }) - expect(() => - stringifyPrettyPersistedStateWithinLimit(state, Buffer.byteLength(native) - 1) - ).toThrow(JsonStringifyByteLimitError) - }) - - it('bounds secret plaintext before encryption can expand it', () => { - assertPersistedStateSecretWithinLimit('🐋', 4) - - expect(() => assertPersistedStateSecretWithinLimit('🐋x', 4)).toThrow( - PersistedStateSecretCapacityError - ) - }) - - it('checks replacement growth before constructing the next payload', () => { - const serialized = '{"value":"slot"}' - const exactBytes = - Buffer.byteLength(serialized) - Buffer.byteLength('slot') + Buffer.byteLength('expanded') - - expect( - replacePersistedStateJsonWithinLimit({ - serialized, - currentBytes: Buffer.byteLength(serialized), - search: 'slot', - replacement: 'expanded', - maxBytes: exactBytes - }) - ).toEqual({ byteLength: exactBytes, serialized: '{"value":"expanded"}' }) - expect(() => - replacePersistedStateJsonWithinLimit({ - serialized, - currentBytes: Buffer.byteLength(serialized), - search: 'slot', - replacement: 'expanded', - maxBytes: exactBytes - 1 - }) - ).toThrow(JsonStringifyByteLimitError) - }) - - it('hashes bounded string ranges without splitting UTF-16 surrogate pairs', () => { - const value = `prefix-${'x'.repeat(8)}🐋-${'y'.repeat(8)}-suffix` - const expected = createHash('sha1').update(value).digest('hex') - const actual = createHash('sha1') - - updatePersistedStateHashWithJsonRange(actual, value, 0, value.length, 2) - - expect(actual.digest('hex')).toBe(expected) - }) - - it('atomically restores only a valid in-limit backup', () => { - const backupPath = join(root, 'backup.json') - const targetPath = join(root, 'profile', 'orca-data.json') - writeFileSync(backupPath, '{"repos":[{"id":"recovered"}]}') - - restorePersistedStateBackupSync(backupPath, targetPath, 1024) - - expect(JSON.parse(readFileSync(targetPath, 'utf8'))).toEqual({ - repos: [{ id: 'recovered' }] - }) - const originalTarget = readFileSync(targetPath) - writeFileSync(backupPath, '{{invalid') - expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow() - expect(readFileSync(targetPath)).toEqual(originalTarget) - expect( - readdirSync(join(root, 'profile')).filter((name) => name.endsWith('.recovery.tmp')) - ).toEqual([]) - }) - - it('leaves the target untouched when a backup exceeds the cap', () => { - const backupPath = join(root, 'backup.json') - const targetPath = join(root, 'orca-data.json') - writeFileSync(targetPath, '{"original":true}') - writeFileSync(backupPath, '') - truncateSync(backupPath, 1025) - - expect(() => restorePersistedStateBackupSync(backupPath, targetPath, 1024)).toThrow( - NodeFileReadTooLargeError - ) - expect(readFileSync(targetPath, 'utf8')).toBe('{"original":true}') - }) -}) diff --git a/src/shared/persisted-state-file-bounds.ts b/src/shared/persisted-state-file-bounds.ts deleted file mode 100644 index 8ad92c95435..00000000000 --- a/src/shared/persisted-state-file-bounds.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { randomUUID, type Hash } from 'node:crypto' -import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' -import { readNodeFileSyncWithinLimit, type BoundedNodeFileRead } from './node-bounded-file-reader' -import { - JsonStringifyByteLimitError, - stringifyJsonWithinByteLimit -} from './node-bounded-json-stringify' -import { - assertJsonTextStructureWithinLimits, - type JsonTextStructureLimits -} from './json-text-structure-limit' - -export const ORCA_PERSISTED_STATE_MAX_BYTES = 64 * 1024 * 1024 -export const ORCA_PERSISTED_STATE_SECRET_MAX_BYTES = 4 * 1024 * 1024 -export const ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS = 64 * 1024 -export const ORCA_PERSISTED_STATE_JSON_LIMITS: JsonTextStructureLimits = { - structuralTokens: 4_000_000, - nestingDepth: 256 -} - -export type PersistedStateJsonRead = { - byteLength: number - value: T -} - -export class PersistedStateSecretCapacityError extends Error { - constructor( - readonly observedBytes: number, - readonly maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES - ) { - super(`Persisted state secret exceeds ${maxBytes} bytes`) - this.name = 'PersistedStateSecretCapacityError' - } -} - -export function readPersistedStateJsonFileSync( - filePath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES, - structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS -): PersistedStateJsonRead { - const { buffer } = readPersistedStateFileBytesSync(filePath, maxBytes) - return { - byteLength: buffer.byteLength, - value: parsePersistedStateJsonBuffer(buffer, structureLimits) - } -} - -export function readPersistedStateFileBytesSync( - filePath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): BoundedNodeFileRead { - return readNodeFileSyncWithinLimit(filePath, maxBytes) -} - -export function parsePersistedStateJsonBuffer( - buffer: Buffer, - structureLimits: JsonTextStructureLimits = ORCA_PERSISTED_STATE_JSON_LIMITS -): T { - const serialized = buffer.toString('utf8') - assertJsonTextStructureWithinLimits(serialized, structureLimits) - return JSON.parse(serialized) as T -} - -export function stringifyPersistedStateWithinLimit( - value: unknown, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): { byteLength: number; serialized: string } { - return stringifyJsonWithinByteLimit(value, maxBytes) -} - -export function stringifyPrettyPersistedStateWithinLimit( - value: unknown, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): { byteLength: number; serialized: string } { - return stringifyJsonWithinByteLimit(value, maxBytes, 2) -} - -export function assertPersistedStateSecretWithinLimit( - value: string, - maxBytes = ORCA_PERSISTED_STATE_SECRET_MAX_BYTES -): void { - const observedBytes = Buffer.byteLength(value, 'utf8') - if (observedBytes > maxBytes) { - throw new PersistedStateSecretCapacityError(observedBytes, maxBytes) - } -} - -export function replacedPersistedStateJsonByteLength(options: { - currentBytes: number - maxBytes?: number - replacement: string - search: string -}): number { - const maxBytes = options.maxBytes ?? ORCA_PERSISTED_STATE_MAX_BYTES - if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { - throw new RangeError('Persisted state JSON byte limit must be a non-negative safe integer') - } - if (!Number.isSafeInteger(options.currentBytes) || options.currentBytes < 0) { - throw new RangeError('Persisted state JSON byte count must be a non-negative safe integer') - } - if (options.currentBytes > maxBytes) { - throw new JsonStringifyByteLimitError(options.currentBytes, maxBytes) - } - const nextBytes = - options.currentBytes - - Buffer.byteLength(options.search, 'utf8') + - Buffer.byteLength(options.replacement, 'utf8') - if (!Number.isSafeInteger(nextBytes) || nextBytes < 0 || nextBytes > maxBytes) { - throw new JsonStringifyByteLimitError(nextBytes, maxBytes) - } - return nextBytes -} - -export function replacePersistedStateJsonWithinLimit(options: { - currentBytes: number - maxBytes?: number - replacement: string - search: string - serialized: string -}): { byteLength: number; serialized: string } { - const byteLength = replacedPersistedStateJsonByteLength(options) - const searchIndex = options.serialized.indexOf(options.search) - if (searchIndex === -1) { - throw new Error('Persisted state JSON replacement slot is missing') - } - if (options.serialized.includes(options.search, searchIndex + options.search.length)) { - throw new Error('Persisted state JSON replacement slot is ambiguous') - } - return { - byteLength, - serialized: options.serialized.replace(options.search, () => options.replacement) - } -} - -export function updatePersistedStateHashWithJsonRange( - hash: Pick, - value: string, - start = 0, - end = value.length, - chunkCodeUnits = ORCA_PERSISTED_STATE_HASH_CHUNK_CODE_UNITS -): void { - if ( - !Number.isSafeInteger(start) || - !Number.isSafeInteger(end) || - start < 0 || - end < start || - end > value.length - ) { - throw new RangeError('Persisted state hash range is invalid') - } - if (!Number.isSafeInteger(chunkCodeUnits) || chunkCodeUnits <= 0) { - throw new RangeError('Persisted state hash chunk size must be a positive safe integer') - } - - let offset = start - while (offset < end) { - let nextOffset = Math.min(end, offset + chunkCodeUnits) - if ( - nextOffset < end && - isHighSurrogate(value.charCodeAt(nextOffset - 1)) && - isLowSurrogate(value.charCodeAt(nextOffset)) - ) { - nextOffset += 1 - } - hash.update(value.slice(offset, nextOffset), 'utf8') - offset = nextOffset - } -} - -export function restorePersistedStateBackupSync( - sourcePath: string, - targetPath: string, - maxBytes = ORCA_PERSISTED_STATE_MAX_BYTES -): number { - const read = readValidatedPersistedStateBytesSync(sourcePath, maxBytes) - mkdirSync(dirname(targetPath), { recursive: true }) - const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.recovery.tmp` - try { - writeFileSync(temporaryPath, read.buffer) - renameSync(temporaryPath, targetPath) - } catch (error) { - rmSync(temporaryPath, { force: true }) - throw error - } - return read.buffer.byteLength -} - -function readValidatedPersistedStateBytesSync( - filePath: string, - maxBytes: number -): BoundedNodeFileRead { - const read = readPersistedStateFileBytesSync(filePath, maxBytes) - parsePersistedStateJsonBuffer(read.buffer) - return read -} - -function isHighSurrogate(code: number): boolean { - return code >= 0xd800 && code <= 0xdbff -} - -function isLowSurrogate(code: number): boolean { - return code >= 0xdc00 && code <= 0xdfff -} diff --git a/src/renderer/src/lib/pr-comment-audience.test.ts b/src/shared/pr-comment-audience.test.ts similarity index 70% rename from src/renderer/src/lib/pr-comment-audience.test.ts rename to src/shared/pr-comment-audience.test.ts index fb48a97e6f9..7499e791b90 100644 --- a/src/renderer/src/lib/pr-comment-audience.test.ts +++ b/src/shared/pr-comment-audience.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from 'vitest' - +import type { PRComment } from './types' +import { createBotAuthorOverrideSet } from './pr-bot-author-overrides' import { - createBotAuthorOverrideSet, filterPRCommentsByAudience, getPRCommentAudienceCounts, isBotPRComment } from './pr-comment-audience' -import type { PRComment } from '../../../shared/types' function comment(overrides: Partial): PRComment { return { @@ -21,9 +20,17 @@ function comment(overrides: Partial): PRComment { } describe('pr comment audience filtering', () => { - it('uses GitHub bot metadata before falling back to login suffixes', () => { - expect(isBotPRComment(comment({ author: 'chatgpt-codex-connector', isBot: true }))).toBe(true) + it('classifies provider metadata and automation logins', () => { + expect(isBotPRComment(comment({ author: 'alice', isBot: true }))).toBe(true) expect(isBotPRComment(comment({ author: 'github-actions[bot]' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'renovate-bot' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'preview-automation' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'qodo-ai-reviewer', isBot: false }))).toBe(true) + expect(isBotPRComment(comment({ author: 'coderabbitai', isBot: false }))).toBe(true) + expect(isBotPRComment(comment({ author: 'sonarcloud' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'codium-ai-reviewer' }))).toBe(true) + expect(isBotPRComment(comment({ author: 'octocat' }))).toBe(false) + expect(isBotPRComment(comment({ author: 'robotics-dev' }))).toBe(false) expect(isBotPRComment(comment({ author: 'human-botany' }))).toBe(false) }) @@ -35,6 +42,7 @@ describe('pr comment audience filtering', () => { ] expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 1, bot: 2 }) + expect(filterPRCommentsByAudience(comments, 'all')).toBe(comments) expect(filterPRCommentsByAudience(comments, 'human').map((item) => item.id)).toEqual([1]) expect(filterPRCommentsByAudience(comments, 'bot').map((item) => item.id)).toEqual([2, 3]) }) diff --git a/mobile/src/components/pr-sidebar/pr-comment-audience.ts b/src/shared/pr-comment-audience.ts similarity index 54% rename from mobile/src/components/pr-sidebar/pr-comment-audience.ts rename to src/shared/pr-comment-audience.ts index 71032a74f81..280d0590a7a 100644 --- a/mobile/src/components/pr-sidebar/pr-comment-audience.ts +++ b/src/shared/pr-comment-audience.ts @@ -1,35 +1,19 @@ -import type { PRComment } from '../../../../src/shared/types' -import { - createBotAuthorOverrideSet, - normalizePRCommentAuthorLogin -} from '../../../../src/shared/pr-bot-author-overrides' +import type { PRComment } from './types' +import { normalizePRCommentAuthorLogin } from './pr-bot-author-overrides' -export { createBotAuthorOverrideSet } - -// Audience filtering for the PR comment timeline, ported from the desktop helper -// (src/renderer/src/lib/pr-comment-audience.ts) minus its i18n wrapper so it stays -// pure + unit-testable under the node Vitest config. Classification must match the -// desktop so the same comment reads as human/bot on both surfaces. export type PRCommentAudienceFilter = 'all' | 'human' | 'bot' -export const PR_COMMENT_AUDIENCE_FILTERS: { value: PRCommentAudienceFilter; label: string }[] = [ - { value: 'all', label: 'All' }, - { value: 'human', label: 'Humans' }, - { value: 'bot', label: 'Bots' } -] - const BOT_LOGIN_SUFFIX = '[bot]' const AUTOMATION_LOGIN_PATTERNS = [ /bot$/i, - /-bot$/i, /\bbot\b/i, /automation/i, /actions/i, /renovate/i, /dependabot/i ] -// Some AI/code-review services use regular user accounts, so GitHub metadata can -// report them as users — keep this list in sync with the desktop helper. + +// GitHub can report regular-account review services as users. const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [ 'chatgpt-codex-connector', 'codex-connector', @@ -55,10 +39,7 @@ export function isBotPRComment( ): boolean { const author = comment.author.trim() const normalized = normalizePRCommentAuthorLogin(author) - if (botAuthorOverrides?.has(normalized)) { - return true - } - if (comment.isBot === true) { + if (botAuthorOverrides?.has(normalized) || comment.isBot === true) { return true } if (normalized.endsWith(BOT_LOGIN_SUFFIX)) { @@ -71,15 +52,11 @@ export function isBotPRComment( } export function getPRCommentAudienceCounts( - comments: PRComment[], + comments: readonly PRComment[], botAuthorOverrides?: ReadonlySet ): Record { const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length - return { - all: comments.length, - human: comments.length - bot, - bot - } + return { all: comments.length, human: comments.length - bot, bot } } export function filterPRCommentsByAudience( @@ -95,14 +72,3 @@ export function filterPRCommentsByAudience( } return comments } - -export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string { - switch (filter) { - case 'bot': - return 'No bot comments.' - case 'human': - return 'No human comments.' - case 'all': - return 'No comments yet.' - } -} diff --git a/src/renderer/src/lib/pr-comment-groups.test.ts b/src/shared/pr-comment-groups.test.ts similarity index 72% rename from src/renderer/src/lib/pr-comment-groups.test.ts rename to src/shared/pr-comment-groups.test.ts index 1bdacc7710a..b2f8de72980 100644 --- a/src/renderer/src/lib/pr-comment-groups.test.ts +++ b/src/shared/pr-comment-groups.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from 'vitest' - +import type { PRComment } from './types' import { getPRCommentGroupCount, getPRCommentGroupId, groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups' -import type { PRComment } from '../../../shared/types' function comment(overrides: Partial): PRComment { return { @@ -22,14 +21,17 @@ function comment(overrides: Partial): PRComment { describe('pr comment groups', () => { it('groups review thread replies while preserving first-comment order', () => { - const groups = groupPRComments([ + const comments = [ comment({ id: 1 }), comment({ id: 2, threadId: 'thread-a', isResolved: true }), - comment({ id: 3, threadId: 'thread-a', isResolved: true }), + comment({ id: 3, threadId: 'thread-a' }), comment({ id: 4 }) - ]) + ] + const groups = groupPRComments(comments) expect(groups.map(getPRCommentGroupId)).toEqual(['comment:1', 'thread:thread-a', 'comment:4']) + expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] }) + expect(groups[1]).toMatchObject({ root: { id: 2 }, replies: [{ id: 3 }] }) expect(getPRCommentGroupCount(groups[1])).toBe(2) expect(isResolvedPRCommentGroup(groups[1])).toBe(true) }) diff --git a/src/shared/pr-comment-groups.ts b/src/shared/pr-comment-groups.ts new file mode 100644 index 00000000000..8d43b9e2b77 --- /dev/null +++ b/src/shared/pr-comment-groups.ts @@ -0,0 +1,47 @@ +import type { PRComment } from './types' + +export type PRCommentGroup = + | { kind: 'standalone'; comment: PRComment } + | { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] } + +export function groupPRComments(comments: readonly PRComment[]): PRCommentGroup[] { + const groups: PRCommentGroup[] = [] + const threads = new Map>() + + for (const comment of comments) { + if (!comment.threadId) { + groups.push({ kind: 'standalone', comment }) + continue + } + const existing = threads.get(comment.threadId) + if (existing) { + existing.replies.push(comment) + continue + } + const group: Extract = { + kind: 'thread', + threadId: comment.threadId, + root: comment, + replies: [] + } + threads.set(comment.threadId, group) + groups.push(group) + } + return groups +} + +export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment { + return group.kind === 'thread' ? group.root : group.comment +} + +export function getPRCommentGroupCount(group: PRCommentGroup): number { + return group.kind === 'thread' ? group.replies.length + 1 : 1 +} + +export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean { + return getPRCommentGroupRoot(group).isResolved === true +} + +export function getPRCommentGroupId(group: PRCommentGroup): string { + return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` +} diff --git a/src/renderer/src/lib/pr-comment-time.test.ts b/src/shared/pr-comment-time.test.ts similarity index 100% rename from src/renderer/src/lib/pr-comment-time.test.ts rename to src/shared/pr-comment-time.test.ts diff --git a/src/renderer/src/lib/pr-comment-time.ts b/src/shared/pr-comment-time.ts similarity index 81% rename from src/renderer/src/lib/pr-comment-time.ts rename to src/shared/pr-comment-time.ts index d5935df2c6d..32f8ec2bc09 100644 --- a/src/renderer/src/lib/pr-comment-time.ts +++ b/src/shared/pr-comment-time.ts @@ -1,5 +1,3 @@ -// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd" out). Pure and -// unit-testable; nowMs is passed in so tests stay deterministic. export function formatPrCommentRelativeTime(iso: string, nowMs: number): string { const ts = Date.parse(iso) if (Number.isNaN(ts)) { diff --git a/src/shared/preserved-branch-cleanup.ts b/src/shared/preserved-branch-cleanup.ts new file mode 100644 index 00000000000..6199ce52d5e --- /dev/null +++ b/src/shared/preserved-branch-cleanup.ts @@ -0,0 +1,23 @@ +import type { ExecutionHostId } from './execution-host' + +export type PreservedBranchCleanup = { + worktreeId: string + branchName: string + expectedHead?: string + hostId?: ExecutionHostId + runtimeEnvironmentId?: string +} + +export function preservedBranchCleanupScopeKey( + cleanup: Pick +): string { + return [cleanup.hostId ?? '', cleanup.runtimeEnvironmentId ?? '', cleanup.worktreeId].join('\0') +} + +export function preservedBranchCleanupKey(cleanup: PreservedBranchCleanup): string { + return [ + preservedBranchCleanupScopeKey(cleanup), + cleanup.branchName, + cleanup.expectedHead ?? '' + ].join('\0') +} diff --git a/src/shared/ripgrep-process-availability.test.ts b/src/shared/ripgrep-process-availability.test.ts new file mode 100644 index 00000000000..033df296666 --- /dev/null +++ b/src/shared/ripgrep-process-availability.test.ts @@ -0,0 +1,90 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { EventEmitter } from 'node:events' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + absorbPendingRipgrepSpawnError, + isRipgrepUnavailableExit, + isRipgrepSpawnCwdUsable, + killSpawnedRipgrepProcess +} from './ripgrep-process-availability' + +function createChild(pid: number | undefined): ChildProcess { + const child = new EventEmitter() as ChildProcess + Object.defineProperty(child, 'pid', { value: pid }) + return child +} + +describe('ripgrep process availability', () => { + it('recognizes native pre-spawn failures before either event wins the race', () => { + expect(isRipgrepUnavailableExit(createChild(undefined), null, null)).toBe(true) + expect(isRipgrepUnavailableExit(createChild(undefined), -2, null)).toBe(true) + }) + + it('keeps all post-spawn and signal exits on their existing paths', () => { + for (const code of [0, 1, 2, 127]) { + expect(isRipgrepUnavailableExit(createChild(1), code, null)).toBe(false) + } + expect(isRipgrepUnavailableExit(createChild(1), null, 'SIGTERM')).toBe(false) + }) + + it('tags only unsupported native launcher exits after spawn', () => { + for (const code of [0, 1, 2]) { + expect( + isRipgrepUnavailableExit(createChild(1), code, null, { + classifyNativeLauncherExit: true + }) + ).toBe(false) + } + for (const code of [3, 126, 127, 9009]) { + expect( + isRipgrepUnavailableExit(createChild(1), code, null, { + classifyNativeLauncherExit: true + }) + ).toBe(true) + } + }) + + it('does not signal a real failed-spawn handle', async () => { + const child = spawn('orca-definitely-missing-rg-admission-test', []) + const error = new Promise((resolve) => child.once('error', () => resolve())) + const close = new Promise((resolve) => child.once('close', () => resolve())) + + expect(killSpawnedRipgrepProcess(child)).toBe(false) + await Promise.all([error, close]) + }) + + it('distinguishes a usable root from a missing spawn cwd', async () => { + await expect(isRipgrepSpawnCwdUsable(process.cwd())).resolves.toBe(true) + await expect( + isRipgrepSpawnCwdUsable(join(process.cwd(), 'orca-definitely-missing-rg-cwd')) + ).resolves.toBe(false) + }) + + it('absorbs a queued spawn error when cleanup wins the race', () => { + const child = createChild(undefined) + absorbPendingRipgrepSpawnError(child, { + errorObserved: false, + unavailableExitObserved: false + }) + + expect(() => child.emit('error', new Error('spawn rg ENOENT'))).not.toThrow() + expect(child.listenerCount('error')).toBe(0) + }) + + it('does not retain a sink for started or already-observed processes', () => { + const started = createChild(1) + absorbPendingRipgrepSpawnError(started, { + errorObserved: false, + unavailableExitObserved: false + }) + const observed = createChild(undefined) + absorbPendingRipgrepSpawnError(observed, { + errorObserved: true, + unavailableExitObserved: true + }) + + expect(started.listenerCount('error')).toBe(0) + expect(observed.listenerCount('error')).toBe(0) + }) +}) diff --git a/src/shared/ripgrep-process-availability.ts b/src/shared/ripgrep-process-availability.ts new file mode 100644 index 00000000000..0b5ef026c0e --- /dev/null +++ b/src/shared/ripgrep-process-availability.ts @@ -0,0 +1,124 @@ +import { constants } from 'node:fs' +import { access, stat } from 'node:fs/promises' +import { spawn, type ChildProcess } from 'node:child_process' + +const RIPGREP_CWD_CHECK_TIMEOUT_MS = 1000 +const RIPGREP_FAILURE_PROBE_TIMEOUT_MS = 5000 + +export class RipgrepUnavailableError extends Error { + constructor() { + super('ripgrep is unavailable') + this.name = 'RipgrepUnavailableError' + } +} + +function ignoreRipgrepSpawnError(): void {} + +export function killSpawnedRipgrepProcess(child: ChildProcess): boolean { + // Why: killing a failed-spawn handle can signal the relay's own process group. + if (Object.hasOwn(child, 'pid') && child.pid === undefined) { + return false + } + return child.kill() +} + +export function absorbPendingRipgrepSpawnError( + child: ChildProcess, + state: { errorObserved: boolean; unavailableExitObserved: boolean } +): void { + if ( + state.errorObserved || + (!state.unavailableExitObserved && !(Object.hasOwn(child, 'pid') && child.pid === undefined)) + ) { + return + } + // Why: concurrent-pass cleanup can win before Node delivers the queued spawn error. + child.once('error', ignoreRipgrepSpawnError) +} + +export async function isRipgrepSpawnCwdUsable(cwd: string): Promise { + let timeout: ReturnType | null = null + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), RIPGREP_CWD_CHECK_TIMEOUT_MS) + timeout.unref?.() + }) + const checked = Promise.all([stat(cwd), access(cwd, constants.X_OK)]).then( + ([entry]) => entry.isDirectory(), + () => false + ) + try { + return await Promise.race([checked, timedOut]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +function checkRipgrepAvailableWithoutCwd(): Promise { + return new Promise((resolve) => { + let child: ChildProcess + try { + child = spawn('rg', ['--version'], { stdio: 'ignore' }) + } catch { + resolve(false) + return + } + let settled = false + let errorObserved = false + let unavailableExitObserved = false + let timeout: ReturnType | null = null + const settle = (available: boolean, kill = false): void => { + if (settled) { + return + } + settled = true + if (timeout) { + clearTimeout(timeout) + } + child.off('error', onError) + child.off('close', onClose) + if (kill) { + child.once('error', ignoreRipgrepSpawnError) + killSpawnedRipgrepProcess(child) + } else { + absorbPendingRipgrepSpawnError(child, { errorObserved, unavailableExitObserved }) + } + resolve(available) + } + const onError = (): void => { + errorObserved = true + settle(false) + } + const onClose = (code: number | null): void => { + unavailableExitObserved = code !== null && code < 0 + settle(code === 0) + } + child.once('error', onError) + child.once('close', onClose) + timeout = setTimeout(() => settle(false, true), RIPGREP_FAILURE_PROBE_TIMEOUT_MS) + timeout.unref?.() + }) +} + +export async function isRipgrepUnavailableAfterLaunchFailure(cwd: string): Promise { + if (await isRipgrepSpawnCwdUsable(cwd)) { + return true + } + return !(await checkRipgrepAvailableWithoutCwd()) +} + +export function isRipgrepUnavailableExit( + child: ChildProcess, + code: number | null, + signal: NodeJS.Signals | null, + options: { classifyNativeLauncherExit?: boolean } = {} +): boolean { + if (signal) { + return false + } + if ((Object.hasOwn(child, 'pid') && child.pid === undefined) || (code !== null && code < 0)) { + return true + } + return Boolean(options.classifyNativeLauncherExit && code !== null && code > 2) +} diff --git a/src/shared/source-control-primary-action-decision-types.ts b/src/shared/source-control-primary-action-decision-types.ts index 1769dbe0958..c1e2147b315 100644 --- a/src/shared/source-control-primary-action-decision-types.ts +++ b/src/shared/source-control-primary-action-decision-types.ts @@ -60,6 +60,21 @@ export type SourceControlPrimaryActionDecision = { requiresForceWithLease?: boolean } +export type SourceControlCommitAreaPrimaryActionDecision = Omit< + SourceControlPrimaryActionDecision, + 'kind' | 'labelIntent' | 'titleIntent' +> & { + kind: Exclude + labelIntent: Exclude< + SourceControlPrimaryActionDecision['labelIntent'], + 'create_pr_intent' | 'create_pr' + > + titleIntent: Exclude< + SourceControlPrimaryActionTitleIntent, + 'prepare_review' | 'create_review' | 'checking_review_creation' + > +} + export type SourceControlPrimaryActionDecisionInputs = { stagedCount: number hasUnstagedChanges: boolean diff --git a/src/shared/source-control-primary-action-decision.test.ts b/src/shared/source-control-primary-action-decision.test.ts index 6f153d74bbe..892d27cbffb 100644 --- a/src/shared/source-control-primary-action-decision.test.ts +++ b/src/shared/source-control-primary-action-decision.test.ts @@ -159,25 +159,30 @@ describe('source-control primary action decision', () => { expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit') }) + it('keeps in-flight review intents out of commit-area decisions', () => { + const input = inputs({ isPrIntentInFlight: true }) + expect(resolveSourceControlPrimaryActionDecision(input).kind).toBe('create_pr_intent') + expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit') + }) + it('returns disabled create review while hosted-review creation eligibility is loading', () => { - const result = resolveSourceControlPrimaryActionDecision( - inputs({ - upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, - hostedReviewCreation: { - provider: 'gitlab', - review: null, - canCreate: false, - blockedReason: null, - nextAction: null, - reviewLookupOutcome: 'not_found' - }, - isHostedReviewCreationLoading: true - }) - ) - expect(result).toMatchObject({ + const input = inputs({ + upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 }, + hostedReviewCreation: { + provider: 'gitlab', + review: null, + canCreate: false, + blockedReason: null, + nextAction: null, + reviewLookupOutcome: 'not_found' + }, + isHostedReviewCreationLoading: true + }) + expect(resolveSourceControlPrimaryActionDecision(input)).toMatchObject({ kind: 'create_pr', titleIntent: 'checking_review_creation', disabled: true }) + expect(resolveSourceControlCommitAreaPrimaryActionDecision(input).kind).toBe('commit') }) }) diff --git a/src/shared/source-control-primary-action-decision.ts b/src/shared/source-control-primary-action-decision.ts index acdaf27cbf8..3a06c6f9133 100644 --- a/src/shared/source-control-primary-action-decision.ts +++ b/src/shared/source-control-primary-action-decision.ts @@ -3,6 +3,7 @@ import { supportsHostedReviewCreation } from './hosted-review-creation-providers import { resolveCreateReviewIntentEligibility } from './source-control-create-review-intent' import { resolveSourceControlPrimaryActionDuringRemoteOp } from './source-control-primary-action-in-flight' import type { + SourceControlCommitAreaPrimaryActionDecision, SourceControlPrimaryActionDecision, SourceControlPrimaryActionDecisionInputs } from './source-control-primary-action-decision-types' @@ -11,6 +12,7 @@ import { resolveUnpublishedSourceControlPrimaryAction } from './source-control-p export type { SourceControlPrimaryActionKind, SourceControlRemoteOpKind, + SourceControlCommitAreaPrimaryActionDecision, SourceControlPrimaryActionDecision, SourceControlPrimaryActionDecisionInputs } from './source-control-primary-action-decision-types' @@ -221,14 +223,14 @@ function shouldOfferCreateReviewLoadingAction( export function resolveSourceControlCommitAreaPrimaryActionDecision( inputs: SourceControlPrimaryActionDecisionInputs -): SourceControlPrimaryActionDecision { +): SourceControlCommitAreaPrimaryActionDecision { // Why: review creation is additive chrome. Commit/mobile bottom areas keep // the local/remote action they would have without review eligibility. return resolveSourceControlPrimaryActionDecision({ ...inputs, hostedReviewCreation: null, isPrIntentInFlight: false - }) + }) as SourceControlCommitAreaPrimaryActionDecision } function resolveCreatePrIntentDecision( diff --git a/src/shared/terminal-input.ts b/src/shared/terminal-input.ts index 52f0ee8960f..bdaa410d04b 100644 --- a/src/shared/terminal-input.ts +++ b/src/shared/terminal-input.ts @@ -3,6 +3,7 @@ import { isClipboardTextByteLengthOverLimitWithYield, measureClipboardTextByteLength } from './clipboard-text' +import { getUtf8ChunkEndIndex } from './utf8-byte-limits' export const TERMINAL_INPUT_CHUNK_MAX_BYTES = 16 * 1024 export const TERMINAL_INPUT_MAX_BYTES = 16 * 1024 * 1024 @@ -13,19 +14,6 @@ export function getTerminalInputByteLength(text: string): number { return measureClipboardTextByteLength(text).byteLength } -function getUtf8ByteLengthForCodePoint(codePoint: number): number { - if (codePoint <= 0x7f) { - return 1 - } - if (codePoint <= 0x7ff) { - return 2 - } - if (codePoint <= 0xffff) { - return 3 - } - return 4 -} - export function assertTerminalInputWithinLimit( text: string, maxBytes = TERMINAL_INPUT_MAX_BYTES @@ -87,23 +75,10 @@ export function* iterateTerminalInputChunks( return } - let currentStart = 0 - let currentBytes = 0 - let index = 0 - while (index < text.length) { - const codePoint = text.codePointAt(index) ?? 0 - const codeUnitLength = codePoint > 0xffff ? 2 : 1 - const nextIndex = index + codeUnitLength - const characterBytes = getUtf8ByteLengthForCodePoint(codePoint) - if (currentBytes > 0 && currentBytes + characterBytes > normalizedMax) { - yield text.slice(currentStart, index) - currentStart = index - currentBytes = 0 - } - currentBytes += characterBytes - index = nextIndex - } - if (currentStart < text.length) { - yield text.slice(currentStart) + let startIndex = 0 + while (startIndex < text.length) { + const endIndex = getUtf8ChunkEndIndex(text, startIndex, normalizedMax) + yield text.slice(startIndex, endIndex) + startIndex = endIndex } } diff --git a/src/shared/terminal-serialize-absolute-cursor.ts b/src/shared/terminal-serialize-absolute-cursor.ts index 18902b27426..ee2ac371296 100644 --- a/src/shared/terminal-serialize-absolute-cursor.ts +++ b/src/shared/terminal-serialize-absolute-cursor.ts @@ -19,7 +19,7 @@ type BufferSerializer = { } /** VT100 DECSC saved-cursor register (0-based, viewport-relative row). */ -export type SavedCursorRegister = { x: number; y: number } +export type SavedCursorRegister = { x: number; y: number; originMode: boolean } // xterm keeps the DECSC register on each Buffer (savedY is absolute: // ybase-included). It is not exposed through the public API, so snapshot @@ -27,7 +27,17 @@ export type SavedCursorRegister = { x: number; y: number } // buffer, so an alt-screen TUI yields the alternate screen's own register, // matching the one a post-restore DECRC would consult. type TerminalWithSavedCursorCore = SerializeCursorTerminal & { - _core?: { buffer?: { savedX?: number; savedY?: number; ybase?: number } } + modes?: { originMode?: boolean } + _core?: { + buffer?: { + savedX?: number + savedY?: number + savedOriginMode?: boolean + ybase?: number + scrollTop?: number + scrollBottom?: number + } + } } /** Reads the source terminal's active-buffer DECSC register, or null when it @@ -48,13 +58,14 @@ export function readSavedCursorRegister( // cols (DECSC during wrap-pending); CUP cannot re-create pending, so clamp. const y = Math.min(Math.max(core.savedY - core.ybase, 0), terminal.rows - 1) const x = Math.min(Math.max(core.savedX, 0), terminal.cols - 1) - if (x === 0 && y === 0) { + const originMode = core.savedOriginMode === true + if (x === 0 && y === 0 && !originMode) { // Home is xterm's never-saved default: a fresh restore terminal already // sends DECRC to home, and skipping the injection avoids overwriting the // fresh terminal's default saved SGR/charset when nothing was ever saved. return null } - return { x, y } + return { x, y, originMode } } export function serializeWithAbsoluteCursor( @@ -70,15 +81,30 @@ export function serializeWithAbsoluteCursor( if (serialized.length === 0) { return serialized } + return `${serialized}${buildAbsoluteCursorRestoreSequence(terminal, savedCursor)}` +} + +/** Cursor state appended after serialized modes; safe to replay without the frame body. */ +export function buildAbsoluteCursorRestoreSequence( + terminal: SerializeCursorTerminal, + savedCursor?: SavedCursorRegister | null, + options: { restoreModesWithoutCursor?: boolean } = {} +): string { const { cursorX, cursorY } = terminal.buffer.active + const terminalWithCore = terminal as TerminalWithSavedCursorCore + const buffer = terminalWithCore._core?.buffer + const scrollTop = buffer?.scrollTop ?? 0 + const scrollBottom = buffer?.scrollBottom ?? terminal.rows - 1 + const originMode = terminalWithCore.modes?.originMode === true + const cupCursorY = cursorY - (originMode ? scrollTop : 0) // Why skip wrap-pending sources (cursorX == cols): plain replay already // reproduces that state exactly, while CUP would clamp to the last column // and clear the pending-wrap flag, changing how the next byte renders. // The remaining bounds checks are defensive: never emit a clamping CUP. - // The saved-cursor injection is skipped with it — it moves the cursor, so - // it may only ride along when the absolute CUP restores the position after. - if (cursorX < 0 || cursorX >= terminal.cols || cursorY < 0 || cursorY >= terminal.rows) { - return serialized + const canRestoreCurrentCursor = + cursorX >= 0 && cursorX < terminal.cols && cupCursorY >= 0 && cupCursorY < terminal.rows + if (!canRestoreCurrentCursor && options.restoreModesWithoutCursor !== true) { + return '' } // Why the DECSC injection: the serialized screen cannot carry the VT100 // saved-cursor register, so a hidden DECSC followed by a post-reveal DECRC @@ -87,9 +113,21 @@ export function serializeWithAbsoluteCursor( // the source's saved position, then CUP back to the real cursor. Saved SGR/ // charset are not carried — the synthetic ESC 7 saves the serializer's // final pen, a deliberate position-only fidelity trade. - const savedRestore = savedCursor ? `\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` : '' - // cursorY is viewport-relative (0 at the buffer's base row), which is the - // same coordinate space CUP addresses after replay; scrollback length - // differences between source and destination do not shift it. - return `${serialized}${savedRestore}\x1b[${cursorY + 1};${cursorX + 1}H` + // Install the saved register against a full region so its absolute row is + // representable even when it predates the current DECSTBM/DECOM state. + const savedRestore = savedCursor + ? `\x1b[r\x1b[?6${savedCursor.originMode ? 'h' : 'l'}\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` + : '' + const mustRestoreModes = savedCursor != null || options.restoreModesWithoutCursor === true + const scrollRegionRestore = + mustRestoreModes && (scrollTop !== 0 || scrollBottom !== terminal.rows - 1) + ? `\x1b[${scrollTop + 1};${scrollBottom + 1}r` + : '' + const originModeRestore = mustRestoreModes ? `\x1b[?6${originMode ? 'h' : 'l'}` : '' + // CUP rows become margin-relative under DECOM; ordinary snapshots keep the + // zero offset because scrollback length does not shift viewport coordinates. + const currentCursorRestore = canRestoreCurrentCursor + ? `\x1b[${cupCursorY + 1};${cursorX + 1}H` + : '' + return `${savedRestore}${scrollRegionRestore}${originModeRestore}${currentCursorRestore}` } diff --git a/src/shared/utf8-byte-limits.test.ts b/src/shared/utf8-byte-limits.test.ts new file mode 100644 index 00000000000..9b3c63c9cea --- /dev/null +++ b/src/shared/utf8-byte-limits.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { getUtf8ChunkEndIndex, isUtf8ByteLengthWithinLimit } from './utf8-byte-limits' + +describe('getUtf8ChunkEndIndex', () => { + it.each([ + { name: 'ASCII', text: 'abcdef', maxBytes: 3, endIndex: 3 }, + { name: 'multibyte characters', text: 'aé中z', maxBytes: 6, endIndex: 3 } + ])('respects the byte budget for $name', ({ text, maxBytes, endIndex }) => { + expect(getUtf8ChunkEndIndex(text, 0, maxBytes)).toBe(endIndex) + }) + + it('never splits valid surrogate pairs', () => { + expect(getUtf8ChunkEndIndex('a😀b', 0, 4)).toBe(1) + expect(getUtf8ChunkEndIndex('a😀b', 1, 4)).toBe(3) + }) + + it.each(['\ud800a', '\udc00a'])('counts a lone surrogate as three bytes', (text) => { + expect(getUtf8ChunkEndIndex(text, 0, 3)).toBe(1) + }) + + it('starts measuring at the requested offset', () => { + expect(getUtf8ChunkEndIndex('skipé中tail', 4, 5)).toBe(6) + }) + + it('returns the starting index when no text remains', () => { + expect(getUtf8ChunkEndIndex('', 0, 1)).toBe(0) + expect(getUtf8ChunkEndIndex('abc', 3, 1)).toBe(3) + }) + + it.each([0, -1])('consumes one code point when the %s-byte budget is exceeded', (maxBytes) => { + expect(getUtf8ChunkEndIndex('😀a', 0, maxBytes)).toBe(2) + }) + + it('preserves raw non-finite budget comparisons', () => { + expect(getUtf8ChunkEndIndex('abc', 0, Number.NaN)).toBe(3) + expect(getUtf8ChunkEndIndex('abc', 0, Number.POSITIVE_INFINITY)).toBe(3) + expect(getUtf8ChunkEndIndex('abc', 0, Number.NEGATIVE_INFINITY)).toBe(1) + }) +}) + +describe('isUtf8ByteLengthWithinLimit', () => { + it.each([ + { name: 'ASCII at a finite limit', text: 'abc', maxBytes: 3, expected: true }, + { name: 'ASCII over a finite limit', text: 'abcd', maxBytes: 3, expected: false }, + { name: 'multibyte text at its limit', text: 'é中', maxBytes: 5, expected: true }, + { name: 'multibyte text over its limit', text: 'é中', maxBytes: 4, expected: false }, + { name: 'empty text with a negative limit', text: '', maxBytes: -1, expected: true }, + { + name: 'nonempty text with negative infinity', + text: 'a', + maxBytes: -Infinity, + expected: false + }, + { name: 'text with a NaN limit', text: '😀', maxBytes: Number.NaN, expected: true }, + { name: 'text with positive infinity', text: '😀', maxBytes: Infinity, expected: true } + ])('$name', ({ text, maxBytes, expected }) => { + expect(isUtf8ByteLengthWithinLimit(text, maxBytes)).toBe(expected) + }) +}) diff --git a/src/shared/utf8-byte-limits.ts b/src/shared/utf8-byte-limits.ts index c49be3fff44..b32bd968d57 100644 --- a/src/shared/utf8-byte-limits.ts +++ b/src/shared/utf8-byte-limits.ts @@ -31,6 +31,16 @@ export function getUtf8ByteLength(text: string): number { return measureUtf8ByteLength(text).byteLength } +export function isUtf8ByteLengthWithinLimit(text: string, maxBytes: number): boolean { + if (text.length === 0) { + return true + } + if (text.length > maxBytes) { + return false + } + return !measureUtf8ByteLength(text, { stopAfterBytes: maxBytes }).exceededLimit +} + export function clampUtf8TextTail(text: string, maxBytes: number): Utf8TextTail { if (!text || maxBytes <= 0) { return { text: '', bytes: 0 } @@ -70,6 +80,21 @@ export function clampUtf8TextPrefix(text: string, maxBytes: number): string { return end === text.length ? text : text.slice(0, end) } +export function getUtf8ChunkEndIndex(text: string, startIndex: number, maxBytes: number): number { + let bytes = 0 + let endIndex = startIndex + while (endIndex < text.length) { + const codePoint = text.codePointAt(endIndex) ?? 0 + const codePointBytes = getUtf8ByteLengthForCodePoint(codePoint) + if (bytes > 0 && bytes + codePointBytes > maxBytes) { + break + } + bytes += codePointBytes + endIndex += codePoint > 0xffff ? 2 : 1 + } + return endIndex +} + export function getUtf8ByteLengthForCodePoint(codePoint: number): number { if (codePoint <= 0x7f) { return 1 diff --git a/src/shared/worktree-sort-order-update.test.ts b/src/shared/worktree-sort-order-update.test.ts new file mode 100644 index 00000000000..4c53ddff56d --- /dev/null +++ b/src/shared/worktree-sort-order-update.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import { planWorktreeSortOrderUpdates } from './worktree-sort-order-update' + +describe('planWorktreeSortOrderUpdates', () => { + it('skips an order that is already represented by descending ranks', () => { + const getMeta = vi.fn((id: string) => ({ + sortOrder: id === 'a' ? 300 : id === 'b' ? 200 : 100 + })) + + expect(planWorktreeSortOrderUpdates(['a', 'b', 'c'], getMeta, 1_000)).toEqual([]) + }) + + it('re-ranks changed order and ignores stale worktree IDs', () => { + const getMeta = vi.fn((id: string) => { + if (id === 'stale') { + return undefined + } + return { sortOrder: id === 'a' ? 100 : 200 } + }) + + expect(planWorktreeSortOrderUpdates(['a', 'stale', 'b'], getMeta, 5_000)).toEqual([ + { worktreeId: 'a', sortOrder: 5_000 }, + { worktreeId: 'b', sortOrder: 4_000 } + ]) + }) + + it('ranks a duplicate worktree ID only once', () => { + const getMeta = vi.fn((id: string) => ({ sortOrder: id === 'a' ? 100 : 200 })) + + expect(planWorktreeSortOrderUpdates(['a', 'a', 'b'], getMeta, 5_000)).toEqual([ + { worktreeId: 'a', sortOrder: 5_000 }, + { worktreeId: 'b', sortOrder: 4_000 } + ]) + expect(getMeta).toHaveBeenCalledTimes(2) + }) + + it('initializes missing ranks without manufacturing metadata', () => { + const getMeta = vi.fn((id: string) => (id === 'known' ? {} : undefined)) + + expect(planWorktreeSortOrderUpdates(['known', 'missing'], getMeta, 9_000)).toEqual([ + { worktreeId: 'known', sortOrder: 9_000 } + ]) + }) +}) diff --git a/src/shared/worktree-sort-order-update.ts b/src/shared/worktree-sort-order-update.ts new file mode 100644 index 00000000000..07dc88214c4 --- /dev/null +++ b/src/shared/worktree-sort-order-update.ts @@ -0,0 +1,32 @@ +export type WorktreeSortOrderUpdate = { + worktreeId: string + sortOrder: number +} + +type WorktreeSortOrderMeta = { + sortOrder?: number +} + +export function planWorktreeSortOrderUpdates( + orderedIds: readonly string[], + getMeta: (worktreeId: string) => WorktreeSortOrderMeta | undefined, + now: number +): WorktreeSortOrderUpdate[] { + const uniqueIds = [...new Set(orderedIds)] + const existing = uniqueIds.flatMap((worktreeId) => { + const meta = getMeta(worktreeId) + return meta ? [{ worktreeId, sortOrder: meta.sortOrder }] : [] + }) + const alreadyOrdered = existing.every( + (entry, index) => + Number.isFinite(entry.sortOrder) && + (index === 0 || existing[index - 1].sortOrder! > entry.sortOrder!) + ) + if (alreadyOrdered) { + return [] + } + return existing.map((entry, index) => ({ + worktreeId: entry.worktreeId, + sortOrder: now - index * 1000 + })) +} diff --git a/tests/e2e/ai-vault-typing-bench-corpus.ts b/tests/e2e/ai-vault-typing-bench-corpus.ts new file mode 100644 index 00000000000..9f8f22713f4 --- /dev/null +++ b/tests/e2e/ai-vault-typing-bench-corpus.ts @@ -0,0 +1,84 @@ +import { mkdirSync, utimesSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +export type SeededVaultBatch = { + newestTitle: string + totalBytes: number +} + +function jsonLine(value: unknown): string { + return JSON.stringify(value) +} + +export function seedVaultTranscriptBatch(args: { + homePath: string + cwd: string + batch: number + sessionCount: number + payloadBytes: number +}): SeededVaultBatch { + const sessionsDir = path.join(args.homePath, '.codex', 'sessions', '2026', '08', '09') + mkdirSync(sessionsDir, { recursive: true }) + const padding = 'x'.repeat(args.payloadBytes) + const baseTimeMs = Date.now() + args.batch * 10_000 + let totalBytes = 0 + let newestTitle = '' + + for (let index = 0; index < args.sessionCount; index += 1) { + const sessionId = `vault-bench-${args.batch}-${index}` + const title = `Vault benchmark batch ${args.batch} session ${index}` + const timestamp = new Date(baseTimeMs + index).toISOString() + const content = `${[ + jsonLine({ + timestamp, + type: 'session_meta', + payload: { id: sessionId, cwd: args.cwd } + }), + jsonLine({ + timestamp, + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: title }] + } + }), + jsonLine({ + timestamp, + type: 'response_item', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: padding }] + } + }) + ].join('\n')}\n` + const filePath = path.join(sessionsDir, `rollout-${sessionId}.jsonl`) + writeFileSync(filePath, content) + const mtime = new Date(baseTimeMs + index) + utimesSync(filePath, mtime, mtime) + totalBytes += Buffer.byteLength(content) + newestTitle = title + } + + return { newestTitle, totalBytes } +} + +export function typingEchoScript(readyMarker: string): string { + return ` +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) process.stdin.setRawMode(true) +process.stdin.resume() +let input = '' +const interrupt = String.fromCharCode(3) +process.stdout.write('\\x1b[2J\\x1b[H${readyMarker}\\n') +process.stdin.on('data', (chunk) => { + if (chunk.includes(interrupt)) process.exit(0) + for (const char of chunk) { + if (char === '\\r' || char === '\\n') continue + input += char + } + process.stdout.write('\\r\\x1b[2K' + input) +}) +` +} diff --git a/tests/e2e/ai-vault-typing-bench-renderer-probe.ts b/tests/e2e/ai-vault-typing-bench-renderer-probe.ts new file mode 100644 index 00000000000..c233aaaae50 --- /dev/null +++ b/tests/e2e/ai-vault-typing-bench-renderer-probe.ts @@ -0,0 +1,119 @@ +import { expect, type Page } from '@stablyai/playwright-test' + +export type RendererJank = { + longTaskCount: number + maxLongTaskMs: number + totalLongTaskMs: number + maxTimerDriftMs: number + maxFrameGapMs: number +} + +export async function startRendererJankProbe(page: Page): Promise { + await page.evaluate(() => { + const durations: number[] = [] + let maxTimerDriftMs = 0 + let maxFrameGapMs = 0 + let lastTimer = performance.now() + let lastFrame = performance.now() + let stopped = false + const observer = new PerformanceObserver((list) => { + durations.push(...list.getEntries().map((entry) => entry.duration)) + }) + observer.observe({ type: 'longtask' }) + const timer = window.setInterval(() => { + const now = performance.now() + maxTimerDriftMs = Math.max(maxTimerDriftMs, now - lastTimer - 16) + lastTimer = now + }, 16) + const frame = (now: number): void => { + maxFrameGapMs = Math.max(maxFrameGapMs, now - lastFrame) + lastFrame = now + if (!stopped) { + requestAnimationFrame(frame) + } + } + requestAnimationFrame(frame) + const target = window as Window & { __vaultBenchJank?: { stop: () => RendererJank } } + target.__vaultBenchJank = { + stop: () => { + stopped = true + window.clearInterval(timer) + durations.push(...observer.takeRecords().map((entry) => entry.duration)) + observer.disconnect() + return { + longTaskCount: durations.length, + maxLongTaskMs: Math.max(0, ...durations), + totalLongTaskMs: durations.reduce((sum, value) => sum + value, 0), + maxTimerDriftMs, + maxFrameGapMs + } + } + } + }) +} + +export async function stopRendererJankProbe(page: Page): Promise { + return page.evaluate(() => { + const target = window as Window & { __vaultBenchJank?: { stop: () => RendererJank } } + if (!target.__vaultBenchJank) { + throw new Error('Renderer jank probe was not installed') + } + const result = target.__vaultBenchJank.stop() + delete target.__vaultBenchJank + return result + }) +} + +export async function triggerVaultRefresh(page: Page): Promise { + const refreshButton = page.getByRole('button', { name: 'Refresh Session History' }) + await page.evaluate(() => { + const button = document.querySelector( + 'button[aria-label="Refresh Session History"]' + ) + if (!button) { + throw new Error('Vault refresh button unavailable') + } + const target = window as Window & { + __vaultBenchRefresh?: { startedAt: number; durationMs: number | null } + } + const refresh = { startedAt: performance.now(), durationMs: null } + target.__vaultBenchRefresh = refresh + let sawBusy = false + const observer = new MutationObserver(() => { + sawBusy ||= button.getAttribute('aria-busy') === 'true' + if (sawBusy && button.getAttribute('aria-busy') !== 'true') { + refresh.durationMs = performance.now() - refresh.startedAt + observer.disconnect() + } + }) + observer.observe(button, { attributes: true, attributeFilter: ['aria-busy'] }) + button.click() + }) + await expect(refreshButton).toHaveAttribute('aria-busy', 'true', { timeout: 5_000 }) +} + +export async function readVaultRefreshDuration(page: Page): Promise { + await expect + .poll( + () => + page.evaluate(() => { + const target = window as Window & { + __vaultBenchRefresh?: { durationMs: number | null } + } + return target.__vaultBenchRefresh?.durationMs ?? null + }), + { timeout: 120_000 } + ) + .not.toBeNull() + return page.evaluate(() => { + const target = window as Window & { + __vaultBenchRefresh?: { durationMs: number | null } + } + const durationMs = target.__vaultBenchRefresh?.durationMs + delete target.__vaultBenchRefresh + if (durationMs === null || durationMs === undefined) { + throw new Error('Vault refresh completion was not observed') + } + return durationMs + }) +} diff --git a/tests/e2e/completed-worker-retirement-resume.spec.ts b/tests/e2e/completed-worker-retirement-resume.spec.ts new file mode 100644 index 00000000000..5540c4d6896 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.spec.ts @@ -0,0 +1,521 @@ +import { test as base, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + waitForActivePaneHookDescriptor, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + cleanupCompletedWorkerFixture, + clearCompletedWorkerLedger, + completedWorkerLaunchEnv, + listRuntimeTerminals, + readCompletedWorkerDispatchCapability, + readCompletedWorkerLedger, + readPersistedWorkerRecoveryRecord, + runBuiltOrcaCli, + seedCurrentCodexTranscript, + terminalIdentity +} from './helpers/completed-worker-retirement-fixture' +import { RuntimeClient } from '../../src/cli/runtime-client' +import type { RuntimeTerminalRead, RuntimeTerminalSummary } from '../../src/shared/runtime-types' +import { splitWorktreeIdForFilesystem } from '../../src/shared/worktree-id' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' + +const test = base.extend({ + launchEnv: [completedWorkerLaunchEnv, { option: true }] +}) + +test.describe.configure({ mode: 'serial' }) + +test.afterAll(() => { + cleanupCompletedWorkerFixture() +}) + +for (const closeMode of ['terminal-close-cli', 'worker-release'] as const) { + test(`completed background worker ${closeMode} retires resume authority before first activation`, async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(180_000) + clearCompletedWorkerLedger() + await waitForSessionReady(orcaPage) + const coordinatorWorktreeId = await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + await orcaPage.evaluate(async () => { + await window.__store?.getState().updateSettings({ + disabledTuiAgents: [], + terminalHiddenViewParking: false + }) + }) + + const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData')) + const isolatedHome = await electronApp.evaluate(({ app }) => app.getPath('home')) + const client = new RuntimeClient(userDataDir, 30_000, null, null) + const coordinatorPane = await waitForActivePaneHookDescriptor(orcaPage) + const coordinatorResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: coordinatorPane.paneKey } + ) + const coordinatorHandle = coordinatorResolved.result.terminal.handle + const coordinator = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + if (!coordinator) { + throw new Error('Coordinator terminal was not runtime-visible') + } + const coordinatorBefore = terminalIdentity(coordinator) + + let targetWorktreeId: string | null = null + await expect + .poll( + async () => { + const listed = await client.call<{ worktrees: { id: string }[] }>('worktree.list', {}) + const rendererWorktreeIds = await orcaPage.evaluate(() => + Object.values(window.__store?.getState().worktreesByRepo ?? {}) + .flat() + .map((worktree) => worktree.id) + ) + targetWorktreeId = + listed.result.worktrees.find( + (worktree) => + worktree.id !== coordinatorWorktreeId && rendererWorktreeIds.includes(worktree.id) + )?.id ?? null + return targetWorktreeId + }, + { timeout: 60_000, message: 'runtime never registered the secondary worktree' } + ) + .not.toBeNull() + if (!targetWorktreeId) { + throw new Error('The seeded repository did not expose its secondary worktree') + } + const targetWorktreePath = splitWorktreeIdForFilesystem(targetWorktreeId)?.worktreePath + if (!targetWorktreePath) { + throw new Error('The secondary worktree did not expose a filesystem path') + } + + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + + const run = await client.call<{ run: { id: string } }>('orchestration.runCreate', { + objective: 'Retire one completed background worker', + from: coordinatorHandle + }) + const task = await client.call<{ task: { id: string } }>('orchestration.taskCreate', { + spec: 'Report completion, then exit normally', + run: run.result.run.id, + callerTerminalHandle: coordinatorHandle + }) + const started = await client.call<{ + dispatchId: string + state: string + effects: { kind: string; role?: string; id?: string }[] + }>('orchestration.workerStart', { + task: task.result.task.id, + from: coordinatorHandle, + worktree: `id:${String(targetWorktreeId)}`, + agent: 'codex', + timeoutMs: 30_000 + }) + expect(started.result.state).toBe('ready') + const workerHandle = started.result.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'agent' + )?.id + if (!workerHandle) { + throw new Error('worker-start did not return its agent terminal') + } + + let worker: RuntimeTerminalSummary | undefined + await expect + .poll( + async () => { + worker = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === workerHandle + ) + return worker?.ptyId ?? null + }, + { timeout: 30_000, message: 'background worker never published its PTY identity' } + ) + .not.toBeNull() + if (!worker?.ptyId || !worker.incarnationId) { + throw new Error('Background worker did not publish exact PTY identity') + } + const workerBefore = terminalIdentity(worker) + const workerPaneKey = `${worker.tabId}:${worker.leafId}` + expect(worker.worktreeId).toBe(targetWorktreeId) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + window.dispatchEvent( + new CustomEvent('orca-background-mount-terminal-worktree', { + detail: { worktreeId, tabIds: [tabId] } + }) + ) + }, + { tabId: worker.tabId, worktreeId: targetWorktreeId } + ) + await expect + .poll(() => + orcaPage.evaluate((tabId) => Boolean(window.__paneManagers?.get(tabId)), workerBefore.tabId) + ) + .toBe(true) + expect( + await orcaPage.evaluate( + (worktreeId) => window.__store?.getState().everActivatedWorktreeIds.has(worktreeId), + targetWorktreeId + ) + ).toBe(false) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'spawn')) + .toHaveLength(1) + let dispatchCapability: string | null = null + await expect + .poll(() => { + dispatchCapability = readCompletedWorkerDispatchCapability() + return dispatchCapability + }) + .not.toBeNull() + if (!dispatchCapability) { + throw new Error('Background worker did not receive its dispatch capability') + } + + const transcriptPath = seedCurrentCodexTranscript( + isolatedHome, + PROVIDER_SESSION_ID, + targetWorktreePath + ) + + await orcaPage.evaluate( + ({ paneKey, providerSessionId, tabId, terminalHandle, transcriptPath, worktreeId }) => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Renderer store unavailable') + } + const providerSession = { + key: 'session_id' as const, + id: providerSessionId, + transcriptPath + } + const metadata = { tabId, worktreeId, terminalHandle } + const recovery = { + providerSession, + launchConfig: { + agentCommand: 'codex', + agentArgs: '--dangerously-bypass-approvals-and-sandbox', + agentEnv: {} + } + } + state.setAgentStatus( + paneKey, + { state: 'working', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + state.setAgentStatus( + paneKey, + { state: 'done', prompt: 'Report completion, then exit normally', agentType: 'codex' }, + 'Completed background worker', + undefined, + metadata, + recovery + ) + }, + { + paneKey: workerPaneKey, + providerSessionId: PROVIDER_SESSION_ID, + tabId: worker.tabId, + terminalHandle: workerHandle, + transcriptPath, + worktreeId: targetWorktreeId + } + ) + + const expectedRecovery = { + origin: 'live', + state: 'working', + providerSessionId: PROVIDER_SESSION_ID + } + await expect + .poll(() => + orcaPage.evaluate((paneKey) => { + const record = window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession.id + } + : null + }, workerPaneKey) + ) + .toEqual(expectedRecovery) + await expect + .poll( + () => { + const record = readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey) + return record + ? { + origin: record.origin, + state: record.state, + providerSessionId: record.providerSession?.id + } + : null + }, + { timeout: 30_000 } + ) + .toEqual(expectedRecovery) + + const completed = await client.call<{ message: { type: string } }>( + 'orchestration.send', + { + from: workerHandle, + subject: 'Completed', + body: 'The fixture completed. It found no work. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.result.task.id, + dispatchId: started.result.dispatchId, + outcome: 'succeeded' + }) + }, + { orchestrationCapability: dispatchCapability } + ) + expect(completed.result.message.type).toBe('worker_done') + await expect + .poll( + async () => { + const dispatch = await client.call<{ dispatch: { status: string } | null }>( + 'orchestration.dispatchShow', + { task: task.result.task.id } + ) + const tasks = await client.call<{ tasks: { id: string; status: string }[] }>( + 'orchestration.taskList', + { run: run.result.run.id } + ) + return { + dispatch: dispatch.result.dispatch?.status ?? null, + task: + tasks.result.tasks.find((candidate) => candidate.id === task.result.task.id) + ?.status ?? null + } + }, + { timeout: 30_000, message: 'worker completion never settled its task and dispatch' } + ) + .toEqual({ dispatch: 'completed', task: 'completed' }) + + await client.call('terminal.send', { + terminal: workerHandle, + text: 'ORCA_E2E_EXIT_AFTER_DONE', + enter: true + }) + await expect + .poll(() => readCompletedWorkerLedger().filter((event) => event.event === 'normal-exit')) + .toHaveLength(1) + expect( + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const state = window.__store?.getState() + return { + tabPresent: Boolean(state?.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state?.sleepingAgentSessionsByPaneKey[paneKey]) + } + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + ).toEqual({ tabPresent: true, recoveryPresent: true }) + + await orcaPage.evaluate( + ({ paneKey, tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + const e2eWindow = window as typeof window & { + __orcaRetiredWorkerTransitions?: Transition[] + __orcaRetiredWorkerUnsubscribe?: () => void + } + const transitions: Transition[] = [ + { + tabPresent: Boolean( + store.getState().tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId) + ), + recoveryPresent: Boolean(store.getState().sleepingAgentSessionsByPaneKey[paneKey]) + } + ] + e2eWindow.__orcaRetiredWorkerTransitions = transitions + e2eWindow.__orcaRetiredWorkerUnsubscribe = store.subscribe((state) => { + const next = { + tabPresent: Boolean(state.tabsByWorktree[worktreeId]?.some((tab) => tab.id === tabId)), + recoveryPresent: Boolean(state.sleepingAgentSessionsByPaneKey[paneKey]) + } + const previous = transitions.at(-1) + if ( + !previous || + previous.tabPresent !== next.tabPresent || + previous.recoveryPresent !== next.recoveryPresent + ) { + transitions.push(next) + } + }) + }, + { paneKey: workerPaneKey, tabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + + if (closeMode === 'terminal-close-cli') { + const closed = runBuiltOrcaCli(['terminal', 'close', '--terminal', workerHandle, '--json'], { + userDataDir, + cwd: process.cwd() + }) + expect(closed).toMatchObject({ + ok: true, + result: { + close: { + handle: workerHandle, + tabId: workerBefore.tabId, + ptyKilled: true + } + } + }) + } else { + const release = await client.call<{ + dispatchId: string + state: string + processAction: string + }>('orchestration.workerRelease', { dispatch: started.result.dispatchId }) + expect(release.result).toMatchObject({ + dispatchId: started.result.dispatchId, + state: 'released', + processAction: 'closed_agent_terminal' + }) + } + await expect + .poll(() => + orcaPage.evaluate(() => { + type Transition = { tabPresent: boolean; recoveryPresent: boolean } + return (window as typeof window & { __orcaRetiredWorkerTransitions?: Transition[] }) + .__orcaRetiredWorkerTransitions + }) + ) + .toEqual( + expect.arrayContaining([ + { tabPresent: true, recoveryPresent: true }, + { tabPresent: false, recoveryPresent: false } + ]) + ) + await orcaPage.evaluate(() => { + const e2eWindow = window as typeof window & { __orcaRetiredWorkerUnsubscribe?: () => void } + e2eWindow.__orcaRetiredWorkerUnsubscribe?.() + delete e2eWindow.__orcaRetiredWorkerUnsubscribe + }) + await expect + .poll(() => + orcaPage.evaluate( + (paneKey) => window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey] ?? null, + workerPaneKey + ) + ) + .toBeNull() + + await orcaPage.evaluate(() => window.dispatchEvent(new Event('beforeunload'))) + await expect + .poll(() => + orcaPage.evaluate(async (paneKey) => { + const session = await window.api.session.get() + return session.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null + }, workerPaneKey) + ) + .toBeNull() + await orcaPage.evaluate(() => window.api.session.flush()) + expect(readPersistedWorkerRecoveryRecord(userDataDir, workerPaneKey)).toBeNull() + + await orcaPage.reload() + await waitForSessionReady(orcaPage) + + const beforeActivation = await orcaPage.evaluate((worktreeId) => { + const state = window.__store?.getState() + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + tabCount: state?.tabsByWorktree[worktreeId]?.length ?? 0, + pendingStartupCount: Object.keys(state?.pendingStartupByTabId ?? {}).length + } + }, targetWorktreeId) + expect(beforeActivation).toEqual({ everActivated: false, tabCount: 0, pendingStartupCount: 0 }) + + const targetCard = orcaPage + .locator(`[data-worktree-id="${String(targetWorktreeId)}"]`) + .first() + .locator('[data-worktree-card-surface]') + await targetCard.evaluate((element: HTMLElement) => element.click()) + await expect + .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId)) + .toBe(targetWorktreeId) + await waitForActiveTerminalManager(orcaPage) + await waitForActivePanePtyId(orcaPage) + const activatedPane = await waitForActivePaneHookDescriptor(orcaPage) + expect(activatedPane.worktreeId).toBe(targetWorktreeId) + const activatedResolved = await client.call<{ terminal: { handle: string } }>( + 'terminal.resolvePane', + { paneKey: activatedPane.paneKey } + ) + await expect + .poll( + async () => { + const read = await client.call<{ terminal: RuntimeTerminalRead }>('terminal.read', { + terminal: activatedResolved.result.terminal.handle, + limit: 50 + }) + return read.result.terminal.tail.join('\n') + }, + { timeout: 30_000, message: 'activated fallback terminal never produced output' } + ) + .not.toBe('') + + const spawnEvents = readCompletedWorkerLedger().filter((event) => event.event === 'spawn') + expect(spawnEvents).toHaveLength(1) + expect( + spawnEvents.filter( + (event) => event.args?.includes('resume') && event.args?.includes(PROVIDER_SESSION_ID) + ) + ).toEqual([]) + await expect(orcaPage.locator('.session-restored-banner')).toHaveCount(0) + + const afterActivation = await orcaPage.evaluate( + ({ originalTabId, worktreeId }) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree[worktreeId] ?? [] + return { + everActivated: state?.everActivatedWorktreeIds.has(worktreeId) ?? false, + originalTabPresent: tabs.some((tab) => tab.id === originalTabId), + replacementTabCount: tabs.filter((tab) => tab.id !== originalTabId).length, + pendingResumeSessionIds: Object.values(state?.pendingStartupByTabId ?? {}).flatMap( + (startup) => (startup.resumeProviderSession ? [startup.resumeProviderSession.id] : []) + ), + resumeClaimCount: Object.keys(state?.automaticAgentResumeClaimsByTabId ?? {}).length + } + }, + { originalTabId: workerBefore.tabId, worktreeId: targetWorktreeId } + ) + expect(afterActivation).toEqual({ + everActivated: true, + originalTabPresent: false, + replacementTabCount: 1, + pendingResumeSessionIds: [], + resumeClaimCount: 0 + }) + + const coordinatorAfter = (await listRuntimeTerminals(client)).find( + (terminal) => terminal.handle === coordinatorHandle + ) + expect(coordinatorAfter ? terminalIdentity(coordinatorAfter) : null).toEqual(coordinatorBefore) + expect(coordinatorAfter?.worktreeId).toBe(coordinatorWorktreeId) + }) +} diff --git a/tests/e2e/completed-worker-retirement-resume.unit.test.ts b/tests/e2e/completed-worker-retirement-resume.unit.test.ts new file mode 100644 index 00000000000..03292dfb0e6 --- /dev/null +++ b/tests/e2e/completed-worker-retirement-resume.unit.test.ts @@ -0,0 +1,610 @@ +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../src/shared/agent-session-resume' +import { makePaneKey } from '../../src/shared/stable-pane-id' +import { tokenizeStartupCommand } from '../../src/shared/tui-agent-startup-shell' +import { parseWorkspaceSession } from '../../src/shared/workspace-session-schema' +import type { TerminalTab, Worktree } from '../../src/shared/types' +import { OrchestrationDb } from '../../src/main/runtime/orchestration/db' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import type { RpcContext } from '../../src/main/runtime/rpc/core' +import { ORCHESTRATION_METHODS } from '../../src/main/runtime/rpc/methods/orchestration' +import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' +import { seedStartupSessionRestoredBanner } from '@/components/terminal-pane/session-restored-banner-pane-state' +import { + resolveLegacyWorkerTerminalRecoveryAction, + rollbackLegacyWorkerTerminalSurfaceInStore +} from '@/hooks/legacy-worker-terminal-recovery-event' +import { useAppStore, type AppState } from '@/store' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session' + +const PROVIDER_SESSION_ID = '019feb51-2269-71c2-89c6-faa8dc65c8dc' +const ORIGINAL_TAB_ID = '1c897bc8-973b-47b4-9449-ac5fc6b726c3' +const ORIGINAL_LEAF_ID = '0526f763-6729-49af-adf8-85ddbcf2b4e7' +const ORIGINAL_PANE_KEY = makePaneKey(ORIGINAL_TAB_ID, ORIGINAL_LEAF_ID) +const ORIGINAL_PTY_ID = 'pty-background-worker' +const REPO_ID = '32a0226d-9f33-42e8-8b7b-24867dea06d4' +const WORKTREE_PATH = path.join(path.sep, 'workspace', 'factory-pr-4626-git-crypt') +const WORKTREE_ID = `${REPO_ID}::${WORKTREE_PATH}` +const CANARY_WORKTREE_PATH = path.join(path.sep, 'workspace', 'canary') +const CANARY_WORKTREE_ID = `${REPO_ID}::${CANARY_WORKTREE_PATH}` +const CANARY_TAB_ID = 'canary-tab' +const CANARY_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const CANARY_PTY_ID = 'pty-unrelated-canary' +const HELPER_TAB_ID = 'worker-child-terminal' +const HELPER_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const HELPER_PANE_KEY = makePaneKey(HELPER_TAB_ID, HELPER_LEAF_ID) +const TERMINAL_HANDLE = 'terminal-background-worker' +const initialAppStoreState = useAppStore.getState() + +function makeWorktree(id: string, workspacePath: string): Worktree { + return { + id, + repoId: REPO_ID, + path: workspacePath, + head: 'abc123', + branch: 'refs/heads/review', + isBare: false, + isMainWorktree: false, + displayName: path.basename(workspacePath), + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + hostId: 'local' + } +} + +function makeTab(id: string, worktreeId: string, ptyId: string, title: string): TerminalTab { + return { + id, + ptyId, + worktreeId, + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(leafId: string, ptyId: string) { + return { + root: { type: 'leaf' as const, leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +function seedWorkspace(options: { helper?: boolean } = {}): void { + useAppStore.setState(initialAppStoreState, true) + const target = makeWorktree(WORKTREE_ID, WORKTREE_PATH) + const canary = makeWorktree(CANARY_WORKTREE_ID, CANARY_WORKTREE_PATH) + const original = makeTab( + ORIGINAL_TAB_ID, + WORKTREE_ID, + ORIGINAL_PTY_ID, + 'PR 4626 unified correction r3' + ) + const unrelated = makeTab(CANARY_TAB_ID, CANARY_WORKTREE_ID, CANARY_PTY_ID, 'Unrelated') + const helper = makeTab(HELPER_TAB_ID, WORKTREE_ID, 'pty-worker-child', 'Worker child') + useAppStore.setState({ + repos: [ + { + id: REPO_ID, + path: path.join(path.sep, 'workspace'), + displayName: 'repo', + badgeColor: '#000000', + addedAt: 0, + executionHostId: 'local' + } + ], + worktreesByRepo: { [REPO_ID]: [target, canary] }, + activeRepoId: REPO_ID, + activeWorktreeId: CANARY_WORKTREE_ID, + activeTabId: CANARY_TAB_ID, + activeTabType: 'terminal', + activeView: 'terminal', + tabsByWorktree: { + [WORKTREE_ID]: options.helper ? [original, helper] : [original], + [CANARY_WORKTREE_ID]: [unrelated] + }, + ptyIdsByTabId: { + [ORIGINAL_TAB_ID]: [ORIGINAL_PTY_ID], + [CANARY_TAB_ID]: [CANARY_PTY_ID], + ...(options.helper ? { [HELPER_TAB_ID]: ['pty-worker-child'] } : {}) + }, + terminalLayoutsByTabId: { + [ORIGINAL_TAB_ID]: makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID), + [CANARY_TAB_ID]: makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID), + ...(options.helper ? { [HELPER_TAB_ID]: makeLayout(HELPER_LEAF_ID, 'pty-worker-child') } : {}) + }, + activeTabIdByWorktree: { + [WORKTREE_ID]: ORIGINAL_TAB_ID, + [CANARY_WORKTREE_ID]: CANARY_TAB_ID + }, + activeTabTypeByWorktree: { + [WORKTREE_ID]: 'terminal', + [CANARY_WORKTREE_ID]: 'terminal' + }, + tabBarOrderByWorktree: { + [WORKTREE_ID]: options.helper ? [ORIGINAL_TAB_ID, HELPER_TAB_ID] : [ORIGINAL_TAB_ID], + [CANARY_WORKTREE_ID]: [CANARY_TAB_ID] + }, + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + activeGroupIdByWorktree: {}, + openFiles: [], + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + activeFileIdByWorktree: {}, + activeBrowserTabIdByWorktree: {}, + pendingStartupByTabId: {}, + automaticAgentResumeClaimsByTabId: {}, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + everActivatedWorktreeIds: new Set([CANARY_WORKTREE_ID]), + settings: { + ...initialAppStoreState.settings, + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--dangerously-bypass-approvals-and-sandbox' }, + setupScriptLaunchMode: 'new-tab' + }, + markWorktreeVisited: vi.fn(), + recordWorktreeVisit: vi.fn(), + refreshGitHubForWorktreeIfStale: vi.fn(), + revealWorktreeInSidebar: vi.fn() + } as Partial) +} + +function recordWorkingWorker() { + const providerSession = { key: 'session_id' as const, id: PROVIDER_SESSION_ID } + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'working', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_478_130, stateStartedAt: 1_786_361_478_130 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + return providerSession +} + +function completeRecordedWorker( + providerSession: ReturnType +): SleepingAgentSessionRecord { + useAppStore + .getState() + .setAgentStatus( + ORIGINAL_PANE_KEY, + { state: 'done', prompt: 'review PR 4626', agentType: 'codex' }, + 'PR 4626 unified correction r3', + { updatedAt: 1_786_361_625_666, stateStartedAt: 1_786_361_625_666 }, + { tabId: ORIGINAL_TAB_ID, worktreeId: WORKTREE_ID, terminalHandle: TERMINAL_HANDLE }, + { providerSession } + ) + expect(useAppStore.getState().agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'done', + providerSession + }) + const record = useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + expect(record).toMatchObject({ + paneKey: ORIGINAL_PANE_KEY, + tabId: ORIGINAL_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession, + state: 'working', + origin: 'live' + }) + return record! +} + +function recordCompletedWorker(): SleepingAgentSessionRecord { + return completeRecordedWorker(recordWorkingWorker()) +} + +function expectCanaryUnchanged(): void { + const state = useAppStore.getState() + expect(state.tabsByWorktree[CANARY_WORKTREE_ID]).toEqual([ + expect.objectContaining({ id: CANARY_TAB_ID, ptyId: CANARY_PTY_ID }) + ]) + expect(state.terminalLayoutsByTabId[CANARY_TAB_ID]).toEqual( + makeLayout(CANARY_LEAF_ID, CANARY_PTY_ID) + ) +} + +function orchestrationMethod(name: string) { + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Missing orchestration method: ${name}`) + } + return method +} + +async function releaseCompletedWorker(terminalState: 'running' | 'exited'): Promise { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const coordinatorPaneKey = 'coordinator-tab:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const run = db.createRun({ + objective: 'Completed worker retirement reproduction', + coordinatorHandle: 'terminal-coordinator', + coordinatorPaneKey + }) + const ctx: RpcContext = { runtime } + const call = async (name: string, params: Record) => { + const method = orchestrationMethod(name) + const parsed = method.params ? method.params.parse(params) : undefined + return method.handler(parsed, ctx) + } + + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'terminal-coordinator' ? coordinatorPaneKey : ORIGINAL_PANE_KEY + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === TERMINAL_HANDLE ? 'runtime:test:worker:1' : null + ) + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation((handle) => + handle === TERMINAL_HANDLE + ? ({ + terminalHandle: TERMINAL_HANDLE, + paneKey: ORIGINAL_PANE_KEY, + processIncarnation: 'runtime:test:worker:1', + hostScope: { kind: 'local', hostId: 'local' } + } as never) + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ id: WORKTREE_ID } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + title: 'PR 4626 unified correction r3' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: TERMINAL_HANDLE, + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + vi.spyOn(runtime, 'getExactWorkerProviderSession').mockReturnValue(null) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + worktreeId: WORKTREE_ID, + ...(terminalState === 'exited' ? { connected: false } : { status: 'running' }) + } as never) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: TERMINAL_HANDLE, + status: terminalState, + tail: terminalState === 'exited' ? [] : ['completed worker output'], + truncated: false, + nextCursor: terminalState === 'exited' ? null : '1' + }) + const closeTerminal = vi.spyOn(runtime, 'closeTerminal').mockImplementation(async () => { + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + return { handle: TERMINAL_HANDLE, tabId: ORIGINAL_TAB_ID, ptyKilled: true } + }) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + try { + const task = db.createTask({ spec: 'release completed worker', runId: run.id }) + const started = (await call('orchestration.workerStart', { + task: task.id, + from: 'terminal-coordinator', + agent: 'codex' + })) as { dispatchId: string; state: string } + expect(started.state).toBe('ready') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('ready') + expect( + db.settleWorkerReport({ + taskId: task.id, + dispatchId: started.dispatchId, + outcome: 'succeeded', + result: 'worker completed' + }).action + ).toBe('settled') + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + + await expect( + call('orchestration.workerRelease', { dispatch: started.dispatchId }) + ).resolves.toMatchObject({ + dispatchId: started.dispatchId, + state: 'released', + processAction: terminalState === 'exited' ? 'closed_exited_terminal' : 'closed_agent_terminal' + }) + expect(db.getWorkerDispatch(started.dispatchId)?.state).toBe('succeeded') + expect(db.getWorkerTerminalResourceByOwner(started.dispatchId)).toMatchObject({ + ownership_state: 'released', + release_state: 'released', + pane_key: ORIGINAL_PANE_KEY, + terminal_handle: TERMINAL_HANDLE + }) + expect(closeTerminal).toHaveBeenCalledOnce() + } finally { + db.close() + } +} + +function persistAndParseCurrentSession() { + const payload = buildWorkspaceSessionPayload(useAppStore.getState()) + const parsed = parseWorkspaceSession(JSON.parse(JSON.stringify(payload))) + expect(parsed.ok).toBe(true) + if (!parsed.ok) { + throw new Error(parsed.error) + } + return parsed.value +} + +async function hydrateSession( + session: ReturnType +): Promise { + seedWorkspace() + useAppStore.getState().hydrateWorkspaceSession(session) + useAppStore.getState().hydrateTabsSession(session) + await useAppStore.getState().reconnectPersistedTerminals() +} + +beforeEach(() => { + vi.spyOn(console, 'debug').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { + pty: { kill: vi.fn().mockResolvedValue(undefined) }, + runtime: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) }, + runtimeEnvironments: { call: vi.fn().mockResolvedValue({ ok: true, result: {} }) } + } + }) + seedWorkspace() +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +describe('completed background-worker retirement resume matrix', () => { + it('does not cold-resume an explicitly completed and retired worker on first activation', async () => { + // Case 1: task completion alone keeps the still-owned provider session recoverable in place. + const ownedRecord = recordCompletedWorker() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toHaveLength(1) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBe( + ownedRecord + ) + + // Case 2: the renderer boundary used by an explicit Orca close retires the exact authority. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 3: both running and PTY-exit-first release retire exact resume authority. + seedWorkspace() + recordCompletedWorker() + await releaseCompletedWorker('running') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + seedWorkspace() + recordCompletedWorker() + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toMatchObject({ + origin: 'live', + state: 'working', + providerSession: { key: 'session_id', id: PROVIDER_SESSION_ID } + }) + await releaseCompletedWorker('exited') + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + + const retiredRestart = persistAndParseCurrentSession() + expect(retiredRestart.tabsByWorktree[WORKTREE_ID]).toEqual([]) + expect(retiredRestart.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeUndefined() + + // Case 4: legacy rollback preserves a fenced record; exited resolution clears it. + seedWorkspace() + const legacyRecord = recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + [ORIGINAL_PANE_KEY]: { + ...legacyRecord, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + } + } + }) + const legacyAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'rolled_back', + ptyId: ORIGINAL_PTY_ID + }) + expect(legacyAction.kind).toBe('rollback-surface') + if (legacyAction.kind === 'rollback-surface') { + expect( + rollbackLegacyWorkerTerminalSurfaceInStore(useAppStore.getState(), legacyAction.detail) + ).toBe('removed') + } + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect( + useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY] + ?.automaticResumeBlockedBy + ).toBe('legacy-orchestration-worker') + const exitedAction = resolveLegacyWorkerTerminalRecoveryAction({ + paneKey: ORIGINAL_PANE_KEY, + resolution: 'exited' + }) + expect(exitedAction).toEqual({ kind: 'clear-sleeping', paneKey: ORIGINAL_PANE_KEY }) + useAppStore.getState().clearSleepingAgentSession(ORIGINAL_PANE_KEY) + + // Case 5: coordinator manual close is the same safe exact-tab retirement boundary. + seedWorkspace() + recordCompletedWorker() + closeTerminalTab(ORIGINAL_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expectCanaryUnchanged() + + // Case 6: normal Codex exit leaves the shell pane; helper close retires only the helper. + seedWorkspace({ helper: true }) + recordCompletedWorker() + useAppStore.setState({ + sleepingAgentSessionsByPaneKey: { + ...useAppStore.getState().sleepingAgentSessionsByPaneKey, + [HELPER_PANE_KEY]: { + paneKey: HELPER_PANE_KEY, + tabId: HELPER_TAB_ID, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: 'unrelated-helper-session' }, + prompt: 'helper', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'live' + } + } + }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(useAppStore.getState().terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toEqual( + makeLayout(ORIGINAL_LEAF_ID, ORIGINAL_PTY_ID) + ) + closeTerminalTab(HELPER_TAB_ID, { + force: true, + skipRunningProcessConfirm: true, + localPtyTeardownOwnedExternally: true + }) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[HELPER_PANE_KEY]).toBeUndefined() + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeDefined() + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expectCanaryUnchanged() + + // Case 7: restart preserves owned panes, while post-retirement restart keeps authority absent. + seedWorkspace() + const workingProviderSession = recordWorkingWorker() + const beforeCompletion = persistAndParseCurrentSession() + expect(beforeCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(beforeCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toMatchObject({ + state: 'working', + origin: 'live', + providerSession: workingProviderSession + }) + await hydrateSession(beforeCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe(ORIGINAL_PTY_ID) + expect(useAppStore.getState().ptyIdsByTabId[ORIGINAL_TAB_ID]).toEqual([ORIGINAL_PTY_ID]) + completeRecordedWorker(recordWorkingWorker()) + const afterCompletion = persistAndParseCurrentSession() + expect(afterCompletion.tabsByWorktree[WORKTREE_ID]?.[0]).toMatchObject({ + id: ORIGINAL_TAB_ID, + ptyId: ORIGINAL_PTY_ID + }) + expect(afterCompletion.terminalLayoutsByTabId[ORIGINAL_TAB_ID]?.ptyIdsByLeafId).toEqual({ + [ORIGINAL_LEAF_ID]: ORIGINAL_PTY_ID + }) + expect(afterCompletion.sleepingAgentSessionsByPaneKey?.[ORIGINAL_PANE_KEY]).toBeDefined() + await hydrateSession(afterCompletion) + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + expect(useAppStore.getState().tabsByWorktree[WORKTREE_ID]?.[0]?.id).toBe(ORIGINAL_TAB_ID) + expect(Object.keys(useAppStore.getState().pendingStartupByTabId)).toEqual([]) + expect(useAppStore.getState().tabsByWorktree[CANARY_WORKTREE_ID]?.[0]?.id).toBe(CANARY_TAB_ID) + + activateAndRevealWorktree(CANARY_WORKTREE_ID, { notifyHostRuntime: false }) + useAppStore.getState().removeAgentStatus(ORIGINAL_PANE_KEY) + useAppStore.getState().closeTab(ORIGINAL_TAB_ID, { reason: 'pty-exit' }) + await releaseCompletedWorker('exited') + const restartAfterRetirement = persistAndParseCurrentSession() + await hydrateSession(restartAfterRetirement) + + // Case 8: first activation of the never-visited target cannot resurrect retired authority. + const beforeActivation = useAppStore.getState() + expect(beforeActivation.everActivatedWorktreeIds.has(WORKTREE_ID)).toBe(false) + expect(beforeActivation.agentStatusByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(beforeActivation.sleepingAgentSessionsByPaneKey[ORIGINAL_PANE_KEY]).toBeUndefined() + expect(Object.keys(beforeActivation.pendingStartupByTabId)).toEqual([]) + const tabCountBeforeActivation = beforeActivation.tabsByWorktree[WORKTREE_ID]?.length ?? 0 + activateAndRevealWorktree(WORKTREE_ID, { notifyHostRuntime: false }) + const activated = useAppStore.getState() + const replacementTabs = (activated.tabsByWorktree[WORKTREE_ID] ?? []).filter( + (tab) => tab.id !== ORIGINAL_TAB_ID + ) + // The provider-ownership gate separately proves this request becomes one transport spawn. + const coldSpawnRequests = replacementTabs.flatMap((tab) => { + const startup = activated.pendingStartupByTabId[tab.id] + if (!startup?.resumeProviderSession) { + return [] + } + const tokens = tokenizeStartupCommand(startup.command, 'posix') + expect(tokens.ok).toBe(true) + const showSessionRestoredBanner = vi.fn() + seedStartupSessionRestoredBanner(startup, 1, showSessionRestoredBanner) + return [ + { + providerSession: startup.resumeProviderSession, + command: startup.command, + argv: tokens.ok ? tokens.tokens : [], + restoredBannerCount: showSessionRestoredBanner.mock.calls.length + } + ] + }) + + expect(tabCountBeforeActivation).toBe(0) + expect(replacementTabs).toHaveLength(1) + expect(activated.tabsByWorktree[WORKTREE_ID]?.some((tab) => tab.id === ORIGINAL_TAB_ID)).toBe( + false + ) + expect(activated.terminalLayoutsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(activated.ptyIdsByTabId[ORIGINAL_TAB_ID]).toBeUndefined() + expect(coldSpawnRequests).toEqual([]) + expectCanaryUnchanged() + expect(Object.keys(activated.pendingStartupByTabId)).toHaveLength(0) + expect(Object.keys(activated.automaticAgentResumeClaimsByTabId)).toHaveLength(0) + + // Required invariant: explicit completion plus retirement must revoke provider-resume authority. + expect(coldSpawnRequests).toEqual([]) + }) +}) diff --git a/tests/e2e/golden-core-flows.spec.ts b/tests/e2e/golden-core-flows.spec.ts index c510ce7a68f..96863251081 100644 --- a/tests/e2e/golden-core-flows.spec.ts +++ b/tests/e2e/golden-core-flows.spec.ts @@ -16,7 +16,6 @@ import { const tempRoots: string[] = [] const SORTABLE_TAB = '[data-testid="sortable-tab"]' -const REPO_STEP_HEADING = /Point Orca at some code/i const TASK_SOURCES_HEADING = /Set up GitHub tasks|Connect your task sources/i const WINDOWS_TERMINAL_HEADING = /Set Windows terminal defaults/i const ONBOARDING_ADVANCE_LABEL = /^Continue\b|^Add your first project\b/ @@ -161,7 +160,7 @@ async function continueThroughOptionalSetupToNotifications(page: Page): Promise< await expect(page.getByRole('heading', { name: /Set up notifications/i })).toBeVisible() } -async function continueFromNotificationsToRepo(page: Page): Promise { +async function continueFromNotificationsToAddProject(page: Page): Promise { await continueOnboarding(page) const taskSourcesVisible = await page .getByRole('heading', { name: TASK_SOURCES_HEADING }) @@ -179,22 +178,9 @@ async function continueFromNotificationsToRepo(page: Page): Promise { if (windowsTerminalVisible) { await continueOnboarding(page) } - const repoHeading = page.getByRole('heading', { name: REPO_STEP_HEADING }) - const addProjectDialog = page.getByRole('dialog', { name: /Add a project/i }) - await expect - .poll( - async () => { - if (await repoHeading.isVisible().catch(() => false)) { - return 'repo-step' - } - if (await addProjectDialog.isVisible().catch(() => false)) { - return 'add-project-dialog' - } - return 'waiting' - }, - { timeout: 15_000 } - ) - .not.toBe('waiting') + await expect(page.getByRole('dialog', { name: /Add a project/i })).toBeVisible({ + timeout: 15_000 + }) } async function waitForRepoLoaded(page: Page, repoPath: string): Promise { @@ -457,16 +443,13 @@ test.describe('New-user golden core flow', () => { await continueThroughOptionalSetupToNotifications(orcaPage) await expect(orcaPage.getByRole('button', { name: /Send Test Notification/i })).toBeVisible() await chooseNotificationSound(orcaPage) - await continueFromNotificationsToRepo(orcaPage) + await continueFromNotificationsToAddProject(orcaPage) const repoPath = await createGitRepo('orca-e2e-golden-new-', 'golden-new-project') await chooseFolderInNativeDialog(electronApp, repoPath) await orcaPage .getByRole('button', { name: /Browse for a folder|Open a folder|Browse folder/i }) .click() - await expect(orcaPage.getByRole('heading', { name: REPO_STEP_HEADING })).toHaveCount(0, { - timeout: 30_000 - }) await waitForRepoLoaded(orcaPage, repoPath) await expectProjectVisible(orcaPage, repoPath) await waitForActiveWorktree(orcaPage) diff --git a/tests/e2e/helpers/completed-worker-retirement-fixture.ts b/tests/e2e/helpers/completed-worker-retirement-fixture.ts new file mode 100644 index 00000000000..d1211234258 --- /dev/null +++ b/tests/e2e/helpers/completed-worker-retirement-fixture.ts @@ -0,0 +1,194 @@ +import { execFileSync } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { RuntimeClient } from '../../../src/cli/runtime-client' +import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../src/shared/orca-profiles' +import type { + RuntimeTerminalListResult, + RuntimeTerminalSummary +} from '../../../src/shared/runtime-types' + +const fakeCliDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-retired-worker-')) +const lifecycleLedgerPath = path.join(fakeCliDir, 'codex-lifecycle.jsonl') +const fakeCodexSource = ` +const { appendFileSync } = require('node:fs') +const ledger = process.env.ORCA_E2E_CODEX_LIFECYCLE_LEDGER +const append = (event) => appendFileSync(ledger, JSON.stringify({ pid: process.pid, ...event }) + '\\n') +const args = process.argv.slice(2) +if (args.includes('app-server')) { + process.stderr.write("error: unrecognized subcommand 'app-server'\\n") + process.exit(2) +} +append({ event: 'spawn', args }) +process.stdout.write('\\u001b]0;Codex Ready\\u0007OpenAI Codex\\nmodel: e2e\\ndirectory: e2e\\n') +process.stdin.on('data', (chunk) => { + const input = chunk.toString() + append({ event: 'input', input }) + if (input.includes('ORCA_E2E_EXIT_AFTER_DONE')) { + append({ event: 'normal-exit' }) + process.exit(0) + } + if (input.includes('\\r')) process.stdout.write('ACK\\n') +}) +process.stdin.resume() +setInterval(() => {}, 60_000) +` + +if (process.platform === 'win32') { + writeFileSync(path.join(fakeCliDir, 'fake-codex.js'), fakeCodexSource) + writeFileSync( + path.join(fakeCliDir, 'codex.cmd'), + '@echo off\r\nnode "%~dp0\\fake-codex.js" %*\r\n' + ) +} else { + const executable = path.join(fakeCliDir, 'codex') + writeFileSync(executable, `#!/usr/bin/env node\n${fakeCodexSource}`) + chmodSync(executable, 0o755) +} + +export const completedWorkerLaunchEnv = { + PATH: `${fakeCliDir}${path.delimiter}${process.env.PATH ?? ''}`, + ORCA_E2E_CODEX_LIFECYCLE_LEDGER: lifecycleLedgerPath +} + +export type LifecycleEvent = { + pid: number + event: 'spawn' | 'input' | 'normal-exit' + args?: string[] + input?: string +} + +export type TerminalIdentity = Pick< + RuntimeTerminalSummary, + 'handle' | 'incarnationId' | 'leafId' | 'ptyId' | 'tabId' | 'worktreeId' +> + +export function clearCompletedWorkerLedger(): void { + rmSync(lifecycleLedgerPath, { force: true }) +} + +export function cleanupCompletedWorkerFixture(): void { + rmSync(fakeCliDir, { recursive: true, force: true }) +} + +export function readCompletedWorkerLedger(): LifecycleEvent[] { + if (!existsSync(lifecycleLedgerPath)) { + return [] + } + const contents = readFileSync(lifecycleLedgerPath, 'utf8') + const lastCompleteLine = contents.lastIndexOf('\n') + if (lastCompleteLine < 0) { + return [] + } + return contents + .slice(0, lastCompleteLine) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line) as LifecycleEvent) +} + +export function readCompletedWorkerDispatchCapability(): string | null { + const input = readCompletedWorkerLedger() + .filter((event) => event.event === 'input') + .map((event) => event.input ?? '') + .join('') + return input.match(/--dispatch-capability\s+(\S+)/)?.[1] ?? null +} + +export function runBuiltOrcaCli( + args: string[], + options: { userDataDir: string; cwd: string } +): unknown { + const { + ORCA_ENVIRONMENT: _environment, + ORCA_PAIRING_CODE: _pairingCode, + ORCA_USER_DATA_PATH: _userDataPath, + ...cleanEnv + } = process.env + void _environment + void _pairingCode + void _userDataPath + const output = execFileSync( + process.execPath, + [path.join(process.cwd(), 'out', 'cli', 'index.js'), ...args], + { + cwd: options.cwd, + env: { ...cleanEnv, ORCA_USER_DATA_PATH: options.userDataDir }, + encoding: 'utf8', + timeout: 30_000 + } + ) + return JSON.parse(output) as unknown +} + +export function seedCurrentCodexTranscript( + isolatedHome: string, + providerSessionId: string, + cwd: string +): string { + const now = new Date() + const transcriptDir = path.join( + isolatedHome, + '.codex', + 'sessions', + String(now.getUTCFullYear()), + String(now.getUTCMonth() + 1).padStart(2, '0'), + String(now.getUTCDate()).padStart(2, '0') + ) + mkdirSync(transcriptDir, { recursive: true }) + const transcriptPath = path.join(transcriptDir, `rollout-${providerSessionId}.jsonl`) + writeFileSync( + transcriptPath, + `${JSON.stringify({ + timestamp: now.toISOString(), + type: 'session_meta', + payload: { id: providerSessionId, cwd } + })}\n` + ) + return transcriptPath +} + +export function terminalIdentity(terminal: RuntimeTerminalSummary): TerminalIdentity { + const { handle, incarnationId, leafId, ptyId, tabId, worktreeId } = terminal + return { handle, incarnationId, leafId, ptyId, tabId, worktreeId } +} + +export async function listRuntimeTerminals( + client: RuntimeClient +): Promise { + return (await client.call('terminal.list')).result.terminals +} + +export function readPersistedWorkerRecoveryRecord(userDataDir: string, paneKey: string) { + const dataPath = path.join( + userDataDir, + 'profiles', + DEFAULT_LOCAL_ORCA_PROFILE_ID, + 'orca-data.json' + ) + if (!existsSync(dataPath)) { + return null + } + const data = JSON.parse(readFileSync(dataPath, 'utf8')) as { + workspaceSession?: { + sleepingAgentSessionsByPaneKey?: Record< + string, + { + origin?: unknown + state?: unknown + providerSession?: { id?: unknown } + } + > + } + } + return data.workspaceSession?.sleepingAgentSessionsByPaneKey?.[paneKey] ?? null +} diff --git a/tests/e2e/helpers/paired-terminal-parking-fixture.ts b/tests/e2e/helpers/paired-terminal-parking-fixture.ts index c49917cb24f..f7632869d3e 100644 --- a/tests/e2e/helpers/paired-terminal-parking-fixture.ts +++ b/tests/e2e/helpers/paired-terminal-parking-fixture.ts @@ -4,6 +4,9 @@ import path from 'node:path' const FILL_ROWS = 6_000 const FLOOD_ROWS = 4_000 +const TITLE_FRAME_INTERVAL_MS = 70 +const TITLE_FRAME_COUNT = 30 +const TITLE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'` @@ -31,6 +34,29 @@ export function createPairedTerminalParkingFixture(): { "process.stdin.setEncoding('utf8')", "process.stdin.on('data', (data) => {", ' for (const command of data.split(/\\r\\n|\\r|\\n/).filter(Boolean)) {', + " if (command.startsWith('TITLE_START:')) {", + " const token = command.slice('TITLE_START:'.length)", + " process.stdout.write('\\u001b]0;⠋ Cursor Agent\\u0007')", + ' process.stdout.write(`TITLE_STARTED:${token}\\r\\n`)', + ' continue', + ' }', + " if (command.startsWith('TITLE_ANIMATE:')) {", + " const token = command.slice('TITLE_ANIMATE:'.length)", + ` const frames = ${JSON.stringify(TITLE_FRAMES)}`, + ' let frameIndex = 0', + ' const emitFrame = () => {', + ` if (frameIndex < ${TITLE_FRAME_COUNT}) {`, + ' process.stdout.write(`\\u001b]0;${frames[frameIndex % frames.length]} Cursor Agent\\u0007`)', + ' frameIndex += 1', + ` setTimeout(emitFrame, ${TITLE_FRAME_INTERVAL_MS})`, + ' return', + ' }', + " process.stdout.write('\\u001b]0;Cursor ready\\u0007')", + ' process.stdout.write(`TITLE_DONE:${token}\\r\\n`)', + ' }', + ` setTimeout(emitFrame, ${TITLE_FRAME_INTERVAL_MS})`, + ' continue', + ' }', " if (command.startsWith('FLOOD:')) {", " const token = command.slice('FLOOD:'.length)", ` for (let row = 0; row < ${FLOOD_ROWS}; row += 1) process.stdout.write(\`flood-${'${token}'}-${'${row}'}-${'x'.repeat(80)}\\r\\n\`)`, diff --git a/tests/e2e/helpers/paired-terminal-parking-oracle.ts b/tests/e2e/helpers/paired-terminal-parking-oracle.ts index 070c73cceb1..c1a385b2162 100644 --- a/tests/e2e/helpers/paired-terminal-parking-oracle.ts +++ b/tests/e2e/helpers/paired-terminal-parking-oracle.ts @@ -12,6 +12,7 @@ import { import { expect } from './orca-app' import { verifyHiddenPairedTerminalOutputSuppression } from './paired-terminal-hidden-output-oracle' import { createPairedTerminalParkingFixture } from './paired-terminal-parking-fixture' +import { verifyPairedTerminalTitleFanout } from './paired-terminal-title-fanout-oracle' import { getTerminalContent, waitForActivePanePtyId } from './terminal' const TARGET_WORKTREE_COUNT = 6 @@ -135,6 +136,7 @@ export async function runPairedTerminalParkingOracle( await expectHostTerminalsUnmounted(options.hostPage, seed.fallbackWorktreeId, remoteTabs) const hiddenFloodTokens = await verifyHiddenPairedTerminalOutputSuppression(page, remoteTabs) + await verifyPairedTerminalTitleFanout(page, remoteTabs) const baseline = await readPairedRetentionSample( page, remoteTabs.map((tab) => tab.tabId) diff --git a/tests/e2e/helpers/paired-terminal-title-fanout-oracle.ts b/tests/e2e/helpers/paired-terminal-title-fanout-oracle.ts new file mode 100644 index 00000000000..24b686997b7 --- /dev/null +++ b/tests/e2e/helpers/paired-terminal-title-fanout-oracle.ts @@ -0,0 +1,310 @@ +import type { Page } from '@stablyai/playwright-test' +import type { RuntimeTerminalRead } from '../../../src/shared/runtime-types' +import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { expect } from './orca-app' + +const INITIAL_TITLE = '⠋ Cursor Agent' +const FINAL_TITLE = 'Cursor ready' + +type TitleTarget = { + tabId: string + terminal: string + worktreeId: string +} + +type TitleTransportEvent = { + decodedEnvelopeBytes: number + snapshotVersion: number + title: string + worktreeId: string +} + +type TitleStoreChange = { + title: string + worktreeId: string +} + +type TitleProbeSample = { + storeChanges: TitleStoreChange[] + transportEvents: TitleTransportEvent[] +} + +type BrowserTitleProbe = TitleProbeSample & { + lastTitles: Record + responseCount: number + subscription: null | { unsubscribe: () => void } + unsubscribeStore: null | (() => void) +} + +async function callRuntime(page: Page, method: string, params: unknown): Promise { + return page.evaluate( + async ({ method, params }) => { + const response = await window.api.runtime.call({ method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params } + ) as Promise +} + +export async function verifyPairedTerminalTitleFanout( + page: Page, + targets: TitleTarget[] +): Promise { + const tokens = targets.map((_, index) => `TITLE_FANOUT_${index}_${Date.now()}`) + await installTitleProbe(page, targets) + try { + await expect.poll(() => readProbeResponseCount(page), { timeout: 30_000 }).toBeGreaterThan(0) + await resetTitleProbe(page, targets) + + await Promise.all( + targets.map((target, index) => + sendFixtureCommand(page, target, `TITLE_START:${tokens[index]}`) + ) + ) + await expectTitles(page, targets, INITIAL_TITLE) + await expect + .poll(async () => (await readTitleProbe(page)).transportEvents.length, { timeout: 30_000 }) + .toBeGreaterThanOrEqual(targets.length) + + await Promise.all( + targets.map((target, index) => + sendFixtureCommand(page, target, `TITLE_ANIMATE:${tokens[index]}`) + ) + ) + await expectFixtureCompletion(page, targets, tokens) + await expectTitles(page, targets, FINAL_TITLE) + await expect + .poll(async () => (await readTitleProbe(page)).transportEvents.length, { timeout: 30_000 }) + .toBeGreaterThanOrEqual(targets.length * 2) + + const sample = await readTitleProbe(page) + expectTitleFanoutSample(sample, targets) + } finally { + await disposeTitleProbe(page) + } +} + +async function installTitleProbe(page: Page, targets: TitleTarget[]): Promise { + await page.evaluate( + async ({ targets, capability }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store is unavailable') + } + const environmentId = Array.from( + store.getState().runtimeStatusByEnvironmentId.entries() + ).find(([, entry]) => entry.status?.capabilities?.includes(capability))?.[0] + if (!environmentId) { + throw new Error('Paired runtime environment is unavailable') + } + const titleFor = ( + state: ReturnType, + target: (typeof targets)[number] + ) => + state.tabsByWorktree[target.worktreeId]?.find((tab) => tab.id === target.tabId)?.title ?? + null + const probe = { + responseCount: 0, + storeChanges: [] as TitleStoreChange[], + transportEvents: [] as TitleTransportEvent[], + lastTitles: Object.fromEntries( + targets.map((target) => [target.worktreeId, titleFor(store.getState(), target)]) + ), + subscription: null as null | { unsubscribe: () => void }, + unsubscribeStore: null as null | (() => void) + } + probe.unsubscribeStore = store.subscribe((state) => { + for (const target of targets) { + const title = titleFor(state, target) + if (title === null || title === probe.lastTitles[target.worktreeId]) { + continue + } + probe.lastTitles[target.worktreeId] = title + probe.storeChanges.push({ worktreeId: target.worktreeId, title }) + } + }) + probe.subscription = await window.api.runtimeEnvironments.subscribe( + { selector: environmentId, method: 'session.tabs.subscribeAll', params: {} }, + { + onResponse: (response) => { + probe.responseCount += 1 + if (!response.ok) { + return + } + const result = response.result as { + type?: string + worktree?: string + snapshotVersion?: number + tabs?: { type?: string; title?: string }[] + snapshots?: { + worktree?: string + snapshotVersion?: number + tabs?: { type?: string; title?: string }[] + }[] + } + const snapshots = result.type === 'snapshots' ? (result.snapshots ?? []) : [result] + const decodedEnvelopeBytes = new TextEncoder().encode( + JSON.stringify(response) + ).byteLength + for (const snapshot of snapshots) { + if (!targets.some((target) => target.worktreeId === snapshot.worktree)) { + continue + } + const title = snapshot.tabs?.find((tab) => tab.type === 'terminal')?.title + if ( + typeof snapshot.worktree === 'string' && + typeof snapshot.snapshotVersion === 'number' && + typeof title === 'string' + ) { + probe.transportEvents.push({ + worktreeId: snapshot.worktree, + snapshotVersion: snapshot.snapshotVersion, + title, + decodedEnvelopeBytes + }) + } + } + } + } + ) + Object.assign(globalThis, { __pairedTitleFanoutProbe: probe }) + }, + { targets, capability: TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } + ) +} + +async function resetTitleProbe(page: Page, targets: TitleTarget[]): Promise { + await page.evaluate((targets) => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + throw new Error('Paired title fanout probe is unavailable') + } + probe.transportEvents.length = 0 + probe.storeChanges.length = 0 + const state = window.__store?.getState() + for (const target of targets) { + probe.lastTitles[target.worktreeId] = + state?.tabsByWorktree[target.worktreeId]?.find((tab) => tab.id === target.tabId)?.title ?? + null + } + }, targets) +} + +async function readProbeResponseCount(page: Page): Promise { + return page.evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + throw new Error('Paired title fanout probe is unavailable') + } + return probe.responseCount + }) +} + +async function readTitleProbe(page: Page): Promise { + return page.evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + throw new Error('Paired title fanout probe is unavailable') + } + return { + storeChanges: [...probe.storeChanges], + transportEvents: [...probe.transportEvents] + } + }) +} + +async function disposeTitleProbe(page: Page): Promise { + await page + .evaluate(() => { + const probe = ( + globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: BrowserTitleProbe } + ).__pairedTitleFanoutProbe + if (!probe) { + return + } + probe.subscription?.unsubscribe() + probe.unsubscribeStore?.() + delete (globalThis as typeof globalThis & { __pairedTitleFanoutProbe?: unknown }) + .__pairedTitleFanoutProbe + }) + .catch(() => undefined) +} + +async function sendFixtureCommand(page: Page, target: TitleTarget, text: string): Promise { + await callRuntime(page, 'terminal.send', { + terminal: target.terminal, + text, + enter: true, + client: { id: 'paired-title-fanout-e2e', type: 'desktop' } + }) +} + +async function expectFixtureCompletion( + page: Page, + targets: TitleTarget[], + tokens: string[] +): Promise { + await expect + .poll( + async () => + Promise.all( + targets.map(async (target, index) => { + const result = await callRuntime<{ terminal: RuntimeTerminalRead }>( + page, + 'terminal.read', + { terminal: target.terminal, limit: 1_000 } + ) + return result.terminal.tail.join('\n').includes(`TITLE_DONE:${tokens[index]}`) + }) + ), + { timeout: 30_000 } + ) + .toEqual(Array(targets.length).fill(true)) +} + +async function expectTitles(page: Page, targets: TitleTarget[], expected: string): Promise { + await expect + .poll( + () => + page.evaluate( + ({ targets, expected }) => + targets.map( + (target) => + window.__store + ?.getState() + .tabsByWorktree[target.worktreeId]?.find((tab) => tab.id === target.tabId) + ?.title === expected + ), + { targets, expected } + ), + { timeout: 30_000 } + ) + .toEqual(Array(targets.length).fill(true)) +} + +function expectTitleFanoutSample(sample: TitleProbeSample, targets: TitleTarget[]): void { + expect(sample.transportEvents).toHaveLength(targets.length * 2) + expect(sample.storeChanges).toHaveLength(targets.length * 2) + for (const target of targets) { + const transport = sample.transportEvents.filter( + (event) => event.worktreeId === target.worktreeId + ) + expect(transport.map((event) => event.title)).toEqual([INITIAL_TITLE, FINAL_TITLE]) + expect(transport[1]?.snapshotVersion).toBeGreaterThan(transport[0]?.snapshotVersion ?? 0) + expect(transport.every((event) => event.decodedEnvelopeBytes > 0)).toBe(true) + expect( + sample.storeChanges + .filter((change) => change.worktreeId === target.worktreeId) + .map((change) => change.title) + ).toEqual([INITIAL_TITLE, FINAL_TITLE]) + } +} diff --git a/tests/e2e/orca-profiles.spec.ts b/tests/e2e/orca-profiles.spec.ts deleted file mode 100644 index 4d8bef3aec6..00000000000 --- a/tests/e2e/orca-profiles.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { test, expect } from './helpers/orca-app' - -// Why: the multi-profile switcher UI is downscoped behind ORCA_MULTI_PROFILE_UI; -// these specs exercise that full UI, so opt the whole file into the flag. -test.use({ launchEnv: { ORCA_MULTI_PROFILE_UI: '1' } }) - -test('opens the profile switcher and profile dialogs', async ({ orcaPage }) => { - const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) - await expect(switcher).toBeVisible() - - await switcher.click() - await expect(orcaPage.getByText('Personal', { exact: true }).first()).toBeVisible() - const manageProfiles = orcaPage.getByRole('menuitem', { name: /Manage profiles/i }) - await expect(manageProfiles).toBeVisible() - await expect(orcaPage.getByRole('menuitem', { name: /New local profile/i })).toBeVisible() - - await manageProfiles.click() - const managementDialog = orcaPage.getByRole('dialog', { name: /Manage profiles/i }) - await expect(managementDialog).toBeVisible() - await expect(managementDialog.getByText(/projects/i).first()).toBeVisible() - - await orcaPage.keyboard.press('Escape') - await expect(managementDialog).toBeHidden() - - await switcher.click() - await orcaPage.getByRole('menuitem', { name: /New local profile/i }).click() - const createDialog = orcaPage.getByRole('dialog', { name: /New local profile/i }) - await expect(createDialog).toBeVisible() - await expect(createDialog.getByPlaceholder(/Profile name/i)).toBeVisible() - await expect(createDialog.getByRole('button', { name: /Create and Switch/i })).toBeVisible() -}) - -test('places the profile switcher in sidebar footer and full-page titlebar', async ({ - orcaPage -}) => { - const switcher = orcaPage.getByRole('button', { name: /^Switch profile$/ }) - const settingsButton = orcaPage.getByRole('button', { name: /^Settings$/ }) - await expect(switcher).toBeVisible() - await expect(settingsButton).toBeVisible() - - const sidebarSwitchBox = await switcher.boundingBox() - const settingsBox = await settingsButton.boundingBox() - const viewport = await orcaPage.evaluate(() => ({ - width: window.innerWidth, - height: window.innerHeight - })) - expect(sidebarSwitchBox).not.toBeNull() - expect(settingsBox).not.toBeNull() - - expect(sidebarSwitchBox!.x + sidebarSwitchBox!.width).toBeLessThanOrEqual(settingsBox!.x + 2) - expect(Math.abs(sidebarSwitchBox!.y - settingsBox!.y)).toBeLessThanOrEqual(2) - expect(sidebarSwitchBox!.y).toBeGreaterThan(viewport.height - 64) - - await settingsButton.click() - await expect - .poll(() => orcaPage.evaluate(() => window.__store?.getState().activeView)) - .toBe('settings') - - const titlebarSwitchBox = await switcher.boundingBox() - expect(titlebarSwitchBox).not.toBeNull() - expect(titlebarSwitchBox!.x).toBeGreaterThan(viewport.width - 260) - expect(titlebarSwitchBox!.y).toBeLessThan(48) -}) - -test.describe('default single-profile mode', () => { - // Why: no flag — the default build shows no account trigger on a local-only - // (cloud-unconfigured) install. - test.use({ launchEnv: {} }) - - test('hides the account trigger when cloud is unconfigured', async ({ orcaPage }) => { - await expect(orcaPage.getByRole('button', { name: /^Switch profile$/ })).toHaveCount(0) - await expect(orcaPage.getByRole('button', { name: /^Account$/ })).toHaveCount(0) - }) -}) diff --git a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts index 9a358c65b54..fae82b45d44 100644 --- a/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts +++ b/tests/e2e/paired-runtime-rejected-input-remount.unit.test.ts @@ -42,9 +42,6 @@ vi.mock('@/store', () => ({ })) vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync: vi.fn() })) -vi.mock('@/components/terminal-pane/terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery: vi.fn() -})) vi.mock('sonner', () => ({ toast: { info: vi.fn() } })) vi.mock('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep: vi.fn() })) vi.mock('@/runtime/web-runtime-session', () => ({ diff --git a/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts new file mode 100644 index 00000000000..48dd86e318d --- /dev/null +++ b/tests/e2e/session-tabs-decorative-title-fanout.unit.test.ts @@ -0,0 +1,772 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../src/shared/runtime-types' +import { detectAgentStatusFromTitle } from '../../src/shared/agent-detection' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../src/shared/agent-status-types' +import { isExplicitAgentStatusFresh } from '../../src/renderer/src/lib/agent-status' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { + SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS, + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS +} from '../../src/main/runtime/mobile-session-tabs-agent-status-heartbeat' +import { + applyFreshWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from '../../src/renderer/src/runtime/web-session-tabs-sync' + +vi.mock('../../src/renderer/src/store', () => ({ + useAppStore: { + setState: vi.fn() + } +})) + +const ENVIRONMENT_ID = 'paired-runtime' +const WORKTREE_COUNT = 24 +const DECORATIVE_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map + ptysById: Map + ptyDelayedForegroundSnapshotTitleObservations: Map + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void +} + +type FanoutCounters = { + hostPublications: number + serializedBytes: number + rendererApplyCalls: number + rendererStoreMutations: number + rawTerminalChunks: number +} + +type FanoutEvidence = { + publishedByWorktree: Map + rawChunksByPty: Map +} + +function makeViewerState(): WebSessionTabsSyncState { + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeTabType: 'terminal', + activeTabTypeByWorktree: {}, + activeWorktreeId: 'workspace-0', + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: {}, + layoutByWorktree: {}, + openFiles: [], + ptyIdsByTabId: {}, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: {}, + unreadTerminalTabs: {}, + sortEpoch: 0 + } +} + +function seedWorktree(runtime: OrcaRuntimeService, index: number): string { + const worktreeId = `workspace-${index}` + const ptyId = `pty-${index}` + const tabId = `host-tab-${index}` + const leafId = `00000000-0000-4000-8000-${index.toString().padStart(12, '0')}` + runtime.registerPty(ptyId, worktreeId) + ;(runtime as unknown as RuntimeInternals).mobileSessionTabsByWorktree.set(worktreeId, { + worktree: worktreeId, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: `group-${index}`, + activeTabId: `${tabId}::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `${tabId}::${leafId}`, + parentTabId: tabId, + leafId, + ptyId, + title: 'Cursor Agent', + parentLayout: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + }, + isActive: true + } + ] + }) + return ptyId +} + +function resetCounters(counters: FanoutCounters): void { + counters.hostPublications = 0 + counters.serializedBytes = 0 + counters.rendererApplyCalls = 0 + counters.rendererStoreMutations = 0 + counters.rawTerminalChunks = 0 +} + +function resetEvidence(evidence: FanoutEvidence): void { + evidence.publishedByWorktree.clear() + evidence.rawChunksByPty.clear() +} + +function recordByKey(map: Map, key: string, value: T): void { + const entries = map.get(key) ?? [] + entries.push(value) + map.set(key, entries) +} + +describe('real PTY decorative session-tabs fanout', () => { + beforeEach(() => { + vi.useFakeTimers() + resetWebSessionTabsSnapshotFreshnessForTests() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('bounds host publication and renderer application across remote worktrees', () => { + const runtime = new OrcaRuntimeService() + const ptyIds = Array.from({ length: WORKTREE_COUNT }, (_, index) => + seedWorktree(runtime, index) + ) + const counters: FanoutCounters = { + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: 0 + } + const evidence: FanoutEvidence = { + publishedByWorktree: new Map(), + rawChunksByPty: new Map() + } + let viewerState = makeViewerState() + const dataUnsubscribes = ptyIds.map((ptyId) => + runtime.subscribeToTerminalData(ptyId, (data) => { + counters.rawTerminalChunks += 1 + recordByKey(evidence.rawChunksByPty, ptyId, data) + }) + ) + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + counters.hostPublications += 1 + counters.serializedBytes += Buffer.byteLength(JSON.stringify(snapshot)) + recordByKey(evidence.publishedByWorktree, snapshot.worktree, structuredClone(snapshot)) + counters.rendererApplyCalls += 1 + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + if (patch !== viewerState) { + counters.rendererStoreMutations += 1 + viewerState = { ...viewerState, ...patch } + } + }) + + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(counters.hostPublications).toBe(WORKTREE_COUNT) + expect(counters.rendererStoreMutations).toBe(WORKTREE_COUNT) + resetCounters(counters) + resetEvidence(evidence) + + for (let cycle = 0; cycle < 4; cycle += 1) { + for (const frame of DECORATIVE_FRAMES) { + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, `\x1b]0;${frame} Cursor Agent\x07`, Date.now()) + } + vi.advanceTimersByTime(30) + } + } + vi.advanceTimersByTime(50) + + expect(counters).toEqual({ + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: WORKTREE_COUNT * DECORATIVE_FRAMES.length * 4 + }) + for (const ptyId of ptyIds) { + expect(evidence.rawChunksByPty.get(ptyId)).toEqual( + Array.from({ length: 4 }, () => + DECORATIVE_FRAMES.map((frame) => `\x1b]0;${frame} Cursor Agent\x07`) + ).flat() + ) + } + expect(evidence.publishedByWorktree.size).toBe(0) + + resetCounters(counters) + resetEvidence(evidence) + for (let cycle = 0; cycle < 4; cycle += 1) { + for (const frame of DECORATIVE_FRAMES) { + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', Date.now()) + runtime.onPtyData(ptyId, `\x1b]0;${frame} Cursor Agent\x07`, Date.now()) + } + vi.advanceTimersByTime(30) + } + } + vi.advanceTimersByTime(50) + expect(counters).toEqual({ + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: WORKTREE_COUNT * DECORATIVE_FRAMES.length * 8 + }) + for (const ptyId of ptyIds) { + expect(evidence.rawChunksByPty.get(ptyId)).toEqual( + Array.from({ length: 4 }, () => + DECORATIVE_FRAMES.flatMap((frame) => [ + '\x1b]0;Cursor Agent\x07', + `\x1b]0;${frame} Cursor Agent\x07` + ]) + ).flat() + ) + } + expect(evidence.publishedByWorktree.size).toBe(0) + + resetCounters(counters) + resetEvidence(evidence) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;Cursor ready\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(counters.hostPublications).toBe(WORKTREE_COUNT) + expect(counters.rendererApplyCalls).toBe(WORKTREE_COUNT) + expect(counters.rendererStoreMutations).toBe(WORKTREE_COUNT) + expect(counters.serializedBytes).toBeGreaterThan(0) + for (let index = 0; index < WORKTREE_COUNT; index += 1) { + const worktreeId = `workspace-${index}` + const snapshots = evidence.publishedByWorktree.get(worktreeId) + expect(snapshots).toHaveLength(1) + const terminal = snapshots?.[0]?.tabs[0] + expect(terminal).toMatchObject({ + type: 'terminal', + parentTabId: `host-tab-${index}`, + leafId: `00000000-0000-4000-8000-${index.toString().padStart(12, '0')}`, + ptyId: `pty-${index}`, + title: 'Cursor ready' + }) + expect(terminal?.type === 'terminal' ? terminal.agentStatus?.state : undefined).toBe('done') + expect(viewerState.tabsByWorktree[worktreeId]).toEqual([ + expect.objectContaining({ title: 'Cursor ready', worktreeId }) + ]) + expect( + detectAgentStatusFromTitle(viewerState.tabsByWorktree[worktreeId]?.[0]?.title ?? '') + ).toBe('idle') + expect(evidence.rawChunksByPty.get(`pty-${index}`)).toEqual(['\x1b]0;Cursor ready\x07']) + } + + resetCounters(counters) + resetEvidence(evidence) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, 'visible output\r\n', Date.now()) + } + vi.advanceTimersByTime(50) + expect(counters).toEqual({ + hostPublications: 0, + serializedBytes: 0, + rendererApplyCalls: 0, + rendererStoreMutations: 0, + rawTerminalChunks: WORKTREE_COUNT + }) + for (const ptyId of ptyIds) { + expect(evidence.rawChunksByPty.get(ptyId)).toEqual(['visible output\r\n']) + } + expect(evidence.publishedByWorktree.size).toBe(0) + + unsubscribe() + for (const dispose of dataUnsubscribes) { + dispose() + } + }) + + it.each([ + ['build ⠁', 'build ⠂'], + ['Codex working task ⠁', 'Codex working task ⠂'] + ])('publishes meaningful real-title changes from %j to %j', (firstTitle, secondTitle) => { + const runtime = new OrcaRuntimeService() + seedWorktree(runtime, 0) + const published: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => published.push(snapshot)) + + runtime.onPtyData('pty-0', `\x1b]0;${firstTitle}\x07`, Date.now()) + vi.advanceTimersByTime(50) + published.length = 0 + runtime.onPtyData('pty-0', `\x1b]0;${secondTitle}\x07`, Date.now()) + vi.advanceTimersByTime(50) + + expect(published).toHaveLength(1) + expect(published[0]?.tabs[0]).toMatchObject({ title: secondTitle, ptyId: 'pty-0' }) + unsubscribe() + }) + + it('globally caps decorative freshness while every working status stays fresh', () => { + const runtime = new OrcaRuntimeService() + const firstWorkingTitleAt = Date.now() + const ptyIds = Array.from({ length: WORKTREE_COUNT }, (_, index) => + seedWorktree(runtime, index) + ) + const publications: { + worktreeId: string + at: number + updatedAt: number + stateStartedAt: number + }[] = [] + let viewerState = makeViewerState() + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + const terminal = snapshot.tabs[0] + if (terminal?.type !== 'terminal') { + return + } + publications.push({ + worktreeId: snapshot.worktree, + at: Date.now(), + updatedAt: terminal.agentStatus?.updatedAt ?? 0, + stateStartedAt: terminal.agentStatus?.stateStartedAt ?? 0 + }) + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + viewerState = { ...viewerState, ...patch } + }) + + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(50) + const initialStateStartedAtByWorktree = new Map( + publications.map(({ worktreeId, stateStartedAt }) => [worktreeId, stateStartedAt]) + ) + publications.length = 0 + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS - 51) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠙ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(publications).toEqual([]) + + vi.advanceTimersByTime(1) + const heartbeatStartedAt = Date.now() + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠹ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * WORKTREE_COUNT + 50) + + expect(publications.map(({ worktreeId }) => worktreeId).sort()).toEqual( + Array.from({ length: WORKTREE_COUNT }, (_, index) => `workspace-${index}`).sort() + ) + for (let index = 1; index < publications.length; index += 1) { + expect(publications[index]!.at - publications[index - 1]!.at).toBeGreaterThanOrEqual( + SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS + ) + } + for (const publication of publications) { + expect(publication.updatedAt).toBeGreaterThanOrEqual(heartbeatStartedAt) + expect(Date.now() - publication.updatedAt).toBeLessThan(AGENT_STATUS_STALE_AFTER_MS) + expect(publication.stateStartedAt).toBe( + initialStateStartedAtByWorktree.get(publication.worktreeId) + ) + } + publications.length = 0 + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;⠸ Cursor Agent\x07', Date.now()) + } + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_SPACING_MS * WORKTREE_COUNT + 50) + expect(publications).toHaveLength(WORKTREE_COUNT) + expect(Date.now() - firstWorkingTitleAt).toBeGreaterThan(AGENT_STATUS_STALE_AFTER_MS) + const viewerAgentStatuses = Object.values(viewerState.agentStatusByPaneKey) + expect(viewerAgentStatuses).toHaveLength(WORKTREE_COUNT) + expect( + viewerAgentStatuses.every( + (entry) => + entry.state === 'working' && + isExplicitAgentStatusFresh(entry, Date.now(), AGENT_STATUS_STALE_AFTER_MS) + ) + ).toBe(true) + + publications.length = 0 + const completionAt = Date.now() + for (const ptyId of ptyIds) { + runtime.onPtyData(ptyId, '\x1b]0;Cursor ready\x07', Date.now()) + } + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(WORKTREE_COUNT) + expect(publications.every(({ updatedAt }) => updatedAt === completionAt)).toBe(true) + unsubscribe() + }) + + it.each([ + { + agent: 'pi' as const, + firstTitle: '⠋ π - project', + heartbeatTitles: ['⠋ π - project', '⠙ π - project'], + expectedTitle: '⠋ Pi' + }, + { + agent: 'grok-build' as const, + firstTitle: '⠋ - Waiting for response… - grok', + heartbeatTitles: ['⠴ - Thinking - grok', '⠦ - Sleep 2s then echo hello… - grok'], + expectedTitle: '⠋ Grok' + } + ])('renews exact and normalized $agent frames beyond the viewer stale boundary', (testCase) => { + const runtime = new OrcaRuntimeService() + const firstWorkingTitleAt = Date.now() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + internals.ptysById.get(ptyId)!.launchAgent = testCase.agent + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + seededTab.agentStatus = { + state: 'working', + prompt: 'task', + updatedAt: firstWorkingTitleAt, + stateStartedAt: firstWorkingTitleAt, + agentType: testCase.agent, + paneKey: seededTab.id, + stateHistory: [] + } + const publications: RuntimeMobileSessionTabsResult[] = [] + let viewerState = makeViewerState() + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + viewerState = { ...viewerState, ...patch } + }) + + runtime.onPtyData(ptyId, `\x1b]0;${testCase.firstTitle}\x07`, Date.now()) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const initialTerminal = publications[0]?.tabs[0] + const initialAgentStatus = + initialTerminal?.type === 'terminal' ? initialTerminal.agentStatus : undefined + expect(initialAgentStatus?.state).toBe('working') + publications.length = 0 + + for (const title of testCase.heartbeatTitles) { + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, `\x1b]0;${title}\x07`, Date.now()) + vi.advanceTimersByTime(50) + } + + expect(publications).toHaveLength(2) + expect( + publications.every((snapshot) => { + const terminal = snapshot.tabs[0] + return terminal?.type === 'terminal' && terminal.title === testCase.expectedTitle + }) + ).toBe(true) + const finalTerminal = publications.at(-1)?.tabs[0] + const finalAgentStatus = + finalTerminal?.type === 'terminal' ? finalTerminal.agentStatus : undefined + expect(finalAgentStatus?.updatedAt).toBeGreaterThan(initialAgentStatus!.updatedAt) + expect(finalAgentStatus?.stateStartedAt).toBe(initialAgentStatus?.stateStartedAt) + expect(finalAgentStatus?.prompt).toBe('') + expect(finalAgentStatus?.agentType).toBe(initialAgentStatus?.agentType) + const acknowledgedAt = initialAgentStatus!.updatedAt + 1 + expect(acknowledgedAt).toBeGreaterThanOrEqual(finalAgentStatus!.stateStartedAt) + expect(Date.now() - firstWorkingTitleAt).toBeGreaterThan(AGENT_STATUS_STALE_AFTER_MS) + const viewerAgentStatus = Object.values(viewerState.agentStatusByPaneKey)[0] + expect(viewerAgentStatus?.state).toBe('working') + expect( + isExplicitAgentStatusFresh(viewerAgentStatus!, Date.now(), AGENT_STATUS_STALE_AFTER_MS) + ).toBe(true) + + publications.length = 0 + vi.advanceTimersByTime(1) + runtime.onPtyData(ptyId, '\x1b]0;bash\x07', Date.now()) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const completedTerminal = publications[0]?.tabs[0] + const completedStatus = + completedTerminal?.type === 'terminal' ? completedTerminal.agentStatus : undefined + expect(completedStatus).toMatchObject({ state: 'done', prompt: '' }) + expect(completedStatus?.stateStartedAt).toBeGreaterThan(finalAgentStatus!.stateStartedAt) + unsubscribe() + }) + + it('does not publish a stored working row over a later permission title', () => { + const runtime = new OrcaRuntimeService() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + seededTab.agentStatus = { + state: 'working', + prompt: 'previous task', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'cursor', + paneKey: seededTab.id, + stateHistory: [] + } + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + vi.advanceTimersByTime(50) + publications.length = 0 + vi.advanceTimersByTime(1) + const permissionAt = Date.now() + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent waiting\x07', permissionAt) + vi.advanceTimersByTime(50) + + const terminal = publications.at(-1)?.tabs[0] + const status = terminal?.type === 'terminal' ? terminal.agentStatus : undefined + expect(status).toMatchObject({ + state: 'blocked', + prompt: '', + updatedAt: permissionAt, + stateStartedAt: permissionAt + }) + unsubscribe() + }) + + it('does not publish a prior done row over a newer working title', () => { + const runtime = new OrcaRuntimeService() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + seededTab.agentStatus = { + state: 'working', + prompt: 'first task', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'cursor', + paneKey: seededTab.id, + stateHistory: [] + } + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + vi.advanceTimersByTime(50) + vi.advanceTimersByTime(1) + runtime.onPtyData(ptyId, '\x1b]0;bash\x07', Date.now()) + vi.advanceTimersByTime(50) + vi.advanceTimersByTime(1) + const doneAt = Date.now() + seededTab.agentStatus = { + state: 'done', + prompt: 'first task', + updatedAt: doneAt, + stateStartedAt: doneAt, + agentType: 'cursor', + paneKey: seededTab.id, + stateHistory: [] + } + publications.length = 0 + + vi.advanceTimersByTime(1) + const nextWorkingAt = Date.now() + runtime.onPtyData(ptyId, '\x1b]0;⠙ Cursor Agent\x07', nextWorkingAt) + vi.advanceTimersByTime(50) + + const terminal = publications.at(-1)?.tabs[0] + const status = terminal?.type === 'terminal' ? terminal.agentStatus : undefined + expect(status).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: nextWorkingAt, + stateStartedAt: nextWorkingAt + }) + unsubscribe() + }) + + it('holds decorative heartbeats behind unresolved foreground ownership', async () => { + let resolveForegroundProcess: (agent: string | null) => void = () => undefined + const foregroundProcess = new Promise((resolve) => { + resolveForegroundProcess = resolve + }) + const getForegroundProcess = vi.fn(() => foregroundProcess) + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-0' }), + write: () => true, + kill: () => true, + getForegroundProcess + }) + const ptyId = seedWorktree(runtime, 0) + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Pi\x07', Date.now()) + runtime.onPtyData(ptyId, '\x1b]0;⠙ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(50) + expect(getForegroundProcess).toHaveBeenCalledTimes(1) + expect(publications).toHaveLength(0) + + resolveForegroundProcess('pi') + await vi.advanceTimersByTimeAsync(50) + expect(publications).toHaveLength(1) + expect(publications[0]?.tabs[0]).toMatchObject({ + type: 'terminal', + title: '⠋ Pi', + agentStatus: { state: 'working' } + }) + + runtime.onPtyData(ptyId, '\x1b]0;⠹ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(50) + expect(publications).toHaveLength(1) + + await vi.advanceTimersByTimeAsync(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, '\x1b]0;⠸ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(50) + expect(publications).toHaveLength(2) + unsubscribe() + }) + + it('clears delayed ownership markers when tracked terminal state resets', async () => { + let resolveForegroundProcess: (agent: string | null) => void = () => undefined + const foregroundProcess = new Promise((resolve) => { + resolveForegroundProcess = resolve + }) + const runtime = new OrcaRuntimeService() + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-0' }), + write: () => true, + kill: () => true, + getForegroundProcess: () => foregroundProcess + }) + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Pi\x07', Date.now()) + await vi.advanceTimersByTimeAsync(0) + expect(internals.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId)).toBe(true) + + internals.resetTrackedTerminalStateForProviderGeneration(ptyId) + expect(internals.ptyDelayedForegroundSnapshotTitleObservations.has(ptyId)).toBe(false) + resolveForegroundProcess(null) + await vi.advanceTimersByTimeAsync(0) + }) + + it('renews retained hook status without resetting its state start', () => { + const runtime = new OrcaRuntimeService() + const ptyId = seedWorktree(runtime, 0) + const internals = runtime as unknown as RuntimeInternals + const seededTab = internals.mobileSessionTabsByWorktree.get('workspace-0')?.tabs[0] + if (seededTab?.type !== 'terminal') { + throw new Error('expected seeded terminal') + } + runtime.registerPty(ptyId, 'workspace-0', null, { + tabId: seededTab.parentTabId, + leafId: seededTab.leafId + }) + const publications: RuntimeMobileSessionTabsResult[] = [] + let viewerState = makeViewerState() + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + const patch = applyFreshWebSessionTabsSnapshot( + viewerState, + snapshot, + ENVIRONMENT_ID, + Date.now() + ) + viewerState = { ...viewerState, ...patch } + }) + + runtime.onPtyData( + ptyId, + '\x1b]9999;{"state":"working","prompt":"retained work","agentType":"codex"}\x07', + Date.now() + ) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const initialTerminal = publications[0]?.tabs[0] + const initialStatus = + initialTerminal?.type === 'terminal' ? initialTerminal.agentStatus : undefined + expect(initialStatus).toMatchObject({ state: 'working', prompt: 'retained work' }) + publications.length = 0 + + vi.advanceTimersByTime(10) + runtime.onPtyData(ptyId, '\x1b]0;⠋ Codex working\x07', Date.now()) + vi.advanceTimersByTime(50) + expect(publications).toHaveLength(1) + const titledTerminal = publications[0]?.tabs[0] + const titledStatus = + titledTerminal?.type === 'terminal' ? titledTerminal.agentStatus : undefined + expect(titledStatus).toMatchObject({ state: 'working', prompt: 'retained work' }) + expect(titledStatus?.stateStartedAt).toBe(initialStatus?.stateStartedAt) + publications.length = 0 + + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, '\x1b]0;⠙ Codex working\x07', Date.now()) + vi.advanceTimersByTime(50) + + expect(publications).toHaveLength(1) + const refreshedTerminal = publications[0]?.tabs[0] + const refreshedStatus = + refreshedTerminal?.type === 'terminal' ? refreshedTerminal.agentStatus : undefined + expect(refreshedStatus).toMatchObject({ state: 'working', prompt: 'retained work' }) + expect(refreshedStatus?.updatedAt).toBeGreaterThan(initialStatus!.updatedAt) + expect(refreshedStatus?.stateStartedAt).toBe(initialStatus?.stateStartedAt) + expect(initialStatus!.updatedAt + 1).toBeGreaterThanOrEqual(refreshedStatus!.stateStartedAt) + + publications.length = 0 + vi.advanceTimersByTime(SESSION_TABS_AGENT_STATUS_HEARTBEAT_INTERVAL_MS) + runtime.onPtyData(ptyId, '\x1b]0;⠹ Codex working\x07', Date.now()) + vi.advanceTimersByTime(50) + + expect(publications).toHaveLength(1) + const expiredHookTerminal = publications[0]?.tabs[0] + const titleOnlyStatus = + expiredHookTerminal?.type === 'terminal' ? expiredHookTerminal.agentStatus : undefined + expect(titleOnlyStatus).toMatchObject({ state: 'working', prompt: '' }) + expect(titleOnlyStatus?.updatedAt).toBeGreaterThan(refreshedStatus!.updatedAt) + expect(titleOnlyStatus?.stateStartedAt).toBe(initialStatus?.stateStartedAt) + expect(Date.now() - initialStatus!.updatedAt).toBeGreaterThan(AGENT_STATUS_STALE_AFTER_MS) + const viewerStatus = Object.values(viewerState.agentStatusByPaneKey)[0] + expect(viewerStatus?.prompt).toBe('') + expect(isExplicitAgentStatusFresh(viewerStatus!, Date.now(), AGENT_STATUS_STALE_AFTER_MS)).toBe( + true + ) + unsubscribe() + }) +}) diff --git a/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts new file mode 100644 index 00000000000..2c60fe19082 --- /dev/null +++ b/tests/e2e/session-tabs-rich-status-boundaries.unit.test.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTabsSnapshot +} from '../../src/shared/runtime-types' + +const PTY_ID = 'pty-0' +const WORKTREE_ID = 'workspace-0' +const TAB_ID = 'host-tab-0' +const LEAF_ID = '00000000-0000-4000-8000-000000000000' + +type TerminalTab = Extract + +type RuntimeInternals = { + mobileSessionTabsByWorktree: Map + ptysById: Map + resetTrackedTerminalStateForProviderGeneration: (ptyId: string) => void +} + +type Harness = { + internals: RuntimeInternals + publications: RuntimeMobileSessionTabsResult[] + runtime: OrcaRuntimeService + tab: TerminalTab + unsubscribe: () => void +} + +function createHarness(): Harness { + const runtime = new OrcaRuntimeService() + runtime.registerPty(PTY_ID, WORKTREE_ID) + const tab: TerminalTab = { + type: 'terminal', + id: `${TAB_ID}::${LEAF_ID}`, + parentTabId: TAB_ID, + leafId: LEAF_ID, + ptyId: PTY_ID, + title: 'Cursor Agent', + parentLayout: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }, + isActive: true + } + const internals = runtime as unknown as RuntimeInternals + internals.mobileSessionTabsByWorktree.set(WORKTREE_ID, { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-0', + activeTabId: tab.id, + activeTabType: 'terminal', + tabs: [tab] + }) + const publications: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + publications.push(structuredClone(snapshot)) + }) + return { internals, publications, runtime, tab, unsubscribe } +} + +function setRichStatus( + tab: TerminalTab, + args: { agentType?: 'claude' | 'cursor'; prompt: string; at: number } +): void { + tab.agentStatus = { + state: 'working', + prompt: args.prompt, + updatedAt: args.at, + stateStartedAt: args.at, + agentType: args.agentType ?? 'cursor', + paneKey: tab.id, + stateHistory: [] + } +} + +function emitTitle(harness: Harness, title: string, at = Date.now()): void { + harness.runtime.onPtyData(PTY_ID, `\x1b]0;${title}\x07`, at) + vi.advanceTimersByTime(50) +} + +function latestStatus(harness: Harness) { + const terminal = harness.publications.at(-1)?.tabs[0] + return terminal?.type === 'terminal' ? terminal.agentStatus : undefined +} + +describe('session-tabs rich-status boundaries', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('does not renew a previous task across a done-to-working title boundary', () => { + const harness = createHarness() + const firstWorkingAt = Date.now() + setRichStatus(harness.tab, { prompt: 'previous task', at: firstWorkingAt }) + emitTitle(harness, '⠋ Cursor Agent', firstWorkingAt) + vi.advanceTimersByTime(1) + emitTitle(harness, 'bash') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const nextWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', nextWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: nextWorkingAt, + stateStartedAt: nextWorkingAt + }) + harness.unsubscribe() + }) + + it('preserves the same task across a neutral title', () => { + const harness = createHarness() + const workingAt = Date.now() + setRichStatus(harness.tab, { prompt: 'current task', at: workingAt }) + emitTitle(harness, '⠋ Cursor Agent', workingAt) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + emitTitle(harness, 'Terminal') + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: 'current task', + updatedAt: workingAt, + stateStartedAt: workingAt + }) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + const resumedWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', resumedWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: 'current task', + updatedAt: resumedWorkingAt, + stateStartedAt: workingAt + }) + harness.unsubscribe() + }) + + it('does not resurrect a task after neutral then shell titles', () => { + const harness = createHarness() + setRichStatus(harness.tab, { prompt: 'completed task', at: Date.now() }) + emitTitle(harness, '⠋ Cursor Agent') + vi.advanceTimersByTime(1) + emitTitle(harness, 'Terminal') + vi.advanceTimersByTime(1) + emitTitle(harness, 'bash') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const nextWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', nextWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: nextWorkingAt, + stateStartedAt: nextWorkingAt + }) + harness.unsubscribe() + }) + + it('does not carry a stale question into the next title interval', () => { + const harness = createHarness() + const firstWorkingAt = Date.now() + harness.tab.agentStatus = { + state: 'waiting', + prompt: 'previous question', + interactivePrompt: '{"question":"previous question"}', + updatedAt: firstWorkingAt, + stateStartedAt: firstWorkingAt, + agentType: 'cursor', + paneKey: harness.tab.id, + stateHistory: [] + } + emitTitle(harness, 'Cursor Agent needs confirmation', firstWorkingAt) + vi.advanceTimersByTime(1) + emitTitle(harness, 'bash') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + emitTitle(harness, '⠋ Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'working', prompt: '' }) + expect(latestStatus(harness)).not.toHaveProperty('interactivePrompt') + harness.unsubscribe() + }) + + it('keeps a renderer question visible under a shell title until the next agent interval', () => { + const harness = createHarness() + const questionAt = Date.now() + harness.tab.agentStatus = { + state: 'waiting', + prompt: 'current question', + interactivePrompt: '{"question":"current question"}', + updatedAt: questionAt, + stateStartedAt: questionAt, + agentType: 'cursor', + paneKey: harness.tab.id, + stateHistory: [] + } + + emitTitle(harness, 'bash') + expect(latestStatus(harness)).toMatchObject({ + state: 'waiting', + interactivePrompt: '{"question":"current question"}' + }) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + emitTitle(harness, '⠋ Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'working', prompt: '' }) + expect(latestStatus(harness)).not.toHaveProperty('interactivePrompt') + harness.unsubscribe() + }) + + it('keeps a retained OSC question visible under a shell title until the next agent interval', () => { + const harness = createHarness() + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: harness.tab.parentTabId, + leafId: harness.tab.leafId + }) + harness.runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"waiting","prompt":"current question","interactivePrompt":"question-payload","agentType":"cursor"}\x07', + Date.now() + ) + + emitTitle(harness, 'bash') + expect(latestStatus(harness)).toMatchObject({ + state: 'waiting', + interactivePrompt: 'question-payload' + }) + + harness.publications.length = 0 + vi.advanceTimersByTime(1) + emitTitle(harness, '⠋ Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'working', prompt: '' }) + expect(latestStatus(harness)).not.toHaveProperty('interactivePrompt') + harness.unsubscribe() + }) + + it('does not resurrect retained status after an identity-only owner change', () => { + const harness = createHarness() + harness.runtime.registerPty(PTY_ID, WORKTREE_ID, null, { + tabId: harness.tab.parentTabId, + leafId: harness.tab.leafId + }) + harness.runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","prompt":"retained task","agentType":"codex"}\x07', + Date.now() + ) + vi.advanceTimersByTime(50) + emitTitle(harness, '⠋ Codex working') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + emitTitle(harness, 'Cursor Agent') + expect(latestStatus(harness)).toMatchObject({ state: 'done', prompt: '' }) + harness.unsubscribe() + }) + + it('does not renew rich status from a replaced provider generation', () => { + const harness = createHarness() + const firstWorkingAt = Date.now() + setRichStatus(harness.tab, { prompt: 'previous provider task', at: firstWorkingAt }) + emitTitle(harness, '⠋ Cursor Agent', firstWorkingAt) + harness.tab.agentStatus!.updatedAt = Date.now() + harness.internals.resetTrackedTerminalStateForProviderGeneration(PTY_ID) + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const replacementWorkingAt = Date.now() + emitTitle(harness, '⠙ Cursor Agent', replacementWorkingAt) + expect(latestStatus(harness)).toMatchObject({ + state: 'working', + prompt: '', + updatedAt: replacementWorkingAt, + stateStartedAt: replacementWorkingAt + }) + harness.unsubscribe() + }) + + it('keeps invalidation across an identity-only owner change', () => { + const harness = createHarness() + setRichStatus(harness.tab, { + prompt: 'previous owner task', + at: Date.now(), + agentType: 'claude' + }) + emitTitle(harness, '⠋ Claude working') + harness.publications.length = 0 + + vi.advanceTimersByTime(1) + const identityChangeAt = Date.now() + emitTitle(harness, 'Cursor Agent', identityChangeAt) + expect(harness.internals.ptysById.get(PTY_ID)?.lastAgentStatusRichInvalidatedAtEpochMs).toBe( + identityChangeAt + ) + expect(latestStatus(harness)).toBeUndefined() + harness.unsubscribe() + }) +}) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index 775f7cc5f62..fe39cfef709 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -50,6 +50,11 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\\''")}'` } +function encodedRemoteNodeCommand(script: string): string { + const encoded = Buffer.from(script, 'utf8').toString('base64') + return `node -e ${shellQuote(`eval(Buffer.from('${encoded}', 'base64').toString('utf8'))`)}` +} + function remoteTypingLoadScript(runId: string): string { return [ "process.stdin.setEncoding('utf8')", @@ -368,7 +373,9 @@ test.describe('Docker SSH relay perf', () => { await waitForActiveTerminalManager(orcaPage, 60_000) const beforePtyId = await waitForActivePanePtyId(orcaPage, 60_000) const beforeMarker = `SSH_RECONNECT_BEFORE_${Date.now()}` - await execInTerminal(orcaPage, beforePtyId, `printf ${shellQuote(beforeMarker)}`) + const beforeCommand = encodedRemoteNodeCommand(`process.stdout.write('${beforeMarker}\\n')`) + expect(beforeCommand).not.toContain(beforeMarker) + await execInTerminal(orcaPage, beforePtyId, beforeCommand) await waitForTerminalOutput(orcaPage, beforeMarker, 20_000, 60_000) const recoveryStartedMarker = `SSH_RECONNECT_RECOVERY_STARTED_${Date.now()}` const recoveryMarker = `SSH_RECONNECT_RECOVERY_${Date.now()}` @@ -382,7 +389,10 @@ test.describe('Docker SSH relay perf', () => { `if (frame === 256) { clearInterval(timer); process.stdout.write('${recoveryMarker}\\n') }`, '}, 10)' ].join(';') - await execInTerminal(orcaPage, beforePtyId, `node -e ${shellQuote(recoveryScript)}`) + const recoveryCommand = encodedRemoteNodeCommand(recoveryScript) + expect(recoveryCommand).not.toContain(recoveryStartedMarker) + expect(recoveryCommand).not.toContain(recoveryMarker) + await execInTerminal(orcaPage, beforePtyId, recoveryCommand) await waitForTerminalOutput(orcaPage, recoveryStartedMarker, 30_000, 80_000) await reconnectDockerSshRelayTarget(orcaPage, remote.targetId) @@ -392,11 +402,16 @@ test.describe('Docker SSH relay perf', () => { await waitForTerminalOutput(orcaPage, recoveryMarker, 30_000, 80_000) const afterMarker = `SSH_RECONNECT_AFTER_${Date.now()}` const remoteProofPath = `/tmp/${afterMarker}` - await execInTerminal( - orcaPage, - afterPtyId, - `printf ${shellQuote(afterMarker)} | tee ${shellQuote(remoteProofPath)}` + const afterCommand = encodedRemoteNodeCommand( + [ + "const fs = require('node:fs')", + `const marker = '${afterMarker}'`, + `fs.writeFileSync('${remoteProofPath}', marker)`, + "process.stdout.write(marker + '\\n')" + ].join(';') ) + expect(afterCommand).not.toContain(afterMarker) + await execInTerminal(orcaPage, afterPtyId, afterCommand) await waitForTerminalOutput(orcaPage, afterMarker, 20_000, 60_000) expect(execDockerSshRelayTargetCommand(target, `cat ${shellQuote(remoteProofPath)}`)).toBe( afterMarker diff --git a/tests/e2e/tab-rename.spec.ts b/tests/e2e/tab-rename.spec.ts index a7063f9af21..6e7f0a7fdc1 100644 --- a/tests/e2e/tab-rename.spec.ts +++ b/tests/e2e/tab-rename.spec.ts @@ -308,6 +308,56 @@ test.describe('Tab Rename (Inline)', () => { await expect(renameInput).toBeHidden() }) + test('terminal title updates do not resize neighboring tabs', async ({ orcaPage }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const tabIds = await orcaPage.evaluate((targetWorktreeId) => { + const state = window.__store!.getState() + const existing = state.tabsByWorktree[targetWorktreeId] ?? [] + for (let index = existing.length; index < 3; index += 1) { + state.createTab(targetWorktreeId, undefined, undefined, { activate: false }) + } + const ids = (window.__store!.getState().tabsByWorktree[targetWorktreeId] ?? []) + .slice(0, 3) + .map((tab) => tab.id) + ids.forEach((id, index) => state.setTabCustomTitle(id, `Tab ${index + 1}`)) + return ids + }, worktreeId) + + const tabs = tabIds.map((id) => + orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${id}"]`) + ) + await expect(tabs[2]!).toBeVisible() + const before = await Promise.all( + tabs.map((tab) => tab.evaluate((element) => element.getBoundingClientRect().width)) + ) + // Why: at the 88px shrink floor widths are stable for the wrong reason, and being above it is + // also what proves the definite tab width applied — so this fails first on a regression. + expect( + Math.min(...before), + 'tabs must be above the 88px shrink floor for the stability check to mean anything' + ).toBeGreaterThan(88) + + await orcaPage.evaluate( + ({ tabId }) => { + window + .__store!.getState() + .setTabCustomTitle( + tabId, + 'Continuously changing generated terminal title that must remain constrained' + ) + }, + { tabId: tabIds[0] } + ) + await expect(tabs[0]!).toContainText('Continuously changing generated terminal title') + const after = await Promise.all( + tabs.map((tab) => tab.evaluate((element) => element.getBoundingClientRect().width)) + ) + + after.forEach((width, index) => expect(Math.abs(width - before[index]!)).toBeLessThanOrEqual(1)) + await tabs[2]!.click() + await expect(tabs[2]!).toHaveAttribute('data-active', 'true') + }) + test('rename input stays at a usable width when many tabs are open', async ({ orcaPage }) => { const worktreeId = (await getActiveWorktreeId(orcaPage))! const targetTabId = await getActiveTabId(orcaPage) diff --git a/tests/e2e/terminal-ai-vault-typing-latency.spec.ts b/tests/e2e/terminal-ai-vault-typing-latency.spec.ts new file mode 100644 index 00000000000..4fd46be33b6 --- /dev/null +++ b/tests/e2e/terminal-ai-vault-typing-latency.spec.ts @@ -0,0 +1,230 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + focusActiveTerminalInput, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + collectCodexEchoLatencyReport, + installCodexEchoLatencyProbe, + summarizeLatencies, + type CodexEchoProbeReport, + type LatencyDistribution +} from './codex-composer-echo-latency-probe' +import { seedVaultTranscriptBatch, typingEchoScript } from './ai-vault-typing-bench-corpus' +import { + readVaultRefreshDuration, + startRendererJankProbe, + stopRendererJankProbe, + triggerVaultRefresh, + type RendererJank +} from './ai-vault-typing-bench-renderer-probe' + +const BENCH_ENABLED = process.env.ORCA_AI_VAULT_TYPING_BENCH === '1' +const RESULTS_DIR = path.resolve(__dirname, '..', 'tools', 'benchmarks', 'results') + +function readPositiveInt(name: string, fallback: number): number { + const value = Number(process.env[name]) + return Number.isInteger(value) && value > 0 ? value : fallback +} + +const ITERATIONS = readPositiveInt('ORCA_AI_VAULT_BENCH_ITERATIONS', 3) +const SESSION_COUNT = readPositiveInt('ORCA_AI_VAULT_BENCH_SESSIONS', 300) +const PAYLOAD_KIB = readPositiveInt('ORCA_AI_VAULT_BENCH_PAYLOAD_KIB', 128) +const KEY_COUNT = readPositiveInt('ORCA_AI_VAULT_BENCH_KEYS', 100) +const KEY_CADENCE_MS = readPositiveInt('ORCA_AI_VAULT_BENCH_CADENCE_MS', 30) +const BENCH_LABEL = process.env.ORCA_AI_VAULT_BENCH_LABEL ?? 'dev' +const TYPING_ALPHABET = 'abcdefghijklmnopqrstuvwxyz' + +type ArmResult = { + iteration: number + scenario: 'control' | 'vault-refresh' + order: number + refreshDurationMs: number | null + echo: CodexEchoProbeReport + parse: LatencyDistribution + render: LatencyDistribution + missingEchoCount: number + rendererJank: RendererJank +} + +async function openAiVaultSidebar(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + store.getState().setRightSidebarOpen(true) + store.getState().setRightSidebarTab('vault') + }) + const refresh = page.getByRole('button', { name: 'Refresh Session History' }) + await expect(refresh).toBeVisible() + await expect(refresh).toBeEnabled({ timeout: 30_000 }) +} + +async function typeAtCadence(page: Page, target: string): Promise { + for (const char of target) { + const startedAt = performance.now() + await page.keyboard.type(char) + const remaining = KEY_CADENCE_MS - (performance.now() - startedAt) + if (remaining > 0) { + await page.waitForTimeout(remaining) + } + } +} + +async function runArm(args: { + page: Page + ptyId: string + scriptPath: string + iteration: number + scenario: ArmResult['scenario'] + order: number +}): Promise { + const readyMarker = `VAULT_TYPING_READY_${randomUUID()}` + writeFileSync(args.scriptPath, typingEchoScript(readyMarker)) + await sendToTerminal(args.page, args.ptyId, `node ${JSON.stringify(args.scriptPath)}\r`) + await waitForTerminalOutput(args.page, readyMarker, 15_000) + const target = TYPING_ALPHABET.repeat(Math.ceil(KEY_COUNT / TYPING_ALPHABET.length)).slice( + 0, + KEY_COUNT + ) + await installCodexEchoLatencyProbe(args.page, target) + await startRendererJankProbe(args.page) + if (args.scenario === 'vault-refresh') { + await triggerVaultRefresh(args.page) + } + await focusActiveTerminalInput(args.page) + await typeAtCadence(args.page, target) + if (args.scenario === 'vault-refresh') { + await expect(args.page.getByRole('button', { name: 'Refresh Session History' })).toBeEnabled({ + timeout: 120_000 + }) + } + const refreshDurationMs = + args.scenario === 'vault-refresh' ? await readVaultRefreshDuration(args.page) : null + await args.page.waitForTimeout(100) + const echo = await collectCodexEchoLatencyReport(args.page) + const rendererJank = await stopRendererJankProbe(args.page) + await sendToTerminal(args.page, args.ptyId, '\x03').catch(() => undefined) + const parse = summarizeLatencies(echo.samples.map((sample) => sample.keyToParseMs)) + const render = summarizeLatencies( + echo.samples.flatMap((sample) => (sample.keyToRenderMs === null ? [] : [sample.keyToRenderMs])) + ) + return { + iteration: args.iteration, + scenario: args.scenario, + order: args.order, + refreshDurationMs, + echo, + parse, + render, + missingEchoCount: KEY_COUNT - echo.samples.length, + rendererJank + } +} + +function aggregate(arms: ArmResult[], scenario: ArmResult['scenario']): object { + const selected = arms.filter((arm) => arm.scenario === scenario) + return { + parse: summarizeLatencies( + selected.flatMap((arm) => arm.echo.samples.map((s) => s.keyToParseMs)) + ), + render: summarizeLatencies( + selected.flatMap((arm) => arm.echo.samples.flatMap((s) => s.keyToRenderMs ?? [])) + ), + maxTimerDriftMs: Math.max(...selected.map((arm) => arm.rendererJank.maxTimerDriftMs)), + maxFrameGapMs: Math.max(...selected.map((arm) => arm.rendererJank.maxFrameGapMs)), + maxLongTaskMs: Math.max(...selected.map((arm) => arm.rendererJank.maxLongTaskMs)), + refresh: summarizeLatencies(selected.flatMap((arm) => arm.refreshDurationMs ?? [])), + missingEchoCount: selected.reduce((sum, arm) => sum + arm.missingEchoCount, 0) + } +} + +function writeReport(testInfo: TestInfo, arms: ArmResult[], seededBytes: number): string { + const report = { + benchmark: 'terminal-ai-vault-typing-latency', + label: BENCH_LABEL, + timestamp: new Date().toISOString(), + config: { + iterations: ITERATIONS, + sessionCount: SESSION_COUNT, + payloadKib: PAYLOAD_KIB, + keyCount: KEY_COUNT, + keyCadenceMs: KEY_CADENCE_MS + }, + seededBytes, + aggregate: { + control: aggregate(arms, 'control'), + vaultRefresh: aggregate(arms, 'vault-refresh') + }, + arms + } + mkdirSync(RESULTS_DIR, { recursive: true }) + const stamp = report.timestamp.replace(/[:.]/g, '-') + const outPath = path.join(RESULTS_DIR, `ai-vault-typing-${BENCH_LABEL}-${stamp}.json`) + writeFileSync(outPath, JSON.stringify(report, null, 2)) + testInfo.annotations.push({ type: 'ai-vault-typing-bench', description: outPath }) + console.log(`[ai-vault-typing] report ${outPath}`) + console.log(`[ai-vault-typing] ${JSON.stringify(report.aggregate)}`) + return outPath +} + +test.describe('Terminal typing during AI Vault refresh bench', () => { + test.setTimeout(10 * 60 * 1000) + + test('alternates control typing and forced Vault refresh typing', async ({ + electronApp, + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:ai-vault-typing') + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await openAiVaultSidebar(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + const homePath = await electronApp.evaluate(({ app }) => app.getPath('home')) + const scriptPath = path.join(testRepoPath, `.orca-vault-typing-${randomUUID()}.mjs`) + const arms: ArmResult[] = [] + let seededBytes = 0 + + try { + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const batch = seedVaultTranscriptBatch({ + homePath, + cwd: testRepoPath, + batch: iteration, + sessionCount: SESSION_COUNT, + payloadBytes: PAYLOAD_KIB * 1024 + }) + seededBytes += batch.totalBytes + const scenarios: ArmResult['scenario'][] = + iteration % 2 === 0 ? ['control', 'vault-refresh'] : ['vault-refresh', 'control'] + for (const [order, scenario] of scenarios.entries()) { + arms.push(await runArm({ page: orcaPage, ptyId, scriptPath, iteration, scenario, order })) + if (scenario === 'vault-refresh') { + await expect( + orcaPage.getByText(batch.newestTitle, { exact: true }).first() + ).toBeVisible({ + timeout: 30_000 + }) + } + } + } + writeReport(testInfo, arms, seededBytes) + expect(arms.every((arm) => arm.missingEchoCount === 0)).toBe(true) + } finally { + await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined) + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs b/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs new file mode 100644 index 00000000000..dfdcfe6cf13 --- /dev/null +++ b/tests/tools/benchmarks/mutation-receipt-capacity-bench.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import { DatabaseSync } from 'node:sqlite' +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' + +const ROW_LIMIT = 10_000 +const PRUNE_BATCH_SIZE = 64 + +function parseArgs(argv) { + const options = { iterations: 32, payloadBytes: 9_500 } + for (let index = 2; index < argv.length; index += 1) { + const value = argv[index + 1] + if (argv[index] === '--iterations') { + options.iterations = Number(value) + } else if (argv[index] === '--payload-bytes') { + options.payloadBytes = Number(value) + } else { + throw new Error(`Unknown argument: ${argv[index]}`) + } + index += 1 + } + if (!Number.isSafeInteger(options.iterations) || options.iterations < 1) { + throw new Error('--iterations must be a positive integer') + } + if (!Number.isSafeInteger(options.payloadBytes) || options.payloadBytes < 0) { + throw new Error('--payload-bytes must be a non-negative integer') + } + return options +} + +function createFixture(path, payloadBytes, optimized) { + const db = new DatabaseSync(path) + db.exec(` + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE mutation_receipts ( + caller_fingerprint TEXT NOT NULL, + request_id TEXT NOT NULL, + method TEXT NOT NULL, + payload_hash TEXT NOT NULL, + state TEXT NOT NULL, + receipt BLOB, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (caller_fingerprint, request_id) + ); + WITH RECURSIVE receipt_numbers(value) AS ( + VALUES (1) + UNION ALL + SELECT value + 1 FROM receipt_numbers WHERE value < ${ROW_LIMIT} + ) + INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state, receipt + ) + SELECT 'caller', printf('request_%05d', value), 'orchestration.send', + printf('hash_%05d', value), 'completed', zeroblob(${payloadBytes}) + FROM receipt_numbers; + `) + if (optimized) { + db.exec(` + CREATE INDEX idx_mutation_receipts_completed_updated + ON mutation_receipts(updated_at) WHERE state = 'completed'; + CREATE TABLE mutation_receipt_ledger ( + singleton INTEGER PRIMARY KEY, + receipt_count INTEGER NOT NULL + ); + INSERT INTO mutation_receipt_ledger VALUES (1, ${ROW_LIMIT}); + CREATE TRIGGER mutation_receipts_count_insert AFTER INSERT ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger SET receipt_count = receipt_count + 1; + END; + CREATE TRIGGER mutation_receipts_count_delete AFTER DELETE ON mutation_receipts + BEGIN + UPDATE mutation_receipt_ledger SET receipt_count = receipt_count - 1; + END; + `) + } + db.exec('PRAGMA wal_checkpoint(TRUNCATE)') + return db +} + +function runLegacyMutation(db, iteration) { + db.exec('BEGIN IMMEDIATE') + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' AND updated_at < datetime('now', '-30 days')` + ).run() + const { count } = db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() + const completedToRemove = count - ROW_LIMIT + 1 + if (completedToRemove > 0) { + db.prepare( + `DELETE FROM mutation_receipts WHERE rowid IN ( + SELECT rowid FROM mutation_receipts WHERE state = 'completed' + ORDER BY updated_at, rowid LIMIT ? + )` + ).run(completedToRemove) + } + db.prepare('SELECT COUNT(*) AS count FROM mutation_receipts').get() + insertReceipt(db, iteration) + db.exec('COMMIT') +} + +function runOptimizedMutation(db, iteration) { + db.exec('BEGIN IMMEDIATE') + db.prepare( + `DELETE FROM mutation_receipts + WHERE state = 'completed' AND updated_at < datetime('now', '-30 days')` + ).run() + const { receipt_count: count } = db + .prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1') + .get() + if (count >= ROW_LIMIT) { + db.prepare( + `DELETE FROM mutation_receipts WHERE rowid IN ( + SELECT rowid FROM mutation_receipts WHERE state = 'completed' + ORDER BY updated_at, rowid LIMIT ? + )` + ).run(count - ROW_LIMIT + PRUNE_BATCH_SIZE) + } + db.prepare('SELECT receipt_count FROM mutation_receipt_ledger WHERE singleton = 1').get() + insertReceipt(db, iteration) + db.exec('COMMIT') +} + +function insertReceipt(db, iteration) { + db.prepare( + `INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES ('benchmark', ?, 'orchestration.send', ?, 'pending')` + ).run(`new_${iteration}`, `new_hash_${iteration}`) +} + +function percentile(sorted, fraction) { + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] +} + +function measure(db, iterations, mutation) { + const samplesMs = [] + for (let index = 0; index < iterations; index += 1) { + const startedAt = performance.now() + mutation(db, index) + samplesMs.push(performance.now() - startedAt) + } + const sorted = samplesMs.toSorted((left, right) => left - right) + return { + firstMs: samplesMs[0], + medianMs: percentile(sorted, 0.5), + p95Ms: percentile(sorted, 0.95), + maxMs: sorted.at(-1), + totalMs: samplesMs.reduce((sum, value) => sum + value, 0) + } +} + +const options = parseArgs(process.argv) +const fixtureDir = mkdtempSync(join(tmpdir(), 'orca-mutation-receipt-bench-')) +try { + const legacyPath = join(fixtureDir, 'legacy.db') + const optimizedPath = join(fixtureDir, 'optimized.db') + const legacyDb = createFixture(legacyPath, options.payloadBytes, false) + const optimizedDb = createFixture(optimizedPath, options.payloadBytes, true) + const databaseBytes = statSync(legacyPath).size + const legacy = measure(legacyDb, options.iterations, runLegacyMutation) + const optimized = measure(optimizedDb, options.iterations, runOptimizedMutation) + legacyDb.close() + optimizedDb.close() + + process.stdout.write( + `${JSON.stringify( + { + rows: ROW_LIMIT, + payloadBytes: options.payloadBytes, + databaseBytes, + iterations: options.iterations, + legacy, + optimized, + medianSpeedup: legacy.medianMs / optimized.medianMs, + totalSpeedup: legacy.totalMs / optimized.totalMs + }, + null, + 2 + )}\n` + ) +} finally { + rmSync(fixtureDir, { recursive: true, force: true }) +}