diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 218ff6a6c82..58cdbb36d1c 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -11,6 +11,7 @@ on: - 'config/scripts/computer-use-smoke.mjs' - 'config/scripts/computer-use-smoke.test.mjs' - 'config/scripts/daemon-boot-smoke.mjs' + - 'config/scripts/windows-daemon-workspace-close-repro.mjs' - 'config/scripts/verify-computer-native.mjs' # Why: the native-smoke job boots the built terminal daemon under plain # Node, so any change to the daemon bundle graph or the main build must @@ -111,6 +112,11 @@ jobs: # the PR when the built daemon cannot start on ubuntu-22.04 / windows. - name: Daemon boot smoke run: node config/scripts/daemon-boot-smoke.mjs + # Why: workspace removal overlaps graceful renderer teardown with the + # forced main-process sweep; exercise that exact pair against real ConPTY. + - name: Windows daemon workspace-close repro + if: runner.os == 'Windows' + run: node config/scripts/windows-daemon-workspace-close-repro.mjs - if: runner.os == 'Linux' env: ORCA_COMPUTER_E2E: '1' diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 464b814e711..30940476103 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -969,7 +969,7 @@ "id": "terminal-platform.windows-conpty-liveness", "title": "Windows ConPTY terminals stay input-live, render-live, and geometry-live", "maturity": "experimental", - "protection": "none", + "protection": "partial", "owner": "terminal-platform", "layer": "windows-electron-conpty", "surfaces": [ @@ -987,34 +987,77 @@ "daemon", "wsl" ], - "coveredPlatforms": [], - "coveredProviders": [], - "coverageNotes": "Registered gap only; no executable coverage is wired yet.", + "coveredPlatforms": [ + "windows" + ], + "coveredProviders": [ + "daemon" + ], + "coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/pull/6541", "https://github.com/stablyai/orca/pull/6858", "https://github.com/stablyai/orca/pull/6876", "https://github.com/stablyai/orca/pull/6968", "https://github.com/stablyai/orca/pull/6970", - "https://github.com/stablyai/orca/pull/6999" + "https://github.com/stablyai/orca/pull/6999", + "https://github.com/stablyai/orca/issues/8048" + ], + "invariant": "Windows local and daemon terminals must spawn with the intended shell, survive overlapping graceful/forced workspace teardown without affecting unrelated PTYs, retain recovered scrollback across the fresh daemon's first checkpoint, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.", + "oracle": "The issue #8048 slice asserts one node-pty ConPTY close for a graceful-then-force sequence, atomically seeds recovered history before fresh shell output and re-anchoring, preserves recovery after seed failure plus adapter restart, and runs 25 built-daemon close races while checking victim session/PID reaping, a stable daemon PID, and a live witness PTY. A broader Windows live gate still needs shell input, resize, cursor, and CJK/wide-glyph pixel evidence.", + "commands": [ + "pnpm vitest run src/main/daemon/pty-subprocess.test.ts src/main/daemon/daemon-pty-adapter.test.ts", + "pnpm build:electron-vite && node config/scripts/windows-daemon-workspace-close-repro.mjs", + "node config/scripts/windows-daemon-workspace-close-repro.mjs" + ], + "testFiles": [ + "src/main/daemon/pty-subprocess.test.ts", + "src/main/daemon/daemon-pty-adapter.test.ts", + "config/scripts/windows-daemon-workspace-close-repro.mjs" + ], + "assertionRefs": [ + { + "file": "src/main/daemon/pty-subprocess.test.ts", + "assertions": [ + "graceful kill followed by force and dispose invokes Windows node-pty kill exactly once and never retries the dead child PID" + ] + }, + { + "file": "src/main/daemon/daemon-pty-adapter.test.ts", + "assertions": [ + "the first checkpoint orders recovered scrollback before synchronously emitted fresh-shell startup output", + "a failed atomic history seed remains non-authoritative across adapter restart and cannot overwrite the recovery files" + ] + }, + { + "file": "config/scripts/windows-daemon-workspace-close-repro.mjs", + "assertions": [ + "all 25 victim sessions and OS PIDs are reaped while the built daemon PID and an unrelated witness PowerShell remain alive" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "windows", + "command": "node config/scripts/windows-daemon-workspace-close-repro.mjs", + "result": "passed", + "durationSeconds": 7.6, + "summary": "All 25 victim sessions and OS PIDs were reaped while the built daemon and witness PTY survived the real ConPTY workspace-close races. The double-close, history ordering, and seed-failure restart regressions produced intentional red failures before their fixes and passed afterward." + } ], - "invariant": "Windows local and daemon terminals must spawn with the intended shell, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.", - "oracle": "A Windows live gate proves shell echo, command submission, keyboard reset, resize readback, cursor visibility, and CJK/wide-glyph redraw using PTY input logs plus visible buffer/pixel evidence.", - "commands": [], - "testFiles": [], - "assertionRefs": [], - "evidenceRuns": [], "runtimeBudget": { "p95Seconds": 90, "scope": "Windows focused Electron ConPTY gate" }, "flakeHistory": { - "status": "not-started", - "evidence": "Windows live E2E remains a known weak area; gate must soak before blocking." + "status": "unknown", + "evidence": "The built-daemon issue #8048 harness passed locally once and is wired into Windows PR CI; it needs repeated CI history before promotion." }, "redGreenEvidence": { - "status": "missing", - "evidence": "Needs proof against stale keyboard-protocol mode, shell resolution failure, dropped resize, and CJK repaint regression." + "status": "partial", + "evidence": "The ConPTY double-close and cold-restore re-anchor assertions were each observed failing before the fix and passing afterward. Keyboard protocol, shell resolution, resize, and CJK repaint still need red/green proof." }, "performanceBudget": { "required": true, @@ -1026,8 +1069,8 @@ "Split shell parity, keyboard reset, and CJK repaint into smaller gates if a combined gate is flaky." ], "knownGaps": [ - "No manifest command yet.", - "Real IME composition may require a separate lower-layer/native-text-forwarding gate." + "Real IME composition may require a separate lower-layer/native-text-forwarding gate.", + "The built-daemon harness proves process/session liveness but not renderer pixels; visible shell input, resize, cursor, and CJK repaint remain uncovered." ], "demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only." }, @@ -2700,6 +2743,109 @@ "Buffer-clean, pixels-stale variants are otherwise invisible to every buffer oracle in the manifest." ], "demotionRule": "Drop from the golden suite back to nightly diagnostic if diff thresholds cannot be kept stable across CI runners." + }, + { + "id": "pty-delivery.renderer-lifecycle-accounting-reset", + "title": "Renderer lifecycle reset clears leaked PTY delivery accounting so surviving PTYs cannot freeze", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-runtime", + "layer": "main-unit", + "surfaces": [ + "terminal output delivery", + "renderer reload / crash recovery", + "surviving daemon and local PTYs" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Local macOS unit evidence over the shared main-process delivery pipeline in registerPtyHandlers. The oracle drives a local mock PTY, but the in-flight/pending accounting is provider-agnostic and daemon PTYs ride the identical pipeline (the manual dev repro that proved the freeze was daemon-backed). SSH is a separate relay/credit path and never enters these counters; mobile/relay ride unaffected paths; WSL terminals ride this same local/daemon pipeline and are covered by the same accounting (no dedicated provider-contract test yet). Motivated by a production incident on v1.4.131 (three frozen/broken terminal panes in one desktop session) diagnosed to leaked in-flight/pending accounting across a renderer reload (rendererGraphEpoch 3). Live validation of the fix on a rebuilt dev instance surfaced a second leak mode (the boot window): after the reset ran, main resumed flushing a flooding PTY into the still-booting page before its pty:data listener re-registered, so those sends were dropped yet counted and re-pinned rendererInFlightChars at 524288. The gate now also covers holding sends until the renderer's pty:rendererDispatcherReady handshake. A watchdog self-heals a lost handshake: on each reset a one-shot ~10s timer arms and, if the handshake never arrives, force-opens the gate (rendererDispatcherReadyForcedCount increments) so a dropped handshake degrades to pre-handshake behavior instead of a permanent hold; the real handshake or a re-registration cancels it. Two new diagnostics — rendererPtyDispatcherReady and rendererDispatcherReadyForcedCount — expose the otherwise-invisible boot-window hold, which early-returns before ackGatedFlushSkipCount increments.", + "motivatingLinks": [ + // #5787 introduced the renderer-reload delivery-accounting path this gate hardens; + // kept for lineage, not the primary motivation. THIS fix is motivated by the + // v1.4.131 three-frozen-panes incident (see coverageNotes). + "https://github.com/stablyai/orca/pull/5787", + "https://github.com/stablyai/orca/pull/8034" + ], + "invariant": "After a renderer lifecycle reset (did-start-loading / render-process-gone / destroyed), no surviving PTY remains delivery-gated by pre-reset unacked bytes: main's in-flight counters and pending backlog equal the true state of the new page (zero in-flight, zero pending). Delivery then resumes only once the reloaded page's pty:data dispatcher re-registers and signals pty:rendererDispatcherReady; during the boot window before that handshake main holds all sends (data accrues losslessly in the capped pending backlog) so bytes cannot be dropped into a listener-less page and re-pin the gate. The hold itself cannot become a permanent freeze: a one-shot ~10s watchdog armed on each reset force-opens the gate (incrementing rendererDispatcherReadyForcedCount) if the handshake is lost, and the real handshake or a re-registration cancels it. The reset fires only for a main-frame load: did-start-loading also fires for in-page subframe loads (sandboxed srcDoc iframes in notebook HTML output), which are filtered out via isLoadingMainFrame() so a subframe load never clears accounting or holds the gate on the still-alive page. If a lifecycle-reset edge is missed entirely — a main-frame reload overlapped by an in-page subframe load emits no did-start-loading at all — a backstop still recovers: because the handshake is one-shot per page load, receiving pty:rendererDispatcherReady while the gate is already open proves a reset was missed (or the watchdog force-opened the gate), so the handler reconciles by clearing the stale accounting before re-opening. The renderer sends that handshake exactly once per page load, after its pty:data listener registers.", + "oracle": "Ingest more than 512 KB of PTY output with no renderer ACKs and assert the per-PTY gate closes (sends stop at the 512 KB high-water, remainder accrues as pending). Fire the registered did-start-loading listener and assert rendererInFlightChars and pendingChars are zero and the new diagnostics record the reset (rendererLifecycleResetCount 1, lastLifecycleResetClearedChars 512 KB). Then, before any dispatcher-ready handshake, ingest another chunk and assert it is NOT sent and NOT counted in-flight (held for the boot window, accruing in pending). Finally fire the pty:rendererDispatcherReady handshake and assert the held chunk is delivered to the renderer. Counters-zero without proving both the boot-window hold and that delivery resumes is insufficient. Additional cases prove the boot-window hold also covers the interactive direct-send fast path (input-primed keystroke echo is held, not sent, until the handshake) and that the self-heal watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) when no handshake arrives, while a timely handshake cancels the watchdog and leaves no orphaned timer. A further case fires did-start-loading with isLoadingMainFrame() false (a subframe/iframe load) and asserts accounting is untouched (rendererLifecycleResetCount stays 0, pending preserved, ready stays true) and delivery still drains on ACK — proving an in-page iframe load cannot trigger a spurious freeze. A backstop case saturates the gate, then fires pty:rendererDispatcherReady while the gate is still open (ready true) with no preceding reset — modeling a missed lifecycle edge — and asserts the handler reconciles: in-flight and pending clear, rendererLifecycleResetCount increments, and fresh output flows immediately (a straggler ACK is clamped and cannot underflow). A renderer-side case (pty-dispatcher-pi-routing.test.ts) asserts ensurePtyDispatcher() sends pty:rendererDispatcherReady exactly once across two attach calls — proving the send fires (it is optional-chained) and the one-shot guard holds.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts" + ], + "testFiles": [ + "src/main/ipc/pty.test.ts", + "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/ipc/pty.test.ts", + "assertions": [ + "a PTY saturated past the 512 KB per-PTY high-water with no ACKs stops sending and accrues pending output (gate closed)", + "firing the registered did-start-loading listener zeroes rendererInFlightChars and pendingData and records rendererLifecycleResetCount and lastLifecycleResetClearedChars", + "after the reset, output ingested during the boot window is NOT sent and NOT counted in-flight until the pty:rendererDispatcherReady handshake fires (held in pending)", + "firing the pty:rendererDispatcherReady handshake releases the held backlog and delivery resumes (delivery gated on the handshake, not just counters cleared)", + "interactive input-primed keystroke echo is also held during the boot window (interactive fast path gated on the handshake) and delivered once it fires", + "when no handshake arrives, the ~10s watchdog force-opens the gate (rendererDispatcherReadyForcedCount 1) and the held backlog drains; a timely handshake cancels the watchdog and leaves no orphaned timer", + "a did-start-loading with isLoadingMainFrame() false (in-page subframe/iframe load) does NOT reset accounting (rendererLifecycleResetCount stays 0, pending and in-flight preserved, rendererPtyDispatcherReady stays true) and delivery still drains on ACK", + "a pty:rendererDispatcherReady handshake arriving while the gate is still open (ready true, no preceding reset — a missed lifecycle edge) reconciles the stale accounting: in-flight and pending clear, rendererLifecycleResetCount increments, fresh output flows, and a straggler ACK is clamped", + "re-registering handlers (macOS re-activate / new window) cancels the prior registration's armed dispatcher-ready watchdog via the cross-registration bridge, leaving no orphaned ~10s timer to force-open a dead window's gate" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts", + "assertions": [ + "ensurePtyDispatcher() sends pty:rendererDispatcherReady exactly once across two attach calls (the optional-chained send fires, and the ptyDispatcherAttached one-shot guard prevents a re-signal)" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-09", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts", + "result": "passed", + "durationSeconds": 1, + "summary": "243 tests passed (232 main-process + 11 renderer dispatcher) including the lifecycle-reset, boot-window (dispatcher-ready handshake), interactive-gate hold, watchdog self-heal, main-frame-filter (subframe did-start-loading is ignored), missed-reset reconcile backstop (handshake-while-open), cross-registration watchdog-cancel, and renderer-side one-shot handshake-send regressions. Removing the reset call reproduces the reload freeze (rendererInFlightChars stays 524288); removing the send-hold reproduces the boot-window leak; removing the interactive-path flag check sends keystroke echo into the not-yet-ready page; removing the watchdog arm leaves the gate held forever; removing the watchdog cancel leaves an orphaned timer after the handshake; removing the isLoadingMainFrame filter lets a subframe iframe load run a spurious reset; removing the handshake-while-open reconcile leaves the survivors pinned at 524288 after a missed lifecycle edge." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "local main-process unit test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "Newly registered with this fix; needs soak history before any promotion. The oracle is deterministic (fake timers, mock webContents)." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Locally verified red/green on every load-bearing branch: (1) reset call removed -> rendererInFlightChars stays 524288 after did-start-loading; (2) boot-window send-hold removed -> post-reload output is sent into the not-yet-ready page ('NOT sent until handshake' fails); (3) interactive-path flag check removed -> input-primed keystroke echo is sent during the hold; (4) watchdog arm removed -> the gate is never force-opened and the held backlog never drains; (5) watchdog cancel removed -> an orphaned ~10s timer survives the handshake (getTimerCount 1); (6) isLoadingMainFrame filter removed -> a subframe did-start-loading runs a spurious reset (rendererLifecycleResetCount 1, pending cleared, ready dropped) — locally verified red; (7) handshake-while-open reconcile removed -> a pty:rendererDispatcherReady arriving after a missed lifecycle edge leaves the gate pinned (rendererInFlightChars stays 524288, pending 90112, rendererLifecycleResetCount 0) — locally verified red; (8) cross-registration bridge cancel removed (top-of-registerPtyHandlers clearRendererDispatcherReadyWatchdog) -> a prior registration's armed watchdog survives re-registration as an orphaned timer (getTimerCount 1 instead of 0) — locally verified red. With the full fix all eight are green. The performance budget below still holds: the watchdog is a single unref'd one-shot per reset, not per-chunk. Needs a saved CI or intentional-break artifact before blocking promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "The reset is an O(#ptys) map clear on rare lifecycle events (reload/crash) plus one unref'd one-shot ~10s watchdog timer per reset (cancelled by the handshake or a re-registration); no polling and no per-chunk work. Any PR changing the delivery gate, in-flight accounting, or flush loop must re-measure terminal throughput before promotion." + }, + "promotionCriteria": [ + "Run in soak for at least 100 consecutive passes or 14 days across required CI platforms.", + "Attach saved red/green evidence from removing the lifecycle-reset accounting clear.", + "Add an Electron-level reload survival test that proves a saturated pane resumes rendering and input after Cmd+R / crash recovery." + ], + "knownGaps": [ + "The unit oracle drives a local mock PTY; daemon and WSL coverage rides the same pipeline but has no dedicated provider-contract test yet.", + "The dev repro covers the output-delivery freeze only; the arrow-key input symptom and mouse-mode flood observed in the same incident are out of scope and need their own repros.", + "No live Electron reload-survival assertion yet — the gate proves the main-side accounting reset, not the full renderer reattach paint." + ], + "demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner." } ] } diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs index 3d4fbaecddd..3ef41583217 100644 --- a/config/scripts/computer-e2e-workflow.test.mjs +++ b/config/scripts/computer-e2e-workflow.test.mjs @@ -127,6 +127,23 @@ describe('computer-use e2e workflow', () => { expect(daemonSmokeIndex).toBeGreaterThan(buildIndex) }) + it('runs the Windows workspace-close daemon repro after the main build', () => { + const workflow = parse( + readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') + ) + const steps = workflow.jobs['native-smoke'].steps + const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite') + const reproIndex = steps.findIndex( + (step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs' + ) + + expect(reproIndex).toBeGreaterThan(buildIndex) + expect(steps[reproIndex].if).toBe("runner.os == 'Windows'") + expect(workflow.on.pull_request.paths).toContain( + 'config/scripts/windows-daemon-workspace-close-repro.mjs' + ) + }) + it('re-runs the native-smoke job when the daemon bundle graph changes', () => { const workflow = parse( readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8') @@ -136,6 +153,7 @@ describe('computer-use e2e workflow', () => { expect(triggerPaths).toEqual( expect.arrayContaining([ 'config/scripts/daemon-boot-smoke.mjs', + 'config/scripts/windows-daemon-workspace-close-repro.mjs', 'electron.vite.config.ts', 'build-plugins/**', 'src/main/daemon/**' diff --git a/config/scripts/windows-daemon-workspace-close-repro.mjs b/config/scripts/windows-daemon-workspace-close-repro.mjs new file mode 100644 index 00000000000..be920ca7192 --- /dev/null +++ b/config/scripts/windows-daemon-workspace-close-repro.mjs @@ -0,0 +1,271 @@ +/** + * Reproduces issue #8048 against the built daemon on Windows. + * + * A witness PowerShell stays alive while victim sessions receive the same + * graceful-then-immediate kill pair emitted when Orca closes a workspace. + * The daemon PID and witness session must survive every iteration. + */ +import { fork } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { connect } from 'node:net' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const projectDir = resolve(import.meta.dirname, '../..') +const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js') +const iterations = Number(process.env.ORCA_WINDOWS_DAEMON_CLOSE_ITERATIONS ?? 25) +const requestTimeoutMs = 15_000 + +function log(message) { + process.stdout.write(`[windows-daemon-workspace-close] ${message}\n`) +} + +function readProtocolVersion() { + const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8') + const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/) + if (!match) { + throw new Error('Could not read the daemon protocol version') + } + return Number(match[1]) +} + +function createRpcClient(socketPath, tokenPath) { + const socket = connect(socketPath) + const pending = new Map() + let buffer = '' + let requestId = 0 + let helloResolve + let helloReject + const hello = new Promise((resolveHello, rejectHello) => { + helloResolve = resolveHello + helloReject = rejectHello + }) + + const rejectPending = (error) => { + helloReject(error) + for (const { reject, timer } of pending.values()) { + clearTimeout(timer) + reject(error) + } + pending.clear() + } + + socket.on('error', rejectPending) + socket.on('close', () => rejectPending(new Error('Daemon control socket closed'))) + socket.on('data', (chunk) => { + buffer += chunk.toString('utf8') + let newline = buffer.indexOf('\n') + while (newline !== -1) { + const line = buffer.slice(0, newline) + buffer = buffer.slice(newline + 1) + const message = JSON.parse(line) + if (message.type === 'hello') { + if (message.ok) { + helloResolve() + } else { + helloReject(new Error(message.error ?? 'Daemon rejected hello')) + } + } else if (message.id) { + const request = pending.get(message.id) + if (request) { + pending.delete(message.id) + clearTimeout(request.timer) + if (message.ok) { + request.resolve(message.payload) + } else { + request.reject(new Error(message.error ?? 'Daemon request failed')) + } + } + } + newline = buffer.indexOf('\n') + } + }) + + const connected = new Promise((resolveConnected, rejectConnected) => { + socket.once('connect', resolveConnected) + socket.once('error', rejectConnected) + }).then(() => { + socket.write( + `${JSON.stringify({ + type: 'hello', + version: readProtocolVersion(), + token: readFileSync(tokenPath, 'utf8').trim(), + clientId: randomUUID(), + role: 'control' + })}\n` + ) + return hello + }) + + return { + async request(type, payload) { + await connected + const id = `repro-${++requestId}` + return new Promise((resolveRequest, rejectRequest) => { + const timer = setTimeout(() => { + pending.delete(id) + rejectRequest(new Error(`Daemon request ${type} timed out`)) + }, requestTimeoutMs) + pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }) + socket.write(`${JSON.stringify({ id, type, ...(payload ? { payload } : {}) })}\n`) + }) + }, + close() { + socket.destroy() + } + } +} + +function waitForReady(child, stderr) { + return new Promise((resolveReady, rejectReady) => { + const timer = setTimeout( + () => rejectReady(new Error(`Daemon readiness timed out.\n${stderr()}`)), + requestTimeoutMs + ) + child.on('message', (message) => { + if (message?.type === 'ready') { + clearTimeout(timer) + resolveReady() + } + }) + child.once('exit', (code, signal) => { + clearTimeout(timer) + rejectReady( + new Error(`Daemon exited before readiness (code=${code}, signal=${signal}).\n${stderr()}`) + ) + }) + }) +} + +async function stopChild(child) { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const exited = new Promise((resolveExit) => child.once('exit', resolveExit)) + child.kill('SIGTERM') + await Promise.race([ + exited, + new Promise((resolveTimeout) => + setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + resolveTimeout() + }, 5_000) + ) + ]) +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + if (error?.code === 'ESRCH') { + return false + } + throw error + } +} + +async function waitForVictimExit(rpc, sessionId, pid) { + const deadline = Date.now() + requestTimeoutMs + while (Date.now() < deadline) { + const { sessions } = await rpc.request('listSessions') + const sessionAlive = sessions.some((session) => session.sessionId === sessionId) + if (!sessionAlive && !isProcessAlive(pid)) { + return sessions + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)) + } + throw new Error(`Victim ${sessionId} or OS pid ${pid} was not reaped`) +} + +async function main() { + if (process.platform !== 'win32') { + log('SKIP: Windows ConPTY is required') + return + } + if (!existsSync(entryPath)) { + throw new Error(`Missing ${entryPath}; run pnpm build:electron-vite first`) + } + + const scratch = mkdtempSync(join(tmpdir(), 'orca-windows-daemon-close-')) + const socketPath = `\\\\.\\pipe\\orca-daemon-close-${process.pid}-${randomUUID()}` + const tokenPath = join(scratch, 'daemon.token') + const daemonLogPath = join(scratch, 'daemon.log') + const child = fork( + entryPath, + ['--socket', socketPath, '--token', tokenPath, '--log-file', daemonLogPath], + { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + windowsHide: true, + env: { ...process.env, ORCA_USER_DATA_PATH: scratch } + } + ) + const daemonPid = child.pid + let stderr = '' + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8') + }) + let rpc + + try { + await waitForReady(child, () => stderr) + rpc = createRpcClient(socketPath, tokenPath) + const witnessId = `repro-witness@@${randomUUID().slice(0, 8)}` + await rpc.request('createOrAttach', { + sessionId: witnessId, + cols: 80, + rows: 24, + cwd: projectDir, + shellOverride: 'powershell.exe' + }) + + for (let index = 0; index < iterations; index += 1) { + const victimId = `repro-victim-${index}@@${randomUUID().slice(0, 8)}` + const victim = await rpc.request('createOrAttach', { + sessionId: victimId, + cols: 80, + rows: 24, + cwd: projectDir, + shellOverride: 'powershell.exe' + }) + if (!Number.isInteger(victim.pid) || victim.pid <= 0) { + throw new Error(`Victim ${victimId} did not return a valid OS pid`) + } + + // Why: sending both RPCs before awaiting either preserves the renderer + // unmount/worktree-sweep overlap that produced issue #8048. + const graceful = rpc.request('kill', { sessionId: victimId, immediate: false }) + const forced = rpc.request('kill', { sessionId: victimId, immediate: true }) + await Promise.all([graceful, forced]) + + const sessions = await waitForVictimExit(rpc, victimId, victim.pid) + if (child.pid !== daemonPid || child.exitCode !== null) { + throw new Error(`Daemon PID ${daemonPid} exited while closing victim ${index}`) + } + if (!sessions.some((session) => session.sessionId === witnessId && session.isAlive)) { + throw new Error(`Witness PTY disappeared while closing victim ${index}`) + } + } + + await rpc.request('kill', { sessionId: witnessId, immediate: true }) + log( + `PASS: ${iterations} victim sessions/PIDs were reaped while daemon ${daemonPid} and the witness PTY survived` + ) + } catch (error) { + const daemonLog = existsSync(daemonLogPath) ? readFileSync(daemonLogPath, 'utf8') : '' + throw new Error(`${error.message}\nstderr:\n${stderr}\ndaemon.log:\n${daemonLog}`) + } finally { + rpc?.close() + await stopChild(child) + rmSync(scratch, { recursive: true, force: true }) + } +} + +main().catch((error) => { + process.stderr.write(`[windows-daemon-workspace-close] FAIL: ${error.message}\n`) + process.exitCode = 1 +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index bb7f03ef094..c6cde5794e1 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.3m + + downloads: 4.4m @@ -15,7 +15,7 @@ downloads downloads - 4.3m - 4.3m + 4.4m + 4.4m diff --git a/docs/wrapped-terminal-file-link-fragments.md b/docs/wrapped-terminal-file-link-fragments.md new file mode 100644 index 00000000000..ac1e68ec54a --- /dev/null +++ b/docs/wrapped-terminal-file-link-fragments.md @@ -0,0 +1,115 @@ +# Wrapped Terminal File-Link Fragments + +## Problem + +A file path hard-wrapped between terminal rows is not clickable when the continuation row also contains sibling content. In the reported three-link line, the middle path ends the first row and continues at the start of the second, while the first and third paths remain clickable. + +The provider builds hard-wrap candidates in `wrapped-terminal-link-ranges.ts:172-224`, and both hover and direct modifier-click consume them through `terminal-link-handlers.ts:106-135` and `terminal-file-link-hit-testing.ts:100-109`. + +## Root cause + +`buildHardWrappedPathLogicalLineCandidates` trims and joins whole physical rows. A continuation row is accepted only when the entire trimmed row is a path fragment (`wrapped-terminal-link-ranges.ts:195-203`). A row such as `transparent-...png · validation-screenshots/03-after-light-theme.png` therefore stops reconstruction. Orca probes the two incomplete middle fragments separately, rejects both as nonexistent, and retains only the complete first and third paths. + +## Non-goals + +- Changing file-path parsing, filesystem existence semantics, tooltip copy, or open routing. +- Joining arbitrary prose, spaced paths, or multiple sibling links into one path. +- Relying on xterm soft-wrap metadata for output that was hard-wrapped by an agent or TUI. +- Adding a new IPC method, bypassing the existing existence cache, or changing local/SSH/runtime routing. + +## Design + +1. Add a focused regression using the exact three-link/two-row shape. Make the existence stub return true only for the three complete paths, then assert that provider calls for either physical row return the same middle link and exact multi-row range. Assert that no candidate/link spans either `·` separator. +2. Reuse the existing conservative hard-wrap fragment alphabet. From a possible first row, slice its maximal fragment suffix; append zero or more continuation rows only while their whole trimmed text is a fragment; then slice the maximal fragment prefix from the first mixed-content row and stop. The only suffixes accepted without a path-name character are an exact POSIX root (`/`), one backslash for the first half of a UNC root, a bare ASCII drive prefix such as `C:`, and the complete relative prefixes `./`, `../`, and `~/`. A boundary candidate is emitted only when it covers the requested row, has at least two non-empty row fragments, and the fully joined text passes the existing path-start predicate. It may end at the first proper prefix slice of a mixed row or at the last available whole-fragment row; the latter is limited to whitelisted incomplete starts and is skipped when whole-row reconstruction already emitted the same text. Existing whole-row candidates remain responsible for ordinary/deep hard wraps, including a mixed starting row followed only by whole-fragment rows. +3. Slice `columns` with each fragment so ranges retain the original xterm cells. Build the async-staleness fingerprint from each source row's full translated text and metadata as well as the selected slice; changing a sibling token must invalidate an in-flight result even if the reconstructed path is unchanged. +4. Generate at most one boundary candidate per scanned start row—never every suffix/prefix combination. Preserve the existing bounds of 20 possible start rows and 20 rows per candidate, logical-line deduplication, and longest-non-overlapping-link selection. The existing builder can emit up to 210 whole-row candidates in its all-fragment worst case; this change may add at most 20 boundary candidates, not another quadratic set. +5. Keep existence validation in the provider's current local/SSH/runtime path and cache. The valid reconstructed path necessarily adds its desired existence lookup compared with the broken behavior; do not add probes for arbitrary suffix/prefix combinations or change existing overlap/cache behavior in this focused fix. +6. Verify direct modifier-click fallback from both halves. This path shares the candidate builder but remains synchronous and uses its existing cache/known-root preference before `openDetectedFilePath` performs normal routing checks. + +## Data flow + +- xterm buffer row under hover/click +- bounded hard-wrap start/candidate windows and conservative endpoint slicing +- whole-row candidates plus at most one boundary candidate per start row +- existing terminal file-link parser +- existing local/SSH/runtime path resolution and existence cache +- mapped multi-row xterm range +- hover tooltip or modifier-click open + +## Edge cases + +- A row boundary immediately after POSIX `/`, drive prefix `C:`, the first `\` of a UNC path, or complete `./`, `../`, and `~/` prefixes must reconstruct the complete path. Other punctuation-only suffixes and bare prose tokens remain ineligible, and the joined text must independently satisfy the full path-start predicate. +- A continuation prefix may end before a separator (`·`), prose, or a sibling path; none of that suffix may enter the reconstructed candidate. +- A starting suffix may begin after prose or a sibling path; its original xterm column must be retained. +- Rows containing only one path fragment must keep the existing deep (up to 20 rows) reconstruction behavior. +- Soft-wrapped rows, Unicode/multi-code-unit column mappings, known worktree roots, and spaced paths must remain unchanged. In particular, this change does not broaden fragment extraction to whitespace-containing paths. +- Full-row source fingerprints must reject stale async results when text inside or outside the selected fragment changes. +- Provider calls for remote paths must still use the owning pane's runtime environment or SSH connection and the connection-scoped existence-cache key; fragment extraction itself must not assume a local filesystem. +- Incomplete fragment combinations remain filtered by the existing filesystem existence check. + +## Test plan + +- Candidate/range unit: cover the exact suffix/prefix slices, original xterm columns, full-row fingerprint changes, at most one added boundary candidate per start row, and no candidate spanning a sibling separator. +- Provider integration: add the exact reported three-link regression to `terminal-link-handlers.test.ts`; across provider calls for both physical rows, assert all three complete links, the same middle range from each call, no incomplete or giant merged link, and that the complete middle path reaches the normal existence check. +- Direct click: exercise hit-testing on the first and second physical halves of the middle path and assert both route the same complete path. +- Compatibility: cover a backslash/drive-letter wrapped path and a remote-runtime or SSH existence call, proving the reconstructed path keeps the owning connection/environment. +- Regression: run `wrapped-terminal-link-ranges.test.ts`, `terminal-link-handlers.test.ts`, and terminal-link parser tests. +- Static: run formatter/check, web typecheck, lint, max-lines ratchet, and relevant repository checks. +- Electron: render the exact text at the reproduced 133-column terminal width; verify pointer/tooltip and modifier-click from both middle fragments, then smoke-test the first and third links. + +## UI quality bar + +No visual styling changes. The exact same terminal text and layout must render without overlap, clipping, or altered wrapping. The only visible behavior change is that both physical halves of the middle path show the same pointer affordance and tooltip and activate the same file, consistent with the first and third links and `docs/STYLEGUIDE.md` interaction guidance. + +## Review screenshots + +1. Before, on the base revision: full Electron window hovering the broken middle path at the reproduced width (no tooltip/link affordance). +2. After: full Electron window hovering the first physical half of the middle path, with tooltip visible. +3. After: full Electron window hovering the continuation half, with the same tooltip/path visible. +4. After adjacent-feature smoke: full Electron window hovering the first or third complete sibling link. + +## Rollout + +1. Add the failing exact-shape regression and range/click assertions. +2. Implement boundary-fragment candidate extraction and cell mapping. +3. Run focused tests and static checks. +4. Validate the exact scenario in Electron and capture screenshots. +5. Open an unmerged PR. + +## Lightweight Eng Review + +- Scope: limited to hard-wrapped path candidate reconstruction; parser, routing, cache, and UI styling stay unchanged. +- Architecture/data flow: the shared candidate builder remains the single boundary for hover and click behavior, so local, daemon, SSH, and remote runtime flows receive identical ranges before their existing existence checks. +- Failure modes covered: + - sibling links accidentally merged into one spaced path + - only one physical half hit-tests + - incorrect xterm columns after slicing + - Windows separators rejected + - deep single-fragment rows regress + - extra remote/local existence probes on hover +- Test coverage required: + - exact three-link provider regression from both hovered rows + - exact range boundary assertions + - direct click from both halves + - existing deep-wrap, Unicode, stale-result, parser, SSH/runtime tests +- Performance/blast radius: preserve the current 20-start-row/20-rows-per-candidate bounds (up to 210 existing whole-row candidates). Boundary extraction is linear per examined row and adds at most 20 candidates, only where a mixed continuation stops whole-row reconstruction. Resolving the previously missing complete path adds the intended cached existence check; the change adds no IPC method and does not alter local/remote routing or cache keys. +- UI quality bar: unchanged rendering and style; consistent pointer, tooltip, and activation across both middle fragments, checked in the real Electron terminal against the style guide. +- Required review screenshots: + 1. exact three-link full-window baseline + 2. middle first-half hover/tooltip + 3. middle continuation-half hover/tooltip + 4. first/third sibling hover smoke +- Residual risks: local macOS is the available live Electron environment; Windows separator and SSH/runtime behavior require automated coverage and shared-code review. + +## Terminal Reliability Proof + +- Reliability class: `terminal-link.path-boundary-reconstruction`; the broader manifest entry `xterm-addon.boundary-containment` is related but does not register file-link correctness, so this remains an explicit accepted manifest gap rather than changing that gate's scope in a bug fix. +- Product change type: renderer runtime hardening with deterministic regression coverage. +- Invariant: one logical hard-wrapped file path maps to the same original xterm cells and owning local/SSH/runtime context from either physical row, without absorbing sibling text. +- Failure source: the reproduced three-link line where only the first and third links were clickable. +- Oracle: both provider row calls return the same complete middle path/range; direct hit-testing on either half opens that path; root-, drive-, and UNC-boundary candidates retain their exact xterm ranges; no emitted boundary candidate contains `·`. +- Provider/platform matrix: local and SSH provider behavior covered; Windows separators/cell mapping covered; daemon and remote-runtime use the same builder and routing but are not live-tested; Linux, Windows, WSL, mobile/relay are accepted live-validation gaps. +- Performance budget: the scan stays capped at 20 starts and 20 rows per candidate, emits at most one boundary candidate per start, and rejects non-path starts before reading possible continuation rows. No polling, timers, listeners, subprocesses, or new IPC methods are added; only the newly valid path reaches the existing cached existence probe. +- Diagnostics: the full source-row fingerprint rejects stale async results; deterministic range/provider tests are the regression breadcrumb. No new product telemetry or raw terminal logging is warranted. +- Gate status: no manifest entry added or promoted. Revisit only if this parser grows beyond bounded local row reconstruction or the regression recurs outside the covered provider/platform matrix. +- Rollback/demotion rule: revert boundary-fragment reconstruction if Electron shows sibling-path merging, incorrect hit regions, or material hover latency; keep the exact red regression as the behavioral oracle. diff --git a/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx new file mode 100644 index 00000000000..50c62205cd7 --- /dev/null +++ b/mobile/app/h/[hostId]/agent-history/[worktreeId].tsx @@ -0,0 +1,18 @@ +import { useLocalSearchParams } from 'expo-router' +import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/MobileAgentSessionHistoryPanel' +import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state' + +export default function MobileAgentSessionHistoryScreen() { + const params = useLocalSearchParams<{ + hostId?: string | string[] + worktreeId?: string | string[] + name?: string | string[] + }>() + return ( + + ) +} diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 71bda7b64dc..7fb6a1f6eea 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -14,7 +14,6 @@ import { Search, X, Pin, - GitBranch, List, SlidersHorizontal, Layers, @@ -52,6 +51,7 @@ import { useActiveWorktreeScroll } from '../../../src/hooks/use-active-worktree- import type { RepoIcon } from '../../../../src/shared/repo-icon' import { PickerModal } from '../../../src/components/PickerModal' import { ActionSheetContent } from '../../../src/components/ActionSheetModal' +import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen' @@ -182,6 +182,7 @@ export function HostScreen({ const [showGroupPicker, setShowGroupPicker] = useState(false) const [showFilterModal, setShowFilterModal] = useState(false) const [actionTarget, setActionTarget] = useState(null) + const [hostCapabilities, setHostCapabilities] = useState([]) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) const [routeActionState, setRouteActionState] = useState(() => @@ -513,6 +514,10 @@ export function HostScreen({ // format is in place to flip a switch in a future release. useEffect(() => { if (connState !== 'connected' || !client) { + // Why: drop the prior host's capabilities while disconnected/switching so + // a capability-gated action (e.g. Agent Session History) can't linger for + // a host that doesn't support it. + setHostCapabilities([]) return } let cancelled = false @@ -524,9 +529,13 @@ export function HostScreen({ return } if (!response.ok) { + setHostCapabilities([]) return } - const status = (response as RpcSuccess).result as DesktopStatus + const status = (response as RpcSuccess).result as DesktopStatus & { + capabilities?: string[] + } + setHostCapabilities(status.capabilities ?? []) const verdict = evaluateCompat({ desktopProtocolVersion: status.protocolVersion, desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion @@ -1359,20 +1368,14 @@ export function HostScreen({ actions={ actionTarget ? [ - { - label: 'Source Control', - icon: GitBranch, - onPress: () => { - const params = new URLSearchParams({ - name: actionTarget.displayName || actionTarget.repo, - origin: 'host' - }) - navigateFromHostList( - `/h/${hostId}/source-control/${encodeURIComponent(actionTarget.worktreeId)}?${params.toString()}` - ) - setActionTarget(null) - } - }, + ...buildWorktreeNavigationActions({ + hostId, + worktreeId: actionTarget.worktreeId, + worktreeName: actionTarget.displayName || actionTarget.repo, + hostCapabilities, + navigate: navigateFromHostList, + onDone: () => setActionTarget(null) + }), { label: 'Sleep', icon: Moon, diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 2043f28e1a9..b881ddebabb 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -36,9 +36,9 @@ import { GitBranch, Globe, Keyboard as KeyboardIcon, - ListChecks, MessageSquare, Monitor, + MoreHorizontal, Plus, RefreshCw, Send, @@ -71,10 +71,14 @@ import { type ActivePanel, canDockSessionPanel, resolvePanelAction, + shouldShowSessionHeaderChecksAction, panelRouteDescriptor } from '../../../../src/session/session-panel-host' import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' +import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton' +import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet' +import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability' import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types' import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result' import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation' @@ -940,6 +944,7 @@ export default function SessionScreen() { useState('idle') const [createTabAgentOptions, setCreateTabAgentOptions] = useState([]) const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false) + const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false) const [actionTarget, setActionTarget] = useState(null) const [markdownActionTarget, setMarkdownActionTarget] = useState(null) + // Why: hosts without aiVault.v1 reject aiVault.listSessions, so the header + // entry stays hidden there (mirrors the gated host-list action) instead of + // opening a dead-end "update this host" panel. + const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState( + null + ) // Why: stable callbacks (handleFileTap) read the live value via this ref, since // the capability probe resolves after the callbacks are created. const browserScreencastSupportedRef = useRef(browserScreencastSupported) @@ -2416,6 +2427,7 @@ export default function SessionScreen() { useEffect(() => { if (!client || connState !== 'connected') { setBrowserScreencastSupported(null) + setAgentSessionHistorySupported(null) return } let stale = false @@ -2429,10 +2441,14 @@ export default function SessionScreen() { setBrowserScreencastSupported( status.capabilities?.includes('browser.screencast.v1') === true ) + setAgentSessionHistorySupported( + status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true + ) }) .catch(() => { if (!stale) { setBrowserScreencastSupported(false) + setAgentSessionHistorySupported(false) } }) return () => { @@ -4451,6 +4467,19 @@ export default function SessionScreen() { }) } + const openAgentSessionHistory = () => { + const params = new URLSearchParams({ name: worktreeName || '' }) + router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`) + } + const showAgentSessionHistoryAction = + !isFolderWorkspaceRoute && agentSessionHistorySupported === true + const showChecksAction = shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute, + repoContextLoaded: prRepoContextLoaded, + hostedChecksSupported: prIsGithubRepo + }) + const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction + return ( @@ -4486,45 +4515,27 @@ export default function SessionScreen() { - [ - styles.filesButton, - pressed && styles.filesButtonPressed, - activePanel === 'files' && styles.filesButtonActive - ]} - onPress={() => handlePanelTap('files')} - hitSlop={8} + - - + icon={Folder} + onPress={() => handlePanelTap('files')} + /> {!isFolderWorkspaceRoute && ( - [ - styles.filesButton, - pressed && styles.filesButtonPressed, - activePanel === 'sourceControl' && styles.filesButtonActive - ]} - onPress={() => handlePanelTap('sourceControl')} - hitSlop={8} + - - + icon={GitBranch} + onPress={() => handlePanelTap('sourceControl')} + /> )} - {prRepoContextLoaded && prIsGithubRepo ? ( - [ - styles.filesButton, - pressed && styles.filesButtonPressed, - activePanel === 'pr' && styles.filesButtonActive - ]} - onPress={() => handlePanelTap('pr')} - hitSlop={8} - accessibilityLabel="Open pull request" - > - - + {showHeaderMoreButton ? ( + setShowHeaderMoreActions(true)} + /> ) : null} @@ -5141,6 +5152,15 @@ export default function SessionScreen() { + handlePanelTap('pr')} + onClose={() => setShowHeaderMoreActions(false)} + /> + + diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryIcon.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryIcon.tsx new file mode 100644 index 00000000000..75a4409d5a0 --- /dev/null +++ b/mobile/src/agent-history/MobileAgentSessionHistoryIcon.tsx @@ -0,0 +1,48 @@ +import Svg, { Path } from 'react-native-svg' + +type MobileAgentSessionHistoryIconProps = { + size?: number + color?: string + strokeWidth?: number +} + +export function MobileAgentSessionHistoryIcon({ + size = 18, + color = '#000000', + strokeWidth = 2 +}: MobileAgentSessionHistoryIconProps) { + return ( + + {/* Why: desktop uses Tabler's category glyph for Agent Session History. */} + + + + + + + ) +} diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx new file mode 100644 index 00000000000..9bc7ceedd91 --- /dev/null +++ b/mobile/src/agent-history/MobileAgentSessionHistoryList.tsx @@ -0,0 +1,177 @@ +import { useCallback, useMemo, useState } from 'react' +import { ActivityIndicator, Pressable, RefreshControl, SectionList, Text, View } from 'react-native' +import { Play } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { MobileAgentIcon } from '../components/MobileAgentIcon' +import { recentSessionConversationTurns } from '../../../src/shared/ai-vault-session-display' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { MobileAgentHistorySection } from './agent-history-sections' +import type { MobileAgentHistoryCard } from './agent-history-session-card' +import { styles } from './agent-history-styles' + +// Lazy-render at most this many preview turns when a card is tapped — the +// scanner already bounds preview text, but rendering them only on tap keeps the +// list cheap. +const PREVIEW_TURN_LIMIT = 5 + +type Props = { + sections: MobileAgentHistorySection[] + sessionsById: ReadonlyMap + refreshing: boolean + showCurrentWorktreeBadges: boolean + resumeActionStateBySessionId?: ReadonlyMap + onResume?: (session: AiVaultSession) => void | Promise + onRefresh: () => void +} + +export function MobileAgentSessionHistoryList({ + sections, + sessionsById, + refreshing, + showCurrentWorktreeBadges, + resumeActionStateBySessionId, + onResume, + onRefresh +}: Props) { + const [expandedId, setExpandedId] = useState(null) + + const toggleExpanded = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)) + }, []) + + const renderItem = useCallback( + ({ item }: { item: MobileAgentHistoryCard }) => ( + toggleExpanded(item.id)} + /> + ), + [ + expandedId, + onResume, + resumeActionStateBySessionId, + sessionsById, + showCurrentWorktreeBadges, + toggleExpanded + ] + ) + + return ( + card.id} + stickySectionHeadersEnabled={false} + contentContainerStyle={styles.list} + refreshControl={ + + } + renderSectionHeader={({ section }) => ( + + + {section.label} + + {section.data.length} + + )} + renderItem={renderItem} + /> + ) +} + +function AgentHistoryCardRow({ + card, + expanded, + session, + showCurrentWorktreeBadge, + resumeActionState, + onResume, + onPress +}: { + card: MobileAgentHistoryCard + expanded: boolean + session: AiVaultSession | null + showCurrentWorktreeBadge: boolean + resumeActionState?: { disabled: boolean; loading: boolean } + onResume?: (session: AiVaultSession) => void | Promise + onPress: () => void +}) { + const previewTurns = useMemo( + () => (expanded && session ? recentSessionConversationTurns(session, PREVIEW_TURN_LIMIT) : []), + [expanded, session] + ) + + return ( + [styles.card, pressed && styles.cardPressed]} + onPress={onPress} + > + + + + {card.title} + + {card.timeAgo ? {card.timeAgo} : null} + + {card.lastMessage ? ( + + {card.lastMessage} + + ) : null} + + {card.agentLabel} + + {card.messageCount} {card.messageCount === 1 ? 'message' : 'messages'} + + {showCurrentWorktreeBadge && card.isCurrentWorktree ? ( + + current worktree + + ) : null} + {session && onResume ? ( + [ + styles.resumeButton, + resumeActionState?.disabled && styles.resumeButtonDisabled, + pressed && !resumeActionState?.disabled && styles.resumeButtonPressed + ]} + onPress={(event) => { + event.stopPropagation() + if (!resumeActionState?.disabled) { + void onResume(session) + } + }} + disabled={resumeActionState?.disabled} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel="Resume agent session" + > + {resumeActionState?.loading ? ( + + ) : ( + + )} + + ) : null} + + {expanded && previewTurns.length > 0 ? ( + + {previewTurns.map((turn, index) => ( + + {turn.role} + {turn.text} + + ))} + + ) : null} + + ) +} diff --git a/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx new file mode 100644 index 00000000000..8879b783687 --- /dev/null +++ b/mobile/src/agent-history/MobileAgentSessionHistoryPanel.tsx @@ -0,0 +1,421 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native' +import { SafeAreaView } from 'react-native-safe-area-context' +import { useRouter } from 'expo-router' +import { ChevronLeft, RefreshCw } from 'lucide-react-native' +import { colors } from '../theme/mobile-theme' +import { useHostClient } from '../transport/client-context' +import type { RpcSuccess } from '../transport/types' +import type { RpcClient } from '../transport/rpc-client' +import { getWorktreeLabel } from '../session/worktree-label' +import { + buildMobileAiVaultResumeLaunch, + createMobileAiVaultResumeMutationRegistry, + readMobileRuntimeHostPlatform, + readMobileRuntimeTerminalWindowsShell, + resolveMobileAiVaultResumePlatform, + resumeAiVaultSessionInTerminal, + RESUME_RPC_TIMEOUT_MS, + type MobileAiVaultResumeSettings +} from '../session/ai-vault-resume-launch' +import { triggerError, triggerSuccess } from '../platform/haptics' +import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' +import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' +import { buildMobileAgentHistorySections } from './agent-history-sections' +import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge' +import { MobileAgentSessionHistoryList } from './MobileAgentSessionHistoryList' +import { + resolveMobileAiVaultSessionResumeTarget, + type MobileAiVaultResumeFolderWorkspace, + type MobileAiVaultResumeProjectGroup, + type MobileAiVaultResumeRepo +} from './agent-history-resume-target' +import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card' +import { styles } from './agent-history-styles' + +export type MobileAgentSessionHistoryPanelProps = { + hostId: string + worktreeId: string + name?: string +} + +const SCOPE_TABS: { scope: AiVaultScope; label: string }[] = [ + { scope: 'workspace', label: 'Workspace' }, + { scope: 'project', label: 'Project' }, + { scope: 'all', label: 'All' } +] + +export function MobileAgentSessionHistoryPanel({ + hostId, + worktreeId, + name = '' +}: MobileAgentSessionHistoryPanelProps) { + const router = useRouter() + const { client, state: connState } = useHostClient(hostId) + const [worktrees, setWorktrees] = useState([]) + const [worktreesLoaded, setWorktreesLoaded] = useState(false) + const [query, setQuery] = useState('') + const [resumingSessionId, setResumingSessionId] = useState(null) + const [resumeMessage, setResumeMessage] = useState(null) + const resumeLaunchInFlightRef = useRef(false) + const resumeMutationRegistryRef = useRef( + createMobileAiVaultResumeMutationRegistry(createMobileAiVaultResumeMutationId) + ) + const worktreeLabel = getWorktreeLabel(name, worktreeId) + + // Why: the worktree list seeds the host-local scopePaths derivation and the + // active-worktree path for the "current worktree" badge. + useEffect(() => { + if (!client || connState !== 'connected') { + return + } + let cancelled = false + void (async () => { + try { + const worktreeResponse = await client.sendRequest('worktree.ps', { limit: 10000 }) + if (cancelled) { + return + } + if (worktreeResponse.ok) { + const result = (worktreeResponse as RpcSuccess).result as { worktrees: Worktree[] } + setWorktrees(result.worktrees) + } + } catch { + // Why: worktree list is best-effort context; the session scan still runs + // (without it, scoped tabs can't narrow and fall back to the full list). + } finally { + // Why: mark loaded even on failure so a scoped tab proceeds with an + // unscoped fetch instead of holding a spinner forever. + if (!cancelled) { + setWorktreesLoaded(true) + } + } + })() + return () => { + cancelled = true + } + }, [client, connState]) + + const { + scope, + screenState, + refreshing, + hostStatusResult, + activeWorktreePath, + scopeFilterPaths, + onSelectScope, + onRefresh, + retry + } = useMobileAgentHistoryState({ hostId, worktreeId, worktrees, worktreesLoaded }) + + const sessions = screenState.kind === 'ready' ? screenState.sessions : EMPTY_SESSIONS + const issues = screenState.kind === 'ready' ? screenState.issues : EMPTY_ISSUES + const sessionsById = useMemo( + () => new Map(sessions.map((session) => [session.id, session])), + [sessions] + ) + const sections = useMemo( + () => + buildMobileAgentHistorySections(sessions, { + query, + scope, + scopeFilterPaths, + activeWorktreePath, + now: Date.now() + }), + [sessions, query, scope, scopeFilterPaths, activeWorktreePath] + ) + + const hostPlatform = useMemo( + () => readMobileRuntimeHostPlatform(hostStatusResult), + [hostStatusResult] + ) + const hostTerminalWindowsShell = useMemo( + () => readMobileRuntimeTerminalWindowsShell(hostStatusResult), + [hostStatusResult] + ) + + const resumeActionStateBySessionId = useMemo( + () => buildMobileAgentHistoryResumeActionState(sessions, resumingSessionId), + [resumingSessionId, sessions] + ) + + const onResumeSession = useCallback( + async (session: AiVaultSession): Promise => { + if (resumeLaunchInFlightRef.current) { + return + } + if (!client || connState !== 'connected') { + setResumeMessage('Waiting for host...') + triggerError() + return + } + if (!session.sessionId) { + setResumeMessage('This session is missing a resume id.') + triggerError() + return + } + + resumeLaunchInFlightRef.current = true + setResumingSessionId(session.id) + setResumeMessage(null) + try { + const { + repos, + folderWorkspaces, + projectGroups, + settings, + worktrees: freshWorktrees + } = await loadMobileResumeMetadata(client) + const target = resolveMobileAiVaultSessionResumeTarget({ + session, + activeWorktreeId: worktreeId, + // Why: resolve against live worktrees so a workspace deleted or + // archived since panel mount can't be picked; the mount-time list is + // only a fallback when the fresh fetch fails. + worktrees: freshWorktrees ?? worktrees, + repos, + folderWorkspaces, + projectGroups + }) + if (target.status !== 'ready') { + setResumeMessage(target.message) + triggerError() + return + } + + const platform = resolveMobileAiVaultResumePlatform( + target.targetStatus, + hostPlatform, + target.workspacePath, + target.terminalPlatform + ) + if (!platform) { + setResumeMessage('Unable to determine host platform.') + triggerError() + return + } + + const launch = buildMobileAiVaultResumeLaunch({ + session, + hostPlatform: platform, + hostTerminalWindowsShell, + settings + }) + await resumeAiVaultSessionInTerminal(client, target.worktreeId, { + ...launch, + clientMutationId: resumeMutationRegistryRef.current.claim(session.id) + }) + resumeMutationRegistryRef.current.releaseOnSuccess(session.id) + triggerSuccess() + setResumeMessage('Agent session queued.') + router.push( + `/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters< + typeof router.push + >[0] + ) + } catch (err) { + triggerError() + setResumeMessage(err instanceof Error ? err.message : 'Failed to resume session.') + } finally { + resumeLaunchInFlightRef.current = false + setResumingSessionId(null) + } + }, + [ + client, + connState, + hostId, + hostPlatform, + hostTerminalWindowsShell, + router, + worktreeId, + worktrees + ] + ) + + return ( + + + + [styles.backButton, pressed && styles.backButtonPressed]} + onPress={() => router.back()} + hitSlop={8} + accessibilityLabel="Back" + > + + + + + Agent Session History + + + {worktreeLabel} + + + [styles.refreshButton, pressed && styles.refreshButtonPressed]} + onPress={() => void onRefresh()} + hitSlop={8} + accessibilityLabel="Refresh agent sessions" + > + + + + + + {screenState.kind === 'loading' ? ( + + + + ) : screenState.kind === 'unsupported' ? ( + + Agent Session History Unavailable + + Update Orca on this host to browse agent session history. + + + ) : screenState.kind === 'error' ? ( + + Unable to Load + {screenState.message} + + Retry + + + ) : ( + <> + + {SCOPE_TABS.map((tab) => { + const active = scope === tab.scope + return ( + onSelectScope(tab.scope)} + > + + {tab.label} + + + ) + })} + + + + + {issues.length > 0 ? ( + + + {issues.length} {issues.length === 1 ? 'transcript' : 'transcripts'} skipped + + + ) : null} + {resumeMessage ? ( + + {resumeMessage} + + ) : null} + {sections.length === 0 ? ( + + No agent sessions + + {query ? 'No sessions match your search.' : 'No past agent sessions in this scope.'} + + + ) : ( + void onRefresh()} + /> + )} + + )} + + ) +} + +const EMPTY_SESSIONS: AiVaultSession[] = [] +const EMPTY_ISSUES: { agent: AiVaultSession['agent']; path: string; message: string }[] = [] + +async function loadMobileResumeMetadata(client: Pick): Promise<{ + repos: MobileAiVaultResumeRepo[] + folderWorkspaces: MobileAiVaultResumeFolderWorkspace[] + projectGroups: MobileAiVaultResumeProjectGroup[] + settings: MobileAiVaultResumeSettings | null + worktrees: Worktree[] | null +}> { + // Why: repo.list can enrich repo remote identities, so fetch resume-only + // metadata after explicit user intent instead of delaying history browsing. + // timeoutMs: without it a socket drop parks these on the reconnect waiter + // for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS). + const [ + repoResponse, + folderWorkspaceResponse, + projectGroupResponse, + settingsResponse, + worktreeResponse + ] = await Promise.all([ + client.sendRequest('repo.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }), + client + .sendRequest('folderWorkspace.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + client + .sendRequest('projectGroup.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + client + .sendRequest('settings.get', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null), + client + .sendRequest('worktree.ps', { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .catch(() => null) + ]) + if (!repoResponse.ok) { + throw new Error(repoResponse.error?.message || 'Unable to load workspace metadata.') + } + const repoResult = repoResponse.result as { repos?: MobileAiVaultResumeRepo[] } + const folderWorkspaceResult = + folderWorkspaceResponse?.ok === true + ? (folderWorkspaceResponse.result as { + folderWorkspaces?: MobileAiVaultResumeFolderWorkspace[] + }) + : null + const projectGroupResult = + projectGroupResponse?.ok === true + ? (projectGroupResponse.result as { groups?: MobileAiVaultResumeProjectGroup[] }) + : null + const settingsResult = + settingsResponse?.ok === true + ? (settingsResponse.result as { settings?: MobileAiVaultResumeSettings }) + : null + const worktreeResult = + worktreeResponse?.ok === true ? (worktreeResponse.result as { worktrees?: Worktree[] }) : null + return { + repos: repoResult.repos ?? [], + folderWorkspaces: folderWorkspaceResult?.folderWorkspaces ?? [], + projectGroups: projectGroupResult?.groups ?? [], + settings: settingsResult?.settings ?? null, + worktrees: worktreeResult?.worktrees ?? null + } +} + +function createMobileAiVaultResumeMutationId(sessionId: string): string { + const sessionPart = sessionId.replace(/[^a-zA-Z0-9_.:-]/g, '_').slice(0, 64) || 'session' + const randomPart = Math.random().toString(36).slice(2, 10) + return `ai-vault-resume:${sessionPart}:${Date.now().toString(36)}:${randomPart}` +} diff --git a/mobile/src/agent-history/agent-history-capability.ts b/mobile/src/agent-history/agent-history-capability.ts new file mode 100644 index 00000000000..28db8f48e2e --- /dev/null +++ b/mobile/src/agent-history/agent-history-capability.ts @@ -0,0 +1,4 @@ +// Why: mobile's protocol-version.ts is a separate copy without the capability +// constants (mirrors MOBILE_TASKS_CAPABILITY in tasks.tsx). Keep this string in +// lockstep with src/shared/protocol-version.ts AI_VAULT_RUNTIME_CAPABILITY. +export const MOBILE_AI_VAULT_CAPABILITY = 'aiVault.v1' diff --git a/mobile/src/agent-history/agent-history-current-worktree-badge.ts b/mobile/src/agent-history/agent-history-current-worktree-badge.ts new file mode 100644 index 00000000000..4dbcae4d6db --- /dev/null +++ b/mobile/src/agent-history/agent-history-current-worktree-badge.ts @@ -0,0 +1,7 @@ +import type { AiVaultScope } from '../../../src/shared/ai-vault-types' + +export function shouldShowMobileCurrentWorktreeBadge(scope: AiVaultScope): boolean { + // Why: Workspace is already the current-worktree-only view; Project and All + // still mix in sibling/other worktrees, so the badge remains useful there. + return scope !== 'workspace' +} diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts new file mode 100644 index 00000000000..d59feb98e15 --- /dev/null +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -0,0 +1,372 @@ +import { describe, expect, it } from 'vitest' +import { + getMobileAiVaultResumeRepoTargetStatus, + getMobileAiVaultResumeWorktreeTargetStatus, + isSupportedMobileAiVaultResumeTargetStatus, + mobileAiVaultResumeTargetBlockMessage, + resolveMobileAiVaultSessionResumeTarget +} from './agent-history-resume-target' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Resume target', + cwd: '/Users/ada/repo/app', + branch: null, + model: null, + filePath: '/Users/ada/.claude/session.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-06-29T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +function worktree(overrides: Partial & { worktreeId: string; path: string }): Worktree { + return { + repoId: 'local-repo', + repo: 'orca', + branch: 'main', + displayName: overrides.worktreeId, + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null, + ...overrides + } +} + +describe('mobile AI Vault resume target guards', () => { + const worktrees = [ + { worktreeId: 'local-wt', repoId: 'local-repo' }, + { worktreeId: 'ssh-wt', repoId: 'ssh-repo' }, + { worktreeId: 'runtime-wt', repoId: 'runtime-repo' }, + { worktreeId: 'host-owned-wt', repoId: 'local-repo', hostId: 'ssh:builder' as const }, + { + worktreeId: 'folder:folder-local', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace' as const + }, + { + worktreeId: 'folder:folder-ssh', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace' as const + }, + { + worktreeId: 'folder:folder-runtime', + repoId: 'folder-workspace:group-runtime', + workspaceKind: 'folder-workspace' as const + } + ] + const repos = [ + { id: 'local-repo', path: '/Users/ada/repo', connectionId: null }, + { id: 'ssh-repo', path: '/home/ada/ssh-repo', connectionId: 'builder' }, + { + id: 'runtime-repo', + path: '/workspace/runtime', + connectionId: null, + executionHostId: 'runtime:devbox' as const + } + ] + const folderWorkspaces = [ + { id: 'folder-local', projectGroupId: 'group-local', folderPath: '/Users/ada/folder' }, + { + id: 'folder-ssh', + projectGroupId: 'group-local', + folderPath: '/home/ada/folder', + connectionId: 'folder-builder' + }, + { id: 'folder-runtime', projectGroupId: 'group-runtime', folderPath: '/workspace/folder' } + ] + const projectGroups = [ + { id: 'group-local', connectionId: null }, + { id: 'group-runtime', executionHostId: 'runtime:devbox' as const } + ] + + it('classifies local, SSH, runtime, and unknown repos', () => { + expect(getMobileAiVaultResumeRepoTargetStatus(repos[0])).toBe('local') + expect(getMobileAiVaultResumeRepoTargetStatus(repos[1])).toBe('ssh') + expect(getMobileAiVaultResumeRepoTargetStatus(repos[2])).toBe('runtime') + expect(getMobileAiVaultResumeRepoTargetStatus(null)).toBe('unknown') + }) + + it('classifies worktrees from their repo, with worktree host taking precedence', () => { + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'local-wt', worktrees, repos }) + ).toBe('local') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'ssh-wt', worktrees, repos }) + ).toBe('ssh') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'runtime-wt', worktrees, repos }) + ).toBe('runtime') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'host-owned-wt', worktrees, repos }) + ).toBe('ssh') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: 'folder:folder-local', + worktrees, + repos, + folderWorkspaces, + projectGroups + }) + ).toBe('local') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: 'folder:folder-ssh', + worktrees, + repos, + folderWorkspaces, + projectGroups + }) + ).toBe('ssh') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: 'folder:folder-runtime', + worktrees, + repos, + folderWorkspaces, + projectGroups + }) + ).toBe('runtime') + expect( + getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'missing', worktrees, repos }) + ).toBe('unknown') + }) + + it('supports local targets only; SSH hosts cannot see host-local transcripts', () => { + expect(isSupportedMobileAiVaultResumeTargetStatus('local')).toBe(true) + expect(isSupportedMobileAiVaultResumeTargetStatus('ssh')).toBe(false) + expect(isSupportedMobileAiVaultResumeTargetStatus('runtime')).toBe(false) + expect(mobileAiVaultResumeTargetBlockMessage('runtime')).toContain('runtime-hosted') + expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('SSH workspace') + expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('local workspace') + expect(mobileAiVaultResumeTargetBlockMessage('unknown')).toContain('local workspace') + }) + + it('resolves project-scope rows to the matched session worktree before the route worktree', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/repo/feature/src' }), + activeWorktreeId: 'route-wt', + worktrees: [ + worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }), + worktree({ worktreeId: 'session-wt', path: '/Users/ada/repo/feature' }) + ], + repos + }) + expect(target).toEqual({ + status: 'ready', + worktreeId: 'session-wt', + targetStatus: 'local', + workspacePath: '/Users/ada/repo/feature', + terminalPlatform: null + }) + }) + + it('falls back to the active route worktree when the session worktree is archived', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/repo/archive/src' }), + activeWorktreeId: 'route-wt', + worktrees: [ + worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }), + worktree({ + worktreeId: 'archived-wt', + path: '/Users/ada/repo/archive', + isArchived: true + }) + ], + repos + }) + expect(target).toEqual({ + status: 'ready', + worktreeId: 'route-wt', + targetStatus: 'local', + workspacePath: '/Users/ada/repo/main', + terminalPlatform: null + }) + }) + + it('blocks runtime targets when no supported fallback is available', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/runtime/app' }), + activeWorktreeId: 'runtime-wt', + worktrees: [ + worktree({ worktreeId: 'runtime-wt', repoId: 'runtime-repo', path: '/Users/ada/runtime' }) + ], + repos + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted') + }) + + it('blocks SSH folder workspace targets because transcripts are host-local', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/folder/src' }), + activeWorktreeId: 'folder:folder-ssh', + worktrees: [ + worktree({ + worktreeId: 'folder:folder-ssh', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/home/ada/folder' + }) + ], + repos, + folderWorkspaces, + projectGroups + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace') + }) + + it('skips an SSH session-worktree candidate in favor of a local active-worktree fallback', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/ssh-repo/feature/src' }), + activeWorktreeId: 'route-wt', + worktrees: [ + worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }), + worktree({ + worktreeId: 'ssh-session-wt', + repoId: 'ssh-repo', + path: '/home/ada/ssh-repo/feature' + }) + ], + repos + }) + expect(target).toEqual({ + status: 'ready', + worktreeId: 'route-wt', + targetStatus: 'local', + workspacePath: '/Users/ada/repo/main', + terminalPlatform: null + }) + }) + + it('blocks SSH session worktrees with the SSH message when no local fallback exists', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/home/ada/ssh-repo/feature/src' }), + activeWorktreeId: 'ssh-session-wt', + worktrees: [ + worktree({ + worktreeId: 'ssh-session-wt', + repoId: 'ssh-repo', + path: '/home/ada/ssh-repo/feature' + }) + ], + repos + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace') + }) + + it('blocks folder workspaces whose candidate repos include a runtime owner', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/workspace/folder/src' }), + activeWorktreeId: 'folder:folder-runtime-candidate', + worktrees: [ + worktree({ + worktreeId: 'folder:folder-runtime-candidate', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/workspace/folder' + }) + ], + repos: [ + ...repos, + { + id: 'runtime-in-folder', + path: '/workspace/folder/repo', + connectionId: null, + executionHostId: 'runtime:devbox' as const + } + ], + folderWorkspaces: [ + { + id: 'folder-runtime-candidate', + projectGroupId: 'group-local', + folderPath: '/workspace/folder' + } + ], + projectGroups + }) + expect(target.status).toBe('blocked') + expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted') + }) + + it('blocks folder workspaces when folder metadata is unavailable', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/folder/src' }), + activeWorktreeId: 'folder:missing-folder', + worktrees: [ + worktree({ + worktreeId: 'folder:missing-folder', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/Users/ada/folder' + }) + ], + repos, + folderWorkspaces: [], + projectGroups + }) + expect(target).toEqual({ + status: 'blocked', + message: 'Open a local workspace before resuming a session.' + }) + }) + + it('blocks folder workspaces with mixed candidate repo hosts as unknown', () => { + const target = resolveMobileAiVaultSessionResumeTarget({ + session: session({ cwd: '/Users/ada/mixed/src' }), + activeWorktreeId: 'folder:folder-mixed', + worktrees: [ + worktree({ + worktreeId: 'folder:folder-mixed', + repoId: 'folder-workspace:group-local', + workspaceKind: 'folder-workspace', + path: '/Users/ada/mixed' + }) + ], + repos: [ + { + id: 'local-in-folder', + path: '/Users/ada/mixed/local', + connectionId: null + }, + { + id: 'ssh-in-folder', + path: '/Users/ada/mixed/ssh', + connectionId: 'builder' + } + ], + folderWorkspaces: [ + { + id: 'folder-mixed', + projectGroupId: 'group-local', + folderPath: '/Users/ada/mixed' + } + ], + projectGroups + }) + expect(target).toEqual({ + status: 'blocked', + message: 'Open a local workspace before resuming a session.' + }) + }) +}) diff --git a/mobile/src/agent-history/agent-history-resume-target.ts b/mobile/src/agent-history/agent-history-resume-target.ts new file mode 100644 index 00000000000..a63a7ca92c7 --- /dev/null +++ b/mobile/src/agent-history/agent-history-resume-target.ts @@ -0,0 +1,304 @@ +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../src/shared/execution-host' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path' +import type { Worktree } from '../worktree/workspace-list-types' +import { + canResumeInMobileSessionWorktree, + resolveMobileAgentHistorySessionWorktree +} from './agent-history-session-worktree' + +export type MobileAiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown' + +export type MobileAiVaultResumeRepo = { + id: string + path?: string | null + projectGroupId?: string | null + connectionId?: string | null + executionHostId?: ExecutionHostId | null +} + +type MobileAiVaultResumeWorktree = Pick & { + path?: string | null + workspaceKind?: Worktree['workspaceKind'] + hostId?: ExecutionHostId | null +} + +export type MobileAiVaultResumeFolderWorkspace = { + id: string + projectGroupId: string + folderPath: string + connectionId?: string | null +} + +export type MobileAiVaultResumeProjectGroup = { + id: string + parentGroupId?: string | null + connectionId?: string | null + executionHostId?: ExecutionHostId | string | null +} + +export type MobileAiVaultSessionResumeTarget = + | { + status: 'ready' + worktreeId: string + targetStatus: 'local' + workspacePath: string | null + terminalPlatform: NodeJS.Platform | null + } + | { status: 'blocked'; message: string } + +export function getMobileAiVaultResumeRepoTargetStatus( + repo: MobileAiVaultResumeRepo | null | undefined +): MobileAiVaultResumeTargetStatus { + if (!repo) { + return 'unknown' + } + return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo)) +} + +export function getMobileAiVaultResumeWorktreeTargetStatus(args: { + worktreeId: string | null + worktrees: readonly MobileAiVaultResumeWorktree[] + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups?: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultResumeTargetStatus { + if (!args.worktreeId) { + return 'unknown' + } + const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId) + if (!worktree) { + return 'unknown' + } + if (worktree.workspaceKind === 'folder-workspace') { + return getMobileAiVaultResumeFolderTargetStatus({ + worktreeId: args.worktreeId, + worktree, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces ?? [], + projectGroups: args.projectGroups ?? [] + }) + } + const worktreeHost = getMobileAiVaultResumeExecutionHostTargetStatus(worktree.hostId) + if (worktreeHost !== 'unknown') { + return worktreeHost + } + return getMobileAiVaultResumeRepoTargetStatus( + args.repos.find((candidate) => candidate.id === worktree.repoId) + ) +} + +export function isSupportedMobileAiVaultResumeTargetStatus( + status: MobileAiVaultResumeTargetStatus +): status is 'local' { + // Why: mobile sessions come from the host-local transcript scan, so an SSH + // workspace cannot see the transcript file (mirror of desktop #6270). + return status === 'local' +} + +export function mobileAiVaultResumeTargetBlockMessage( + status: MobileAiVaultResumeTargetStatus +): string { + if (status === 'runtime') { + return 'Resume from history is not available in runtime-hosted workspaces.' + } + if (status === 'ssh') { + return 'This session is stored on the host machine, so it cannot be resumed in an SSH workspace. Open a local workspace for this project.' + } + return 'Open a local workspace before resuming a session.' +} + +export function resolveMobileAiVaultSessionResumeTarget(args: { + session: AiVaultSession + activeWorktreeId: string | null + worktrees: readonly Worktree[] + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups?: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultSessionResumeTarget { + const sessionWorktree = resolveMobileAgentHistorySessionWorktree({ + session: args.session, + worktrees: args.worktrees, + activeWorktreeId: args.activeWorktreeId + }) + const sessionWorktreeId = canResumeInMobileSessionWorktree(sessionWorktree) + ? sessionWorktree?.worktreeId + : null + const candidateWorktreeIds = [ + sessionWorktreeId, + args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId + ? args.activeWorktreeId + : null + ].filter((candidate): candidate is string => Boolean(candidate)) + + for (const candidateWorktreeId of candidateWorktreeIds) { + const targetStatus = getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: candidateWorktreeId, + worktrees: args.worktrees, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces, + projectGroups: args.projectGroups + }) + if (!isSupportedMobileAiVaultResumeTargetStatus(targetStatus)) { + continue + } + return { + status: 'ready', + worktreeId: candidateWorktreeId, + targetStatus, + workspacePath: + args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId)?.path ?? + null, + terminalPlatform: + args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId) + ?.terminalPlatform ?? null + } + } + + const blockedStatus = getMobileAiVaultResumeWorktreeTargetStatus({ + worktreeId: candidateWorktreeIds[0] ?? args.activeWorktreeId, + worktrees: args.worktrees, + repos: args.repos, + folderWorkspaces: args.folderWorkspaces, + projectGroups: args.projectGroups + }) + return { status: 'blocked', message: mobileAiVaultResumeTargetBlockMessage(blockedStatus) } +} + +function getMobileAiVaultResumeFolderTargetStatus(args: { + worktreeId: string + worktree: MobileAiVaultResumeWorktree + repos: readonly MobileAiVaultResumeRepo[] + folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[] + projectGroups: readonly MobileAiVaultResumeProjectGroup[] +}): MobileAiVaultResumeTargetStatus { + const folderWorkspaceId = args.worktreeId.startsWith('folder:') + ? args.worktreeId.slice('folder:'.length) + : null + const folderWorkspace = folderWorkspaceId + ? args.folderWorkspaces.find((workspace) => workspace.id === folderWorkspaceId) + : null + if (!folderWorkspace) { + return 'unknown' + } + const projectGroupId = + folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(args.worktree.repoId) + const projectGroup = projectGroupId + ? args.projectGroups.find((group) => group.id === projectGroupId) + : null + + const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId) + if (groupHostId) { + return getMobileAiVaultResumeExecutionHostTargetStatus(groupHostId) + } + + const explicitConnectionId = ( + folderWorkspace?.connectionId ?? + projectGroup?.connectionId ?? + '' + ).trim() + if (explicitConnectionId) { + return getMobileAiVaultResumeExecutionHostTargetStatus( + toSshExecutionHostId(explicitConnectionId) + ) + } + + return mergeMobileAiVaultResumeExecutionHostTargetStatuses( + getMobileFolderWorkspaceCandidateRepos({ + folderWorkspace, + projectGroupId, + projectGroups: args.projectGroups, + repos: args.repos + }).map(getRepoExecutionHostId) + ) +} + +function getMobileAiVaultResumeExecutionHostTargetStatus( + hostId: ExecutionHostId | null | undefined +): MobileAiVaultResumeTargetStatus { + const parsed = parseExecutionHostId(hostId) + if (!parsed) { + return 'unknown' + } + return parsed.kind +} + +function parseFolderWorkspaceRepoId(repoId: string): string | null { + const prefix = 'folder-workspace:' + return repoId.startsWith(prefix) ? repoId.slice(prefix.length) || null : null +} + +function getMobileFolderWorkspaceCandidateRepos(args: { + folderWorkspace: MobileAiVaultResumeFolderWorkspace | null | undefined + projectGroupId: string | null + projectGroups: readonly MobileAiVaultResumeProjectGroup[] + repos: readonly MobileAiVaultResumeRepo[] +}): MobileAiVaultResumeRepo[] { + if (!args.folderWorkspace || !args.projectGroupId) { + return [] + } + const folderWorkspace = args.folderWorkspace + const groupIds = getMobileProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId) + const groupRepos = args.repos.filter( + (repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId) + ) + const pathRepos = args.repos.filter( + (repo) => + !(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) && + typeof repo.path === 'string' && + repo.path.trim().length > 0 && + isPathInsideOrEqual(folderWorkspace.folderPath, repo.path) + ) + if (folderWorkspace.connectionId) { + return [ + ...groupRepos, + ...pathRepos.filter((repo) => (repo.connectionId ?? null) === folderWorkspace.connectionId) + ] + } + if (groupRepos.length === 0) { + return pathRepos + } + const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null)) + return [ + ...groupRepos, + ...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null)) + ] +} + +function getMobileProjectGroupSubtreeIds( + projectGroups: readonly MobileAiVaultResumeProjectGroup[], + projectGroupId: string +): Set { + const ids = new Set([projectGroupId]) + let changed = true + while (changed) { + changed = false + for (const group of projectGroups) { + if (group.parentGroupId && ids.has(group.parentGroupId) && !ids.has(group.id)) { + ids.add(group.id) + changed = true + } + } + } + return ids +} + +function mergeMobileAiVaultResumeExecutionHostTargetStatuses( + hostIds: readonly ExecutionHostId[] +): MobileAiVaultResumeTargetStatus { + if (hostIds.length === 0) { + return 'local' + } + const statuses = hostIds.map(getMobileAiVaultResumeExecutionHostTargetStatus) + const uniqueStatuses = new Set(statuses) + if (uniqueStatuses.has('runtime')) { + return 'runtime' + } + return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown' +} diff --git a/mobile/src/agent-history/agent-history-scope-paths.test.ts b/mobile/src/agent-history/agent-history-scope-paths.test.ts new file mode 100644 index 00000000000..59f16ec9ef8 --- /dev/null +++ b/mobile/src/agent-history/agent-history-scope-paths.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../worktree/workspace-list-types' +import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths' + +function worktree(overrides: Partial): Pick { + return { + worktreeId: overrides.worktreeId ?? 'w1', + path: overrides.path ?? '/Users/ada/repo/app', + repoId: overrides.repoId ?? 'repo-1' + } +} + +const active = worktree({ worktreeId: 'w1', path: '/Users/ada/repo/app', repoId: 'repo-1' }) +const sibling = worktree({ worktreeId: 'w2', path: '/Users/ada/repo/app-2', repoId: 'repo-1' }) +const otherRepo = worktree({ worktreeId: 'w3', path: '/Users/ada/other/ui', repoId: 'repo-2' }) + +describe('deriveMobileAiVaultScopePaths', () => { + it('workspace scope returns only the active worktree path', () => { + expect( + deriveMobileAiVaultScopePaths('workspace', active, [active, sibling, otherRepo]) + ).toEqual(['/Users/ada/repo/app']) + }) + + it('project scope adds same-repo siblings but not other-repo worktrees', () => { + expect(deriveMobileAiVaultScopePaths('project', active, [active, sibling, otherRepo])).toEqual([ + '/Users/ada/repo/app', + '/Users/ada/repo/app-2' + ]) + }) + + it('all scope returns no scope hints (global recency list)', () => { + expect(deriveMobileAiVaultScopePaths('all', active, [active, sibling])).toEqual([]) + }) + + it('returns no paths when there is no active worktree', () => { + expect(deriveMobileAiVaultScopePaths('workspace', null, [sibling])).toEqual([]) + }) + + it('caps project scope at 64 paths so the host RPC bound does not reject the request', () => { + const siblings = Array.from({ length: 200 }, (_, index) => + worktree({ + worktreeId: `w-sib-${index}`, + path: `/Users/ada/repo/app-${index}`, + repoId: 'repo-1' + }) + ) + const result = deriveMobileAiVaultScopePaths('project', active, [active, ...siblings]) + expect(result.length).toBe(64) + // Active worktree is seeded first, so it survives truncation. + expect(result[0]).toBe('/Users/ada/repo/app') + }) + + it('dedupes and skips non-absolute paths', () => { + const dupe = worktree({ worktreeId: 'w4', path: '/Users/ada/repo/app', repoId: 'repo-1' }) + const relative = worktree({ worktreeId: 'w5', path: 'relative/path', repoId: 'repo-1' }) + expect(deriveMobileAiVaultScopePaths('project', active, [active, dupe, relative])).toEqual([ + '/Users/ada/repo/app' + ]) + }) +}) diff --git a/mobile/src/agent-history/agent-history-scope-paths.ts b/mobile/src/agent-history/agent-history-scope-paths.ts new file mode 100644 index 00000000000..da3b98457d2 --- /dev/null +++ b/mobile/src/agent-history/agent-history-scope-paths.ts @@ -0,0 +1,60 @@ +import { + isRuntimePathAbsolute, + normalizeRuntimePathForComparison +} from '../../../src/shared/cross-platform-path' +import type { Worktree } from '../worktree/workspace-list-types' +import { + AI_VAULT_SCOPE_PATHS_MAX_COUNT, + type AiVaultScope +} from '../../../src/shared/ai-vault-types' + +// Why: the renderer's deriveAiVault* helpers are renderer-located and +// Metro-unresolvable, so mobile does its own minimal derivation seeded by the +// active worktree's path plus same-repo sibling worktrees (mobile already loads +// the full worktree list via worktree.ps). scopePaths only widen the host scan's +// discovery breadth; they are host-local match prefixes, never device paths. +export function deriveMobileAiVaultScopePaths( + scope: AiVaultScope, + activeWorktree: Pick | null, + liveWorktrees: readonly Pick[] +): string[] { + // 'all' scope scans without scope hints — the host returns the global recency + // list, so no scopePaths are needed (and would only narrow discovery). + if (scope === 'all' || !activeWorktree) { + return [] + } + + const paths: string[] = [] + addScopePath(paths, activeWorktree.path) + + // Workspace scope = the active worktree only. Project scope additionally + // covers same-repo sibling worktrees so the project view stays complete. + if (scope === 'project') { + for (const worktree of liveWorktrees) { + // Why: the RPC rejects (does not truncate) oversized scopePaths, and the + // list only widens discovery — dropping tail siblings beats hard-failing. + if (paths.length >= AI_VAULT_SCOPE_PATHS_MAX_COUNT) { + break + } + if (worktree.repoId === activeWorktree.repoId) { + addScopePath(paths, worktree.path) + } + } + } + + return paths +} + +function addScopePath(paths: string[], pathValue: string | undefined): void { + const trimmedPath = pathValue?.trim() + if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) { + return + } + const comparisonPath = normalizeRuntimePathForComparison(trimmedPath) + if ( + paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath) + ) { + return + } + paths.push(trimmedPath) +} diff --git a/mobile/src/agent-history/agent-history-sections.ts b/mobile/src/agent-history/agent-history-sections.ts new file mode 100644 index 00000000000..52f76b202d0 --- /dev/null +++ b/mobile/src/agent-history/agent-history-sections.ts @@ -0,0 +1,59 @@ +import { + filterAiVaultSessions, + groupAiVaultSessions +} from '../../../src/shared/ai-vault-session-filters' +import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types' +import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAgentHistoryCard, + type MobileAgentHistoryCard +} from './agent-history-session-card' + +// Why: `data` (not `cards`) is the field name React Native's SectionList reads +// for each section's rows — naming it anything else renders empty sections. +export type MobileAgentHistorySection = { + key: string + label: string + data: MobileAgentHistoryCard[] +} + +// Why: the host treats scopePaths as a WIDENING union (it adds in-scope sessions +// beyond the recency cap, never restricts), so the Workspace/Project tabs must +// narrow on the client like the desktop panel does. Mobile has no project-key +// metadata, so both scoped tabs narrow by cwd path-prefix: Workspace = the active +// worktree path, Project = the active worktree + same-repo siblings (the same set +// deriveMobileAiVaultScopePaths produces). 'all' applies no path narrowing. +// hideEmptySessions matches the desktop default. +export function buildMobileAgentHistorySections( + sessions: readonly AiVaultSession[], + options: { + query: string + scope: AiVaultScope + scopeFilterPaths: readonly string[] + activeWorktreePath: string | null + now: number + } +): MobileAgentHistorySection[] { + // Why: narrow scoped tabs by cwd path-prefix. When the worktree list hasn't + // loaded yet, scopeFilterPaths is empty — fall back to unnarrowed rather than + // filtering everything out (which would flash an empty list); the memo re-runs + // with real paths once worktree.ps resolves. + const narrowByPath = options.scope !== 'all' && options.scopeFilterPaths.length > 0 + const filtered = filterAiVaultSessions(sessions, { + query: options.query, + agents: AI_VAULT_AGENTS, + scope: narrowByPath ? 'workspace' : 'all', + sort: 'updated', + activeWorktreePaths: narrowByPath ? options.scopeFilterPaths : [], + hideEmptySessions: true + }) + + const groups = groupAiVaultSessions(filtered, 'folder') + return groups.map((group) => ({ + key: group.key, + label: group.label, + data: group.sessions.map((session) => + buildMobileAgentHistoryCard(session, options.activeWorktreePath, options.now) + ) + })) +} diff --git a/mobile/src/agent-history/agent-history-session-card.test.ts b/mobile/src/agent-history/agent-history-session-card.test.ts new file mode 100644 index 00000000000..d38285aee14 --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-card.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAgentHistoryResumeActionState, + buildMobileAgentHistoryCard, + isSessionInActiveWorktree +} from './agent-history-session-card' +import { buildMobileAgentHistorySections } from './agent-history-sections' +import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability' +import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge' + +const NOW = Date.parse('2026-06-29T00:00:00.000Z') + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement vault filters', + cwd: '/Users/ada/repo/app', + branch: 'feature/vault', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-06-28T23:00:00.000Z', + updatedAt: '2026-06-28T23:55:00.000Z', + modifiedAt: '2026-06-28T23:55:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [ + { role: 'user', text: 'add the scope tabs', timestamp: null }, + { role: 'assistant', text: 'done — tabs added', timestamp: null } + ], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +describe('buildMobileAgentHistoryCard', () => { + it('maps the card view-model with time-ago, label, and last message', () => { + const card = buildMobileAgentHistoryCard(session(), '/Users/ada/repo/app', NOW) + expect(card.agentLabel).toBe('Claude') + expect(card.timeAgo).toBe('5m') + expect(card.lastMessage).toBe('done — tabs added') + expect(card.messageCount).toBe(4) + expect(card.isCurrentWorktree).toBe(true) + }) + + it('omits the current-worktree badge when cwd is outside the active worktree', () => { + const card = buildMobileAgentHistoryCard(session(), '/Users/ada/other', NOW) + expect(card.isCurrentWorktree).toBe(false) + }) + + it('falls back to a title for empty session titles', () => { + const card = buildMobileAgentHistoryCard(session({ title: '' }), null, NOW) + expect(card.title).toBe('Untitled session') + expect(card.isCurrentWorktree).toBe(false) + }) +}) + +describe('buildMobileAgentHistoryResumeActionState', () => { + it('disables all resume buttons while one session is launching', () => { + const state = buildMobileAgentHistoryResumeActionState( + [session({ id: 'claude:1' }), session({ id: 'codex:2', agent: 'codex' })], + 'codex:2' + ) + expect(state.get('claude:1')).toEqual({ disabled: true, loading: false }) + expect(state.get('codex:2')).toEqual({ disabled: true, loading: true }) + }) + + it('keeps resume buttons enabled when no launch is in flight', () => { + const state = buildMobileAgentHistoryResumeActionState([session({ id: 'claude:1' })], null) + expect(state.get('claude:1')).toEqual({ disabled: false, loading: false }) + }) +}) + +describe('isSessionInActiveWorktree', () => { + it('matches a nested cwd inside the worktree path', () => { + expect( + isSessionInActiveWorktree({ cwd: '/Users/ada/repo/app/src' }, '/Users/ada/repo/app') + ).toBe(true) + }) + + it('is false without a cwd or active path', () => { + expect(isSessionInActiveWorktree({ cwd: null }, '/Users/ada/repo/app')).toBe(false) + expect(isSessionInActiveWorktree({ cwd: '/Users/ada/repo/app' }, null)).toBe(false) + }) +}) + +describe('buildMobileAgentHistorySections', () => { + it('hides empty sessions by default and groups remaining by folder', () => { + const sections = buildMobileAgentHistorySections( + [ + session(), + // Truly empty: no turns, no preview turns, no recoverable signals — + // preview turns alone now count as resumable content and stay visible. + session({ id: 'claude:empty', messageCount: 0, previewMessages: [] }) + ], + { + query: '', + scope: 'all', + scopeFilterPaths: [], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + } + ) + const allCards = sections.flatMap((s) => s.data) + expect(allCards.map((card) => card.id)).toEqual(['claude:1']) + }) + + it('applies the search query across sections', () => { + const sections = buildMobileAgentHistorySections( + [session(), session({ id: 'claude:2', title: 'Repair terminal tabs' })], + { query: 'repair', scope: 'all', scopeFilterPaths: [], activeWorktreePath: null, now: NOW } + ) + expect(sections.flatMap((s) => s.data).map((card) => card.id)).toEqual(['claude:2']) + }) + + // Why: the host union widens, so the screen must narrow scoped tabs by cwd + // path-prefix on the client — otherwise Workspace/Project show the same set as All. + it('narrows Workspace scope to sessions whose cwd is inside the active worktree path', () => { + const sessions = [ + session({ id: 'claude:in', cwd: '/Users/ada/repo/app/src' }), + session({ id: 'claude:out', cwd: '/Users/ada/other-repo' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'workspace', + scopeFilterPaths: ['/Users/ada/repo/app'], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + }) + expect(sections.flatMap((s) => s.data).map((card) => card.id)).toEqual(['claude:in']) + }) + + it('narrows Project scope to the active worktree plus same-repo sibling paths', () => { + const sessions = [ + session({ id: 'claude:active', cwd: '/Users/ada/repo/app' }), + session({ id: 'claude:sibling', cwd: '/Users/ada/repo/app-feature/lib' }), + session({ id: 'claude:foreign', cwd: '/Users/ada/unrelated' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'project', + scopeFilterPaths: ['/Users/ada/repo/app', '/Users/ada/repo/app-feature'], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + }) + expect( + sections + .flatMap((s) => s.data) + .map((card) => card.id) + .sort() + ).toEqual(['claude:active', 'claude:sibling']) + }) + + // Why: before worktree.ps resolves, scopeFilterPaths is empty on a scoped tab; + // the builder must fall back to unnarrowed rather than filter to an empty list. + it('falls back to unnarrowed when a scoped tab has no scopeFilterPaths yet', () => { + const sessions = [ + session({ id: 'claude:a', cwd: '/Users/ada/repo/app' }), + session({ id: 'claude:b', cwd: '/Users/ada/elsewhere' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'workspace', + scopeFilterPaths: [], + activeWorktreePath: null, + now: NOW + }) + expect( + sections + .flatMap((s) => s.data) + .map((card) => card.id) + .sort() + ).toEqual(['claude:a', 'claude:b']) + }) + + it('shows all sessions under All scope regardless of path', () => { + const sessions = [ + session({ id: 'claude:a', cwd: '/Users/ada/repo/app' }), + session({ id: 'claude:b', cwd: '/Users/ada/elsewhere' }) + ] + const sections = buildMobileAgentHistorySections(sessions, { + query: '', + scope: 'all', + scopeFilterPaths: [], + activeWorktreePath: '/Users/ada/repo/app', + now: NOW + }) + expect( + sections + .flatMap((s) => s.data) + .map((card) => card.id) + .sort() + ).toEqual(['claude:a', 'claude:b']) + }) +}) + +describe('MOBILE_AI_VAULT_CAPABILITY', () => { + it('stays in lockstep with the host AI_VAULT_RUNTIME_CAPABILITY string', () => { + // Why: mobile's protocol-version copy lacks the host constant; this literal + // must match src/shared/protocol-version.ts AI_VAULT_RUNTIME_CAPABILITY. + expect(MOBILE_AI_VAULT_CAPABILITY).toBe('aiVault.v1') + }) +}) + +describe('shouldShowMobileCurrentWorktreeBadge', () => { + it('hides the repeated current-worktree badge in Workspace scope', () => { + expect(shouldShowMobileCurrentWorktreeBadge('workspace')).toBe(false) + }) + + it('keeps the badge in mixed Project and All scopes', () => { + expect(shouldShowMobileCurrentWorktreeBadge('project')).toBe(true) + expect(shouldShowMobileCurrentWorktreeBadge('all')).toBe(true) + }) +}) diff --git a/mobile/src/agent-history/agent-history-session-card.ts b/mobile/src/agent-history/agent-history-session-card.ts new file mode 100644 index 00000000000..e18ac3f5410 --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-card.ts @@ -0,0 +1,68 @@ +import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path' +import { latestSessionConversationTurn } from '../../../src/shared/ai-vault-session-display' +import { aiVaultAgentLabel } from '../../../src/shared/ai-vault-types' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { formatTimeAgo } from '../worktree/agent-row-display' + +export type MobileAgentHistoryCard = { + id: string + agent: AiVaultSession['agent'] + agentLabel: string + title: string + lastMessage: string + messageCount: number + timeAgo: string + isCurrentWorktree: boolean +} + +export type MobileAgentHistoryResumeActionState = { + disabled: boolean + loading: boolean +} + +// Why: a session belongs to the active worktree when its recorded cwd is inside +// (or equal to) the worktree path; session rows carry no host id, so this is a +// path-prefix check (same logic the desktop badge uses). +export function isSessionInActiveWorktree( + session: Pick, + activeWorktreePath: string | null +): boolean { + if (!activeWorktreePath || !session.cwd) { + return false + } + return isPathInsideOrEqual(activeWorktreePath, session.cwd) +} + +export function buildMobileAgentHistoryCard( + session: AiVaultSession, + activeWorktreePath: string | null, + now: number +): MobileAgentHistoryCard { + const latestTurn = latestSessionConversationTurn(session) + const updatedAtMs = Date.parse(session.updatedAt ?? session.modifiedAt) + return { + id: session.id, + agent: session.agent, + agentLabel: aiVaultAgentLabel(session.agent), + title: session.title || 'Untitled session', + lastMessage: latestTurn?.text.trim() ?? '', + messageCount: session.messageCount, + timeAgo: Number.isFinite(updatedAtMs) ? formatTimeAgo(updatedAtMs, now) : '', + isCurrentWorktree: isSessionInActiveWorktree(session, activeWorktreePath) + } +} + +export function buildMobileAgentHistoryResumeActionState( + sessions: readonly Pick[], + resumingSessionId: string | null +): ReadonlyMap { + return new Map( + sessions.map((session) => [ + session.id, + { + disabled: resumingSessionId !== null, + loading: resumingSessionId === session.id + } + ]) + ) +} diff --git a/mobile/src/agent-history/agent-history-session-worktree.test.ts b/mobile/src/agent-history/agent-history-session-worktree.test.ts new file mode 100644 index 00000000000..9a7ad0d3981 --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-worktree.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' +import { + canResumeInMobileSessionWorktree, + resolveMobileAgentHistorySessionWorktree +} from './agent-history-session-worktree' + +function session(cwd: string | null): Pick { + return { cwd } +} + +function worktree(overrides: Partial & { worktreeId: string; path: string }): Worktree { + return { + repoId: 'repo-1', + repo: 'orca', + branch: 'main', + displayName: overrides.worktreeId, + liveTerminalCount: 0, + hasAttachedPty: false, + preview: '', + unread: false, + isPinned: false, + linkedPR: null, + ...overrides + } +} + +describe('resolveMobileAgentHistorySessionWorktree', () => { + it('matches the active worktree when session cwd is inside it', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/repo/app/src'), + worktrees: [worktree({ worktreeId: 'wt-1', path: '/Users/ada/repo/app' })], + activeWorktreeId: 'wt-1' + }) + expect(resolved).toMatchObject({ status: 'current', worktreeId: 'wt-1' }) + }) + + it('prefers the longest active path match', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/repo/app/packages/mobile'), + worktrees: [ + worktree({ worktreeId: 'root', path: '/Users/ada/repo' }), + worktree({ worktreeId: 'app', path: '/Users/ada/repo/app' }) + ], + activeWorktreeId: 'root' + }) + expect(resolved).toMatchObject({ status: 'active', worktreeId: 'app' }) + }) + + it('marks archived worktrees as unavailable for resume', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/repo/app'), + worktrees: [ + worktree({ worktreeId: 'archived', path: '/Users/ada/repo/app', isArchived: true }) + ], + activeWorktreeId: 'other' + }) + expect(resolved).toMatchObject({ status: 'archived', worktreeId: 'archived' }) + expect(canResumeInMobileSessionWorktree(resolved)).toBe(false) + }) + + it('returns null when the session cwd has no active worktree match', () => { + expect( + resolveMobileAgentHistorySessionWorktree({ + session: session('/Users/ada/missing'), + worktrees: [worktree({ worktreeId: 'wt-1', path: '/Users/ada/repo/app' })], + activeWorktreeId: 'wt-1' + }) + ).toBeNull() + }) + + it('matches WSL UNC worktree paths against Linux transcript paths', () => { + const resolved = resolveMobileAgentHistorySessionWorktree({ + session: session('/home/ada/repo/app'), + worktrees: [ + worktree({ + worktreeId: 'wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + }) + ], + activeWorktreeId: 'wsl' + }) + expect(resolved).toMatchObject({ status: 'current', worktreeId: 'wsl' }) + }) +}) diff --git a/mobile/src/agent-history/agent-history-session-worktree.ts b/mobile/src/agent-history/agent-history-session-worktree.ts new file mode 100644 index 00000000000..0e859bbf5ee --- /dev/null +++ b/mobile/src/agent-history/agent-history-session-worktree.ts @@ -0,0 +1,103 @@ +import { isPathInsideOrEqual, isRuntimePathAbsolute } from '../../../src/shared/cross-platform-path' +import { parseWslUncPath } from '../../../src/shared/wsl-paths' +import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree-id' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' + +export type MobileAgentHistorySessionWorktreeStatus = 'current' | 'active' | 'archived' + +export type MobileAgentHistorySessionWorktreeInfo = { + status: MobileAgentHistorySessionWorktreeStatus + worktreeId: string + path: string +} + +type WorktreeCandidate = { + worktree: WorktreeWithPriorIds + path: string + source: 'current-path' | 'prior-path' +} + +type WorktreeWithPriorIds = Worktree & { + priorWorktreeIds?: readonly string[] +} + +export function resolveMobileAgentHistorySessionWorktree(args: { + session: Pick + worktrees: readonly Worktree[] + activeWorktreeId: string | null +}): MobileAgentHistorySessionWorktreeInfo | null { + if (!args.session.cwd) { + return null + } + const sessionCwd = args.session.cwd + + const candidates = buildMobileWorktreeCandidates(args.worktrees) + .filter((candidate) => isSessionInWorktreePath(candidate.path, sessionCwd)) + .sort(compareWorktreeCandidates) + const best = candidates[0] + if (!best) { + return null + } + + return { + status: + best.worktree.worktreeId === args.activeWorktreeId + ? 'current' + : best.worktree.isArchived + ? 'archived' + : 'active', + worktreeId: best.worktree.worktreeId, + path: best.path + } +} + +export function canResumeInMobileSessionWorktree( + worktreeInfo: MobileAgentHistorySessionWorktreeInfo | null +): boolean { + return Boolean(worktreeInfo && worktreeInfo.status !== 'archived') +} + +function buildMobileWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] { + const candidates: WorktreeCandidate[] = [] + for (const worktree of worktrees as readonly WorktreeWithPriorIds[]) { + if (hasUsablePath(worktree.path)) { + candidates.push({ worktree, path: worktree.path, source: 'current-path' }) + } + for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) { + const parsed = splitWorktreeIdForFilesystem(priorWorktreeId) + if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) { + continue + } + candidates.push({ worktree, path: parsed.worktreePath, source: 'prior-path' }) + } + } + return candidates +} + +function hasUsablePath(pathValue: string): boolean { + return Boolean(pathValue.trim() && isRuntimePathAbsolute(pathValue)) +} + +function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean { + if (isPathInsideOrEqual(worktreePath, sessionCwd)) { + return true + } + const wslPath = parseWslUncPath(worktreePath) + return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false +} + +function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number { + const lengthDifference = normalizedPathLength(right.path) - normalizedPathLength(left.path) + if (lengthDifference !== 0) { + return lengthDifference + } + if (left.source === right.source) { + return 0 + } + return left.source === 'current-path' ? -1 : 1 +} + +function normalizedPathLength(pathValue: string): number { + return pathValue.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase().length +} diff --git a/mobile/src/agent-history/agent-history-styles.ts b/mobile/src/agent-history/agent-history-styles.ts new file mode 100644 index 00000000000..c3174623bf4 --- /dev/null +++ b/mobile/src/agent-history/agent-history-styles.ts @@ -0,0 +1,242 @@ +import { StyleSheet } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +export const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + header: { + backgroundColor: colors.bgBase + }, + topBar: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + backButton: { + padding: spacing.xs, + borderRadius: radii.button + }, + backButtonPressed: { + backgroundColor: colors.bgRaised + }, + titleBlock: { + flex: 1, + marginHorizontal: spacing.sm + }, + title: { + color: colors.textPrimary, + fontSize: typography.titleSize, + fontWeight: '600' + }, + meta: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + refreshButton: { + padding: spacing.xs, + borderRadius: radii.button + }, + refreshButtonPressed: { + backgroundColor: colors.bgRaised + }, + scopeTabs: { + flexDirection: 'row', + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + gap: spacing.xs + }, + scopeTab: { + flex: 1, + alignItems: 'center', + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgPanel + }, + scopeTabActive: { + backgroundColor: colors.bgRaised + }, + scopeTabText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + scopeTabTextActive: { + color: colors.textPrimary, + fontWeight: '600' + }, + searchRow: { + paddingHorizontal: spacing.md, + paddingTop: spacing.sm + }, + searchInput: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.textPrimary, + fontSize: typography.bodySize + }, + list: { + paddingHorizontal: spacing.md, + paddingTop: spacing.sm, + paddingBottom: spacing.xl + }, + groupHeader: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: spacing.sm, + gap: spacing.sm + }, + groupHeaderText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + fontWeight: '600', + textTransform: 'uppercase' + }, + groupHeaderCount: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + card: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + padding: spacing.md, + marginBottom: spacing.sm + }, + cardPressed: { + backgroundColor: colors.bgRaised + }, + cardTopRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + cardTitle: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + cardTimeAgo: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + cardLastMessage: { + color: colors.textSecondary, + fontSize: typography.metaSize, + marginTop: spacing.xs + }, + cardMetaRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.sm, + marginTop: spacing.xs + }, + cardMetaText: { + color: colors.textMuted, + fontSize: typography.metaSize + }, + currentBadge: { + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + currentBadgeText: { + color: colors.accentBlue, + fontSize: typography.metaSize, + fontWeight: '600' + }, + resumeButton: { + minHeight: 28, + minWidth: 28, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 'auto', + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + borderRadius: radii.button + }, + resumeButtonPressed: { + opacity: 0.78 + }, + resumeButtonDisabled: { + opacity: 0.45 + }, + preview: { + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + gap: spacing.sm + }, + previewTurn: { + gap: 2 + }, + previewRole: { + color: colors.textMuted, + fontSize: typography.metaSize, + fontWeight: '600', + textTransform: 'uppercase' + }, + previewText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + noticeBanner: { + marginHorizontal: spacing.md, + marginTop: spacing.sm, + padding: spacing.sm, + borderRadius: radii.input, + backgroundColor: colors.bgPanel + }, + noticeText: { + color: colors.statusAmber, + fontSize: typography.metaSize + }, + resumeBanner: { + marginHorizontal: spacing.md, + marginTop: spacing.sm, + padding: spacing.sm, + borderRadius: radii.input, + backgroundColor: colors.bgPanel + }, + resumeBannerText: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + state: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: spacing.xl, + gap: spacing.sm + }, + stateTitle: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + stateText: { + color: colors.textSecondary, + fontSize: typography.metaSize, + textAlign: 'center' + }, + retryButton: { + marginTop: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + borderRadius: radii.button, + backgroundColor: colors.bgRaised + }, + retryText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + } +}) diff --git a/mobile/src/agent-history/use-mobile-agent-history-state.ts b/mobile/src/agent-history/use-mobile-agent-history-state.ts new file mode 100644 index 00000000000..a0a82bee019 --- /dev/null +++ b/mobile/src/agent-history/use-mobile-agent-history-state.ts @@ -0,0 +1,192 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useHostClient, useForceReconnect } from '../transport/client-context' +import type { RpcSuccess } from '../transport/types' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultScope, + AiVaultSession +} from '../../../src/shared/ai-vault-types' +import type { Worktree } from '../worktree/workspace-list-types' +import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths' +import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability' + +export { MOBILE_AI_VAULT_CAPABILITY } + +// Why: mirror the desktop renderer's SESSION_LIMIT=500. limit caps only the +// global recency list; the in-scope Claude union is capped separately host-side, +// so the response can still be large — hence virtualization + lazy preview. +const MOBILE_AI_VAULT_SESSION_LIMIT = 500 + +export type AgentHistoryScreenState = + | { kind: 'loading' } + | { kind: 'unsupported' } + | { kind: 'error'; message: string } + | { kind: 'ready'; sessions: AiVaultSession[]; issues: AiVaultScanIssue[] } + +type StatusWithCapabilities = { capabilities?: string[] } + +export type MobileAgentHistoryStateParams = { + hostId: string + worktreeId: string + worktrees: readonly Worktree[] + // Why: distinguishes "worktree list not fetched yet" from "fetched, but this + // worktree isn't in it" so a scoped tab holds loading only for the former. + worktreesLoaded: boolean +} + +export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams) { + const { hostId, worktreeId, worktrees, worktreesLoaded } = params + const { client, state: connState } = useHostClient(hostId) + const forceReconnect = useForceReconnect() + const [scope, setScope] = useState('workspace') + const [screenState, setScreenState] = useState({ kind: 'loading' }) + const [hostStatusResult, setHostStatusResult] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const generationRef = useRef(0) + const mountedRef = useRef(true) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const activeWorktree = useMemo( + () => worktrees.find((worktree) => worktree.worktreeId === worktreeId) ?? null, + [worktrees, worktreeId] + ) + const activeWorktreePath = activeWorktree?.path ?? null + // Why: the host union only widens, so the screen narrows by these cwd + // path-prefixes for the current scope (empty for 'all'). Same derivation the + // RPC scopePaths use, reused client-side for the actual narrowing. + const scopeFilterPaths = useMemo( + () => deriveMobileAiVaultScopePaths(scope, activeWorktree, worktrees), + [scope, activeWorktree, worktrees] + ) + + const loadSessions = useCallback( + async (options: { scope: AiVaultScope; force: boolean }): Promise => { + const generation = generationRef.current + 1 + generationRef.current = generation + const isCurrent = () => mountedRef.current && generationRef.current === generation + + if (!client || connState !== 'connected') { + if (isCurrent()) { + // Why: keep the stale list visible through transient reconnects + // (connState flips re-run the load effect) instead of tearing it + // down to a full-screen error, matching the host list screen. + setScreenState((prev) => + prev.kind === 'ready' ? prev : { kind: 'error', message: 'Waiting for host…' } + ) + } + return + } + + setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + try { + // Gate on the capability so older hosts lacking the method are detected + // and we never call a missing RPC. + const statusResponse = await client.sendRequest('status.get') + if (!isCurrent()) { + return + } + if (!statusResponse.ok) { + throw new Error(statusResponse.error?.message || 'Unable to reach host') + } + const status = (statusResponse as RpcSuccess).result as StatusWithCapabilities + setHostStatusResult(status) + if (!status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY)) { + setScreenState({ kind: 'unsupported' }) + return + } + + // Why: a scoped tab needs the active worktree's path to narrow. Until the + // worktree list has loaded, hold loading rather than firing an unscoped + // fetch that would briefly show unrelated host history. Once loaded we + // proceed even if the worktree isn't found, to avoid a stuck spinner. + if (options.scope !== 'all' && !activeWorktree && !worktreesLoaded) { + if (isCurrent()) { + setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' })) + } + return + } + + const scopePaths = deriveMobileAiVaultScopePaths(options.scope, activeWorktree, worktrees) + const response = await client.sendRequest('aiVault.listSessions', { + limit: MOBILE_AI_VAULT_SESSION_LIMIT, + force: options.force, + scopePaths + }) + if (!isCurrent()) { + return + } + if (!response.ok) { + throw new Error(response.error?.message || 'Unable to load agent sessions') + } + const result = (response as RpcSuccess).result as AiVaultListResult + setScreenState({ kind: 'ready', sessions: result.sessions, issues: result.issues }) + } catch (err) { + if (!isCurrent()) { + return + } + const message = err instanceof Error ? err.message : 'Unable to load agent sessions' + setHostStatusResult(null) + setScreenState({ kind: 'error', message }) + } + }, + [activeWorktree, client, connState, worktrees, worktreesLoaded] + ) + + // Initial + reconnect load. Why: scope switches reuse the host's 15s cache + // (force:false) so a tab tap is cheap; only an explicit refresh bypasses it. + useEffect(() => { + void loadSessions({ scope, force: false }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- scope handled by onSelectScope + }, [loadSessions]) + + const onSelectScope = useCallback( + (nextScope: AiVaultScope) => { + setScope(nextScope) + void loadSessions({ scope: nextScope, force: false }) + }, + [loadSessions] + ) + + const onRefresh = useCallback(async () => { + setRefreshing(true) + try { + // Why: pull-to-refresh bypasses the host TTL (force:true) and joins any + // active inflight scan; otherwise a refresh within 15s shows no change. + await loadSessions({ scope, force: true }) + } finally { + if (mountedRef.current) { + setRefreshing(false) + } + } + }, [loadSessions, scope]) + + const retry = useCallback(() => { + if (connState !== 'connected' && hostId) { + void forceReconnect(hostId) + return + } + void loadSessions({ scope, force: false }) + }, [connState, forceReconnect, hostId, loadSessions, scope]) + + return { + connState, + scope, + screenState, + refreshing, + hostStatusResult, + activeWorktreePath, + scopeFilterPaths, + onSelectScope, + onRefresh, + retry + } +} + +export type MobileAgentHistoryState = ReturnType diff --git a/mobile/src/agent-history/worktree-navigation-actions.ts b/mobile/src/agent-history/worktree-navigation-actions.ts new file mode 100644 index 00000000000..8359c8ee020 --- /dev/null +++ b/mobile/src/agent-history/worktree-navigation-actions.ts @@ -0,0 +1,50 @@ +import { GitBranch } from 'lucide-react-native' +import type { ActionSheetAction } from '../components/ActionSheetModal' +import { colors } from '../theme/mobile-theme' +import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability' +import { MobileAgentSessionHistoryIcon } from './MobileAgentSessionHistoryIcon' + +type Args = { + hostId: string + worktreeId: string + worktreeName: string + hostCapabilities: readonly string[] + navigate: (target: string) => void + onDone: () => void +} + +// Why: builds the per-worktree "navigate to a screen" action-sheet actions +// (Source Control + Agent Session History). The Agent Session History action is +// included only on hosts advertising the aiVault.v1 capability, so we never +// navigate to a screen that would call a missing RPC method. Extracted from the +// host index action sheet to keep that file under its max-lines budget. +export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[] { + const actions: ActionSheetAction[] = [ + { + label: 'Source Control', + icon: GitBranch, + onPress: () => { + const params = new URLSearchParams({ name: args.worktreeName, origin: 'host' }) + args.navigate( + `/h/${args.hostId}/source-control/${encodeURIComponent(args.worktreeId)}?${params.toString()}` + ) + args.onDone() + } + } + ] + if (args.hostCapabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) { + actions.push({ + label: 'Agent Session History', + renderIcon: () => + MobileAgentSessionHistoryIcon({ size: 16, color: colors.textSecondary, strokeWidth: 2 }), + onPress: () => { + const params = new URLSearchParams({ name: args.worktreeName }) + args.navigate( + `/h/${args.hostId}/agent-history/${encodeURIComponent(args.worktreeId)}?${params.toString()}` + ) + args.onDone() + } + }) + } + return actions +} diff --git a/mobile/src/session/MobileSessionHeaderIconButton.tsx b/mobile/src/session/MobileSessionHeaderIconButton.tsx new file mode 100644 index 00000000000..68e1a1173a4 --- /dev/null +++ b/mobile/src/session/MobileSessionHeaderIconButton.tsx @@ -0,0 +1,39 @@ +import type { ComponentType } from 'react' +import { Pressable } from 'react-native' +import { colors } from '../theme/mobile-theme' +import { styles } from '../../app/h/[hostId]/session/mobile-session-styles' + +type HeaderIconProps = { + size?: number + color?: string + strokeWidth?: number +} + +type MobileSessionHeaderIconButtonProps = { + active?: boolean + accessibilityLabel: string + icon: ComponentType + onPress: () => void +} + +export function MobileSessionHeaderIconButton({ + active = false, + accessibilityLabel, + icon: Icon, + onPress +}: MobileSessionHeaderIconButtonProps) { + return ( + [ + styles.filesButton, + pressed && styles.filesButtonPressed, + active && styles.filesButtonActive + ]} + onPress={onPress} + hitSlop={8} + accessibilityLabel={accessibilityLabel} + > + + + ) +} diff --git a/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx b/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx new file mode 100644 index 00000000000..d6f6ebb4518 --- /dev/null +++ b/mobile/src/session/MobileSessionHeaderMoreActionsSheet.tsx @@ -0,0 +1,57 @@ +import { ListChecks } from 'lucide-react-native' +import { MobileAgentSessionHistoryIcon } from '../agent-history/MobileAgentSessionHistoryIcon' +import { ActionSheetModal } from '../components/ActionSheetModal' +import { colors } from '../theme/mobile-theme' + +type Props = { + visible: boolean + showAgentSessionHistory: boolean + showChecks: boolean + onOpenAgentSessionHistory: () => void + onOpenChecks: () => void + onClose: () => void +} + +export function MobileSessionHeaderMoreActionsSheet({ + visible, + showAgentSessionHistory, + showChecks, + onOpenAgentSessionHistory, + onOpenChecks, + onClose +}: Props) { + return ( + ( + + ), + onPress: onOpenAgentSessionHistory + } + ] + : []), + ...(showChecks + ? [ + { + label: 'Checks', + hint: 'Open pull request checks', + icon: ListChecks, + onPress: onOpenChecks + } + ] + : []) + ]} + onClose={onClose} + /> + ) +} diff --git a/mobile/src/session/ai-vault-resume-launch.test.ts b/mobile/src/session/ai-vault-resume-launch.test.ts new file mode 100644 index 00000000000..c97849298fb --- /dev/null +++ b/mobile/src/session/ai-vault-resume-launch.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildMobileAiVaultResumeLaunch, + buildMobileAiVaultResumeCommand, + createMobileAiVaultResumeMutationRegistry, + readMobileRuntimeHostPlatform, + readMobileRuntimeTerminalWindowsShell, + resolveMobileAiVaultResumePlatform, + resumeAiVaultSessionInTerminal, + RESUME_RPC_TIMEOUT_MS +} from './ai-vault-resume-launch' + +function session(overrides: Partial = {}): AiVaultSession { + return { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session 1', + title: 'Resume me', + cwd: '/Users/ada/repo', + branch: 'main', + model: null, + filePath: '/Users/ada/.claude/session.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-06-29T00:00:00.000Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null, + ...overrides + } +} + +describe('buildMobileAiVaultResumeCommand', () => { + it('delegates POSIX command construction to the shared AI Vault builder', () => { + expect(buildMobileAiVaultResumeCommand({ session: session(), hostPlatform: 'darwin' })).toBe( + "cd '/Users/ada/repo' && claude --resume 'session 1'" + ) + }) + + it('uses PowerShell-safe queued command construction for default local Windows terminals', () => { + expect( + buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'codex', + sessionId: 'codex-1', + cwd: 'C:\\repo app', + codexHome: 'C:\\Users\\Ada\\.codex' + }), + hostPlatform: 'win32' + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\repo app'; $env:CODEX_HOME='C:\\Users\\Ada\\.codex'; codex resume 'codex-1'" + ) + }) + + it('resumes OMP sessions by absolute transcript path like desktop', () => { + // Regression: custom OMP_CODING_AGENT_DIR / WSL-store sessions miss on an + // id lookup, so the forwarded filePath must win over the session id. + expect( + buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'omp', + sessionId: '019f27cd-4268-7000-96e7-62f42a55c144', + filePath: '/Users/ada/.omp/agent/sessions/repo/sess.jsonl', + cwd: '/Users/ada/repo' + }), + hostPlatform: 'darwin' + }) + ).toBe("cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/sess.jsonl'") + }) + + it('uses cmd wrapping when the host Windows terminal is configured as cmd', () => { + const command = buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'codex', + sessionId: 'codex-1', + cwd: 'C:\\repo app', + codexHome: 'C:\\Users\\Ada\\.codex' + }), + hostPlatform: 'win32', + hostTerminalWindowsShell: 'cmd.exe' + }) + expect(command).toContain('cmd /d /s /c') + expect(command).toContain('cd /d ""C:\\repo app""') + expect(command).toContain('set ""CODEX_HOME=C:\\Users\\Ada\\.codex""') + expect(command).toContain('codex resume ""codex-1""') + }) + + it('uses POSIX command construction and converts Codex home for WSL targets', () => { + expect( + buildMobileAiVaultResumeCommand({ + session: session({ + agent: 'codex', + sessionId: 'codex-1', + cwd: '/home/ada/repo', + codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' + }), + hostPlatform: 'linux' + }) + ).toBe("cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'codex-1'") + }) + + it('passes command overrides through to the shared builder', () => { + expect( + buildMobileAiVaultResumeCommand({ + session: session({ sessionId: 'abc' }), + hostPlatform: 'linux', + commandOverride: 'claude-dev' + }) + ).toBe("cd '/Users/ada/repo' && claude-dev --resume 'abc'") + }) +}) + +describe('buildMobileAiVaultResumeLaunch', () => { + it('uses shared TUI startup planning for default args, env, and launch config', () => { + const launch = buildMobileAiVaultResumeLaunch({ + session: session({ + agent: 'claude', + sessionId: 'abc 123', + cwd: '/Users/ada/repo' + }), + hostPlatform: 'darwin', + settings: { + agentCmdOverrides: { claude: 'claude-dev' }, + agentDefaultArgs: { claude: '--model opus' }, + agentDefaultEnv: { claude: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } } + } + }) + expect(launch.command).toBe( + "cd '/Users/ada/repo' && claude-dev '--model' 'opus' '--resume' 'abc 123'" + ) + expect(launch.env).toEqual({ ANTHROPIC_BASE_URL: 'http://localhost:3000' }) + expect(launch.launchAgent).toBe('claude') + expect(launch.launchConfig).toEqual({ + agentCommand: "claude-dev '--model' 'opus'", + agentArgs: '--model opus', + agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } + }) + }) +}) + +describe('resumeAiVaultSessionInTerminal', () => { + it('creates a fresh terminal and sends the command with Enter', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } }) + + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { + command: 'claude --resume abc', + env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } + }, + launchAgent: 'claude', + clientMutationId: 'resume-1' + }) + ).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' }) + expect(sendRequest).toHaveBeenNthCalledWith( + 1, + 'session.tabs.createTerminal', + { + worktree: 'id:worktree-1', + env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }, + launchConfig: { + agentCommand: 'claude', + agentArgs: '', + agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } + }, + launchAgent: 'claude', + clientMutationId: 'resume-1' + }, + // Why: a socket drop mid-resume must reject within the request timeout + // instead of parking on the reconnect waiter with the spinner pinned. + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + expect(sendRequest).toHaveBeenNthCalledWith( + 2, + 'terminal.send', + { + terminal: 'pty-1', + text: 'claude --resume abc', + enter: true + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + }) + + it('throws when terminal creation fails', async () => { + const sendRequest = vi.fn().mockResolvedValueOnce({ + ok: false, + error: { message: 'no terminal' } + }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) + ).rejects.toThrow('no terminal') + }) + + it('throws when the created terminal response is malformed', async () => { + const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' }) + ).rejects.toThrow('Created terminal response was invalid') + }) + + it('throws when terminal send fails or is locked', async () => { + const failedSend = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } + }) + .mockResolvedValueOnce({ ok: false, error: { message: 'send failed' } }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest: failedSend }, 'worktree-1', { + command: 'command' + }) + ).rejects.toThrow('send failed') + + const lockedSend = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } } + }) + .mockResolvedValueOnce({ ok: true, result: { send: { accepted: false } } }) + await expect( + resumeAiVaultSessionInTerminal({ sendRequest: lockedSend }, 'worktree-1', { + command: 'command' + }) + ).rejects.toThrow('Terminal input is locked') + }) +}) + +describe('createMobileAiVaultResumeMutationRegistry', () => { + it('reuses the claimed id across retries until a success releases it', () => { + let mints = 0 + const registry = createMobileAiVaultResumeMutationRegistry((sessionId) => { + mints += 1 + return `${sessionId}:mutation-${mints}` + }) + + expect(registry.claim('session-a')).toBe('session-a:mutation-1') + // A failed attempt keeps the key so the host can dedup the retry. + expect(registry.claim('session-a')).toBe('session-a:mutation-1') + + registry.releaseOnSuccess('session-a') + // A resume after success mints fresh so the user can fork intentionally. + expect(registry.claim('session-a')).toBe('session-a:mutation-2') + }) + + it('tracks sessions independently', () => { + const registry = createMobileAiVaultResumeMutationRegistry((sessionId) => `${sessionId}:id`) + expect(registry.claim('session-a')).toBe('session-a:id') + expect(registry.claim('session-b')).toBe('session-b:id') + registry.releaseOnSuccess('session-b') + expect(registry.claim('session-a')).toBe('session-a:id') + }) +}) + +describe('resume platform helpers', () => { + it('reads a valid host platform from status.get', () => { + expect(readMobileRuntimeHostPlatform({ hostPlatform: 'win32' })).toBe('win32') + expect(readMobileRuntimeHostPlatform({ hostPlatform: 'not-a-platform' })).toBeNull() + }) + + it('reads the host Windows terminal shell from status.get', () => { + expect(readMobileRuntimeTerminalWindowsShell({ terminalWindowsShell: 'wsl.exe' })).toBe( + 'wsl.exe' + ) + expect(readMobileRuntimeTerminalWindowsShell({ terminalWindowsShell: '' })).toBeNull() + }) + + it('uses Linux/POSIX construction for SSH targets and host platform for local targets', () => { + expect(resolveMobileAiVaultResumePlatform('ssh', 'win32')).toBe('linux') + expect(resolveMobileAiVaultResumePlatform('local', 'darwin')).toBe('darwin') + expect( + resolveMobileAiVaultResumePlatform( + 'local', + 'win32', + '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo' + ) + ).toBe('linux') + expect(resolveMobileAiVaultResumePlatform('local', 'win32', 'C:\\repo', 'linux')).toBe('linux') + expect(resolveMobileAiVaultResumePlatform('runtime', 'linux')).toBeNull() + }) +}) diff --git a/mobile/src/session/ai-vault-resume-launch.ts b/mobile/src/session/ai-vault-resume-launch.ts new file mode 100644 index 00000000000..61144c0de2b --- /dev/null +++ b/mobile/src/session/ai-vault-resume-launch.ts @@ -0,0 +1,269 @@ +import type { AiVaultSession } from '../../../src/shared/ai-vault-types' +import { + buildAiVaultResumeCommand, + buildAiVaultResumeShellCommand +} from '../../../src/shared/ai-vault-types' +import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume' +import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume' +import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../../src/shared/tui-agent-launch-defaults' +import type { TuiAgent } from '../../../src/shared/types' +import { parseWslUncPath } from '../../../src/shared/wsl-paths' +import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell' +import type { RpcClient } from '../transport/rpc-client' +import { + readMobileReviewCreatedTerminal, + readMobileReviewTerminalSendAccepted, + type MobileReviewTerminalTab +} from './mobile-diff-review-rpc' +import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target' + +const NODE_PLATFORMS = new Set([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +]) + +export function buildMobileAiVaultResumeCommand(args: { + session: Pick & + Partial> + hostPlatform: NodeJS.Platform + hostTerminalWindowsShell?: string | null + commandOverride?: string | null +}): string { + // Why: this command is typed into the freshly created host terminal, so on + // Windows it must match the host's live shell instead of the phone platform. + const shell = + args.hostPlatform === 'win32' + ? resolveWindowsShellStartupFamily(args.hostTerminalWindowsShell) + : undefined + return buildAiVaultResumeCommand({ + agent: args.session.agent, + sessionId: args.session.sessionId, + // Why: OMP resumes by absolute transcript path (custom OMP dir / WSL-store + // sessions miss on an id lookup), so mobile forwards it like desktop does. + resumeFilePath: args.session.filePath, + cwd: args.session.cwd, + platform: args.hostPlatform, + commandOverride: args.commandOverride, + codexHome: getMobileAiVaultResumeCodexHome(args.session.codexHome, args.hostPlatform), + shell + }) +} + +export type MobileAiVaultResumeSettings = { + agentCmdOverrides?: Partial> + agentDefaultArgs?: Partial> + agentDefaultEnv?: Partial>> +} + +export type MobileAiVaultResumeLaunch = { + command: string + env?: Record + launchConfig?: SleepingAgentLaunchConfig + launchAgent?: TuiAgent +} + +export function buildMobileAiVaultResumeLaunch(args: { + session: Pick & + Partial> + hostPlatform: NodeJS.Platform + hostTerminalWindowsShell?: string | null + settings?: MobileAiVaultResumeSettings | null +}): MobileAiVaultResumeLaunch { + const shell = + args.hostPlatform === 'win32' + ? resolveWindowsShellStartupFamily(args.hostTerminalWindowsShell) + : undefined + const codexHome = getMobileAiVaultResumeCodexHome(args.session.codexHome, args.hostPlatform) + const cmdOverrides = normalizeMobileAiVaultResumeCommandOverrides( + args.settings?.agentCmdOverrides + ) + const commandOverride = cmdOverrides[args.session.agent] ?? null + if (isResumableTuiAgent(args.session.agent)) { + const startupPlan = buildAgentResumeStartupPlan({ + agent: args.session.agent, + providerSession: { key: 'session_id', id: args.session.sessionId }, + cmdOverrides, + platform: args.hostPlatform, + shell, + agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings?.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv) + }) + if (startupPlan) { + return { + command: buildAiVaultResumeShellCommand({ + resumeCommand: startupPlan.launchCommand, + cwd: args.session.cwd, + platform: args.hostPlatform, + codexHome, + shell + }), + ...(startupPlan.env ? { env: startupPlan.env } : {}), + launchConfig: startupPlan.launchConfig, + launchAgent: startupPlan.agent + } + } + } + return { + command: buildMobileAiVaultResumeCommand({ + session: args.session, + hostPlatform: args.hostPlatform, + hostTerminalWindowsShell: args.hostTerminalWindowsShell, + commandOverride + }) + } +} + +function normalizeMobileAiVaultResumeCommandOverrides( + overrides: Partial> | null | undefined +): Partial> { + const normalized: Partial> = {} + if (!overrides) { + return normalized + } + for (const [agent, command] of Object.entries(overrides) as [TuiAgent, string | null][]) { + if (typeof command === 'string' && command.trim()) { + normalized[agent] = command + } + } + return normalized +} + +// Why: without an explicit timeout, a socket drop mid-resume parks the request +// on the reconnect waiter for the full reconnect budget, pinning the spinner. +export const RESUME_RPC_TIMEOUT_MS = 30_000 + +export async function resumeAiVaultSessionInTerminal( + client: Pick, + worktreeId: string, + launch: MobileAiVaultResumeLaunch & { clientMutationId?: string } +): Promise { + const created = await client.sendRequest( + 'session.tabs.createTerminal', + { + worktree: `id:${worktreeId}`, + ...(launch.env ? { env: launch.env } : {}), + ...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}), + ...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}), + ...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {}) + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + if (!created.ok) { + throw new Error(created.error?.message || 'Failed to create terminal') + } + const terminalTab = readMobileReviewCreatedTerminal(created.result) + if (!terminalTab) { + throw new Error('Created terminal response was invalid') + } + const sent = await client.sendRequest( + 'terminal.send', + { + terminal: terminalTab.terminal, + text: launch.command, + enter: true + }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) + if (!sent.ok) { + throw new Error(sent.error?.message || 'Failed to send resume command') + } + if (!readMobileReviewTerminalSendAccepted(sent.result)) { + throw new Error('Terminal input is locked') + } + return terminalTab +} + +export type MobileAiVaultResumeMutationRegistry = { + claim(sessionId: string): string + releaseOnSuccess(sessionId: string): void +} + +// Why: a retry after a failed/interrupted resume must reuse the same +// idempotency key so the host dedups the create, while a resume after success +// mints a fresh key so the user can intentionally fork the session. +export function createMobileAiVaultResumeMutationRegistry( + mintId: (sessionId: string) => string +): MobileAiVaultResumeMutationRegistry { + const bySessionId = new Map() + return { + claim(sessionId: string): string { + const existing = bySessionId.get(sessionId) + if (existing) { + return existing + } + const minted = mintId(sessionId) + bySessionId.set(sessionId, minted) + return minted + }, + releaseOnSuccess(sessionId: string): void { + bySessionId.delete(sessionId) + } + } +} + +export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null { + if (!statusResult || typeof statusResult !== 'object') { + return null + } + const hostPlatform = (statusResult as { hostPlatform?: unknown }).hostPlatform + return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform) + ? (hostPlatform as NodeJS.Platform) + : null +} + +export function readMobileRuntimeTerminalWindowsShell(statusResult: unknown): string | null { + if (!statusResult || typeof statusResult !== 'object') { + return null + } + const shell = (statusResult as { terminalWindowsShell?: unknown }).terminalWindowsShell + return typeof shell === 'string' && shell.trim().length > 0 ? shell : null +} + +export function resolveMobileAiVaultResumePlatform( + targetStatus: MobileAiVaultResumeTargetStatus, + hostPlatform: NodeJS.Platform | null, + workspacePath?: string | null, + terminalPlatform?: NodeJS.Platform | null +): NodeJS.Platform | null { + if (targetStatus === 'ssh') { + // Why: desktop builds SSH resume commands for the remote POSIX execution + // host instead of the phone or local desktop platform. + return 'linux' + } + if (targetStatus === 'local') { + if (terminalPlatform === 'linux' && hostPlatform === 'win32') { + // Why: Windows-hosted WSL project terminals run a POSIX shell even when + // the visible workspace path is a normal Windows path. + return 'linux' + } + if (workspacePath && parseWslUncPath(workspacePath)) { + // Why: a WSL UNC workspace on a Windows host runs in a Linux shell. + return 'linux' + } + return hostPlatform + } + return null +} + +function getMobileAiVaultResumeCodexHome( + codexHome: string | null, + platform: NodeJS.Platform +): string | null { + if (!codexHome || platform !== 'linux') { + return codexHome + } + return parseWslUncPath(codexHome)?.linuxPath ?? codexHome +} diff --git a/mobile/src/session/session-panel-host.test.ts b/mobile/src/session/session-panel-host.test.ts index fb2845143d1..1b17fa4f1e1 100644 --- a/mobile/src/session/session-panel-host.test.ts +++ b/mobile/src/session/session-panel-host.test.ts @@ -4,6 +4,7 @@ import { nextActivePanel, resolvePanelAction, panelRouteDescriptor, + shouldShowSessionHeaderChecksAction, type ActivePanel } from './session-panel-host' @@ -77,6 +78,48 @@ describe('canDockSessionPanel', () => { }) }) +describe('shouldShowSessionHeaderChecksAction', () => { + it('shows Checks on git worktree routes once a checks-capable provider is confirmed', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: false, + repoContextLoaded: true, + hostedChecksSupported: true + }) + ).toBe(true) + }) + + it('hides Checks for providers without hosted checks (pr-panel guard would no-op it)', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: false, + repoContextLoaded: true, + hostedChecksSupported: false + }) + ).toBe(false) + }) + + it('hides Checks until the provider probe resolves', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: false, + repoContextLoaded: false, + hostedChecksSupported: false + }) + ).toBe(false) + }) + + it('hides Checks on folder workspace session routes', () => { + expect( + shouldShowSessionHeaderChecksAction({ + isFolderWorkspaceRoute: true, + repoContextLoaded: true, + hostedChecksSupported: true + }) + ).toBe(false) + }) +}) + describe('panelRouteDescriptor', () => { it('maps each panel to its expo-router pathname', () => { expect(panelRouteDescriptor('sourceControl')).toEqual({ diff --git a/mobile/src/session/session-panel-host.ts b/mobile/src/session/session-panel-host.ts index fdf08602752..5e1e4793e25 100644 --- a/mobile/src/session/session-panel-host.ts +++ b/mobile/src/session/session-panel-host.ts @@ -19,6 +19,17 @@ export type PanelAction = export const SESSION_DOCK_MIN_MAIN_WIDTH = 360 +export function shouldShowSessionHeaderChecksAction(args: { + isFolderWorkspaceRoute: boolean + repoContextLoaded: boolean + hostedChecksSupported: boolean +}): boolean { + // Why: the hosted checks panel is provider-gated and the pr-panel guard + // force-closes it for unsupported providers, so offering the action there + // (or before the provider probe resolves) would be a silent no-op. + return !args.isFolderWorkspaceRoute && args.repoContextLoaded && args.hostedChecksSupported +} + export function canDockSessionPanel(args: { isWideLayout: boolean availableWidth: number diff --git a/mobile/src/worktree/mobile-workspace-lineage.test.ts b/mobile/src/worktree/mobile-workspace-lineage.test.ts index f32219da431..f844593e371 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.test.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.test.ts @@ -25,11 +25,18 @@ function worktree(overrides: Partial = {}): Worktree { describe('applyMobileWorkspaceLineage', () => { it('places visible children directly under their parent', () => { - const parent = worktree({ worktreeId: 'parent', displayName: 'parent' }) + const parent = worktree({ + worktreeId: 'parent', + displayName: 'parent', + worktreeInstanceId: 'parent-instance' + }) const child = worktree({ worktreeId: 'child', displayName: 'child', - parentWorktreeId: 'parent' + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'parent-instance' }) const rows = applyMobileWorkspaceLineage([child, parent]) @@ -61,6 +68,31 @@ describe('applyMobileWorkspaceLineage', () => { expect(rows[0]?.lineageCollapsed).toBe(true) }) + it('keeps legacy host lineage nesting when instance ids are unavailable', () => { + const parent = worktree({ worktreeId: 'parent' }) + const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' }) + + const rows = applyMobileWorkspaceLineage([child, parent]) + + expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child']) + }) + + it('does not nest stale lineage when instance ids no longer match', () => { + const parent = worktree({ worktreeId: 'parent', worktreeInstanceId: 'new-parent-instance' }) + const child = worktree({ + worktreeId: 'child', + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'old-parent-instance' + }) + + const rows = applyMobileWorkspaceLineage([child, parent]) + + expect(rows.map((row) => row.worktreeId)).toEqual(['child', 'parent']) + expect(rows.map((row) => row.lineageDepth)).toEqual([0, 0]) + }) + it('keeps rows visible when lineage is cyclic', () => { const first = worktree({ worktreeId: 'first', parentWorktreeId: 'second' }) const second = worktree({ worktreeId: 'second', parentWorktreeId: 'first' }) diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts index b28b7395928..bdf1ae2d8a1 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.ts @@ -4,17 +4,40 @@ export function getMobileWorkspaceLineageGroupKey(worktreeId: string): string { return `workspace-lineage:${encodeURIComponent(worktreeId)}` } +function hasValidLineageParent(worktree: Worktree, parent: Worktree): boolean { + if ( + worktree.lineageWorktreeInstanceId === undefined && + worktree.parentWorktreeInstanceId === undefined + ) { + return true + } + // Why: desktop rejects stale lineage when a path is reused by a new workspace + // instance; mobile needs the same guard before nesting parent/child rows. + return ( + worktree.worktreeInstanceId === worktree.lineageWorktreeInstanceId && + parent.worktreeInstanceId === worktree.parentWorktreeInstanceId + ) +} + export function applyMobileWorkspaceLineage( worktrees: readonly Worktree[], collapsedGroups: ReadonlySet = new Set() ): Worktree[] { const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId)) + const worktreeById = new Map(worktrees.map((worktree) => [worktree.worktreeId, worktree])) const childrenByParentId = new Map() const childIds = new Set() for (const worktree of worktrees) { const parentId = worktree.parentWorktreeId - if (!parentId || parentId === worktree.worktreeId || !visibleIds.has(parentId)) { + const parent = parentId ? worktreeById.get(parentId) : undefined + if ( + !parentId || + parentId === worktree.worktreeId || + !visibleIds.has(parentId) || + !parent || + !hasValidLineageParent(worktree, parent) + ) { continue } childIds.add(worktree.worktreeId) diff --git a/mobile/src/worktree/workspace-list-ordering.ts b/mobile/src/worktree/workspace-list-ordering.ts new file mode 100644 index 00000000000..4654c6091e5 --- /dev/null +++ b/mobile/src/worktree/workspace-list-ordering.ts @@ -0,0 +1,124 @@ +import type { Worktree } from './workspace-list-types' +import type { MobileSortMode } from './workspace-view-settings' + +export const CREATE_GRACE_MS = 5 * 60 * 1000 + +function getManualSortRank(worktree: Worktree): number | null { + const rank = worktree.manualOrder ?? worktree.sortOrder + return typeof rank === 'number' && Number.isFinite(rank) ? rank : null +} + +function compareDisplayName(a: Worktree, b: Worktree): number { + return a.displayName.localeCompare(b.displayName) +} + +function getRecentActivity(worktree: Worktree): number { + const lastActivityAt = worktree.lastActivityAt ?? 0 + const lastOutputAt = worktree.lastOutputAt ?? 0 + // Why: headless serve hosts only stamp lastActivityAt at creation, so live + // terminal output must still count as recency for mobile-only pairings. + return Math.max( + Number.isFinite(lastActivityAt) ? lastActivityAt : 0, + Number.isFinite(lastOutputAt) ? lastOutputAt : 0 + ) +} + +function effectiveRecentActivity(worktree: Worktree, now: number): number { + const lastActivityAt = getRecentActivity(worktree) + const { createdAt } = worktree + if ( + createdAt === undefined || + !Number.isFinite(createdAt) || + now >= createdAt + CREATE_GRACE_MS + ) { + return lastActivityAt + } + return Math.max(lastActivityAt, createdAt + CREATE_GRACE_MS) +} + +function compareByRecent(a: Worktree, b: Worktree, now: number): number { + return ( + effectiveRecentActivity(b, now) - effectiveRecentActivity(a, now) || compareDisplayName(a, b) + ) +} + +const AGENT_ATTENTION_STATUS_ORDER = { permission: 0, working: 1, done: 2, active: 3, inactive: 4 } + +function compareByAgentAttention(a: Worktree, b: Worktree, now: number): number { + return ( + AGENT_ATTENTION_STATUS_ORDER[getWorktreeStatus(a)] - + AGENT_ATTENTION_STATUS_ORDER[getWorktreeStatus(b)] || compareByRecent(a, b, now) + ) +} + +export function sortWorktrees( + worktrees: Worktree[], + mode: MobileSortMode, + now = Date.now() +): Worktree[] { + if (mode === 'manual') { + return [...worktrees].sort((a, b) => { + const aRank = getManualSortRank(a) + const bRank = getManualSortRank(b) + if (aRank !== null && bRank !== null && aRank !== bRank) { + // Why: desktop assigns higher sort/manual ranks to earlier list positions. + return bRank - aRank + } + if (aRank !== null && bRank === null) { + return -1 + } + if (aRank === null && bRank !== null) { + return 1 + } + return compareDisplayName(a, b) + }) + } + return [...worktrees].sort((a, b) => { + if (mode === 'name') { + return compareDisplayName(a, b) + } + if (mode === 'recent') { + return compareByRecent(a, b, now) + } + if (mode === 'repo') { + const repoComparison = a.repo.localeCompare(b.repo) + return repoComparison || compareDisplayName(a, b) + } + const aRank = typeof a.sortOrder === 'number' && Number.isFinite(a.sortOrder) ? a.sortOrder : 0 + const bRank = typeof b.sortOrder === 'number' && Number.isFinite(b.sortOrder) ? b.sortOrder : 0 + if (aRank !== bRank) { + // Why: desktop persists its computed Agent activity order into sortOrder; + // mobile should render that source-of-truth before local fallback signals. + return bRank - aRank + } + if (aRank === 0) { + // Why: headless serve hosts never persist desktop smart ranks; unranked + // rows fall back to agent attention order instead of a frozen A-Z list. + return compareByAgentAttention(a, b, now) + } + return compareDisplayName(a, b) + }) +} + +export function getWorktreeStatus( + w: Worktree +): 'working' | 'active' | 'permission' | 'done' | 'inactive' { + // Why: desktop's sidebar activity is the parity source. Runtime status may + // still report retained/background PTYs as active after desktop hides them. + if (w.hasHostSidebarActivity === false) { + return 'inactive' + } + if (w.status && w.status !== 'inactive') { + return w.status + } + if (w.hasHostSidebarActivity === true) { + return 'active' + } + if (w.status) { + return w.status + } + if (w.liveTerminalCount > 0) { + return 'active' + } + return 'inactive' +} diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index e6de6d8f466..d6d04d43d3e 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -1,7 +1,13 @@ import { join } from 'node:path' import { describe, expect, it } from 'vitest' import type { Worktree } from './workspace-list-sections' -import { buildSections, filterWorktrees, getWorktreeStatus } from './workspace-list-sections' +import { + CREATE_GRACE_MS, + buildSections, + filterWorktrees, + getWorktreeStatus, + sortWorktrees +} from './workspace-list-sections' import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses' function worktree(overrides: Partial = {}): Worktree { @@ -145,6 +151,16 @@ describe('getWorktreeStatus', () => { }) describe('buildSections', () => { + it('matches desktop Name sort by display name', () => { + const beta = worktree({ worktreeId: 'beta', displayName: 'Beta', repo: 'aaa' }) + const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', repo: 'zzz' }) + + expect(sortWorktrees([beta, alpha], 'name').map((item) => item.worktreeId)).toEqual([ + 'alpha', + 'beta' + ]) + }) + it('uses desktop manual order ranks in Manual sort mode', () => { const low = worktree({ worktreeId: 'low', displayName: 'low', manualOrder: 10 }) const high = worktree({ worktreeId: 'high', displayName: 'high', manualOrder: 30 }) @@ -168,6 +184,264 @@ describe('buildSections', () => { ]) }) + it('uses desktop display-name tie-breaks in Manual sort mode', () => { + const zed = worktree({ worktreeId: 'zed', displayName: 'Zed', manualOrder: 10 }) + const alpha = worktree({ worktreeId: 'alpha', displayName: 'Alpha', manualOrder: 10 }) + + expect(sortWorktrees([zed, alpha], 'manual').map((item) => item.worktreeId)).toEqual([ + 'alpha', + 'zed' + ]) + }) + + it('uses desktop-persisted smart order before mobile fallback signals', () => { + const desktopFirst = worktree({ + worktreeId: 'desktop-first', + displayName: 'desktop-first', + sortOrder: 30, + status: 'inactive', + unread: false, + lastOutputAt: 1 + }) + const mobileFallbackFirst = worktree({ + worktreeId: 'mobile-fallback-first', + displayName: 'mobile-fallback-first', + sortOrder: 10, + status: 'working', + unread: true, + lastOutputAt: 100 + }) + + const sections = buildSections( + [mobileFallbackFirst, desktopFirst], + 'smart', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'workspaceStatus', + new Set(), + new Map(), + DEFAULT_MOBILE_WORKSPACE_STATUSES + ) + + expect(sections.flatMap((section) => section.data.map((item) => item.worktreeId))).toEqual([ + 'desktop-first', + 'mobile-fallback-first' + ]) + }) + + it('uses desktop display-name tie-breaks for equal persisted smart ranks', () => { + const zed = worktree({ worktreeId: 'zed', displayName: 'Zed', sortOrder: 20, unread: true }) + const alpha = worktree({ + worktreeId: 'alpha', + displayName: 'Alpha', + sortOrder: 20, + status: 'working', + lastOutputAt: 100 + }) + + expect(sortWorktrees([zed, alpha], 'smart').map((item) => item.worktreeId)).toEqual([ + 'alpha', + 'zed' + ]) + }) + + it('counts live terminal output as Recent activity for headless serve hosts', () => { + // Serve hosts only stamp lastActivityAt at creation, so output must rank. + const streamingOnServe = worktree({ + worktreeId: 'streaming', + displayName: 'streaming', + lastActivityAt: 100, + lastOutputAt: 1_000 + }) + const touchedOnDesktop = worktree({ + worktreeId: 'touched', + displayName: 'touched', + lastActivityAt: 200, + lastOutputAt: 1 + }) + + expect( + sortWorktrees([touchedOnDesktop, streamingOnServe], 'recent').map((item) => item.worktreeId) + ).toEqual(['streaming', 'touched']) + }) + + it('keeps desktop activity ranking in Recent when it is newest', () => { + const newerActivity = worktree({ + worktreeId: 'newer-activity', + displayName: 'newer', + lastActivityAt: 2_000, + lastOutputAt: 1 + }) + const quietOlder = worktree({ + worktreeId: 'quiet-older', + displayName: 'quiet', + lastActivityAt: 100, + lastOutputAt: 150 + }) + + expect( + sortWorktrees([quietOlder, newerActivity], 'recent').map((item) => item.worktreeId) + ).toEqual(['newer-activity', 'quiet-older']) + }) + + it('falls back to agent attention order in Smart when no desktop ranks exist', () => { + // Display names are deliberately reverse-alphabetical to prove status ranks. + const idle = worktree({ worktreeId: 'idle', displayName: 'Aardvark' }) + const needsPermission = worktree({ + worktreeId: 'needs-permission', + displayName: 'Zebra', + status: 'permission' + }) + const working = worktree({ worktreeId: 'working', displayName: 'Yak', status: 'working' }) + + expect( + sortWorktrees([idle, working, needsPermission], 'smart').map((item) => item.worktreeId) + ).toEqual(['needs-permission', 'working', 'idle']) + }) + + it('keeps desktop-ranked rows above unranked attention-fallback rows in Smart', () => { + const ranked = worktree({ worktreeId: 'ranked', displayName: 'Ranked', sortOrder: 5 }) + const unrankedWorking = worktree({ + worktreeId: 'unranked-working', + displayName: 'Working', + status: 'working' + }) + + expect( + sortWorktrees([unrankedWorking, ranked], 'smart').map((item) => item.worktreeId) + ).toEqual(['ranked', 'unranked-working']) + }) + + it('matches desktop Recent create grace for newly-created workspaces', () => { + const now = 10_000 + const created = worktree({ + worktreeId: 'created', + displayName: 'created', + lastActivityAt: 100, + createdAt: now - 1_000 + }) + const active = worktree({ + worktreeId: 'active', + displayName: 'active', + lastActivityAt: now + CREATE_GRACE_MS - 2_000 + }) + + expect(sortWorktrees([active, created], 'recent', now).map((item) => item.worktreeId)).toEqual([ + 'created', + 'active' + ]) + }) + + it('matches desktop Repo sort by repo display name then workspace display name', () => { + const betaAlpha = worktree({ + worktreeId: 'beta-alpha', + repo: 'Beta Repo', + displayName: 'Alpha' + }) + const alphaZed = worktree({ + worktreeId: 'alpha-zed', + repo: 'Alpha Repo', + displayName: 'Zed' + }) + const betaBravo = worktree({ + worktreeId: 'beta-bravo', + repo: 'Beta Repo', + displayName: 'Bravo' + }) + + expect( + sortWorktrees([betaBravo, betaAlpha, alphaZed], 'repo').map((item) => item.worktreeId) + ).toEqual(['alpha-zed', 'beta-alpha', 'beta-bravo']) + }) + + it('uses desktop localeCompare semantics for Repo sort names', () => { + const accentRepo = worktree({ + worktreeId: 'accent-repo', + repo: 'áb repo', + displayName: 'Alpha' + }) + const plainRepo = worktree({ + worktreeId: 'plain-repo', + repo: 'ab repo', + displayName: 'Zed' + }) + + expect(sortWorktrees([accentRepo, plainRepo], 'repo').map((item) => item.worktreeId)).toEqual([ + 'plain-repo', + 'accent-repo' + ]) + }) + + it('keeps a desktop-ranked parent and child stack above unrelated active rows', () => { + const parent = worktree({ + worktreeId: 'parent', + displayName: 'Add agents to mobile', + repo: 'orca', + sortOrder: 30, + status: 'inactive' + }) + const child = worktree({ + worktreeId: 'child', + displayName: 'Agent Session History resume (PR2)', + repo: 'orca', + parentWorktreeId: 'parent', + sortOrder: 20, + status: 'inactive' + }) + const unrelatedActive = worktree({ + worktreeId: 'active', + displayName: 'Overlapping tui output', + repo: 'orca', + sortOrder: 10, + status: 'working', + unread: true, + lastOutputAt: 100 + }) + + const sections = buildSections( + [unrelatedActive, child, parent], + 'smart', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'repo', + new Set(), + new Map([['orca', 'repo-1']]), + DEFAULT_MOBILE_WORKSPACE_STATUSES + ) + + expect(sections[0]?.data.map((item) => item.worktreeId)).toEqual(['parent', 'child', 'active']) + expect(sections[0]?.data.map((item) => item.lineageDepth)).toEqual([0, 1, 0]) + }) + + it('keeps the main workspace first inside repo-grouped sections like desktop', () => { + const child = worktree({ + worktreeId: 'child', + displayName: 'Child', + repo: 'orca', + sortOrder: 30, + isMainWorktree: false + }) + const main = worktree({ + worktreeId: 'main', + displayName: 'Main', + repo: 'orca', + sortOrder: 10, + isMainWorktree: true + }) + + const sections = buildSections( + [child, main], + 'smart', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'repo', + new Set(), + new Map([['orca', 'repo-1']]) + ) + + expect(sections[0]?.data.map((item) => item.worktreeId)).toEqual(['main', 'child']) + }) + it('renders empty repo sections from repo placeholders in repo grouping', () => { const sections = buildSections( [worktree({ repoId: 'repo-1', repo: 'orca' })], @@ -307,7 +581,7 @@ describe('buildSections', () => { expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['progress']) }) - it('does not duplicate pinned worktrees in their canonical status group', () => { + it('keeps pinned worktrees in their canonical status group like desktop', () => { const pinned = worktree({ worktreeId: 'pinned', workspaceStatus: 'in-progress', @@ -326,7 +600,40 @@ describe('buildSections', () => { ) expect(withoutSectionListKeys(sections)).toEqual([ - { key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] } + { key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] }, + { key: 'workspace-status:in-progress', title: 'In progress', data: [pinned] } + ]) + }) + + it('renders one sorted All section when grouping is off like desktop', () => { + const inactiveFirst = worktree({ + worktreeId: 'inactive-first', + displayName: 'inactive-first', + manualOrder: 30, + status: 'inactive' + }) + const activeSecond = worktree({ + worktreeId: 'active-second', + displayName: 'active-second', + manualOrder: 10, + status: 'working' + }) + + const sections = buildSections( + [activeSecond, inactiveFirst], + 'manual', + { filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false }, + '', + 'none', + new Set(), + new Map(), + DEFAULT_MOBILE_WORKSPACE_STATUSES + ) + + expect(sections.map((section) => section.key)).toEqual(['all']) + expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual([ + 'inactive-first', + 'active-second' ]) }) diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index 1691659e9f4..bdb9e4783e3 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -9,8 +9,10 @@ import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage' import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-status-groups' import type { FilterState, Section, Worktree } from './workspace-list-types' import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings' +import { sortWorktrees } from './workspace-list-ordering' export type { FilterState, Section, Worktree } from './workspace-list-types' +export { CREATE_GRACE_MS, getWorktreeStatus, sortWorktrees } from './workspace-list-ordering' function makeSection( key: string, @@ -31,29 +33,6 @@ function makeSection( } } -export function getWorktreeStatus( - w: Worktree -): 'working' | 'active' | 'permission' | 'done' | 'inactive' { - // Why: desktop's sidebar activity is the parity source. Runtime status may - // still report retained/background PTYs as active after desktop hides them. - if (w.hasHostSidebarActivity === false) { - return 'inactive' - } - if (w.status && w.status !== 'inactive') { - return w.status - } - if (w.hasHostSidebarActivity === true) { - return 'active' - } - if (w.status) { - return w.status - } - if (w.liveTerminalCount > 0) { - return 'active' - } - return 'inactive' -} - // Why: the previous 10-minute lastOutputAt window was too strict — most // worktrees with idle terminal prompts had no recent output and were excluded. // Any worktree with live terminals or unread output counts as "active". @@ -86,54 +65,12 @@ function isDefaultBranchWorkspace(w: Worktree): boolean { return branch === 'main' || branch === 'master' } -function getManualSortRank(worktree: Worktree): number | null { - const rank = worktree.manualOrder ?? worktree.sortOrder - return typeof rank === 'number' && Number.isFinite(rank) ? rank : null -} - -export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Worktree[] { - if (mode === 'manual') { - return [...worktrees].sort((a, b) => { - const aRank = getManualSortRank(a) - const bRank = getManualSortRank(b) - if (aRank !== null && bRank !== null && aRank !== bRank) { - // Why: desktop assigns higher sort/manual ranks to earlier list positions. - return bRank - aRank - } - if (aRank !== null && bRank === null) { - return -1 - } - if (aRank === null && bRank !== null) { - return 1 - } - return 0 - }) +function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { + const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) + if (mainWorktrees.length === 0) { + return worktrees } - return [...worktrees].sort((a, b) => { - if (mode === 'name') { - return (a.displayName || a.repo).localeCompare(b.displayName || b.repo) - } - if (mode === 'recent') { - return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) - } - if (mode === 'repo') { - const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' }) - return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo) - } - if (a.unread !== b.unread) { - return a.unread ? -1 : 1 - } - const aStatus = getWorktreeStatus(a) - const bStatus = getWorktreeStatus(b) - const statusOrder = { permission: 0, working: 1, done: 2, active: 3, inactive: 4 } - if (statusOrder[aStatus] !== statusOrder[bStatus]) { - return statusOrder[aStatus] - statusOrder[bStatus] - } - if ((a.lastOutputAt ?? 0) !== (b.lastOutputAt ?? 0)) { - return (b.lastOutputAt ?? 0) - (a.lastOutputAt ?? 0) - } - return (a.displayName || a.repo).localeCompare(b.displayName || b.repo) - }) + return [...mainWorktrees, ...worktrees.filter((worktree) => !worktree.isMainWorktree)] } export function filterWorktrees( @@ -155,7 +92,7 @@ export function filterWorktrees( const q = search.toLowerCase() result = result.filter( (w) => - (w.displayName || w.repo).toLowerCase().includes(q) || + w.displayName.toLowerCase().includes(q) || w.branch.toLowerCase().includes(q) || w.repo.toLowerCase().includes(q) ) @@ -182,12 +119,9 @@ export function buildSections( const sorted = sortWorktrees(filtered, sortMode) const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds)) - const unpinned = sorted.filter((w) => !isWorktreePinned(w, pinnedIds)) - // Why: mobile shows pinned workspaces once in the pinned section; duplicating - // them in status/project sections makes the phone list harder to scan. - const canonicalGroupWorktrees = unpinned - const active = canonicalGroupWorktrees.filter(isWorktreeActive) - const inactive = canonicalGroupWorktrees.filter((w) => !isWorktreeActive(w)) + // Why: desktop treats Pinned as an overlay. Keeping pinned rows in canonical + // groups preserves exact cross-surface order and literal section counts. + const canonicalGroupWorktrees = sorted const sections: Section[] = [] if (pinned.length > 0) { @@ -195,19 +129,8 @@ export function buildSections( } if (groupMode === 'none') { - if (active.length > 0) { - sections.push(makeSection('all-active', 'Active', active, undefined, collapsedGroups)) - } - if (inactive.length > 0) { - sections.push( - makeSection( - 'all', - pinned.length > 0 || active.length > 0 ? 'All' : '', - inactive, - undefined, - collapsedGroups - ) - ) + if (canonicalGroupWorktrees.length > 0) { + sections.push(makeSection('all', 'All', canonicalGroupWorktrees, undefined, collapsedGroups)) } } else if (groupMode === 'repo') { const byRepo = new Map() @@ -238,7 +161,9 @@ export function buildSections( } for (const [repo, items] of byRepo) { const key = `repo:${repoIdsByName.get(repo) ?? repo}` - sections.push(makeSection(key, repo, items, undefined, collapsedGroups)) + sections.push( + makeSection(key, repo, orderMainWorktreeFirst(items), undefined, collapsedGroups) + ) } } else if (groupMode === 'workspaceStatus') { const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 8418b2753ad..4dfbc74fa53 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../../src/shared/execution-host' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' export type Worktree = { @@ -5,18 +6,25 @@ export type Worktree = { workspaceKind?: 'git' | 'folder-workspace' worktreeId: string repoId: string + hostId?: ExecutionHostId + terminalPlatform?: NodeJS.Platform repo: string branch: string displayName: string workspaceStatus?: string sortOrder?: number manualOrder?: number + lastActivityAt?: number + createdAt?: number // Why: on-disk worktree directory path. Needed by NewWorktreeModal so the // marine-creature fallback dedupes against filesystem basenames. path: string isArchived?: boolean isMainWorktree?: boolean hasHostSidebarActivity?: boolean + worktreeInstanceId?: string + lineageWorktreeInstanceId?: string + parentWorktreeInstanceId?: string parentWorktreeId?: string | null childWorktreeIds?: string[] lineageDepth?: number diff --git a/mobile/src/worktree/worktree-list-snapshot.test.ts b/mobile/src/worktree/worktree-list-snapshot.test.ts index 339d5d1c9a3..d6d3af1930c 100644 --- a/mobile/src/worktree/worktree-list-snapshot.test.ts +++ b/mobile/src/worktree/worktree-list-snapshot.test.ts @@ -96,6 +96,31 @@ describe('areWorktreeListsEqual', () => { ).toBe(false) }) + it('detects desktop ordering field changes', () => { + expect( + areWorktreeListsEqual( + [worktree({ lastActivityAt: 10, createdAt: 1 })], + [worktree({ lastActivityAt: 20, createdAt: 1 })] + ) + ).toBe(false) + expect( + areWorktreeListsEqual( + [worktree({ lastActivityAt: 10, createdAt: 1 })], + [worktree({ lastActivityAt: 10, createdAt: 2 })] + ) + ).toBe(false) + }) + + it('detects resume host and terminal platform changes', () => { + expect(areWorktreeListsEqual([worktree()], [worktree({ hostId: 'ssh:box' })])).toBe(false) + expect( + areWorktreeListsEqual( + [worktree({ terminalPlatform: 'win32' })], + [worktree({ terminalPlatform: 'linux' })] + ) + ).toBe(false) + }) + it('detects lineage changes', () => { const base = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-a' }) const changedParent = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-b' }) @@ -109,6 +134,25 @@ describe('areWorktreeListsEqual', () => { expect(areWorktreeListsEqual([base], [changedChildren])).toBe(false) }) + it('detects lineage instance changes', () => { + const base = worktree({ + worktreeId: 'child', + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'parent-instance' + }) + const changedParentInstance = worktree({ + worktreeId: 'child', + parentWorktreeId: 'parent', + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'new-parent-instance' + }) + + expect(areWorktreeListsEqual([base], [changedParentInstance])).toBe(false) + }) + it('detects agent status changes', () => { const first = [worktree({ agents: [agent({ state: 'working' })] })] const second = [worktree({ agents: [agent({ state: 'waiting' })] })] diff --git a/mobile/src/worktree/worktree-list-snapshot.ts b/mobile/src/worktree/worktree-list-snapshot.ts index c851bda24d9..bb9df6cf9af 100644 --- a/mobile/src/worktree/worktree-list-snapshot.ts +++ b/mobile/src/worktree/worktree-list-snapshot.ts @@ -25,14 +25,21 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean { left.repoId === right.repoId && left.repo === right.repo && left.branch === right.branch && + (left.hostId ?? null) === (right.hostId ?? null) && + (left.terminalPlatform ?? null) === (right.terminalPlatform ?? null) && left.displayName === right.displayName && (left.workspaceStatus ?? null) === (right.workspaceStatus ?? null) && (left.sortOrder ?? null) === (right.sortOrder ?? null) && (left.manualOrder ?? null) === (right.manualOrder ?? null) && + (left.lastActivityAt ?? null) === (right.lastActivityAt ?? null) && + (left.createdAt ?? null) === (right.createdAt ?? null) && left.path === right.path && (left.isArchived ?? false) === (right.isArchived ?? false) && (left.isMainWorktree ?? false) === (right.isMainWorktree ?? false) && (left.hasHostSidebarActivity ?? false) === (right.hasHostSidebarActivity ?? false) && + (left.worktreeInstanceId ?? null) === (right.worktreeInstanceId ?? null) && + (left.lineageWorktreeInstanceId ?? null) === (right.lineageWorktreeInstanceId ?? null) && + (left.parentWorktreeInstanceId ?? null) === (right.parentWorktreeInstanceId ?? null) && (left.parentWorktreeId ?? null) === (right.parentWorktreeId ?? null) && areStringArraysEqual(left.childWorktreeIds ?? [], right.childWorktreeIds ?? []) && left.liveTerminalCount === right.liveTerminalCount && diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index 1e3a42479dc..cb352882776 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "expo/tsconfig.base", + "extends": "expo/tsconfig.base.json", "compilerOptions": { "baseUrl": ".", "strict": true, diff --git a/package.json b/package.json index 757b3d7e22c..a45b19a9457 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.133", + "version": "1.4.135-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 79539599922..90726a2d7a7 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -411,6 +411,7 @@ describe('remote hook service installers', () => { 'SessionStart', 'UserPromptSubmit', 'Stop', + 'StopFailure', 'SessionEnd', 'PreToolUse', 'PostToolUse', @@ -422,7 +423,10 @@ describe('remote hook service installers', () => { expect(command).toContain('/home/dev/.orca/agent-hooks/grok-hook.sh') expect(command).toMatch(/^if \[ -x /) } - expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('*') + // Why: Grok tool matchers are real regexes; bare `*` is invalid match-all. + expect(grokConfig.hooks.PreToolUse?.[0]?.matcher).toBe('.*') + expect(grokConfig.hooks.PostToolUse?.[0]?.matcher).toBe('.*') + expect(grokConfig.hooks.StopFailure?.[0]?.matcher).toBeUndefined() const devinConfig = JSON.parse(devin.fs.files.get('/home/dev/.config/devin/config.json')!) as { permissions: { mode: string } @@ -451,6 +455,35 @@ describe('remote hook service installers', () => { expect(devin.fs.files.get('/home/dev/.orca/agent-hooks/devin-hook.sh')).toContain('/hook/devin') }) + it('installs remote Grok config in the explicit guest GROK_HOME', async () => { + const { sftp, fs } = createFakeSftp() + + const status = await new GrokHookService().installRemote( + sftp, + '/home/dev', + '/srv/grok profile/' + ) + + expect(status.configPath).toBe('/srv/grok profile/hooks/orca-status.json') + expect(fs.files.has('/srv/grok profile/hooks/orca-status.json')).toBe(true) + expect(fs.files.has('/home/dev/.grok/hooks/orca-status.json')).toBe(false) + const script = fs.files.get('/home/dev/.orca/agent-hooks/grok-hook.sh')! + expect(script).toContain('${#GROK_HOME}" -le 4096') + expect(script).toContain('--data-urlencode "grokHome=${grok_home}"') + }) + + it.each(['relative/grok', '/bad\\grok', `/${'x'.repeat(4096)}`])( + 'falls back to login-home Grok config for invalid remote home %s', + async (remoteGrokHome) => { + const { sftp, fs } = createFakeSftp() + + const status = await new GrokHookService().installRemote(sftp, '/home/dev', remoteGrokHome) + + expect(status.configPath).toBe('/home/dev/.grok/hooks/orca-status.json') + expect(fs.files.has('/home/dev/.grok/hooks/orca-status.json')).toBe(true) + } + ) + it('installs remote Kimi hooks as a managed config.toml block preserving user config', async () => { const userConfig = 'default_model = "kimi-k2.6"\n\n[providers."mine"]\napi_key = "sk-secret"\n' const { sftp, fs } = createFakeSftp({ '/home/dev/.kimi-code/config.toml': userConfig }) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 0151f63755c..39507f091f2 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -21,6 +21,8 @@ export type RemoteManagedHookInstallOptions = { * defers the config.toml trust write until that file exists, so the * launch path's only-if-absent seed is never pre-empted. */ codexHomeDir?: string + /** Explicit GROK_HOME for remote runtimes that redirect Grok's config. */ + grokHomeDir?: string } type RemoteManagedHookInstaller = readonly [ @@ -52,7 +54,11 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['cursor', (sftp, remoteHome) => cursorHookService.installRemote(sftp, remoteHome)], ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], ['copilot', (sftp, remoteHome) => copilotHookService.installRemote(sftp, remoteHome)], - ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], + [ + 'grok', + (sftp, remoteHome, options) => + grokHookService.installRemote(sftp, remoteHome, options?.grokHomeDir) + ], ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], diff --git a/src/main/agent-hooks/server-grok-discovery.test.ts b/src/main/agent-hooks/server-grok-discovery.test.ts new file mode 100644 index 00000000000..cea007c3d2f --- /dev/null +++ b/src/main/agent-hooks/server-grok-discovery.test.ts @@ -0,0 +1,119 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as agentHookListener from '../../shared/agent-hook-listener' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() +}) + +function hookBody(payload: Record): Record { + return { + paneKey: PANE_KEY, + tabId: 'tab-1', + worktreeId: 'wt-1', + env: 'production', + payload + } +} + +async function postGrokHook( + endpoint: { port: string; token: string }, + payload: Record +): Promise { + return fetch(`http://127.0.0.1:${endpoint.port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': endpoint.token + }, + body: JSON.stringify(hookBody(payload)) + }) +} + +describe('AgentHookServer Grok discovery retries', () => { + it('waits for delayed discovery beyond the transcript retry window', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const server = new AgentHookServer() + const root = mkdtempSync(join(tmpdir(), 'orca-grok-delayed-discovery-')) + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf532' + const cwd = join(root, 'workspace') + const sessionDir = join(root, '.grok', 'sessions', encodeURIComponent(cwd), sessionId) + mkdirSync(sessionDir, { recursive: true }) + const history = join(sessionDir, 'chat_history.jsonl') + writeFileSync(history, '') + vi.stubEnv('HOME', root) + vi.stubEnv('USERPROFILE', root) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const endpoint = { port: env.ORCA_AGENT_HOOK_PORT, token: env.ORCA_AGENT_HOOK_TOKEN } + const listener = vi.fn() + server.setListener(listener) + + await postGrokHook(endpoint, { hookEventName: 'UserPromptSubmit', prompt: 'delayed result' }) + await postGrokHook(endpoint, { hookEventName: 'Stop', sessionId, cwd }) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(listener.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + + writeFileSync( + history, + `${JSON.stringify({ type: 'assistant', content: 'Found after discovery.' })}\n` + ) + releaseDiscovery() + + await vi.waitFor(() => { + expect(listener.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe( + 'Found after discovery.' + ) + }) + } finally { + server.stop() + rmSync(root, { recursive: true, force: true }) + } + }) + + it('does not overwrite a newer same-text prompt when delayed discovery completes', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const server = new AgentHookServer() + const root = mkdtempSync(join(tmpdir(), 'orca-grok-stale-discovery-')) + vi.stubEnv('HOME', root) + vi.stubEnv('USERPROFILE', root) + await server.start({ env: 'production' }) + try { + const env = server.buildPtyEnv() + const endpoint = { port: env.ORCA_AGENT_HOOK_PORT, token: env.ORCA_AGENT_HOOK_TOKEN } + await postGrokHook(endpoint, { hookEventName: 'UserPromptSubmit', prompt: 'old prompt' }) + await postGrokHook(endpoint, { + hookEventName: 'Stop', + sessionId: '019e37f4-5135-7b63-a4ab-6d13aa6bf533', + cwd: join(root, 'workspace') + }) + await postGrokHook(endpoint, { hookEventName: 'UserPromptSubmit', prompt: 'old prompt' }) + + releaseDiscovery() + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ state: 'working', prompt: 'old prompt', agentType: 'grok' }) + ]) + } finally { + server.stop() + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 91b0dc66051..bf3cf0c8f84 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -5305,6 +5305,8 @@ describe('Copilot hook normalization', () => { }) ) + // Let the first 50ms retry miss so continuation across SessionEnd is proven. + await new Promise((resolve) => setTimeout(resolve, 70)) writeFileSync( transcriptPath, `${JSON.stringify({ diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 167331765cf..da33b4231cb 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -31,6 +31,7 @@ import { parseFormEncodedBody, readRequestBody, resolveHookSource, + preparePendingGrokResultDiscovery, warnOnHookEnvOrVersionMismatch, writeEndpointFile, type AgentHookEventPayload, @@ -804,7 +805,8 @@ export class AgentHookServer { source: AgentHookSource, body: unknown, original: EnrichedAgentHookEventPayload, - attempt = 1 + attempt = 1, + discoveryReady = false ): void { if ( original.payload.lastAssistantMessage || @@ -814,28 +816,27 @@ export class AgentHookServer { return } this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: slug-group discovery can outlive the bounded transcript-flush + // timers. Its completion must drive the first retry deterministically. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, 1, true) + } + }) + .catch((err) => { + console.error('[agent-hooks] Grok result discovery failed:', err) + }) + return + } + } const timer = setTimeout(() => { try { this.assistantMessageRetryTimers.delete(original.paneKey) - const current = this.state.lastStatusByPaneKey.get(original.paneKey) as - | EnrichedAgentHookEventPayload - | undefined - if ( - !current || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const normalized = normalizeHookPayload(this.state, source, body, this.env) - if (!normalized?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, attempt + 1) - return - } - // Why: some agents POST Stop before their transcript/chat-history line - // is flushed. Retry from a timer so the hook request returns immediately. - this.applyNormalizedStatus(normalized) + this.applyAssistantMessageRetry(source, body, original, attempt + 1, discoveryReady) } catch (err) { console.error('[agent-hooks] assistant message retry failed:', err) } @@ -846,6 +847,35 @@ export class AgentHookServer { } } + private applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: EnrichedAgentHookEventPayload, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) as + | EnrichedAgentHookEventPayload + | undefined + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const normalized = normalizeHookPayload(this.state, source, body, this.env) + if (!normalized?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry(source, body, original, nextAttempt, requireExactOriginal) + return + } + // Why: some agents POST Stop before their transcript/chat-history line is + // flushed. Discovery is event-driven; subsequent content retries stay timed. + this.applyNormalizedStatus(normalized) + } + setPaneKeyAliasPersistenceListener(listener: PaneKeyAliasPersistenceListener | null): void { this.paneKeyAliasPersistenceListener = listener } diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts new file mode 100644 index 00000000000..4b39e75eb72 --- /dev/null +++ b/src/main/ai-vault/cached-session-list.ts @@ -0,0 +1,101 @@ +import { join } from 'node:path' +import { scanAiVaultSessions } from './session-scanner' +import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' + +// Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime +// RPC method share a single cache instance — opening the desktop panel and the +// mobile screen for the same scope must not double-scan hundreds of transcripts. +const AI_VAULT_CACHE_TTL_MS = 15_000 + +// Why: codex-home + WSL home dirs must be sourced from a serve-mode-reachable +// seam (the OrcaRuntimeService deps), NOT the window-only registerCoreHandlers +// path — `orca serve` never runs that path, so sourcing it there would silently +// drop managed-Codex sessions from remote/SSH results. +export type AiVaultSessionSources = { + getAdditionalCodexHomePaths?: () => readonly string[] +} + +type CachedAiVaultList = { + key: string + result: AiVaultListResult + expiresAt: number +} + +let cachedList: CachedAiVaultList | null = null +let inflightList: Promise | null = null +let inflightKey: string | null = null +let sources: AiVaultSessionSources = {} + +export function configureAiVaultSessionSources(next: AiVaultSessionSources): void { + sources = next +} + +export async function listAiVaultSessions(args?: AiVaultListArgs): Promise { + // Scope paths change the result set, so they must be part of the cache key. + const key = JSON.stringify({ + limit: args?.limit ?? 'default', + scopePaths: args?.scopePaths ?? [] + }) + const now = Date.now() + // Why: opening this panel repeatedly should not re-parse hundreds of JSONL + // transcripts; explicit refreshes bypass the cache but not an active scan. + if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) { + return cachedList.result + } + if (inflightList && inflightKey === key) { + return inflightList + } + + inflightKey = key + const additionalCodexSessionsDirs = + sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] + inflightList = (async () => + scanAiVaultSessions({ + limit: args?.limit, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + // Why: this scan is always host-local; callers addressing this host by a + // runtime id get the result restamped at the RPC edge, never rescanned. + executionHostId: LOCAL_EXECUTION_HOST_ID + }))() + .then((result) => { + cachedList = { + key, + result, + expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + } + return result + }) + .finally(() => { + // Only clear tracking if it still refers to this request: a concurrent + // different-key scan may have replaced it and must stay dedupable. + if (inflightKey === key) { + inflightKey = null + inflightList = null + } + }) + return inflightList +} + +// Exported for the subagent-transcript IPC path, which validates +// renderer-supplied paths against the same WSL-aware Claude roots the scan uses. +export async function getAiVaultWslHomeDirs(): Promise { + if (process.platform !== 'win32') { + return [] + } + const homes = await Promise.all( + (await listWslDistrosAsync()).map((distro) => getWslHomeAsync(distro)) + ) + return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) +} + +// Why: tests reset module-level cache/source state between cases. +export function resetAiVaultSessionListCacheForTests(): void { + cachedList = null + inflightList = null + inflightKey = null + sources = {} +} diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index d02519eaff2..b2f318d8e40 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { AI_VAULT_AGENTS, + AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListArgs, type AiVaultListResult, type AiVaultSession @@ -121,7 +122,11 @@ export async function scanRuntimeAiVaultSessions( { limit: args.limit, force: args.force, - scopePaths: args.scopePaths, + // Why: cap here so the set of scanned paths is explicit on this side — + // the RPC schema CLAMPS to the same bound anyway (older hosts had no + // cap). Dropped paths only lose the older-than-recency-cap guarantee, + // never the recent sessions themselves. + scopePaths: args.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT), executionHostId }, options.timeoutMs diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index ea21dc590f6..06f8df9c39a 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -25,6 +25,29 @@ export function aiVaultScanIssueResult(args: { } } +// Why: the serving-side scan is host-local and cached once for every caller +// (desktop parent, web, mobile), so callers that address this host by a runtime +// id get the cached result restamped on the way out instead of a per-host scan. +// Mirrors the scanner's stamp recipe so ids stay stable across both paths. +export function restampAiVaultListResult( + result: AiVaultListResult, + executionHostId: ExecutionHostId +): AiVaultListResult { + return { + sessions: result.sessions.map((session) => + session.executionHostId === executionHostId + ? session + : { + ...session, + executionHostId, + id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` + } + ), + issues: result.issues.map((issue) => ({ ...issue, executionHostId })), + scannedAt: result.scannedAt + } +} + export function mergeAiVaultListResults( results: readonly AiVaultListResult[], rawLimit: number | undefined diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index acf573f4964..f77404bfc5a 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -8,6 +8,7 @@ import { droidDiscoveries, kimiDiscoveries } from './session-scanner-droid-kimi- import { opencodeDiscoveries } from './session-scanner-opencode-sources' import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types' import { normalizeAgentSessionsDir } from './session-scanner-values' +import { resolveGrokSessionsDir } from '../../shared/grok-session-paths' const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects') export const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex') @@ -19,10 +20,7 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const GROK_SESSIONS_DIR = join( - process.env.GROK_HOME?.trim() || join(homedir(), '.grok'), - 'sessions' -) +const GROK_SESSIONS_DIR = resolveGrokSessionsDir() const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index e0c741943c9..c854d1f0acb 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -1,4 +1,5 @@ /* oxlint-disable max-lines */ +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -129,7 +130,7 @@ describe('browserManager', () => { expect(shellOpenExternalMock).toHaveBeenCalledWith('http://localhost:3000/') }) - it('routes safe popup URLs into a new Orca browser tab for the owning renderer', () => { + it('allows registered safe popup URLs as Electron child windows', () => { const rendererSendMock = vi.fn() const guest = { id: 103, @@ -160,18 +161,84 @@ describe('browserManager', () => { const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { url: string - }) => { action: 'deny' } - expect(handler({ url: 'https://example.com/login' })).toEqual({ action: 'deny' }) + features?: string + }) => { action: 'allow' | 'deny' } + expect(handler({ url: 'about:blank' })).toMatchObject({ action: 'allow' }) + expect( + handler({ + url: 'https://example.com/login', + features: 'alwaysOnTop=yes,frame=no,fullscreen=yes,kiosk=yes,modal=yes,transparent=yes' + }) + ).toEqual({ + action: 'allow', + overrideBrowserWindowOptions: { + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false + } + }) expect(shellOpenExternalMock).not.toHaveBeenCalled() - expect(rendererSendMock).toHaveBeenCalledWith('browser:open-link-in-orca-tab', { + expect(rendererSendMock).not.toHaveBeenCalled() + }) + + it('blocks unsafe popup URLs for registered guests', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 106, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ browserPageId: 'browser-1', - url: 'https://example.com/login' + webContentsId: guest.id, + rendererWebContentsId }) + + const handler = guestSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { action: 'allow' | 'deny' } + expect(handler({ url: 'javascript:alert(1)' })).toEqual({ action: 'deny' }) + expect(handler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(handler({ url: 'file:///C:/Users/example/.ssh/id_rsa' })).toEqual({ action: 'deny' }) + expect(handler({ url: 'file://server/share/private.txt' })).toEqual({ action: 'deny' }) + + expect(shellOpenExternalMock).not.toHaveBeenCalled() + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:open-link-in-orca-tab', + expect.anything() + ) expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { browserPageId: 'browser-1', - origin: 'https://example.com', - action: 'opened-in-orca' + origin: 'null', + action: 'blocked' }) }) @@ -855,6 +922,202 @@ describe('browserManager', () => { expect(guestSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) expect(guestOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) expect(guestOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) + expect(guestOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength( + 1 + ) + }) + + it('attaches guest policies to created popup child windows', () => { + const rendererSendMock = vi.fn() + const childSetBackgroundThrottlingMock = vi.fn() + const childSetWindowOpenHandlerMock = vi.fn() + const childOnMock = vi.fn() + const childOffMock = vi.fn() + const childOpenDevToolsMock = vi.fn() + const childGuest = { + id: 4040, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: childSetBackgroundThrottlingMock, + setWindowOpenHandler: childSetWindowOpenHandlerMock, + on: childOnMock, + off: childOffMock, + openDevTools: childOpenDevToolsMock + } + const guest = { + id: 404, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + + browserManager.attachGuestPolicies(guest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: guest.id, + rendererWebContentsId + }) + + const didCreateWindowHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'did-create-window' + )?.[1] as ((window: { webContents: typeof childGuest }) => void) | undefined + expect(didCreateWindowHandler).toBeTypeOf('function') + + didCreateWindowHandler?.({ webContents: childGuest }) + + expect(childSetBackgroundThrottlingMock).toHaveBeenCalledWith(false) + expect(childSetWindowOpenHandlerMock).toHaveBeenCalledTimes(1) + expect(childOnMock.mock.calls.filter(([event]) => event === 'did-create-window')).toHaveLength( + 1 + ) + expect(childOnMock.mock.calls.filter(([event]) => event === 'will-navigate')).toHaveLength(1) + expect(childOnMock.mock.calls.filter(([event]) => event === 'will-redirect')).toHaveLength(1) + + const childWindowOpenHandler = childSetWindowOpenHandlerMock.mock.calls[0][0] as (details: { + url: string + }) => { action: 'allow' | 'deny' } + expect(childWindowOpenHandler({ url: 'https://identity.example.com/login' })).toMatchObject({ + action: 'allow' + }) + expect(childWindowOpenHandler({ url: 'file:///etc/passwd' })).toEqual({ action: 'deny' }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:popup', { + browserPageId: 'browser-1', + origin: 'null', + action: 'blocked' + }) + browserManager.notifyPermissionDenied({ + guestWebContentsId: childGuest.id, + permission: 'notifications', + rawUrl: 'https://identity.example.com/login' + }) + expect(rendererSendMock).toHaveBeenCalledWith('browser:permission-denied', { + browserPageId: 'browser-1', + permission: 'notifications', + origin: 'https://identity.example.com' + }) + + const childDidFailLoadHandler = childOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as + | (( + event: Electron.Event, + errorCode: number, + errorDescription: string, + validatedURL: string, + isMainFrame: boolean + ) => void) + | undefined + childDidFailLoadHandler?.( + {} as Electron.Event, + -105, + 'Name not resolved', + 'https://identity.example.com/unavailable', + true + ) + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:guest-load-failed', + expect.anything() + ) + + const childDownloadItem = createDownloadItem() + browserManager.handleGuestWillDownload({ + guestWebContentsId: childGuest.id, + item: childDownloadItem + }) + expect(rendererSendMock).toHaveBeenCalledWith( + 'browser:download-requested', + expect.objectContaining({ browserPageId: 'browser-1' }) + ) + const childDownloadDoneHandler = getDownloadItemEventHandler(childDownloadItem, 'once', 'done') + childDownloadDoneHandler?.({} as Electron.Event, 'completed') + + const managerState = browserManager as unknown as { + popupOwnerContextByGuestId: Map + } + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(true) + + const cleanupChildOnMock = vi.fn() + const cleanupChildGuest = { + ...childGuest, + id: 4041, + on: cleanupChildOnMock, + off: vi.fn(), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn() + } + const childDidCreateWindowHandler = childOnMock.mock.calls.find( + ([event]) => event === 'did-create-window' + )?.[1] as ((window: { webContents: typeof cleanupChildGuest }) => void) | undefined + childDidCreateWindowHandler?.({ webContents: cleanupChildGuest }) + expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(true) + const cleanupChildWindowOpenHandler = cleanupChildGuest.setWindowOpenHandler.mock + .calls[0][0] as (details: { url: string }) => { action: 'allow' | 'deny' } + expect( + cleanupChildWindowOpenHandler({ url: 'https://identity.example.com/continue' }) + ).toMatchObject({ action: 'allow' }) + const cleanupChildDestroyedHandler = cleanupChildOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + cleanupChildDestroyedHandler?.() + expect(managerState.popupOwnerContextByGuestId.has(cleanupChildGuest.id)).toBe(false) + + const replacementGuest = { + ...guest, + id: 405, + on: vi.fn(), + off: vi.fn(), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn() + } + webContentsFromIdMock.mockImplementation((id: number) => { + if (id === guest.id) { + return guest + } + if (id === replacementGuest.id) { + return replacementGuest + } + if (id === rendererWebContentsId) { + return { isDestroyed: vi.fn(() => false), send: rendererSendMock } + } + return null + }) + browserManager.attachGuestPolicies(replacementGuest as never) + browserManager.registerGuest({ + browserPageId: 'browser-1', + webContentsId: replacementGuest.id, + rendererWebContentsId + }) + + expect(childWindowOpenHandler({ url: 'https://identity.example.com/next' })).toEqual({ + action: 'deny' + }) + expect(shellOpenExternalMock).toHaveBeenCalledWith('https://identity.example.com/next') + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) + + const childDestroyedHandler = childOnMock.mock.calls.find( + ([event]) => event === 'destroyed' + )?.[1] as (() => void) | undefined + childDestroyedHandler?.() + expect(managerState.popupOwnerContextByGuestId.has(childGuest.id)).toBe(false) + + browserManager.unregisterAll() + + expect(childOffMock).toHaveBeenCalledWith('did-create-window', expect.any(Function)) + expect(childOffMock).toHaveBeenCalledWith('will-navigate', expect.any(Function)) + expect(childOffMock).toHaveBeenCalledWith('will-redirect', expect.any(Function)) }) it('cleans attached guest policy state when a guest is destroyed before registration', () => { @@ -1068,11 +1331,11 @@ describe('browserManager', () => { origin: 'https://example.com', totalBytes: 2048, mimeType: 'text/csv', - savePath: '/downloads/report.csv', + savePath: join('/downloads', 'report.csv'), status: 'downloading' }) ) - expect(item.setSavePath).toHaveBeenCalledWith('/downloads/report.csv') + expect(item.setSavePath).toHaveBeenCalledWith(join('/downloads', 'report.csv')) }) it('sets the download save path immediately and reports progress and completion', () => { @@ -1106,7 +1369,7 @@ describe('browserManager', () => { }) browserManager.handleGuestWillDownload({ guestWebContentsId: guest.id, item }) - expect(item.setSavePath).toHaveBeenCalledWith('/downloads/report.csv') + expect(item.setSavePath).toHaveBeenCalledWith(join('/downloads', 'report.csv')) expect(item.on).toHaveBeenCalledWith('updated', expect.any(Function)) expect(item.once).toHaveBeenCalledWith('done', expect.any(Function)) expect(rendererSendMock).toHaveBeenCalledWith( @@ -1114,7 +1377,7 @@ describe('browserManager', () => { expect.objectContaining({ browserPageId: 'browser-1', filename: 'report.csv', - savePath: '/downloads/report.csv', + savePath: join('/downloads', 'report.csv'), status: 'downloading' }) ) @@ -1139,7 +1402,7 @@ describe('browserManager', () => { expect.objectContaining({ browserPageId: 'browser-1', status: 'completed', - savePath: '/downloads/report.csv', + savePath: join('/downloads', 'report.csv'), error: null }) ) diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index 99c91f29e49..18cddd8c78c 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -5,6 +5,7 @@ in one file avoids scattering the browser security boundary across modules. */ import { randomUUID } from 'node:crypto' import { shell, webContents } from 'electron' +import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, @@ -140,6 +141,27 @@ export type BrowserGuestRegistration = { type PendingPermissionEvent = Omit type PendingPopupEvent = Omit type BrowserDownloadDoneState = 'completed' | 'cancelled' | 'interrupted' +type PopupOwnerContext = { + browserTabId: string + rootGuestWebContentsId: number +} + +const SAFE_POPUP_WINDOW_OPTIONS = { + alwaysOnTop: false, + closable: true, + focusable: true, + frame: true, + fullscreen: false, + kiosk: false, + modal: false, + movable: true, + opacity: 1, + show: true, + simpleFullscreen: false, + skipTaskbar: false, + titleBarStyle: 'default', + transparent: false +} satisfies Electron.BrowserWindowConstructorOptions type ActiveDownload = { downloadId: string @@ -181,6 +203,7 @@ export class BrowserManager { // Why: reverse map enables O(1) guest→tab lookups instead of O(N) linear // scans on every mouse event, load failure, permission, and popup event. private readonly tabIdByWebContentsId = new Map() + private readonly popupOwnerContextByGuestId = new Map() // Why: guest registration is keyed by browser page id, but renderer // visibility/focus state is keyed by browser workspace id. Screenshot prep // has to bridge that mismatch to activate the right tab before capture. @@ -293,7 +316,23 @@ export class BrowserManager { } private resolveBrowserTabIdForGuestWebContentsId(guestWebContentsId: number): string | null { - return this.tabIdByWebContentsId.get(guestWebContentsId) ?? null + return this.resolvePopupOwnerContext(guestWebContentsId)?.browserTabId ?? null + } + + private resolvePopupOwnerContext(guestWebContentsId: number): PopupOwnerContext | null { + const browserTabId = this.tabIdByWebContentsId.get(guestWebContentsId) + if (browserTabId) { + return { browserTabId, rootGuestWebContentsId: guestWebContentsId } + } + const inherited = this.popupOwnerContextByGuestId.get(guestWebContentsId) + if ( + inherited && + this.webContentsIdByTabId.get(inherited.browserTabId) === inherited.rootGuestWebContentsId + ) { + return inherited + } + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + return null } private resolveRendererForBrowserTab(browserTabId: string): Electron.WebContents | null { @@ -558,11 +597,17 @@ export class BrowserManager { } } - attachGuestPolicies(guest: Electron.WebContents): void { + attachGuestPolicies( + guest: Electron.WebContents, + inheritedOwnerContext: PopupOwnerContext | null = null + ): void { if (this.policyAttachedGuestIds.has(guest.id)) { return } this.policyAttachedGuestIds.add(guest.id) + if (inheritedOwnerContext) { + this.popupOwnerContextByGuestId.set(guest.id, inheritedOwnerContext) + } // Why: Cloudflare Turnstile and similar bot detectors probe browser APIs // (navigator.webdriver, plugins, window.chrome) that differ in Electron @@ -575,22 +620,24 @@ export class BrowserManager { // Orca window is not the focused foreground app. With throttling enabled, // the compositor stops producing frames and capturePage() returns empty. guest.setBackgroundThrottling(false) + const handleDidCreateWindow = (window: Electron.BrowserWindow): void => { + // Why: popup descendants inherit the opener's owner context for routing, + // but must not replace its primary guest registration. + this.attachGuestPolicies(window.webContents, this.resolvePopupOwnerContext(guest.id)) + } + guest.on('did-create-window', handleDidCreateWindow) guest.setWindowOpenHandler(({ url }) => { const browserTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id) const browserUrl = normalizeBrowserNavigationUrl(url) const externalUrl = normalizeExternalBrowserUrl(url) - // Why: popup-capable guests are required for OAuth and target=_blank - // flows, but Orca still does not host child windows itself. For normal - // web URLs, route the request into Orca's own browser-tab model first so - // the user stays in the IDE. Only fall back to the system browser when - // Orca cannot safely host the destination or when the guest is not yet - // associated with a trusted browser tab/renderer. - if (browserTabId && browserUrl && this.openLinkInOrcaTab(browserTabId, browserUrl)) { - this.forwardOrQueuePopupEvent(guest.id, { - origin: safeOrigin(browserUrl), - action: 'opened-in-orca' - }) + // Why: file URLs are valid for user-opened in-pane previews, but remote + // content must not create native child windows targeting local paths. + const canOpenAsChild = Boolean(externalUrl || browserUrl === ORCA_BROWSER_BLANK_URL) + if (browserTabId && canOpenAsChild) { + // Why: OAuth may request ordinary size/position features, but browser + // content must not create deceptive or inescapable native chrome. + return { action: 'allow', overrideBrowserWindowOptions: SAFE_POPUP_WINDOW_OPTIONS } } else if (externalUrl) { // Why: a target=_blank click on a Kagi search result page produces a // popup URL that still contains the bearer token; redact before @@ -671,6 +718,7 @@ export class BrowserManager { disposeAntiDetection() try { guest.off('destroyed', handleDestroyed) + guest.off('did-create-window', handleDidCreateWindow) } catch { // guest may already be destroyed } @@ -687,17 +735,28 @@ export class BrowserManager { // swaps renderer processes. Late events from the dead guest must stop // resolving to the live page, or stale download/popup/permission callbacks // can be delivered to the wrong session after the swap. - this.tabIdByWebContentsId.delete(previousWebContentsId) this.cleanupGuestPolicyAttachment(previousWebContentsId) + this.tabIdByWebContentsId.delete(previousWebContentsId) } private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { + const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { policyCleanup() this.policyCleanupByGuestId.delete(guestWebContentsId) } this.policyAttachedGuestIds.delete(guestWebContentsId) + this.popupOwnerContextByGuestId.delete(guestWebContentsId) + // Why: a popup must stop inheriting authorization as soon as its primary + // owner is retired, even if Chromium has not destroyed the child yet. + if (isPrimaryGuest) { + for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) { + if (owner.rootGuestWebContentsId === guestWebContentsId) { + this.popupOwnerContextByGuestId.delete(popupGuestId) + } + } + } this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) this.pendingPopupEventsByGuestId.delete(guestWebContentsId) @@ -883,6 +942,7 @@ export class BrowserManager { } this.policyCleanupByGuestId.clear() this.tabIdByWebContentsId.clear() + this.popupOwnerContextByGuestId.clear() this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.pendingLoadFailuresByGuestId.clear() @@ -1773,26 +1833,6 @@ export class BrowserManager { } }) } - - private openLinkInOrcaTab(browserTabId: string, rawUrl: string): boolean { - const renderer = this.resolveRendererForBrowserTab(browserTabId) - if (!renderer) { - return false - } - const normalizedUrl = normalizeBrowserNavigationUrl(rawUrl) - if (!normalizedUrl || normalizedUrl === 'about:blank') { - return false - } - // Why: the guest context menu knows which browser tab the click came from, - // but only the renderer owns the worktree/tab model. Forward the validated - // URL back to that renderer so it can open a sibling Orca browser tab in - // the same worktree without letting the guest process mutate app state. - renderer.send('browser:open-link-in-orca-tab', { - browserPageId: browserTabId, - url: normalizedUrl - }) - return true - } } export const browserManager = new BrowserManager() diff --git a/src/main/browser/browser-session-permission-policy.ts b/src/main/browser/browser-session-permission-policy.ts index a66e27980e3..895161db9e3 100644 --- a/src/main/browser/browser-session-permission-policy.ts +++ b/src/main/browser/browser-session-permission-policy.ts @@ -9,7 +9,10 @@ const AUTO_GRANTED_BROWSER_PERMISSIONS = new Set([ 'notifications', // Chromium can request this at runtime even though Electron's TS union does // not list it; chatgpt.com uses it to keep browser storage from eviction. - 'persistent-storage' + 'persistent-storage', + // Chromium still requires user activation, so this only removes Orca's + // otherwise unactionable denial for immersive browser apps. + 'pointerLock' ]) export function isAutoGrantedBrowserSessionPermission(permission: string): boolean { diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 9de0a884f23..8d81590ae5c 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -206,6 +206,20 @@ describe('BrowserSessionRegistry', () => { expect(mockSession?.setDevicePermissionHandler).toHaveBeenCalled() }) + it('auto-grants pointer lock for browser partitions', () => { + browserSessionRegistry.createProfile('isolated', 'Pointer Lock Test') + const mockSession = sessionFromPartitionMock.mock.results[0]?.value + const requestHandler = mockSession.setPermissionRequestHandler.mock.calls[0][0] + const checkHandler = mockSession.setPermissionCheckHandler.mock.calls[0][0] + const callback = vi.fn() + const guestWc = { id: 7, getURL: vi.fn(() => 'https://example.com/') } + + requestHandler(guestWc, 'pointerLock', callback, {}) + + expect(callback).toHaveBeenCalledWith(true) + expect(checkHandler(null, 'pointerLock', '', {})).toBe(true) + }) + it('routes media permission requests through macOS TCC for isolated partitions', async () => { // Why: verify the parallel fix to the default partition — isolated/imported // profiles must also defer media permission checks to macOS instead of diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 9447e81ef75..9958e0fe867 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -79,7 +79,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalRightClickToPaste: false, terminalFocusFollowsMouse: false, terminalClipboardOnSelect: false, - terminalAllowOsc52Clipboard: false, + terminalAllowOsc52Clipboard: true, setupScriptLaunchMode: 'split-vertical', terminalScrollbackRows: 5_000, localAccountRuntime: 'host', diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index abcafe1b438..a7cf0d033e2 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -84,7 +84,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings terminalRightClickToPaste: false, terminalFocusFollowsMouse: false, terminalClipboardOnSelect: false, - terminalAllowOsc52Clipboard: false, + terminalAllowOsc52Clipboard: true, setupScriptLaunchMode: 'split-vertical', terminalScrollbackRows: 5_000, localAccountRuntime: 'host', diff --git a/src/main/codex/codex-config-mirror.test.ts b/src/main/codex/codex-config-mirror.test.ts index 885e2030548..325faf70e15 100644 --- a/src/main/codex/codex-config-mirror.test.ts +++ b/src/main/codex/codex-config-mirror.test.ts @@ -314,6 +314,170 @@ describe('syncSystemConfigIntoManagedCodexHome', () => { expect(runtimeConfig.match(/\[projects\."\/repo"\]/g)?.length).toBe(1) }) + it('deduplicates basic and literal project headers by decoded Windows path', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'c:\\gemini_etl']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ['[projects."c:\\\\gemini_etl"]', 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain("[projects.'c:\\gemini_etl']") + }) + + it('lets a semantically matching system revocation replace runtime trust', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + ["[projects.'c:\\gemini_etl']", 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + ['[projects."C:/gemini_etl"]', 'trust_level = "untrusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('[projects."C:/gemini_etl"]') + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it.each([ + { + name: 'drive-letter casing and separators', + runtimePath: 'c:\\work\\repo', + systemPath: 'C:/work/repo' + }, + { + name: 'WSL UNC path', + runtimePath: '\\\\wsl$\\Ubuntu\\home\\u\\proj', + systemPath: '//WSL$/ubuntu/home/u/proj' + }, + { + name: 'server UNC path', + runtimePath: '\\\\server\\share\\proj', + systemPath: '//SERVER/share/proj' + } + ])('deduplicates $name across mirror inputs', ({ runtimePath, systemPath }) => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [`[projects.'${runtimePath}']`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [`[projects."${systemPath}"]`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8').match(/\[projects\./g)).toHaveLength(1) + }) + + it('self-heals duplicate project tables already present in the runtime config', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[projects."c:\\\\gemini_etl"]', + 'trust_level = "trusted"', + '', + "[projects.'c:\\gemini_etl']", + 'trust_level = "trusted"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('[projects."c:\\\\gemini_etl"]') + }) + + it('keeps untrusted when self-healing duplicate project tables', () => { + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [ + '[projects."c:\\\\gemini_etl"]', + 'trust_level = "trusted"', + '', + "[projects.'C:\\gemini_etl']", + 'trust_level = "untrusted"', + '' + ].join('\n'), + 'utf-8' + ) + writeFileSync(getSystemConfigPath(), 'model = "gpt-5"\n', 'utf-8') + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it('keeps untrusted when self-healing duplicate system project tables', () => { + writeFileSync( + getSystemConfigPath(), + [ + '[projects."c:\\\\gemini_etl"]', + 'trust_level = "trusted"', + '', + "[projects.'C:\\gemini_etl']", + 'trust_level = "untrusted"', + '' + ].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + const runtimeConfig = readFileSync(getRuntimeConfigPath(), 'utf-8') + expect(runtimeConfig.match(/\[projects\./g)).toHaveLength(1) + expect(runtimeConfig).toContain('trust_level = "untrusted"') + expect(runtimeConfig).not.toContain('trust_level = "trusted"') + }) + + it('deduplicates a POSIX project path containing a quote and backslash', () => { + const projectPath = '/tmp/with"quote\\and-backslash' + mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) + writeFileSync( + getRuntimeConfigPath(), + [`[projects.'${projectPath}']`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + writeFileSync( + getSystemConfigPath(), + [`[projects."/tmp/with\\"quote\\\\and-backslash"]`, 'trust_level = "trusted"', ''].join('\n'), + 'utf-8' + ) + + syncSystemConfigIntoManagedCodexHome() + + expect(readFileSync(getRuntimeConfigPath(), 'utf-8').match(/\[projects\./g)).toHaveLength(1) + }) + it('does not treat TOML table headers inside multiline strings as sections', () => { mkdirSync(join(userDataDir, 'codex-runtime-home', 'home'), { recursive: true }) writeFileSync( diff --git a/src/main/codex/codex-config-mirror.ts b/src/main/codex/codex-config-mirror.ts index 422b0e79280..1949a703d41 100644 --- a/src/main/codex/codex-config-mirror.ts +++ b/src/main/codex/codex-config-mirror.ts @@ -15,6 +15,10 @@ import { isTomlStructuralLine, updateTomlLineScanState } from './config-toml-line-scan' +import { + normalizeCodexProjectPathForLookup, + parseCodexProjectHeaderPath +} from './config-toml-trust' export function syncSystemConfigIntoManagedCodexHome( homes: CodexSettingsPromotionHomes = { @@ -160,14 +164,14 @@ function normalizeFeatureSectionLines(lines: string[], start: number, end: numbe } function mergeSystemCodexConfigIntoRuntime(runtimeConfig: string, systemConfig: string): string { - const runtimeSections = getTomlSections(runtimeConfig) + const runtimeSections = deduplicateProjectTomlSections(getTomlSections(runtimeConfig)) const runtimeProjectHeaders = new Set( runtimeSections .filter((section) => isRuntimeProjectTomlSection(section.header)) .map((section) => getTomlSectionHeaderKey(section.header)) ) const systemUntrustedProjectHeaders = new Set( - getTomlSections(systemConfig) + deduplicateProjectTomlSections(getTomlSections(systemConfig)) .filter((section) => isRuntimeProjectTomlSection(section.header)) .filter((section) => getProjectTrustLevel(section.block) === 'untrusted') .map((section) => getTomlSectionHeaderKey(section.header)) @@ -200,8 +204,9 @@ function stripRuntimeOwnedTomlSections( runtimeProjectHeaders = new Set() ): string { const lines = config.split('\n') - const sections = getTomlSections(config) - const firstSectionIndex = sections[0]?.start ?? -1 + const sourceSections = getTomlSections(config) + const sections = deduplicateProjectTomlSections(sourceSections) + const firstSectionIndex = sourceSections[0]?.start ?? -1 const preamble = firstSectionIndex === -1 ? config : lines.slice(0, firstSectionIndex).join('\n') return joinTomlBlocks([ preamble, @@ -262,11 +267,44 @@ function isRuntimeHookTrustTomlSection(header: string): boolean { } function isRuntimeProjectTomlSection(header: string): boolean { - return header.trimStart().startsWith('[projects.') + return parseCodexProjectHeaderPath(header) !== null } function getTomlSectionHeaderKey(header: string): string { - return header.trim() + const projectPath = parseCodexProjectHeaderPath(header) + return projectPath === null + ? header.trim() + : `project:${normalizeCodexProjectPathForLookup(projectPath)}` +} + +// Why: hook upsert already removes both quote representations, while its paired +// Windows slash variants are required for Codex 0.140 and must remain distinct. +function deduplicateProjectTomlSections(sections: TomlSection[]): TomlSection[] { + const deduplicated: TomlSection[] = [] + const projectIndexes = new Map() + for (const section of sections) { + if (!isRuntimeProjectTomlSection(section.header)) { + deduplicated.push(section) + continue + } + const key = getTomlSectionHeaderKey(section.header) + const existingIndex = projectIndexes.get(key) + if (existingIndex === undefined) { + projectIndexes.set(key, deduplicated.length) + deduplicated.push(section) + continue + } + const existing = deduplicated[existingIndex] + if ( + existing && + getProjectTrustLevel(existing.block) !== 'untrusted' && + getProjectTrustLevel(section.block) === 'untrusted' + ) { + // Why: revocation must survive self-healing regardless of duplicate order. + deduplicated[existingIndex] = section + } + } + return deduplicated } function getProjectTrustLevel(block: string): 'trusted' | 'untrusted' | null { diff --git a/src/main/codex/config-toml-trust.test.ts b/src/main/codex/config-toml-trust.test.ts index 8be83c8f340..bda4ac3499f 100644 --- a/src/main/codex/config-toml-trust.test.ts +++ b/src/main/codex/config-toml-trust.test.ts @@ -1108,6 +1108,60 @@ describe('upsertProjectTrustLevel', () => { expect(updated).not.toContain('trust_level = "untrusted"') }) + it('updates a Codex literal-string Windows project block without duplicating it', () => { + const original = ["[projects.'c:\\gemini_etl']", 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, 'c:\\gemini_etl', 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain("[projects.'c:\\gemini_etl']") + expect(updated).toContain('trust_level = "trusted"') + expect(updated).not.toContain('[projects."c:\\\\gemini_etl"]') + }) + + it.each([ + { + name: 'drive-letter casing and separators', + existingPath: 'c:\\work\\repo', + incomingPath: 'C:/work/repo' + }, + { + name: 'WSL UNC path casing and separators', + existingPath: '\\\\wsl$\\Ubuntu\\home\\u\\proj', + incomingPath: '//WSL$/ubuntu/home/u/proj' + }, + { + name: 'server UNC path casing and separators', + existingPath: '\\\\server\\share\\proj', + incomingPath: '//SERVER/share/proj' + } + ])('matches $name by decoded Windows path value', ({ existingPath, incomingPath }) => { + const original = [`[projects.'${existingPath}']`, 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, incomingPath, 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain(`[projects.'${existingPath}']`) + expect(updated).toContain('trust_level = "trusted"') + }) + + it('matches a literal-string POSIX project path containing a quote and backslash', () => { + const projectPath = '/tmp/with"quote\\and-backslash' + const original = [`[projects.'${projectPath}']`, 'trust_level = "untrusted"', ''].join('\n') + + const updated = upsertProjectTrustLevelInContent(original, projectPath, 'trusted', { + alreadyCanonical: true + }) + + expect(updated.match(/\[projects\./g)).toHaveLength(1) + expect(updated).toContain(`[projects.'${projectPath}']`) + expect(updated).toContain('trust_level = "trusted"') + }) + it('preserves an already-canonical remote Windows project path', () => { // Why: SSH project paths are resolved on the remote; local realpath would // canonicalize the wrong machine if the same path happens to exist locally. diff --git a/src/main/codex/config-toml-trust.ts b/src/main/codex/config-toml-trust.ts index 54dc00bc83d..54331900cd1 100644 --- a/src/main/codex/config-toml-trust.ts +++ b/src/main/codex/config-toml-trust.ts @@ -9,7 +9,6 @@ import { } from 'node:fs' import { dirname, join } from 'node:path' import { createHash, randomUUID } from 'node:crypto' -import { escapeRegex } from '../../shared/string-utils' import { copyFileWithWindowsRetry, renameFileWithWindowsRetry } from '../codex-accounts/fs-utils' import { createTomlLineScanState, @@ -202,7 +201,19 @@ function normalizeWindowsPathSeparators(sourcePath: string): string { } function usesWindowsPathSeparators(sourcePath: string): boolean { - return /^[A-Za-z]:[\\/]/.test(sourcePath) || sourcePath.startsWith('\\\\') + return ( + /^[A-Za-z]:[\\/]/.test(sourcePath) || + sourcePath.startsWith('\\\\') || + sourcePath.startsWith('//') + ) +} + +// Why: Codex and Orca can disagree on quote style, separators, and casing for +// the same Windows project, including when the caller targets a remote host. +export function normalizeCodexProjectPathForLookup(projectPath: string): string { + return usesWindowsPathSeparators(projectPath) + ? normalizeWindowsPathSeparators(projectPath).toLowerCase() + : projectPath } export function parseTrustKey(key: string): { @@ -346,12 +357,11 @@ export function upsertProjectTrustLevelInContent( const trustedProjectPath = options?.alreadyCanonical ? projectPath : getCodexCanonicalProjectPath(projectPath) - const headerPattern = buildProjectHeaderPattern(trustedProjectPath) - const match = headerPattern.exec(existing) + const headerLineEnd = findProjectHeaderLineEnd(existing, trustedProjectPath) const eol = existing.includes('\r\n') ? '\r\n' : '\n' const trustLine = `trust_level = "${trustLevel}"` - if (!match) { + if (headerLineEnd === null) { const block = [`[projects."${escapeTomlString(trustedProjectPath)}"]`, trustLine].join(eol) if (existing.length === 0) { return `${block}${eol}` @@ -364,7 +374,6 @@ export function upsertProjectTrustLevelInContent( return `${existing}${separator}${block}${eol}` } - const headerLineEnd = match.index + match[0].length const after = existing.slice(headerLineEnd) const nextHeaderRel = findNextTableHeader(after) const blockEnd = nextHeaderRel === -1 ? existing.length : headerLineEnd + nextHeaderRel @@ -552,20 +561,6 @@ function findTrustBlockRanges(content: string, key: string): TrustBlockRange[] { return ranges } -function buildProjectHeaderPattern(projectPath: string): RegExp { - const headerPathValues = [projectPath] - if (usesWindowsPathSeparators(projectPath)) { - headerPathValues.push(projectPath.replace(/\//g, '\\'), projectPath.replace(/\\/g, '/')) - } - const headerPaths = [...new Set(headerPathValues)].map((path) => - escapeRegex(escapeTomlString(path)) - ) - return new RegExp( - `(^|\\r?\\n)[ \\t]*\\[projects\\."(?:${headerPaths.join('|')})"\\][ \\t]*(?:#[^\\r\\n]*)?(?=\\r?\\n|$)`, - process.platform === 'win32' ? 'i' : undefined - ) -} - type ParsedTomlString = { value: string endIndex: number @@ -591,6 +586,46 @@ function parseHookStateHeaderKey(line: string): string | null { return index === trimmed.length || trimmed[index] === '#' ? parsedKey.value : null } +export function parseCodexProjectHeaderPath(line: string): string | null { + const trimmed = line.trimStart() + const prefixMatch = /^\[[ \t]*projects[ \t]*\.[ \t]*/.exec(trimmed) + if (!prefixMatch) { + return null + } + const parsedPath = parseTomlSingleLineString(trimmed, prefixMatch[0].length) + if (!parsedPath) { + return null + } + let index = skipTomlInlineWhitespace(trimmed, parsedPath.endIndex) + if (trimmed[index] !== ']') { + return null + } + index = skipTomlInlineWhitespace(trimmed, index + 1) + return index === trimmed.length || trimmed[index] === '#' ? parsedPath.value : null +} + +function findProjectHeaderLineEnd(content: string, projectPath: string): number | null { + const lookupPath = normalizeCodexProjectPathForLookup(projectPath) + let cursor = 0 + let scanState = createTomlLineScanState() + while (cursor < content.length) { + const newlineIndex = content.indexOf('\n', cursor) + const lineEnd = newlineIndex === -1 ? content.length : newlineIndex + const rawLine = content.slice(cursor, lineEnd) + const line = rawLine.replace(/\r$/, '') + const existingPath = isTomlStructuralLine(scanState) ? parseCodexProjectHeaderPath(line) : null + if (existingPath !== null && normalizeCodexProjectPathForLookup(existingPath) === lookupPath) { + return rawLine.endsWith('\r') ? lineEnd - 1 : lineEnd + } + scanState = updateTomlLineScanState(scanState, line) + if (newlineIndex === -1) { + return null + } + cursor = newlineIndex + 1 + } + return null +} + function parseTomlSingleLineString(line: string, startIndex: number): ParsedTomlString | null { if (line[startIndex] === '"') { return parseTomlBasicSingleLineString(line, startIndex + 1) diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index af7756e45e7..f369980bc5e 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs' import { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonServer } from './daemon-server' +import { HeadlessEmulator } from './headless-emulator' import { getHistorySessionDirName } from './history-paths' import type { HistoryReader } from './history-reader' import type { SubprocessHandle } from './session' @@ -29,7 +30,7 @@ function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'daemon-adapter-test-')) } -function createMockSubprocess(): SubprocessHandle & { +function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -47,6 +48,9 @@ function createMockSubprocess(): SubprocessHandle & { signal: vi.fn(), onData(cb) { onDataCb = cb + if (dataOnSubscribe) { + cb(dataOnSubscribe) + } }, onExit(cb) { onExitCb = cb @@ -86,8 +90,10 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { env?: Record command?: string } | null + let subprocessDataOnSubscribe: string | undefined beforeEach(async () => { + subprocessDataOnSubscribe = undefined dir = createTestDir() socketPath = getDaemonSocketPath(dir) tokenPath = join(dir, 'test.token') @@ -97,7 +103,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { tokenPath, spawnSubprocess: (opts) => { lastSpawnOpts = opts - lastSubprocess = createMockSubprocess() + lastSubprocess = createMockSubprocess(subprocessDataOnSubscribe) return lastSubprocess } }) @@ -938,7 +944,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) }) - it('persists final take records that are not represented in the snapshot', async () => { + itOnPosix('persists final take records that are not represented in the snapshot', async () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) const { id } = await historyAdapter.spawn({ @@ -1335,9 +1341,9 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.coldRestore).toBeDefined() expect(result.coldRestore!.scrollback).toContain('raced output') - // Documented race delta: the fresh shell spawns with the renderer's - // requested params, not the recovered ones. - expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 80, rows: 24 }) + // The unseeded race winner is replaced before exposure, so the retained + // shell uses the recovered dimensions as well as the recovered history. + expect(lastSpawnOpts).toMatchObject({ sessionId, cols: 100, rows: 30 }) // The recovery data must survive — openSession would have deleted it. expect(existsSync(join(sessionDir, 'scrollback.bin'))).toBe(true) const internals = historyAdapter as unknown as { @@ -1520,6 +1526,87 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(meta.rows).toBe(24) }) + it('keeps recovered scrollback when the fresh daemon session re-anchors history', async () => { + const sessionId = 'cold-restore-reanchor' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-07-10T08:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'recovered marker\r\n') + subprocessDataOnSubscribe = 'fresh shell output\r\n' + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore?.scrollback).toContain('recovered marker') + + const internals = historyAdapter as unknown as { + checkpointSessions(sessionIds: Iterable): Promise> + } + await internals.checkpointSessions([sessionId]) + const checkpointPath = join(sessionDir, 'checkpoint.json') + const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf8')) + + expect(checkpoint.snapshotAnsi).toContain('recovered marker') + expect(checkpoint.snapshotAnsi).toContain('fresh shell output') + expect(checkpoint.snapshotAnsi.indexOf('recovered marker')).toBeLessThan( + checkpoint.snapshotAnsi.indexOf('fresh shell output') + ) + }) + + it('keeps recovery persistence suspended across an adapter restart after seed failure', async () => { + const sessionId = 'cold-restore-seed-failure' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/tmp', + cols: 80, + rows: 24, + startedAt: '2026-07-10T08:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync(join(sessionDir, 'scrollback.bin'), 'recovered marker\r\n') + + const first = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const originalWriteSync = HeadlessEmulator.prototype.writeSync + const writeSyncSpy = vi + .spyOn(HeadlessEmulator.prototype, 'writeSync') + .mockImplementation(function (this: HeadlessEmulator, data) { + return data.includes('recovered marker') ? false : originalWriteSync.call(this, data) + }) + try { + await first.spawn({ cols: 80, rows: 24, sessionId }) + lastSubprocess._simulateData('fresh-only output\r\n') + await first.disconnectOnly() + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore?.scrollback).toContain('recovered marker') + const internals = historyAdapter as unknown as { + checkpointSessions(sessionIds: Iterable): Promise> + } + await internals.checkpointSessions([sessionId]) + + expect(existsSync(join(sessionDir, 'checkpoint.json'))).toBe(false) + expect(readFileSync(join(sessionDir, 'scrollback.bin'), 'utf8')).toContain( + 'recovered marker' + ) + } finally { + writeSyncSpy.mockRestore() + } + }) + it('does not cold-restore for clean shutdown (endedAt set)', async () => { const sessionId = 'clean-exit' const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 648870abe1c..cac7f14e480 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -36,6 +36,14 @@ type ColdRestorePayload = { oscLinks?: TerminalOscLinkRange[] } +function getRecoveredHistorySeed(restoreInfo: ColdRestoreInfo): string | null { + // Why: alt-screen snapshots represent the TUI buffer; prefer its normal + // scrollback so a dead TUI is not revived as the fresh shell's active screen. + return restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi +} + export type DaemonPtyAdapterOptions = { socketPath: string tokenPath: string @@ -166,9 +174,9 @@ export class DaemonPtyAdapter implements IPtyProvider { restoreInfo = this.historyReader.detectColdRestore(sessionId) } } - const effectiveCwd = restoreInfo?.cwd ?? opts.cwd - const effectiveCols = restoreInfo?.cols ?? opts.cols - const effectiveRows = restoreInfo?.rows ?? opts.rows + let effectiveCwd = restoreInfo?.cwd ?? opts.cwd + let effectiveCols = restoreInfo?.cols ?? opts.cols + let effectiveRows = restoreInfo?.rows ?? opts.rows const shellReadySupported = opts.command ? supportsPtyStartupBarrier(opts.env ?? {}) : false const isCodexStartupCommand = @@ -184,26 +192,31 @@ export class DaemonPtyAdapter implements IPtyProvider { ? CODEX_SHELL_READY_TIMEOUT_MS : undefined - const result = await this.client.request('createOrAttach', { - sessionId, - cols: effectiveCols, - rows: effectiveRows, - cwd: effectiveCwd, - env: opts.env, - envToDelete: opts.envToDelete, - command: opts.command, - startupCommandDelivery: opts.startupCommandDelivery, - // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or - // PowerShell as a fallback — regardless of which shell the renderer - // asked for in the "+" menu or persisted as the default. Forwarding - // the override makes the daemon path behave the same as the in-process - // LocalPtyProvider. - shellOverride: opts.shellOverride, - terminalWindowsWslDistro: opts.terminalWindowsWslDistro, - terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, - shellReadySupported, - ...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}) - }) + const createOrAttach = (historySeed: string | null) => + this.client.request('createOrAttach', { + sessionId, + cols: effectiveCols, + rows: effectiveRows, + cwd: effectiveCwd, + env: opts.env, + envToDelete: opts.envToDelete, + command: opts.command, + startupCommandDelivery: opts.startupCommandDelivery, + // Why: without this, the daemon always spawns cmd.exe (COMSPEC) or + // PowerShell as a fallback — regardless of which shell the renderer + // asked for in the "+" menu or persisted as the default. Forwarding + // the override makes the daemon path behave the same as the in-process + // LocalPtyProvider. + shellOverride: opts.shellOverride, + terminalWindowsWslDistro: opts.terminalWindowsWslDistro, + terminalWindowsPowerShellImplementation: opts.terminalWindowsPowerShellImplementation, + shellReadySupported, + ...(shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs } : {}), + ...(historySeed ? { historySeed } : {}) + }) + + let scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null + let result = await createOrAttach(scrollback) if (effectiveCwd) { this.initialCwds.set(sessionId, effectiveCwd) @@ -212,7 +225,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: the daemon RPC returns the shell pid of the backing subprocess. // Surfacing it through PtySpawnResult lets ipc/pty register with the // memory collector without a provider-specific accessor. - const pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + let pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null // Why: check sticky cache first — StrictMode double-mounts call spawn // twice. The second call finds an existing daemon session (isNew=false) @@ -238,6 +251,21 @@ export class DaemonPtyAdapter implements IPtyProvider { if (result.isNew && restoreSkippedForLiveSession) { restoreInfo = this.historyReader?.detectColdRestore(sessionId, { ignoreCleanEnd: true }) ?? null + scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null + if (restoreInfo && scrollback) { + // Why: the aliveness probe raced with session death, so the first + // create lacked recovery bytes. Replace it before exposing the PTY. + await this.client.request('kill', { sessionId, immediate: true }) + effectiveCwd = restoreInfo.cwd + effectiveCols = restoreInfo.cols + effectiveRows = restoreInfo.rows + result = await createOrAttach(scrollback) + pid = typeof result.pid === 'number' && result.pid > 0 ? result.pid : null + this.initialCwds.set(sessionId, effectiveCwd) + } + } else if (!result.isNew && result.historySeeded === false) { + restoreInfo = this.historyReader?.detectColdRestore(sessionId) ?? null + scrollback = restoreInfo ? getRecoveredHistorySeed(restoreInfo) : null } const wasAlreadyManaged = this.activeSessionIds.has(sessionId) @@ -246,7 +274,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // Cold restore: daemon created a new session but disk history shows // an unclean shutdown → return saved scrollback so the renderer can // display the previous terminal content. - if (result.isNew && restoreInfo) { + if (restoreInfo && (result.isNew || result.historySeeded === false)) { // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer // (vim/less/htop); normal sessions use the full snapshot + rehydrate. // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) @@ -254,25 +282,33 @@ export class DaemonPtyAdapter implements IPtyProvider { // (no rehydrateSequences — they start with \x1b[?1049h, which the // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as // normal scrollback. An empty snapshot still yields null → no-op. - const isAltScreen = restoreInfo.modes.alternateScreen - const scrollback = isAltScreen - ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null - : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + const canReanchorHistory = !scrollback || result.historySeeded === true // Why: use registerWriter (not openSession) to avoid deleting the // existing checkpoint.json. If the revived daemon crashes again before // the next 5s tick, the checkpoint is the only recovery data available. if (this.historyManager) { - this.historyManager.registerWriter(sessionId) - this.sessionsNeedingFullCheckpoint.add(sessionId) - // Why: the revived generation has no valid checkpoint of its own; a - // cooldown inherited from the pre-crash generation (daemon respawn - // within one adapter) must not defer this re-anchor. - this.lastFullCheckpointAt.delete(sessionId) + if (canReanchorHistory) { + this.historyManager.registerWriter(sessionId) + this.sessionsNeedingFullCheckpoint.add(sessionId) + // Why: the revived generation has no valid checkpoint of its own; a + // cooldown inherited from the pre-crash generation (daemon respawn + // within one adapter) must not defer this re-anchor. + this.lastFullCheckpointAt.delete(sessionId) + } else { + // Preserve the old recovery files when the new daemon cannot include + // them; a fresh-only checkpoint would make the data loss permanent. + this.historyManager.suspendSession(sessionId) + } } if (scrollback) { const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } this.coldRestoreCache.set(sessionId, coldRestore) - return { id: sessionId, pid, coldRestore } + return { + id: sessionId, + pid, + coldRestore, + ...(!result.isNew ? { isReattach: true } : {}) + } } return { id: sessionId, pid } } @@ -285,6 +321,10 @@ export class DaemonPtyAdapter implements IPtyProvider { rows: effectiveRows }) .catch((err) => console.warn('[history] openSession failed:', sessionId, err)) + } else if (this.historyManager && result.historySeeded === false) { + // Why: the daemon keeps this failure bit with the live session, so a new + // adapter cannot promote its fresh-only snapshot after an app restart. + this.historyManager.suspendSession(sessionId) } else if (this.historyManager) { // Why: on warm reattach after app relaunch, the HistoryManager is a // fresh instance with no writers. registerWriter adds the writer diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 3a585a0896c..7d98babfdee 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -290,6 +290,7 @@ export class DaemonServer { terminalWindowsWslDistro: p.terminalWindowsWslDistro, terminalWindowsPowerShellImplementation: p.terminalWindowsPowerShellImplementation, shellReadySupported: p.shellReadySupported, + historySeed: p.historySeed, ...(p.shellReadyTimeoutMs !== undefined ? { shellReadyTimeoutMs: p.shellReadyTimeoutMs } : {}), @@ -332,7 +333,8 @@ export class DaemonServer { isNew: result.isNew, snapshot: result.snapshot, pid: result.pid, - shellState: result.shellState + shellState: result.shellState, + ...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}) } } diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 1a7dd6c5abe..0a5832247c6 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -498,6 +498,46 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).toContain('\x1b[?1006h') expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') }) + + it('rehydrates kitty keyboard flags a TUI pushed (CSI > u)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + // OMP/pi negotiate progressive enhancement with a level-1 push. + await emulator.write('\x1b[>1u') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(1) + expect(snapshot.rehydrateSequences).toContain('\x1b[=1;1u') + }) + + it('omits kitty rehydration after the TUI pops its flags', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[>1u') + await emulator.write('\x1b[ { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[>1u') + + const snapshot = emulator.getSnapshot() + const altScreenIndex = snapshot.rehydrateSequences.indexOf('\x1b[?1049h') + const kittyIndex = snapshot.rehydrateSequences.indexOf('\x1b[=1;1u') + expect(altScreenIndex).toBeGreaterThanOrEqual(0) + expect(kittyIndex).toBeGreaterThan(altScreenIndex) + }) + + it('drops kitty rehydration after a TUI soft reset (DECSTR)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[>1u') + await emulator.write('\x1b[!p') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(0) + }) }) describe('dispose', () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 74381d3d543..7ce69db0cc8 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -4,6 +4,7 @@ import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' +import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' import { extractLastOscTitle } from '../../shared/agent-detection' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' @@ -36,6 +37,7 @@ export class HeadlessEmulator { private lastTitle: string | null = null private oscScanTail = '' private privateModes = new TerminalPrivateModeTracker() + private kittyKeyboardModes = new TerminalKittyKeyboardModeTracker() private restoredOscLinks: TerminalOscLinkRange[] = [] // Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's // parser, not the screen buffer, so serialize() drops them and the next @@ -97,6 +99,7 @@ export class HeadlessEmulator { // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. this.privateModes.scan(data) + this.kittyKeyboardModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) @@ -124,6 +127,7 @@ export class HeadlessEmulator { // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) this.privateModes.scan(data) + this.kittyKeyboardModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } @@ -286,7 +290,8 @@ export class HeadlessEmulator { sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, - alternateScreen: buffer.type === 'alternate' + alternateScreen: buffer.type === 'alternate', + kittyKeyboardFlags: this.kittyKeyboardModes.flags } } @@ -326,6 +331,15 @@ export class HeadlessEmulator { } else if (modes.sgrMouseMode) { seqs.push('\x1b[?1006h') } + // Why: kitty keyboard flags are per-screen state SerializeAddon cannot + // capture; without re-arming them, the still-running TUI keeps expecting + // protocol-encoded keys the restored client no longer sends. `=` (set) + // instead of `>` (push) so repeated replays cannot grow the flag stack. + // Emitted after the alt-screen switch above so the flags land on the + // screen the TUI negotiated them on. + if (modes.kittyKeyboardFlags && modes.kittyKeyboardFlags > 0) { + seqs.push(`\x1b[=${modes.kittyKeyboardFlags};1u`) + } return seqs.join('') } } diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 0edcdb91ba2..9453488c2d9 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -132,6 +132,13 @@ export class HistoryManager { }) } + suspendSession(sessionId: string): void { + // Why: if a fresh daemon cannot accept recovered scrollback, leaving its + // writer active would let the next checkpoint overwrite the only good copy. + this.writers.delete(sessionId) + this.disabledSessions.delete(sessionId) + } + /** Appends one take batch to the incremental log. Returns 'needs-checkpoint' * when the log is at capacity — the caller must take a full snapshot, which * subsumes the un-appended records (they were already applied to the live diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index ad8f14cb068..9ff69f89ab3 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2372,6 +2372,32 @@ describe('createPtySubprocess', () => { } }) + it('does not issue a second Windows ConPTY kill when force follows graceful kill', () => { + const proc = mockPtyProcess(123456) as ReturnType & { + destroy: ReturnType + } + proc.destroy = vi.fn(() => proc.kill()) + spawnMock.mockReturnValue(proc) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('already gone') + }) + const origPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'win32' }) + try { + const handle = createPtySubprocess({ sessionId: 'test', cols: 80, rows: 24 }) + handle.kill() + handle.forceKill() + handle.dispose() + + expect(proc.kill).toHaveBeenCalledOnce() + expect(killSpy).not.toHaveBeenCalled() + expect(proc.destroy).not.toHaveBeenCalled() + } finally { + killSpy.mockRestore() + restorePlatform(origPlatform) + } + }) + it('dispose() on Windows skips destroy after forceKill falls back to node-pty kill()', () => { const proc = mockPtyProcess(123456) as ReturnType & { destroy: ReturnType diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 30eb7b40082..c732a16e1cf 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1094,7 +1094,9 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl // has run, proc.pid refers to a recycled pid. Sending SIGKILL would // terminate an unrelated process. The fd release is handled by // dispose()/destroy(); forceKill is strictly for signalling a live child. - if (dead) { + // Why: Windows node-pty kill already closes ConPTY; retrying it through + // forceKill can double-close the native handle during workspace teardown. + if (dead || (process.platform === 'win32' && nodePtyKillIssued)) { return } try { diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index d156aeb4802..0d377c7f3f0 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -65,6 +65,7 @@ export type SessionOptions = { subprocess: SubprocessHandle shellReadySupported: boolean shellReadyTimeoutMs?: number + historySeed?: string scrollback?: number // Why: fired once the session reaches a terminal state (natural exit or // kill-timeout force-dispose) so the owner (TerminalHost) can reap it — @@ -101,6 +102,7 @@ export class Session { private pendingOutputBytes = 0 private pendingOutputOverflowed = false private pendingOutputSeq = 0 + private readonly _historySeeded: boolean | undefined constructor(opts: SessionOptions) { this.sessionId = opts.sessionId @@ -117,6 +119,10 @@ export class Session { // responder; any daemon reply races ahead via in-process parsing and // clobbers the renderer's answer. See the comment in HeadlessEmulator. }) + // Why: recovery must precede listener registration; shells can emit their + // prompt synchronously as soon as onData subscribes. + this._historySeeded = + opts.historySeed === undefined ? undefined : this.emulator.writeSync(opts.historySeed) if (opts.shellReadySupported) { this._shellState = 'pending' @@ -141,6 +147,10 @@ export class Session { return this._shellState } + get historySeeded(): boolean | undefined { + return this._historySeeded + } + get exitCode(): number | null { return this._exitCode } diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 2a54ce2c19b..8b8968931f5 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -31,6 +31,7 @@ export type CreateOrAttachOptions = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean shellReadyTimeoutMs?: number + historySeed?: string streamClient: { onData: (data: string) => void; onExit: (code: number) => void } } @@ -39,6 +40,7 @@ export type CreateOrAttachResult = { snapshot: TerminalSnapshot | null pid: number | null shellState: ShellReadyState + historySeeded?: boolean attachToken: symbol } @@ -105,6 +107,7 @@ export class TerminalHost { snapshot, pid: existing.pid, shellState: existing.shellState, + ...(existing.historySeeded !== undefined ? { historySeeded: existing.historySeeded } : {}), attachToken: token } } @@ -140,6 +143,7 @@ export class TerminalHost { terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE, subprocess, shellReadySupported: opts.shellReadySupported ?? false, + historySeed: opts.historySeed, // Why: reap the dead session (dispose emulator + drop from the map) the // moment its subprocess exits, instead of retaining it for the daemon's // lifetime. Nothing reads a dead session's emulator (getSnapshot/ @@ -180,6 +184,7 @@ export class TerminalHost { snapshot: null, pid: subprocess.pid, shellState: session.shellState, + ...(session.historySeeded !== undefined ? { historySeeded: session.historySeeded } : {}), attachToken: token } } diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index 10d27f2c8e0..faf0df30996 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -3,13 +3,11 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or -// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. -// Why: bump when adding daemon wire behavior so same-version old daemons do -// not silently accept the handshake and then reject new RPCs. -export const PROTOCOL_VERSION = 18 +// Why: daemons survive app updates; bump for IPC shape or baked behavior that +// wrapper refresh cannot deliver, so old daemons reject unsupported RPCs. +export const PROTOCOL_VERSION = 19 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -47,6 +45,10 @@ export type TerminalModes = { sgrMousePixelsMode?: boolean applicationCursor: boolean alternateScreen: boolean + /** Kitty keyboard protocol flags (CSI > u) the session's TUI negotiated; + * 0/absent when inactive. SerializeAddon cannot capture these, so the + * emulator mirrors them for snapshot rehydration. */ + kittyKeyboardFlags?: number } // The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it @@ -100,6 +102,8 @@ export type CreateOrAttachRequest = { terminalWindowsPowerShellImplementation?: 'auto' | 'powershell.exe' | 'pwsh.exe' shellReadySupported?: boolean shellReadyTimeoutMs?: number + /** Recovered ANSI applied before the new subprocess can emit startup output. */ + historySeed?: string } } @@ -309,6 +313,7 @@ export type CreateOrAttachResult = { snapshot: TerminalSnapshot | null pid: number | null shellState: ShellReadyState + historySeeded?: boolean } export type GetSnapshotResult = { diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 23cc2eeaf5f..c6f89509e16 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -15,7 +15,7 @@ vi.mock('os', async () => { } }) -import { GrokHookService } from './hook-service' +import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = @@ -55,12 +55,25 @@ describe('GrokHookService', () => { 'SessionEnd', 'SessionStart', 'Stop', + 'StopFailure', 'UserPromptSubmit' ].sort() ) - expect(config.hooks.PreToolUse[0].matcher).toBe('*') - expect(config.hooks.PostToolUseFailure[0].matcher).toBe('*') + // Why: Grok matchers are real regexes; bare `*` does not match-all. + expect(config.hooks.PreToolUse[0].matcher).toBe('.*') + expect(config.hooks.PostToolUseFailure[0].matcher).toBe('.*') + expect(config.hooks.PostToolUse[0].matcher).toBe('.*') + // Why: StopFailure must not carry a tool matcher — lifecycle-only event. + expect(config.hooks.StopFailure[0].matcher).toBeUndefined() expect(config.hooks.Notification[0].matcher).toBeUndefined() + // Why: assert the shipped helper still matches what install wrote (regression + // guard if GROK_TOOL_EVENT_MATCHER drifts from install). + expect(getGrokToolEventMatcherForTests()).toBe('.*') + expect(getGrokToolEventMatcherForTests()).not.toBe('*') + expect(new RegExp(getGrokToolEventMatcherForTests()).test('run_terminal_command')).toBe(true) + // Why: build the invalid pattern at runtime so static lint does not flag it. + const bareStar = ['*', ''].join('') + expect(() => new RegExp(bareStar)).toThrow() expect(config.hooks.PreToolUse[0].hooks[0].command).toMatch( process.platform === 'win32' ? WINDOWS_POWERSHELL_LAUNCHER : /grok-hook/ ) @@ -75,12 +88,20 @@ describe('GrokHookService', () => { expect(script).toContain('/hook/grok') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%GROK_HOME:~4096,1%') + expect(script).toContain( + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."' + ) + expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands // on the curl command line (EDR oversized-command-line false positive). expect(script).toContain('payload=$(cat)') expect(script).toContain('printf \'%s\' "$payload" | curl') expect(script).toContain('--data-urlencode "payload@-"') + expect(script).toContain('${#GROK_HOME}" -le 4096') + expect(script).toContain('--data-urlencode "grokHome=${grok_home}"') expect(script).not.toContain('--data-urlencode "payload=${payload}"') } }) @@ -112,6 +133,31 @@ describe('GrokHookService', () => { } ) + it('installs hooks under GROK_HOME when set', () => { + const grokHome = mkdtempSync(join(tmpdir(), 'orca-grok-home-env-')) + const previous = process.env.GROK_HOME + process.env.GROK_HOME = grokHome + try { + const status = new GrokHookService().install() + expect(status.state).toBe('installed') + expect(status.configPath).toBe(join(grokHome, 'hooks', 'orca-status.json')) + expect(readFileSync(join(grokHome, 'hooks', 'orca-status.json'), 'utf8')).toContain( + 'SessionStart' + ) + // Why: must not also write into the mocked ~/.grok when GROK_HOME wins. + expect(() => + readFileSync(join(homeDir, '.grok', 'hooks', 'orca-status.json'), 'utf8') + ).toThrow() + } finally { + if (previous === undefined) { + delete process.env.GROK_HOME + } else { + process.env.GROK_HOME = previous + } + rmSync(grokHome, { recursive: true, force: true }) + } + }) + it('preserves user-authored hook entries in the Orca Grok config file', () => { const configPath = join(homeDir, '.grok', 'hooks', 'orca-status.json') mkdirSync(dirname(configPath), { recursive: true }) diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 6fbbacadbc7..c88e1254feb 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -1,7 +1,7 @@ -import { homedir } from 'node:os' import { join } from 'node:path' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { resolveGrokHomeDir } from '../../shared/grok-session-paths' import { buildManagedCommandHook, createManagedCommandMatcher, @@ -21,33 +21,80 @@ import { writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +// Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare +// `*` is not a valid "match all" pattern and can fail to load/match, so tool +// lifecycle hooks never fire. `.*` matches every tool name (same as Command +// Code's managed hooks). +const GROK_TOOL_EVENT_MATCHER = '.*' +const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 +const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul' + const GROK_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'UserPromptSubmit', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Stop', definition: { hooks: [{ type: 'command', command: '' }] } }, + // Why: Grok can end a turn on API error without a normal Stop; without this + // the sidebar can stick on working (same rationale as Claude StopFailure). + { eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SessionEnd', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PreToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PostToolUse', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, { eventName: 'PostToolUseFailure', - definition: { matcher: '*', hooks: [{ type: 'command', command: '' }] } + definition: { matcher: GROK_TOOL_EVENT_MATCHER, hooks: [{ type: 'command', command: '' }] } }, { eventName: 'Notification', definition: { hooks: [{ type: 'command', command: '' }] } } ] as const +/** Test seam: the matcher string written for Pre/Post tool lifecycle hooks. */ +export function getGrokToolEventMatcherForTests(): string { + return GROK_TOOL_EVENT_MATCHER +} + function getConfigPath(): string { - // Why: Grok loads trusted global hook files from ~/.grok/hooks/*.json. Keep - // Orca's managed entries in a dedicated file so user-authored hook files stay - // untouched and project-level trust is not required for status reporting. - return join(homedir(), '.grok', 'hooks', 'orca-status.json') + // Why: Grok loads trusted global hook files from $GROK_HOME/hooks/*.json + // (or ~/.grok when unset). Honor GROK_HOME so install/status match the same + // home Grok and transcript lookup use; keep Orca entries in a dedicated file + // so user-authored hook files stay untouched. + return join(resolveGrokHomeDir(), 'hooks', 'orca-status.json') +} + +/** Validated guest Grok home with a login-home fallback. */ +function getRemoteGrokHome(remoteHome: string, remoteGrokHome?: string): string { + // Why: SFTP paths are always POSIX — never use host path.join here. + const home = remoteHome.replace(/\/+$/, '') || remoteHome + const candidate = remoteGrokHome?.trim() + if ( + candidate && + candidate === remoteGrokHome && + candidate.startsWith('/') && + !candidate.includes('\\') && + candidate.length <= GROK_HOME_ENVELOPE_MAX_LENGTH && + !hasControlCharacter(candidate) + ) { + return candidate.replace(/\/+$/, '') || '/' + } + return `${home}/.grok` +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) } +const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace( + WINDOWS_HOOK_PAYLOAD_FORM_LINE, + ` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}` +) + function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' } @@ -71,7 +118,12 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if "%ORCA_AGENT_HOOK_PORT%"=="" exit /b 0', 'if "%ORCA_AGENT_HOOK_TOKEN%"=="" exit /b 0', 'if "%ORCA_PANE_KEY%"=="" exit /b 0', - buildWindowsAgentHookPostCommand('grok'), + 'set "ORCA_GROK_HOME=%GROK_HOME%"', + `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, + // Why: a trailing backslash escapes curl's closing argv quote on Windows, + // merging the payload option into grokHome and dropping the hook body. + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', + WINDOWS_GROK_HOOK_POST_COMMAND, 'exit /b 0', '' ].join('\r\n') @@ -89,6 +141,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { 'if [ -z "$payload" ]; then', ' exit 0', 'fi', + 'grok_home=', + `if [ -n "\${GROK_HOME:-}" ] && [ "\${#GROK_HOME}" -le ${GROK_HOME_ENVELOPE_MAX_LENGTH} ]; then`, + ' grok_home=$GROK_HOME', + 'fi', // Timeout caps best-effort hook posts if the local listener stalls. // Why: pipe payload to curl's stdin (`payload@-`) instead of an inline // `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl @@ -103,6 +159,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "grokHome=${grok_home}" \\', ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', 'exit 0', '' @@ -213,9 +270,15 @@ export class GrokHookService { return this.getStatus() } - async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + async installRemote( + sftp: SFTPWrapper, + remoteHome: string, + remoteGrokHome?: string + ): Promise { const home = remoteHome.replace(/\/$/, '') - const remoteConfigPath = `${home}/.grok/hooks/orca-status.json` + // Why: only a guest-resolved path can describe remote Grok; never apply the + // host process's GROK_HOME to SFTP paths. + const remoteConfigPath = `${getRemoteGrokHome(home, remoteGrokHome)}/hooks/orca-status.json` const remoteScriptPath = `${home}/.orca/agent-hooks/grok-hook.sh` try { const config = await readHooksJsonRemote(sftp, remoteConfigPath) diff --git a/src/main/index.ts b/src/main/index.ts index 1b91bf3696a..2ff6ca879fc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1808,6 +1808,11 @@ app.whenReady().then(async () => { // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), + // Why: source codex-home here (runs in BOTH window and serve modes) so the + // aiVault.listSessions RPC includes managed-Codex sessions on remote/SSH + // hosts; the window-only registerCoreHandlers path never runs under serve. + getAdditionalAiVaultCodexHomePaths: () => + codexRuntimeHome ? [codexRuntimeHome.getHostRuntimeHomePath()] : [], buildAgentHookPtyEnv: () => isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 85e547da860..5a5dbe0c6ea 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -1,12 +1,17 @@ import { app, ipcMain } from 'electron' -import { join, resolve } from 'node:path' +import { resolve } from 'node:path' +import { + configureAiVaultSessionSources, + getAiVaultWslHomeDirs, + listAiVaultSessions as listCachedLocalAiVaultSessions, + resetAiVaultSessionListCacheForTests, + type AiVaultSessionSources +} from '../ai-vault/cached-session-list' import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' -import { scanAiVaultSessions } from '../ai-vault/session-scanner' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' -import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult, @@ -30,8 +35,7 @@ import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 -type AiVaultHandlerOptions = { - getAdditionalCodexHomePaths?: () => readonly string[] +type AiVaultHandlerOptions = AiVaultSessionSources & { getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: ( environmentId: string, @@ -64,6 +68,13 @@ async function listAiVaultSessions(args?: AiVaultListArgs): Promise { - if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { - return scanLocalAiVaultSessions(args) - } - if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] @@ -218,15 +225,13 @@ function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult { } async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { - const additionalCodexSessionsDirs = - handlerOptions.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? - [] - return scanAiVaultSessions({ + // Why: the shared cache module owns codex-home/WSL sourcing and the local + // scan cache, so the desktop IPC path and the runtime RPC method (mobile) + // share one cache instance and one source of managed-Codex homes. + return listCachedLocalAiVaultSessions({ limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs(), - executionHostId: LOCAL_EXECUTION_HOST_ID + force: args?.force, + scopePaths: args?.scopePaths }) } @@ -268,6 +273,11 @@ function sshScanIssueResult(args: { export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { handlerOptions = options + // Why: configure the SAME shared cache module the runtime RPC method uses so + // there is exactly one cache instance and neither caller drops codex-home or + // WSL injection. The runtime also configures these sources from its deps + // (serve-mode reachable); this desktop path supplies the same source. + configureAiVaultSessionSources(options) ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => listAiVaultSessions(args) ) @@ -324,6 +334,9 @@ function resetAiVaultCacheForTests(): void { inflightList = null inflightKey = null handlerOptions = {} + // The local leg delegates to the shared cache module; reset it too so tests + // never see a scan cached by an earlier case. + resetAiVaultSessionListCacheForTests() } export const _internals = { @@ -331,13 +344,3 @@ export const _internals = { listAiVaultSubagentSessions, resetAiVaultCacheForTests } - -async function getAiVaultWslHomeDirs(): Promise { - if (process.platform !== 'win32') { - return [] - } - const homes = await Promise.all( - (await listWslDistrosAsync()).map((distro) => getWslHomeAsync(distro)) - ) - return homes.filter((homeDir): homeDir is string => Boolean(homeDir)) -} diff --git a/src/main/ipc/filesystem-watcher-ignore.test.ts b/src/main/ipc/filesystem-watcher-ignore.test.ts index 6a3ab329fef..28f90a53a04 100644 --- a/src/main/ipc/filesystem-watcher-ignore.test.ts +++ b/src/main/ipc/filesystem-watcher-ignore.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT, WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOption + buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' const realPlatform = process.platform @@ -11,39 +11,45 @@ function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { value: platform }) } -describe('buildParcelWatcherIgnoreOption', () => { +describe('buildParcelWatcherIgnoreOptions', () => { afterEach(() => { setPlatform(realPlatform) }) it('keeps at most 8 plain paths on macOS so FSEventStreamSetExclusionPaths succeeds', () => { setPlatform('darwin') - const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) + const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) + const option = options.ignore ?? [] // @parcel/watcher passes non-glob entries to FSEventStreamSetExclusionPaths, // which rejects the WHOLE set past 8 paths — silently disabling daemon-side // exclusion of node_modules/.git churn. const plainPaths = option.filter((entry) => !entry.includes('*')) expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) - // Every ignore dir must still be covered, as a path or as a glob. - for (const dir of WATCHER_IGNORE_DIRS) { - expect( - option.some((entry) => entry === dir || entry === `**/${dir}` || entry === `**/${dir}/**`) - ).toBe(true) + expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) + const fallbackRegex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)') + for (const dir of WATCHER_IGNORE_DIRS.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) { + expect(fallbackRegex.test(`packages/app/${dir}/file.ts`)).toBe(true) } + expect(options.ignoreGlobs?.[0]).not.toContain('?!') }) - it('expands to nested globs on Linux/Windows so nested node_modules/.git are excluded', () => { + it('uses one lookahead-free native regex for nested ignores on Linux/Windows', () => { for (const platform of ['linux', 'win32'] as const) { setPlatform(platform) - const option = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) - // @parcel/watcher resolves plain names to top-level-only absolute paths on - // these platforms, so nested dirs must be matched via depth-agnostic globs. + const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) + expect(options.ignore).toBeUndefined() + expect(options.ignoreGlobs).toHaveLength(1) + const source = options.ignoreGlobs![0] + expect(source).not.toContain('?!') + const regex = new RegExp(source) for (const dir of WATCHER_IGNORE_DIRS) { - expect(option).toContain(`**/${dir}`) - expect(option).toContain(`**/${dir}/**`) + expect(regex.test(dir)).toBe(true) + expect(regex.test(`packages/app/${dir}`)).toBe(true) + expect(regex.test(`packages\\app\\${dir}\\nested\\file.ts`)).toBe(platform === 'win32') } - // No plain (glob-free) entries — those would only exclude the top level. - expect(option.every((entry) => entry.includes('*'))).toBe(true) + expect(regex.test('packages/app/.github/workflows')).toBe(false) + expect(regex.test('packages/app/node_modules-cache/file.ts')).toBe(false) + expect(regex.test('project\\node_modules/file.ts')).toBe(platform === 'win32') } }) }) diff --git a/src/main/ipc/filesystem-watcher-ignore.ts b/src/main/ipc/filesystem-watcher-ignore.ts index 9828f4a5911..7c483814c30 100644 --- a/src/main/ipc/filesystem-watcher-ignore.ts +++ b/src/main/ipc/filesystem-watcher-ignore.ts @@ -23,19 +23,42 @@ export const WATCHER_IGNORE_DIRS: string[] = [ // therefore meaningful: the first 8 get true daemon-side exclusion on macOS. export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8 -export function buildParcelWatcherIgnoreOption(ignoreDirs: readonly string[]): string[] { +export type ParcelWatcherIgnoreOptions = { + ignore?: string[] + // @parcel/watcher's wrapper preserves this native option even though its + // public TypeScript declaration omits it. See parcel-bundler/watcher#244. + ignoreGlobs?: string[] +} + +function escapeRegex(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') +} + +function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string { + const alternatives = ignoreDirs.map(escapeRegex).join('|') + if (process.platform === 'win32') { + return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/].*)?$` + } + // Why: backslash is a legal POSIX filename character, not a path separator. + return `^(?:[^/]+/)*(?:${alternatives})(?:/.*)?$` +} + +export function buildParcelWatcherIgnoreOptions( + ignoreDirs: readonly string[] +): ParcelWatcherIgnoreOptions { + if (ignoreDirs.length === 0) { + return {} + } if (process.platform !== 'darwin') { - // Linux/Windows: @parcel/watcher resolves plain names to top-level-only - // absolute paths, so nested node_modules/.git/build/etc. (monorepos) get - // fully watched — on Linux that means one inotify watch per nested dir, - // exhausting fs.inotify.max_user_watches. Nested globs match at any depth - // (and **/dir still matches the top-level dir), pruning those subtrees. - return ignoreDirs.flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]) + // Why: Parcel 2.5.6 turns leading-** globs into nested-lookahead regexes + // that are pathological in native std::regex (10-17x slower upstream). + // One component-aware regex preserves nested pruning without that CPU cost. + return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] } + } + const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) + const remainingDirs = ignoreDirs.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) + return { + ignore: [...daemonExcludedDirs], + ...(remainingDirs.length > 0 ? { ignoreGlobs: [buildNestedDirectoryRegex(remainingDirs)] } : {}) } - return [ - ...ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT), - ...ignoreDirs - .slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) - .flatMap((dir) => [`**/${dir}`, `**/${dir}/**`]) - ] } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 56ab2aa2981..224c348b871 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -18,7 +18,7 @@ import { disposeWatcherProcess, subscribeViaWatcherProcess } from './parcel-watc // events never leave the OS/daemon. This list is separate from the File // Explorer display filter (which only hides rows). Directories like `dist` // and `build` remain visible in the tree but will not auto-refresh. -import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOption } from './filesystem-watcher-ignore' +import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' // ── Debounce helpers ───────────────────────────────────────────────── @@ -293,7 +293,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise { + if (typeof args?.projectKey !== 'string' || !args.projectKey.trim()) { + return { statusIdsByColumn: [] } + } + return getProjectStatusOrder(args.projectKey.trim(), normalizeSiteId(args.siteId)) + } + ) } diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts new file mode 100644 index 00000000000..13f808ab650 --- /dev/null +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -0,0 +1,218 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { subscribeMock, writeFileSyncMock } = vi.hoisted(() => ({ + subscribeMock: vi.fn(), + writeFileSyncMock: vi.fn() +})) + +vi.mock('node:fs', () => ({ + mkdtempSync: vi.fn(() => '/tmp/orca-watcher-canary-test'), + rmSync: vi.fn(), + writeFileSync: writeFileSyncMock +})) +vi.mock('@parcel/watcher', () => ({ subscribe: subscribeMock })) + +describe('parcel watcher process canary', () => { + let originalMessageListeners: ReturnType + let originalExitListeners: ReturnType + let originalDisconnectListeners: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + vi.resetModules() + subscribeMock.mockReset() + writeFileSyncMock.mockReset() + originalMessageListeners = process.listeners('message') + originalExitListeners = process.listeners('exit') + originalDisconnectListeners = process.listeners('disconnect') + }) + + afterEach(() => { + for (const listener of process.listeners('message')) { + if (!originalMessageListeners.includes(listener)) { + process.off('message', listener) + } + } + for (const listener of process.listeners('exit')) { + if (!originalExitListeners.includes(listener)) { + process.off('exit', listener) + } + } + for (const listener of process.listeners('disconnect')) { + if (!originalDisconnectListeners.includes(listener)) { + process.off('disconnect', listener) + } + } + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('does not restart while a native subscription is still crawling', async () => { + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockReturnValueOnce(new Promise(() => undefined)) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/large-repo', opts: {} }) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(writeFileSyncMock).not.toHaveBeenCalled() + expect(exitSpy).not.toHaveBeenCalled() + }) + + it('invalidates an outstanding probe when another subscription starts crawling', async () => { + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockReturnValueOnce(new Promise(() => undefined)) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(10_000) + expect(writeFileSyncMock).toHaveBeenCalledTimes(1) + + process.emit('message', { op: 'subscribe', id: 2, dir: '/large-repo', opts: {} }) + await vi.advanceTimersByTimeAsync(20_000) + + expect(writeFileSyncMock).toHaveBeenCalledTimes(1) + expect(exitSpy).not.toHaveBeenCalled() + }) + + it('still restarts when unsubscribe deadlocks while another root remains live', async () => { + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn(() => new Promise(() => undefined)) }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + + process.emit('message', { op: 'unsubscribe', id: 1 }) + await vi.advanceTimersByTimeAsync(30_000) + + expect(writeFileSyncMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('drains active crawls before starting teardown deadlock detection', async () => { + let finishSecondCrawl: + | ((subscription: { unsubscribe: () => Promise }) => void) + | undefined + const deadlockedUnsubscribe = vi.fn(() => new Promise(() => undefined)) + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: deadlockedUnsubscribe }) + .mockReturnValueOnce( + new Promise((resolve) => { + finishSecondCrawl = resolve + }) + ) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'unsubscribe', id: 1 }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(deadlockedUnsubscribe).not.toHaveBeenCalled() + expect(writeFileSyncMock).not.toHaveBeenCalled() + + finishSecondCrawl?.({ unsubscribe: vi.fn().mockResolvedValue(undefined) }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(30_000) + + expect(deadlockedUnsubscribe).toHaveBeenCalledTimes(1) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('keeps later crawls queued behind teardown deadlock detection', async () => { + const deadlockedUnsubscribe = vi.fn(() => new Promise(() => undefined)) + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: deadlockedUnsubscribe }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo-a', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/repo-b', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'unsubscribe', id: 1 }) + await vi.advanceTimersByTimeAsync(0) + expect(deadlockedUnsubscribe).toHaveBeenCalledTimes(1) + + process.emit('message', { op: 'subscribe', id: 3, dir: '/repo-c', opts: {} }) + await vi.advanceTimersByTimeAsync(30_000) + + expect(subscribeMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('reserves teardown order while its subscription is still crawling', async () => { + let finishCrawl: ((subscription: { unsubscribe: () => Promise }) => void) | undefined + const deadlockedUnsubscribe = vi.fn(() => new Promise(() => undefined)) + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockReturnValueOnce( + new Promise((resolve) => { + finishCrawl = resolve + }) + ) + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/live-repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 2, dir: '/crawling-repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'unsubscribe', id: 2 }) + process.emit('message', { op: 'subscribe', id: 3, dir: '/later-repo', opts: {} }) + + await vi.advanceTimersByTimeAsync(30_000) + expect(writeFileSyncMock).not.toHaveBeenCalled() + + finishCrawl?.({ unsubscribe: deadlockedUnsubscribe }) + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(30_000) + + expect(deadlockedUnsubscribe).toHaveBeenCalledTimes(1) + expect(subscribeMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) + + it('still restarts after consecutive missed events once every subscription is live', async () => { + subscribeMock.mockResolvedValue({ unsubscribe: vi.fn() }) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never) + + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(30_000) + + expect(writeFileSyncMock).toHaveBeenCalledTimes(3) + expect(exitSpy).toHaveBeenCalledWith(2) + }) +}) diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index 03676721df2..6a296eb2af9 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -16,6 +16,7 @@ export type WatcherProcessEvent = { export type WatcherProcessSubscribeOptions = { ignore?: string[] + ignoreGlobs?: string[] backend?: string } @@ -45,7 +46,7 @@ const CANARY_INTERVAL_MS = 10_000 const CANARY_EVENT_TIMEOUT_MS = 5_000 const CANARY_MAX_MISSES = 2 -async function startCanary(hasLiveSubscriptions: () => boolean): Promise { +async function startCanary(getStableActivityRevision: () => number | null): Promise { let canaryDir: string let lastEventAt = 0 try { @@ -79,9 +80,11 @@ async function startCanary(hasLiveSubscriptions: () => boolean): Promise { let misses = 0 setInterval(() => { - // An idle watcher process can't wedge anything visible; only probe while - // roots are subscribed so an idle child doesn't restart pointlessly. - if (!hasLiveSubscriptions()) { + // Why: Parcel holds its shared backend mutex throughout each initial tree + // crawl, which legitimately starves canary delivery. Only apply the 5 s + // event SLA after every requested subscription has finished crawling. + const activityRevision = getStableActivityRevision() + if (activityRevision === null) { misses = 0 return } @@ -92,6 +95,12 @@ async function startCanary(hasLiveSubscriptions: () => boolean): Promise { return } setTimeout(() => { + // A root may start crawling after this probe was written. Invalidate the + // probe instead of misclassifying lifecycle work as a delivery deadlock. + if (getStableActivityRevision() !== activityRevision) { + misses = 0 + return + } if (lastEventAt >= probedAt) { misses = 0 return @@ -120,6 +129,29 @@ function main(): void { // that races a still-crawling subscribe awaits it instead of leaking the // native handle — on Windows a leaked handle keeps the worktree dir locked. const subscriptions = new Map>() + const liveSubscriptionIds = new Set() + let pendingSubscriptionCrawls = 0 + let subscriptionActivityRevision = 0 + let nativeLifecycleTail = Promise.resolve() + + const beginSubscriptionCrawl = (): void => { + pendingSubscriptionCrawls++ + subscriptionActivityRevision++ + } + + const finishSubscriptionCrawl = (): void => { + pendingSubscriptionCrawls-- + subscriptionActivityRevision++ + } + + const runNativeWatcherLifecycleExclusive = (operation: () => Promise): Promise => { + const ready = nativeLifecycleTail + let release: () => void + nativeLifecycleTail = new Promise((resolve) => { + release = resolve + }) + return ready.then(operation).finally(() => release()) + } const handleSubscribe = async ( id: number, @@ -127,43 +159,66 @@ function main(): void { opts: WatcherProcessSubscribeOptions ): Promise => { try { - const watcher = await import('@parcel/watcher') - const subscription = await watcher.subscribe( - dir, - (err, events) => { - if (err) { - send({ op: 'watch-error', id, message: errorMessage(err) }) - return - } - if (events.length > 0) { - send({ - op: 'events', - id, - events: events.map((event) => ({ type: event.type, path: event.path })) - }) - } - }, - opts as ParcelWatcher.Options - ) + const subscription = await runNativeWatcherLifecycleExclusive(async () => { + beginSubscriptionCrawl() + try { + const watcher = await import('@parcel/watcher') + return await watcher.subscribe( + dir, + (err, events) => { + if (err) { + send({ op: 'watch-error', id, message: errorMessage(err) }) + return + } + if (events.length > 0) { + send({ + op: 'events', + id, + events: events.map((event) => ({ type: event.type, path: event.path })) + }) + } + }, + opts as ParcelWatcher.Options + ) + } finally { + finishSubscriptionCrawl() + } + }) + // An unsubscribe can remove the record while subscribe() is crawling. + // Only advertise it as live if it is still owned by this process. + if (subscriptions.has(id)) { + liveSubscriptionIds.add(id) + } send({ op: 'subscribed', id }) return subscription } catch (err) { subscriptions.delete(id) + liveSubscriptionIds.delete(id) send({ op: 'subscribe-failed', id, message: errorMessage(err) }) return null } } const handleUnsubscribe = async (id: number): Promise => { + // Why: invalidate a probe already in flight, but keep future probes active. + // A native teardown deadlock is the failure the canary exists to recover. + subscriptionActivityRevision++ const pending = subscriptions.get(id) subscriptions.delete(id) + liveSubscriptionIds.delete(id) try { - const subscription = await pending - await subscription?.unsubscribe() + // Why: Parcel already serializes crawl and teardown on one backend mutex. + // Mirror that ordering here so neither operation can mask a canary miss. + await runNativeWatcherLifecycleExclusive(async () => { + const subscription = await pending + await subscription?.unsubscribe() + }) } catch (err) { process.stderr.write( `[parcel-watcher-process] unsubscribe ${id} failed: ${errorMessage(err)}\n` ) + } finally { + subscriptionActivityRevision++ } send({ op: 'unsubscribed', id }) } @@ -181,7 +236,12 @@ function main(): void { } }) - void startCanary(() => subscriptions.size > 0) + void startCanary(() => { + if (pendingSubscriptionCrawls > 0 || liveSubscriptionIds.size === 0) { + return null + } + return subscriptionActivityRevision + }) // Why: if the host dies (or kills us during shutdown), exit immediately — // process death releases every native watcher handle without running the diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 9b4216d924d..407f1710436 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -240,7 +240,11 @@ describe('registerPtyHandlers', () => { webContents: { on: vi.fn(), send: vi.fn(), - removeListener: vi.fn() + removeListener: vi.fn(), + // Why: the did-start-loading reset handler filters to main-frame loads via + // isLoadingMainFrame; default true so lifecycle-reset tests still reset. A + // subframe-load case overrides it to false. + isLoadingMainFrame: vi.fn(() => true) } } const mainWindowIpcEvent = { sender: mainWindow.webContents } @@ -333,6 +337,21 @@ describe('registerPtyHandlers', () => { removeHandlerMock.mockImplementation((channel: string) => { handlers.delete(channel) }) + // Why: production holds PTY sends until the renderer's pty:data dispatcher + // registers and sends pty:rendererDispatcherReady (the §1b boot-window gate). + // These tests model a live page whose dispatcher is already listening, so + // fire the handshake as soon as it registers. Lifecycle-reset tests re-close + // the gate via did-start-loading and re-open it with an explicit handshake. + onMock.mockImplementation((channel: string, listener: (...args: unknown[]) => void) => { + if (channel === 'pty:rendererDispatcherReady') { + listener(mainWindowIpcEvent) + // Drain the empty flush the handshake schedules so it can't fire later + // (once real output is pending) and perturb send-timing assertions. + if (vi.isFakeTimers()) { + vi.advanceTimersByTime(0) + } + } + }) getPathMock.mockReturnValue('/tmp/orca-user-data') // Why: shell-ready wrapper roots resolve from ORCA_USER_DATA_PATH (main // canonicalizes it to app.getPath('userData') at startup before any spawn); @@ -598,6 +617,19 @@ describe('registerPtyHandlers', () => { return visibleCall[1] as (event: unknown, args: { id: string; visible: boolean }) => void } + function getPtyRendererDispatcherReadyListener(): () => void { + const readyCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:rendererDispatcherReady' + ) + if (!readyCall) { + throw new Error('missing pty:rendererDispatcherReady listener') + } + const listener = readyCall[1] as (event: unknown) => void + // Why: the production handler sender-guards its destructive reconcile, so + // tests must present as the main window. + return () => listener(mainWindowIpcEvent) + } + function getMainWindowWebContentsListener(eventName: string): (...args: unknown[]) => void { const listenerCall = mainWindow.webContents.on.mock.calls.find( (call: unknown[]) => call[0] === eventName @@ -6898,10 +6930,13 @@ describe('registerPtyHandlers', () => { })) as { id: string } const setRendererPtyVisible = getPtySetRendererPtyVisibleListener() const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() mainWindow.webContents.send.mockClear() setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) handleRendererLoading() + // Reloaded page's dispatcher re-registers, releasing held sends (§1b). + handleRendererDispatcherReady() mockProc.emitData('reload-gap output') vi.advanceTimersByTime(8) @@ -6925,6 +6960,424 @@ describe('registerPtyHandlers', () => { } }) + it('resets leaked delivery accounting on renderer lifecycle reset so a saturated PTY resumes', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() + // Drain the initial dispatcher-ready flush (beforeEach fires the handshake + // to model a live page) so the flood timing below starts from a clean slate. + vi.advanceTimersByTime(1) + mainWindow.webContents.send.mockClear() + + // Saturate the PTY past the 512 KB per-PTY high-water with no ACKs. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 31; index++) { + vi.advanceTimersByTime(1) + } + + // Gate closed: sends stop at the cap and the remainder accrues as pending. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + vi.advanceTimersByTime(1) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + expect(vi.getTimerCount()).toBe(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024, + pendingPtyCount: 1, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0 + }) + + // Renderer reload: the dead page never ACKs, so its in-flight/pending + // accounting must clear or the surviving PTY stays delivery-gated forever. + handleRendererLoading() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0, + pendingChars: 0, + pendingPtyCount: 0, + rendererLifecycleResetCount: 1, + lastLifecycleResetClearedChars: 512 * 1024 + }) + + // Boot window (§1b): the reloaded page's dispatcher has not re-registered + // yet, so main must hold sends — bytes sent into the listener-less page are + // dropped but still counted in-flight, which would re-pin the gate. The + // output must accrue in pending, unsent and NOT counted in-flight. + mainWindow.webContents.send.mockClear() + mockProc.emitData('post-reload output') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + pendingChars: 'post-reload output'.length, + pendingPtyCount: 1 + }) + + // The reloaded page's dispatcher-ready handshake releases the held backlog. + // Counters-zero alone is insufficient — prove delivery actually resumes and + // the pending backlog drains (bytes now correctly counted in-flight, the + // mirror image of the NOT-counted boot-window hold above). + handleRendererDispatcherReady() + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'post-reload output' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 'post-reload output'.length, + pendingChars: 0, + pendingPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('ignores a subframe did-start-loading (isLoadingMainFrame false) so an in-page iframe load cannot freeze delivery', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const ackData = getPtyAckDataListener() + // Drain the initial dispatcher-ready flush (beforeEach fires the handshake). + vi.advanceTimersByTime(1) + mainWindow.webContents.send.mockClear() + + // Saturate the PTY past the 512 KB per-PTY high-water with no ACKs. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 31; index++) { + vi.advanceTimersByTime(1) + } + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + + // A sandboxed srcDoc iframe (notebook HTML output) loading fires + // did-start-loading with isLoadingMainFrame() === false. This is NOT a + // renderer lifecycle reset: the still-alive page keeps its dispatcher, so + // clearing accounting or dropping the ready flag here would freeze every + // pane for the whole watchdog window. Nothing must change. + mainWindow.webContents.isLoadingMainFrame.mockReturnValueOnce(false) + handleRendererLoading() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024, + pendingPtyCount: 1, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: true + }) + + // The gate is still open: ACKing the in-flight cap drains the held backlog + // (a spurious reset would have cleared pending and dropped ready, so this + // send would never fire). + mainWindow.webContents.send.mockClear() + ackData(null, { id: spawnResult.id, charCount: 512 * 1024 }) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('reconciles stale delivery accounting when a fresh dispatcher-ready handshake arrives while the gate is still open (missed lifecycle reset)', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() + const ackData = getPtyAckDataListener() + // Drain the initial dispatcher-ready flush (beforeEach fires the handshake). + vi.advanceTimersByTime(1) + mainWindow.webContents.send.mockClear() + + // Saturate the PTY past the 512 KB per-PTY high-water with no ACKs. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 31; index++) { + vi.advanceTimersByTime(1) + } + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024, + rendererLifecycleResetCount: 0, + rendererPtyDispatcherReady: true + }) + + // A main-frame reload overlapped by an in-page subframe load emits no + // did-start-loading, so the lifecycle reset never ran: the gate stayed open + // holding the dead page's in-flight accounting. The reloaded page's fresh + // dispatcher now sends its one-shot handshake. Receiving it while the gate is + // already open is proof a reset was missed, so it must reconcile — clear the + // stale accounting and count the reset — before re-opening. Without that + // reconcile the survivors stay pinned at the cap forever (this turns red). + mainWindow.webContents.send.mockClear() + handleRendererDispatcherReady() + const reconciled = getPtyRendererDeliveryDebugSnapshot() + expect(reconciled).toMatchObject({ + rendererInFlightChars: 0, + pendingChars: 0, + pendingPtyCount: 0, + rendererLifecycleResetCount: 1, + rendererPtyDispatcherReady: true + }) + expect(reconciled.lastLifecycleResetClearedChars).toBeGreaterThan(0) + + // Delivery has resumed: fresh output flows immediately instead of piling up + // behind the stale cap. + mockProc.emitData('post-reconcile output') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'post-reconcile output' + }) + + // A straggler ACK from the dead page is clamped and cannot underflow the + // reconciled counters below zero. + ackData(null, { id: spawnResult.id, charCount: 512 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot().rendererInFlightChars).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('holds interactive input echo during the boot window until the dispatcher-ready handshake', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() + const writeListener = getPtyWriteListener() + // Drain the initial ready-flush the beforeEach handshake schedules. + vi.advanceTimersByTime(1) + + // Reload closes the gate; the reloaded page's dispatcher has not re-registered. + handleRendererLoading() + mainWindow.webContents.send.mockClear() + + // Prime the interactive window with a keystroke, then emit a small redraw: + // shouldSendInteractiveOutputNow() is true here, so ONLY the + // `&& rendererPtyDispatcherReady` guard on the interactive fast path keeps this + // echo from being sent into the still-listener-less page (removing that flag + // check turns this red). It must accrue in pending, unsent and NOT in-flight. + const redraw = '\x1b[20;2Hredraw' + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + mockProc.emitData(redraw) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + pendingChars: redraw.length, + pendingPtyCount: 1 + }) + + // The handshake releases the held echo (drained via the batch flush). + handleRendererDispatcherReady() + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: redraw + }) + } finally { + vi.useRealTimers() + } + }) + + it('force-opens the delivery gate if no dispatcher-ready handshake arrives after a reload', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + vi.advanceTimersByTime(1) + + // Reload closes the gate and arms the ~10s watchdog; the reloaded page never + // sends the handshake (dropped IPC), so output stays held in pending. + handleRendererLoading() + mainWindow.webContents.send.mockClear() + mockProc.emitData('post-reload output') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 + }) + + // Past the watchdog window (10s) the gate self-heals: ready is forced, the + // counter increments, and the held backlog drains — degrading to pre-handshake + // behavior instead of a permanent freeze. + vi.advanceTimersByTime(10_000) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'post-reload output' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererPtyDispatcherReady: true, + rendererDispatcherReadyForcedCount: 1 + }) + } finally { + vi.useRealTimers() + } + }) + + it('ignores a dispatcher-ready handshake from a sender other than the main window', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const readyCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:rendererDispatcherReady' + )! + const rawReadyListener = readyCall[1] as (event: unknown) => void + vi.advanceTimersByTime(1) + + // Why: a straggler handshake from a dying window's webContents must not + // reopen the gate (or trigger the destructive reconcile) for the new page. + handleRendererLoading() + mainWindow.webContents.send.mockClear() + rawReadyListener({ sender: { isDestroyed: () => false } }) + mockProc.emitData('post-reload output') + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererPtyDispatcherReady: false + }) + + // The genuine main-window handshake still opens the gate and drains. + rawReadyListener({ sender: mainWindow.webContents }) + vi.advanceTimersByTime(8) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'post-reload output' + }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererPtyDispatcherReady: true + }) + } finally { + vi.useRealTimers() + } + }) + + it('cancels the dispatcher-ready watchdog when the handshake arrives in time', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + const handleRendererDispatcherReady = getPtyRendererDispatcherReadyListener() + vi.advanceTimersByTime(1) + + // Reload arms the watchdog; a timely handshake must cancel it so no orphaned + // ~10s timer lingers. Draining the handshake's empty flush must leave zero + // pending timers — a surviving watchdog would show up here (the forced-count + // guard alone can't catch it, since the watchdog no-ops once ready is true). + handleRendererLoading() + handleRendererDispatcherReady() + vi.advanceTimersByTime(0) + expect(vi.getTimerCount()).toBe(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererPtyDispatcherReady: true, + rendererDispatcherReadyForcedCount: 0 + }) + + // Advancing well past the watchdog window leaves the forced counter at zero. + vi.advanceTimersByTime(20_000) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererPtyDispatcherReady: true, + rendererDispatcherReadyForcedCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it("cancels a prior registration's armed dispatcher-ready watchdog when handlers re-register (no orphaned timer across window re-creation)", async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + // Drain the initial dispatcher-ready flush; the baseline is timer-free. + vi.advanceTimersByTime(1) + expect(vi.getTimerCount()).toBe(0) + + // A reload closes the gate and arms the ~10s self-heal watchdog on THIS + // registration's closure. + handleRendererLoading() + expect(vi.getTimerCount()).toBe(1) + + // Re-registering handlers (macOS re-activate / new window owns delivery) must + // cancel that armed watchdog via the cross-registration bridge at the top of + // registerPtyHandlers — otherwise the prior closure's timer survives and later + // force-opens a dead window's gate. The re-registration's own dispatcher-ready + // handshake schedules and drains one 0ms flush; nothing else may remain. + registerPtyHandlers(mainWindow as never) + expect(vi.getTimerCount()).toBe(0) + + // And no orphaned ~10s watchdog fires later (removing the bridge cancel turns + // this red: the prior closure's timer would still be pending here). + vi.advanceTimersByTime(20_000) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('preserves background-origin metadata for repaint output caused by a hidden resize', async () => { vi.useFakeTimers() const mockProc = createMockProc() diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 588df2d5704..7564b09b750 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1148,6 +1148,11 @@ let didFinishLoadHandler: (() => void) | null = null let didFinishLoadWebContents: WebContents | null = null let rendererLifecycleResetWebContents: WebContents | null = null let rendererLifecycleResetHandler: (() => void) | null = null +// Why: did-start-loading also fires for in-page subframe loads (e.g. the +// sandboxed srcDoc iframes notebook HTML output renders), which are not renderer +// lifecycle resets. A dedicated handler filters those via isLoadingMainFrame so a +// subframe load cannot reset delivery accounting on the still-alive page. +let rendererDidStartLoadingHandler: (() => void) | null = null // Why: the "Restart daemon" path needs to re-bind provider→renderer listeners // against the freshly-created adapter after replaceDaemonProvider swaps the @@ -1175,6 +1180,16 @@ export type PtyRendererDeliveryDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the + // leaked-delivery-accounting freeze this reset fixes; the count tracks how + // many renderer lifecycle resets have run since launch. + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + // Why: the boot-window hold early-returns before ackGatedFlushSkipCount++, so + // without these a held gate is invisible in the snapshot; forcedCount > 0 flags + // that the watchdog self-healed a lost handshake. + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number } const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { @@ -1190,13 +1205,24 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh peakMaxPendingCharsByPty: 0, peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0 + ackGatedFlushSkipCount: 0, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 } let readPtyRendererDeliveryDebugSnapshot = (): PtyRendererDeliveryDebugSnapshot => ({ ...EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT }) let resetPtyRendererDeliveryDebugSnapshot = (): void => {} +// Bridged into the registerPtyHandlers closure (like readPtyRendererDeliveryDebugSnapshot) +// so the module-scope lifecycle-reset handler can zero the closure-owned delivery +// accounting on a renderer reload/crash. +let resetRendererDeliveryAccountingForLifecycleReset = (): void => {} +// Bridged so a re-registration (new window) can cancel the prior closure's +// dispatcher-ready watchdog before wiring up its own. +let clearRendererDispatcherReadyWatchdog = (): void => {} export function getPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { return readPtyRendererDeliveryDebugSnapshot() @@ -1219,17 +1245,22 @@ function markRendererPtysHiddenForRendererLifecycleReset(): void { // surviving daemon/SSH PTYs fail closed until the new renderer reports again. activeRendererPtys.clear() visibleRendererPtys.clear() + // Why: the dead page never ACKs its in-flight bytes, so leaked in-flight/pending + // accounting would delivery-gate surviving PTYs forever after a reload/crash. + resetRendererDeliveryAccountingForLifecycleReset() } function clearRendererLifecycleResetHandlers(): void { if (!rendererLifecycleResetWebContents) { return } - if (rendererLifecycleResetHandler) { + if (rendererDidStartLoadingHandler) { rendererLifecycleResetWebContents.removeListener( 'did-start-loading', - rendererLifecycleResetHandler + rendererDidStartLoadingHandler ) + } + if (rendererLifecycleResetHandler) { rendererLifecycleResetWebContents.removeListener( 'render-process-gone', rendererLifecycleResetHandler @@ -1238,6 +1269,7 @@ function clearRendererLifecycleResetHandlers(): void { } rendererLifecycleResetWebContents = null rendererLifecycleResetHandler = null + rendererDidStartLoadingHandler = null } function registerRendererLifecycleResetHandlers(webContents: WebContents): void { @@ -1245,7 +1277,18 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void markRendererPtysHiddenForRendererLifecycleReset() rendererLifecycleResetWebContents = webContents rendererLifecycleResetHandler = markRendererPtysHiddenForRendererLifecycleReset - webContents.on('did-start-loading', rendererLifecycleResetHandler) + // Why: did-start-loading also fires for in-page subframe loads (sandboxed + // srcDoc iframes in notebook HTML output), where isLoadingMainFrame() is false. + // Only a main-frame load is a real renderer lifecycle reset; filtering here + // stops a subframe load from clearing pendingData and holding the send gate + // (a spurious multi-second freeze) on the otherwise-alive page. + rendererDidStartLoadingHandler = () => { + if (!webContents.isLoadingMainFrame()) { + return + } + markRendererPtysHiddenForRendererLifecycleReset() + } + webContents.on('did-start-loading', rendererDidStartLoadingHandler) webContents.on('render-process-gone', rendererLifecycleResetHandler) webContents.on('destroyed', rendererLifecycleResetHandler) } @@ -1278,6 +1321,12 @@ export function registerPtyHandlers( isRecoveryReloadInFlight?: (webContentsId: number) => boolean } ): void { + // Why: a re-registration means a new window owns delivery. Cancel any watchdog the + // prior closure armed, and neutralize its bridged reset so the registration-time + // mark-hidden below can't arm a timer against the now-dead closure — the fresh + // closure re-installs both bridges once its state is set up. + clearRendererDispatcherReadyWatchdog() + resetRendererDeliveryAccountingForLifecycleReset = () => {} registerRendererLifecycleResetHandlers(mainWindow.webContents) const getLocalPtyStartupPromise = (connectionId?: string | null): Promise | undefined => { @@ -1411,6 +1460,10 @@ export function registerPtyHandlers( const PTY_BATCH_FLUSH_MAX_WRITES = 2 const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 + // Why: self-heal bound for the dispatcher-ready gate — if a reloaded page never + // sends pty:rendererDispatcherReady (lost IPC), force sends back on after this + // window so a dropped handshake can't itself become a permanent hold. + const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 // Why: the in-flight caps above bound only SENT-but-unacked bytes; the unsent // `pendingData` backlog is drained only when the renderer ACKs. A background- // throttled/frozen renderer (Win/Linux — macOS disables throttling) stops @@ -1435,6 +1488,19 @@ export function registerPtyHandlers( let peakRendererInFlightChars = 0 let peakMaxRendererInFlightCharsByPty = 0 let ackGatedFlushSkipCount = 0 + let rendererLifecycleResetCount = 0 + let lastLifecycleResetClearedChars = 0 + // Why: how many times the watchdog force-opened the gate because no handshake + // arrived; nonzero flags a dropped-handshake self-heal (degrades to pre-§1b + // behavior, observable + recoverable at the next reset, never a freeze). + let rendererDispatcherReadyForcedCount = 0 + // Why: gate sends until the current page's pty:data listener is registered. + // A reloaded/first-load page has no listener yet, so webContents.send drops + // the bytes but still counts them in-flight, permanently pinning the gate. + // Flipped true by the pty:rendererDispatcherReady handshake, reset false on + // every lifecycle reset (below); starts false so nothing sends pre-handshake. + let rendererPtyDispatcherReady = false + let dispatcherReadyWatchdogTimer: ReturnType | null = null function getMaxMapValue(values: Iterable): number { let max = 0 @@ -1465,7 +1531,11 @@ export function registerPtyHandlers( peakMaxPendingCharsByPty, peakRendererInFlightChars, peakMaxRendererInFlightCharsByPty, - ackGatedFlushSkipCount + ackGatedFlushSkipCount, + rendererLifecycleResetCount, + lastLifecycleResetClearedChars, + rendererPtyDispatcherReady, + rendererDispatcherReadyForcedCount } } @@ -1500,6 +1570,28 @@ export function registerPtyHandlers( ackGatedFlushSkipCount = 0 recordPtyRendererDeliveryPressure() } + resetRendererDeliveryAccountingForLifecycleReset = () => { + // Why: clearing pendingData is lossless — its bytes were only ever bound for + // the dead page, and the replacement page repaints each pane from main's + // authoritative sources (daemon snapshot / headless buffer / cold-restore), + // which are fed before the pendingData append so they always superset it. + lastLifecycleResetClearedChars = rendererInFlightTotalChars + rendererLifecycleResetCount += 1 + rendererInFlightCharsByPty.clear() + rendererInFlightTotalChars = 0 + pendingData.clear() + // Why: the reloading page's pty:data listener is gone until it re-registers + // and re-sends the handshake; hold sends until then so the boot window can't + // re-pin the gate with bytes dropped into a listener-less page. + rendererPtyDispatcherReady = false + // Why: arm the self-heal watchdog so a never-arriving handshake can't leave the + // gate held forever; the real handshake cancels it. + armDispatcherReadyWatchdog() + recordPtyRendererDeliveryPressure() + } + // Why: let a later re-registration cancel this closure's watchdog (armed via a + // hoisted fn, so this bridge assignment can precede its definition). + clearRendererDispatcherReadyWatchdog = clearDispatcherReadyWatchdog function isLikelyInteractiveRedraw(data: string): boolean { if (data.length <= INTERACTIVE_OUTPUT_MAX_CHARS) { @@ -1686,15 +1778,50 @@ export function registerPtyHandlers( flushTimer = setTimeout(flushPendingData, delayMs) } + function clearDispatcherReadyWatchdog(): void { + if (dispatcherReadyWatchdogTimer) { + clearTimeout(dispatcherReadyWatchdogTimer) + dispatcherReadyWatchdogTimer = null + } + } + + function armDispatcherReadyWatchdog(): void { + clearDispatcherReadyWatchdog() + if (mainWindow.isDestroyed()) { + return + } + // Why: one-shot self-heal — if the reloaded page never signals ready, force the + // gate open so a dropped handshake degrades to pre-handshake behavior (bounded + // duplicate/overwrite at worst) instead of a permanent hold. Unref'd so it can + // never keep the process alive. + dispatcherReadyWatchdogTimer = setTimeout(() => { + dispatcherReadyWatchdogTimer = null + if (rendererPtyDispatcherReady || mainWindow.isDestroyed()) { + return + } + rendererPtyDispatcherReady = true + rendererDispatcherReadyForcedCount += 1 + schedulePendingDataFlush(0) + }, PTY_DISPATCHER_READY_WATCHDOG_MS) + dispatcherReadyWatchdogTimer.unref?.() + } + function flushPendingData(): void { flushTimer = null if (mainWindow.isDestroyed()) { pendingData.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 + clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() return } + // Why: hold sends until the page's pty:data listener is registered. Bytes + // keep accruing in pendingData (2 MB cap + droppedBacklog rebuild it + // losslessly); the ready handshake reschedules this flush. + if (!rendererPtyDispatcherReady) { + return + } let writes = 0 for (const [id, pending] of getPendingPtyFlushEntries()) { if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) { @@ -1862,6 +1989,7 @@ export function registerPtyHandlers( pendingData.clear() rendererInFlightCharsByPty.clear() rendererInFlightTotalChars = 0 + clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() return } @@ -1887,7 +2015,10 @@ export function registerPtyHandlers( nextData, performance.now() ) - if (isInteractiveOutput) { + // Why: gate the interactive fast path on the dispatcher handshake too, so + // boot-window keystroke echo accrues in pendingData instead of being sent + // into a listener-less page and pinning the gate. + if (isInteractiveOutput && rendererPtyDispatcherReady) { // Why: user-input echo should not be pinned behind unrelated bulk // terminal output already handed to the renderer. The reserve is // bounded, and the per-PTY cap still prevents an active TUI runaway. @@ -3675,6 +3806,33 @@ export function registerPtyHandlers( } }) + // Why: the renderer sends this once its pty:data listener is live (per page + // load / reload). Until it arrives, sends are held so boot-window bytes can't + // drop into a listener-less page and pin the delivery gate; on arrival, flush + // the backlog that accrued during the boot window. + ipcMain.removeAllListeners('pty:rendererDispatcherReady') + ipcMain.on('pty:rendererDispatcherReady', (event) => { + // Why: the reconcile below destructively clears delivery accounting, so a + // straggler handshake from a dying window must not reset the new window. + if (!isPtyWriteEventFromMainWindow(event, mainWindow.webContents)) { + return + } + // Why: the handshake is one-shot per page load, so receiving it while the gate + // is already open means a fresh page loaded but its lifecycle reset was missed — + // a main-frame reload overlapped by an in-page subframe load emits no + // did-start-loading, and the watchdog may have force-opened the gate — leaving + // main holding the dead page's in-flight accounting, which permanently gates the + // survivors. Reconcile by clearing that stale accounting before re-opening. + if (rendererPtyDispatcherReady) { + resetRendererDeliveryAccountingForLifecycleReset() + } + // Why: real handshake landed — cancel the self-heal watchdog so it can't later + // force-open the gate and inflate rendererDispatcherReadyForcedCount. + clearDispatcherReadyWatchdog() + rendererPtyDispatcherReady = true + schedulePendingDataFlush(0) + }) + ipcMain.removeAllListeners('pty:setActiveRendererPty') ipcMain.on('pty:setActiveRendererPty', (_event, args: { id: string; active: boolean }) => { if (typeof args.id !== 'string' || !args.id) { diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 49a49a46076..1cf9ab31212 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -398,4 +398,100 @@ describe('Jira issue operations', () => { } ]) }) + + describe('getProjectStatusOrder', () => { + it('returns an empty order when no clients are available', async () => { + getClientsMock.mockReturnValue([]) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + }) + + it('returns an empty order when an omitted site resolves to multiple clients', async () => { + getClientsMock.mockReturnValue([makeEntry('site-1'), makeEntry('site-2')]) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP')).resolves.toEqual({ statusIdsByColumn: [] }) + expect(jiraRequestMock).not.toHaveBeenCalled() + }) + + it('returns an empty order when the project has no accessible board', async () => { + jiraRequestMock.mockResolvedValueOnce({ values: [] }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP & OPS', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(String(jiraRequestMock.mock.calls[0][1])).toContain( + '/rest/agile/1.0/board?projectKeyOrId=ALP+%26+OPS&maxResults=2' + ) + }) + + it('keeps alphabetical fallback when a project has multiple boards', async () => { + jiraRequestMock.mockResolvedValueOnce({ + total: 2, + values: [{ id: 42 }, { id: 43 }] + }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + }) + + it('keeps alphabetical fallback when Jira reports another board page', async () => { + jiraRequestMock.mockResolvedValueOnce({ + isLast: false, + values: [{ id: 42 }] + }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + }) + + it('returns status IDs grouped by Jira board column order', async () => { + jiraRequestMock + .mockResolvedValueOnce({ total: 1, values: [{ id: 42 }] }) + .mockResolvedValueOnce({ + columnConfig: { + columns: [ + { statuses: [{ id: '1' }, { id: '2' }] }, + { statuses: [{ id: '3' }, { id: '2' }] }, + { statuses: [] } + ] + } + }) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [['1', '2'], ['3']] + }) + expect(jiraRequestMock.mock.calls[1]?.[1]).toBe('/rest/agile/1.0/board/42/configuration') + }) + + it('clears the token and surfaces credential failures', async () => { + const authError = new Error('Unauthorized') + isAuthErrorMock.mockReturnValue(true) + jiraRequestMock.mockRejectedValueOnce(authError) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).rejects.toThrow('Unauthorized') + expect(clearTokenMock).toHaveBeenCalledWith('site-1') + }) + + it('falls back to an empty order on operational errors', async () => { + jiraRequestMock.mockRejectedValueOnce(new Error('Service Unavailable')) + const { getProjectStatusOrder } = await import('./issues') + + await expect(getProjectStatusOrder('ALP', 'site-1')).resolves.toEqual({ + statusIdsByColumn: [] + }) + }) + }) }) diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 3bf8237349c..5a19b9897b3 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -14,6 +14,7 @@ import type { JiraMutationResult, JiraPriority, JiraProject, + JiraProjectStatusOrder, JiraSite, JiraSiteSelection, JiraStatus, @@ -110,6 +111,13 @@ function asString(value: unknown, fallback = ''): string { return typeof value === 'string' ? value : fallback } +function asIdentifier(value: unknown): string { + if (typeof value === 'string') { + return value + } + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + function asStringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') @@ -810,3 +818,77 @@ export async function listTransitions( release() } } + +export async function getProjectStatusOrder( + projectKey: string, + siteId?: string | null +): Promise { + // Why: an omitted site can resolve to the persisted "all" selection; board + // metadata is only truthful when exactly one Jira connection owns the project. + const entries = getClients(siteId) + const entry = entries.length === 1 ? entries[0] : undefined + if (!entry) { + return { statusIdsByColumn: [] } + } + await acquire() + try { + // Why: without an explicit board picker there is no truthful way to choose + // among multiple project boards, so ambiguous projects keep alphabetical order. + const params = new URLSearchParams({ projectKeyOrId: projectKey, maxResults: '2' }) + const boardsResponse = await jiraRequest>( + entry, + `/rest/agile/1.0/board?${params.toString()}` + ) + const boards = boardsResponse.values ?? [] + const boardCount = asFiniteNumber(boardsResponse.total) + const singleBoardIsProven = + boards.length === 1 && + boardsResponse.isLast !== false && + (boardCount === 1 || (boardCount === null && boardsResponse.isLast === true)) + const boardId = singleBoardIsProven ? asIdentifier(asRecord(boards[0]).id) : '' + if (!boardId) { + return { statusIdsByColumn: [] } + } + + const configResponse = await jiraRequest( + entry, + `/rest/agile/1.0/board/${encodeURIComponent(boardId)}/configuration` + ) + const columns = asRecord(asRecord(configResponse).columnConfig).columns + if (!Array.isArray(columns)) { + return { statusIdsByColumn: [] } + } + + // Why: issues already carry status names and IDs, so returning board IDs + // avoids a second metadata request and keeps duplicate names unambiguous. + const seenStatusIds = new Set() + const statusIdsByColumn: string[][] = [] + for (const column of columns) { + const statuses = asRecord(column).statuses + if (!Array.isArray(statuses)) { + continue + } + const columnStatusIds: string[] = [] + for (const status of statuses) { + const statusId = asIdentifier(asRecord(status).id) + if (statusId && !seenStatusIds.has(statusId)) { + seenStatusIds.add(statusId) + columnStatusIds.push(statusId) + } + } + if (columnStatusIds.length > 0) { + statusIdsByColumn.push(columnStatusIds) + } + } + return { statusIdsByColumn } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.site.id) + throw error + } + console.warn('[jira] getProjectStatusOrder failed:', error) + return { statusIdsByColumn: [] } + } finally { + release() + } +} diff --git a/src/main/native-chat/session-file-resolver.test.ts b/src/main/native-chat/session-file-resolver.test.ts index 66d9ca47bc8..5dd3cbc8dab 100644 --- a/src/main/native-chat/session-file-resolver.test.ts +++ b/src/main/native-chat/session-file-resolver.test.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' + import { resolveSessionFilePath } from './session-file-resolver' let tempRoots: string[] = [] @@ -38,6 +39,77 @@ describe('resolveSessionFilePath', () => { expect(resolved).toBe(target) }) + it('resolves Grok chat_history.jsonl under encodeURIComponent(cwd)/sessionId', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-') + const grokSessionsDir = join(root, 'grok-sessions') + const sessionDir = join(grokSessionsDir, encodeURIComponent('/tmp/work'), 'sess-grok-1') + await mkdir(sessionDir, { recursive: true }) + const target = join(sessionDir, 'chat_history.jsonl') + await writeFile(target, '{"type":"user","content":"hi"}\n') + + const resolved = await resolveSessionFilePath('grok', 'sess-grok-1', { grokSessionsDir }) + expect(resolved).toBe(target) + }) + + it('resolves Grok chat_history by session id under a long-cwd slug group', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-long-') + const grokSessionsDir = join(root, 'grok-sessions') + const sessionDir = join(grokSessionsDir, 'slug-hash-ab12', 'sess-long-1') + await mkdir(sessionDir, { recursive: true }) + const target = join(sessionDir, 'chat_history.jsonl') + await writeFile(join(grokSessionsDir, 'slug-hash-ab12', '.cwd'), `/${'x'.repeat(400)}\n`) + await writeFile(target, '{"type":"assistant","content":"ok"}\n') + + await expect(resolveSessionFilePath('grok', 'sess-long-1', { grokSessionsDir })).resolves.toBe( + target + ) + }) + + it('ignores nested Grok decoys outside the direct group/session layout', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-decoy-') + const grokSessionsDir = join(root, 'grok-sessions') + const decoy = join( + grokSessionsDir, + 'group', + 'other-session', + 'nested', + 'sess-decoy', + 'chat_history.jsonl' + ) + await mkdir(dirname(decoy), { recursive: true }) + await writeFile(decoy, '{}\n') + + await expect( + resolveSessionFilePath('grok', 'sess-decoy', { grokSessionsDir }) + ).resolves.toBeNull() + }) + + it('rejects unsafe Grok session ids before filesystem discovery', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-invalid-') + const grokSessionsDir = join(root, 'grok-sessions') + await mkdir(grokSessionsDir, { recursive: true }) + + await expect( + resolveSessionFilePath('grok', '../escape', { grokSessionsDir }) + ).resolves.toBeNull() + }) + + it('resolves Grok sessions under GROK_HOME when no override is passed', async () => { + const root = await makeRoot('orca-native-chat-resolve-grok-home-') + const sessionsDir = join(root, 'sessions') + const sessionDir = join(sessionsDir, encodeURIComponent('/repo'), 'sess-env-1') + await mkdir(sessionDir, { recursive: true }) + const target = join(sessionDir, 'chat_history.jsonl') + await writeFile(target, '{}\n') + const previous = process.env.GROK_HOME + process.env.GROK_HOME = root + try { + await expect(resolveSessionFilePath('grok', 'sess-env-1')).resolves.toBe(target) + } finally { + restoreEnv('GROK_HOME', previous) + } + }) + it('matches Codex rollout files by session id suffix', async () => { const root = await makeRoot('orca-native-chat-resolve-codex-') const codexSessionsDir = join(root, 'codex-sessions') diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 3b2cda9965a..adfa3856f0e 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -4,6 +4,10 @@ import { basename, extname, join } from 'node:path' import type { AgentType } from '../../shared/native-chat-types' import { walkSessionFiles } from '../ai-vault/session-scanner-discovery' import { getOrcaManagedCodexHomePath } from '../codex/codex-home-paths' +import { + findGrokChatHistoryBySessionId, + resolveGrokSessionsDir +} from '../../shared/grok-session-paths' // Why: these mirror the path constants in ai-vault/session-scanner.ts. Reads // run in the main process against the runtime's own home directory; over SSH @@ -28,12 +32,18 @@ function codexSessionsDirs(): string[] { return candidates.filter((dir, index) => candidates.indexOf(dir) === index) } +function grokSessionsDir(): string { + return resolveGrokSessionsDir(process.env, homedir()) +} + export type ResolveSessionFileOptions = { /** Override the Claude projects root (used by tests / isolated scans). */ claudeProjectsDir?: string /** Override the Codex sessions roots, searched in order (tests / isolated * scans). Defaults to the orca-managed home then CODEX_HOME/~/.codex. */ codexSessionsDirs?: string[] + /** Override the Grok sessions root (`~/.grok/sessions`). */ + grokSessionsDir?: string /** Authoritative transcript path reported by the agent hook * (`providerSession.transcriptPath`). When set and the file exists, it is used * directly — recent Claude Code names the transcript with a UUID that differs @@ -76,6 +86,9 @@ export async function resolveSessionFilePath( if (agent === 'codex') { return resolveCodexSessionFile(trimmedId, options.codexSessionsDirs ?? codexSessionsDirs()) } + if (agent === 'grok') { + return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir()) + } return null } @@ -115,3 +128,13 @@ async function resolveCodexSessionFile( } return null } + +async function resolveGrokSessionFile( + sessionId: string, + sessionsDir: string +): Promise { + // Why: Native Chat runs on the main thread; use the bounded async direct-layout + // lookup instead of blocking, then repeating, a recursive full-tree scan. + const history = await findGrokChatHistoryBySessionId(sessionsDir, sessionId) + return history +} diff --git a/src/main/native-chat/transcript-fallback-id.test.ts b/src/main/native-chat/transcript-fallback-id.test.ts new file mode 100644 index 00000000000..8ef46edfcf6 --- /dev/null +++ b/src/main/native-chat/transcript-fallback-id.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { transcriptFallbackId } from './transcript-fallback-id' + +describe('transcriptFallbackId', () => { + it('sorts line positions numerically when timestamps are unavailable', () => { + const ids = [10, 2, 100, 9].map((offset) => transcriptFallbackId('/chat.jsonl', offset)) + + expect(ids.sort()).toEqual([ + transcriptFallbackId('/chat.jsonl', 2), + transcriptFallbackId('/chat.jsonl', 9), + transcriptFallbackId('/chat.jsonl', 10), + transcriptFallbackId('/chat.jsonl', 100) + ]) + }) + + it('sorts later incremental byte ranges after earlier ranges', () => { + const earlier = transcriptFallbackId('/chat.jsonl', 99) + const later = transcriptFallbackId('/chat.jsonl', 100) + + expect(earlier < later).toBe(true) + }) +}) diff --git a/src/main/native-chat/transcript-fallback-id.ts b/src/main/native-chat/transcript-fallback-id.ts new file mode 100644 index 00000000000..6c993159902 --- /dev/null +++ b/src/main/native-chat/transcript-fallback-id.ts @@ -0,0 +1,10 @@ +const TRANSCRIPT_POSITION_WIDTH = 16 + +/** Stable JSONL position id shared by full reads and incremental tailing. */ +export function transcriptFallbackId(filePath: string, byteOffset: number): string { + return `${filePath}:${positionPart(byteOffset)}` +} + +function positionPart(value: number): string { + return value.toString().padStart(TRANSCRIPT_POSITION_WIDTH, '0') +} diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts new file mode 100644 index 00000000000..68dcf77701b --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -0,0 +1,55 @@ +// Claude JSONL line → NativeChatMessage decoder. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks } from './transcript-record-blocks' + +export function decodeClaudeTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const role = record.type + if (role !== 'user' && role !== 'assistant') { + return null + } + const message = asRecord(record.message) + const blocks = claudeContentBlocks(message?.content) + if (blocks.length === 0) { + return null + } + const messageId = extractString(record.uuid) ?? extractString(message?.id) + return { + id: messageId ?? fallbackId, + role: claudeMessageRole(role, blocks), + blocks, + timestamp: parseTimestamp(record.timestamp), + source: 'transcript' + } +} + +// Claude marks reasoning via `thinking` content blocks; when a message is made +// up solely of reasoning, surface it as a reasoning-role message. +function claudeMessageRole( + role: 'user' | 'assistant', + blocks: NativeChatBlock[] +): NativeChatMessage['role'] { + if (role === 'user') { + const onlyToolResults = blocks.every((block) => block.type === 'tool-result') + return onlyToolResults && blocks.length > 0 ? 'tool' : 'user' + } + return role +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts new file mode 100644 index 00000000000..64d04fa5ae3 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -0,0 +1,139 @@ +// Codex JSONL line → NativeChatMessage decoder. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' + +export function decodeCodexTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const payload = asRecord(record.payload) + if (!payload) { + return null + } + const timestamp = parseTimestamp(record.timestamp) + const baseId = extractString(payload.id) ?? fallbackId + + if (record.type === 'response_item') { + return codexResponseItem(payload, baseId, timestamp) + } + if (record.type === 'event_msg') { + return codexEventMessage(payload, baseId, timestamp) + } + return null +} + +function codexResponseItem( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'message') { + const blocks = claudeContentBlocks(payload.content) + if (blocks.length === 0) { + return null + } + const role = + payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system' + return { id, role, blocks, timestamp, source: 'transcript' } + } + if (payload.type === 'reasoning') { + const text = extractString(payload.text) ?? codexSummaryText(payload.summary) + if (!text) { + return null + } + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call' || payload.type === 'local_shell_call') { + const name = extractString(payload.name) ?? 'tool' + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], + timestamp, + source: 'transcript' + } + } + if (payload.type === 'function_call_output') { + return { + id, + role: 'tool', + blocks: [codexToolResult(payload.output)], + timestamp, + source: 'transcript' + } + } + return null +} + +function codexEventMessage( + payload: Record, + id: string, + timestamp: number | null +): NativeChatMessage | null { + if (payload.type === 'user_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + if (payload.type === 'agent_message') { + const text = extractString(payload.message) + return text + ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } + : null + } + return null +} + +function codexCallInput(payload: Record): unknown { + if (payload.arguments !== undefined) { + return payload.arguments + } + return payload.input ?? payload.action ?? null +} + +function codexToolResult(output: unknown): NativeChatBlock { + const record = asRecord(output) + const isError = record?.success === false || record?.is_error === true + return { + type: 'tool-result', + output: toolResultOutput(record?.content ?? record?.output ?? output), + ...(isError ? { isError: true } : {}) + } +} + +function codexSummaryText(summary: unknown): string | null { + if (!Array.isArray(summary)) { + return null + } + const parts: string[] = [] + for (const item of summary) { + const text = extractString(asRecord(item)?.text) ?? extractString(item) + if (text) { + parts.push(text) + } + } + return parts.length ? parts.join('\n') : null +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders-grok.ts b/src/main/native-chat/transcript-line-decoders-grok.ts new file mode 100644 index 00000000000..76386453970 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-grok.ts @@ -0,0 +1,247 @@ +// Grok chat_history.jsonl line → NativeChatMessage decoder. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' + +/** + * Grok `chat_history.jsonl` rows: user/assistant/reasoning/tool records with + * Claude-like content blocks plus Grok-specific `backend_tool_call` / + * `tool_result` shapes. System prompts are skipped. + */ +export function decodeGrokTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + const type = extractString(record.type) + if (!type) { + return null + } + const timestamp = parseTimestamp(record.timestamp) + const recordId = extractString(record.id) + // Why: Grok rows frequently omit timestamps and only some carry ids; prefix + // every row with its JSONL position so Native Chat preserves transcript order. + const id = recordId ? `${fallbackId}:${recordId}` : fallbackId + + if (type === 'user' || type === 'assistant') { + // Why: Grok records bootstrap context as user rows; only submitted prompts + // belong in the conversation shown to the user. + if ( + type === 'user' && + (hasNonEmptySyntheticReason(record) || isGrokBootstrapContext(record.content)) + ) { + return null + } + const rawBlocks = claudeContentBlocks(record.content) + const blocks = type === 'user' ? rawBlocks.flatMap(normalizeGrokUserQueryBlock) : rawBlocks + if (blocks.length === 0) { + // Empty assistant rows often only hold tool_calls — surface those. + const toolBlocks = grokToolCallBlocks(record.tool_calls) + if (toolBlocks.length === 0) { + return null + } + return { id, role: 'assistant', blocks: toolBlocks, timestamp, source: 'transcript' } + } + if (type === 'assistant') { + const toolBlocks = grokToolCallBlocks(record.tool_calls) + return { + id, + role: 'assistant', + blocks: [...blocks, ...toolBlocks], + timestamp, + source: 'transcript' + } + } + return { id, role: 'user', blocks, timestamp, source: 'transcript' } + } + + if (type === 'reasoning') { + const text = + extractString(record.text) ?? + grokSummaryText(record.summary) ?? + extractString(asRecord(record.content)?.text) + if (!text?.trim()) { + return null + } + return { + id, + role: 'reasoning', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + + if (type === 'backend_tool_call' || type === 'tool_call') { + const name = + extractString(asRecord(record.kind)?.tool_type) ?? + extractString(record.name) ?? + extractString(record.tool) ?? + 'tool' + return { + id, + role: 'assistant', + blocks: [{ type: 'tool-call', name, input: record.kind ?? record.arguments ?? record.input }], + timestamp, + source: 'transcript' + } + } + + if (type === 'tool_result') { + const output = toolResultOutput(record.content ?? record.output ?? record.result) + return { + id, + role: 'tool', + blocks: [ + { + type: 'tool-result', + output, + ...(record.is_error === true || record.isError === true ? { isError: true } : {}) + } + ], + timestamp, + source: 'transcript' + } + } + + return null +} + +function grokToolCallBlocks(value: unknown): NativeChatBlock[] { + if (!Array.isArray(value)) { + return [] + } + const blocks: NativeChatBlock[] = [] + for (const item of value) { + const record = asRecord(item) + if (!record) { + continue + } + const name = extractString(record.name) ?? extractString(record.tool) ?? 'tool' + let input: unknown = record.arguments ?? record.input ?? record.args + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch { + // keep string + } + } + blocks.push({ type: 'tool-call', name, input }) + } + return blocks +} + +function grokSummaryText(value: unknown): string | null { + if (typeof value === 'string') { + return value + } + if (!Array.isArray(value)) { + return null + } + const parts: string[] = [] + for (const item of value) { + const record = asRecord(item) + const text = extractString(record?.text) ?? extractString(record?.summary_text) + if (text) { + parts.push(text) + } + } + return parts.length > 0 ? parts.join('\n') : null +} + +function hasNonEmptySyntheticReason(record: Record): boolean { + return typeof record.synthetic_reason === 'string' && record.synthetic_reason.trim().length > 0 +} + +function isGrokBootstrapContext(content: unknown): boolean { + const text = standaloneTextContent(content) + if (!text) { + return false + } + const normalized = text.trim().toLowerCase() + if (!normalized.startsWith('')) { + return false + } + const userInfoEnd = normalized.indexOf('') + if (userInfoEnd === -1) { + return false + } + const remainder = normalized.slice(userInfoEnd + ''.length).trim() + // Why: Grok 0.2.93 appends its git snapshot to the user-info bootstrap row; + // reject only that known envelope so real prompts mentioning either tag survive. + return ( + remainder.length === 0 || + (remainder.startsWith('') && remainder.endsWith('')) + ) +} + +function standaloneTextContent(content: unknown): string | null { + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content) || content.length !== 1) { + return null + } + const block = asRecord(content[0]) + return block?.type === 'text' ? (extractString(block.text) ?? null) : null +} + +function normalizeGrokUserQueryBlock(block: NativeChatBlock): NativeChatBlock[] { + if (block.type !== 'text') { + return [block] + } + const stripped = stripGrokUserQueryEnvelope(block.text) + if (!stripped.trim()) { + return [] + } + const pastedImage = splitGrokPastedImageQuery(stripped) + if (!pastedImage) { + return [stripped === block.text ? block : { type: 'text', text: stripped }] + } + return [ + { type: 'image-ref', path: pastedImage.path }, + ...(pastedImage.query ? [{ type: 'text' as const, text: pastedImage.query }] : []) + ] +} + +function splitGrokPastedImageQuery(text: string): { path: string; query: string } | null { + // Why: Grok 0.2.93 persists clipboard images as an absolute temp path directly + // concatenated with the prompt; recover Orca's attachment without exposing it. + const match = text.match( + /^((?:[a-z]:[\\/]|\/|[\\/]{2}[^\\/\r\n]+[\\/][^\\/\r\n]+[\\/])(?:.*?[\\/])?orca-paste-[^\\/\r\n]+?\.png)([\s\S]*)$/i + ) + if (!match?.[1]) { + return null + } + return { path: match[1], query: (match[2] ?? '').trim() } +} + +function stripGrokUserQueryEnvelope(text: string): string { + const opener = '' + const closer = '' + const lower = text.toLowerCase() + const start = lower.indexOf(opener) + if (start === -1) { + return text + } + const bodyStart = start + opener.length + const end = lower.indexOf(closer, bodyStart) + if (end === -1) { + return text.slice(bodyStart).trim() + } + return text.slice(bodyStart, end).trim() +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders.grok.test.ts b/src/main/native-chat/transcript-line-decoders.grok.test.ts new file mode 100644 index 00000000000..13295dcfcdd --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.grok.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'vitest' +import { decodeGrokTranscriptLine } from './transcript-line-decoders' + +describe('decodeGrokTranscriptLine', () => { + it('decodes user text and strips wrappers', () => { + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: '\nFix the bug\n' }], + timestamp: '2026-06-18T00:00:00.000Z' + }) + expect(decodeGrokTranscriptLine(line, 'fb-1')).toEqual({ + id: 'fb-1', + role: 'user', + blocks: [{ type: 'text', text: 'Fix the bug' }], + timestamp: Date.parse('2026-06-18T00:00:00.000Z'), + source: 'transcript' + }) + }) + + it('decodes assistant tool calls on empty content rows', () => { + const line = JSON.stringify({ + type: 'assistant', + content: '', + tool_calls: [{ id: 'c1', name: 'grep', arguments: '{"pattern":"foo"}' }], + id: 'asst-1' + }) + expect(decodeGrokTranscriptLine(line, 'fb-2')).toEqual({ + id: 'fb-2:asst-1', + role: 'assistant', + blocks: [{ type: 'tool-call', name: 'grep', input: { pattern: 'foo' } }], + timestamp: null, + source: 'transcript' + }) + }) + + it.each([ + 'C:\\Users\\me\\AppData\\Local\\Temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + '/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + 'C:\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + '/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png', + '\\\\server\\temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png' + ])('restores a pasted image and prompt from Grok transcript text for %s', (imagePath) => { + const line = JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: `\n${imagePath}Describe this image\n` + } + ] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-image')).toMatchObject({ + role: 'user', + blocks: [ + { type: 'image-ref', path: imagePath }, + { type: 'text', text: 'Describe this image' } + ] + }) + }) + + it('restores an attachment-only pasted image from Grok transcript text', () => { + const imagePath = '/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png' + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: `${imagePath}` }] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-image-only')).toMatchObject({ + role: 'user', + blocks: [{ type: 'image-ref', path: imagePath }] + }) + }) + + it('preserves ordinary prompts that mention pasted-image filenames', () => { + const text = 'Explain what an orca-paste-123-example.png file is' + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: `${text}` }] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-image-mention')).toMatchObject({ + role: 'user', + blocks: [{ type: 'text', text }] + }) + }) + + it('preserves assistant text containing a literal user_query tag', () => { + const text = 'Before quoted example after' + const line = JSON.stringify({ type: 'assistant', content: text }) + + expect(decodeGrokTranscriptLine(line, 'fb-assistant-tag')).toMatchObject({ + role: 'assistant', + blocks: [{ type: 'text', text }] + }) + }) + + it.each(['project_instructions', 'system_reminder'])( + 'skips Grok synthetic user rows with reason %s', + (syntheticReason) => { + const line = JSON.stringify({ + type: 'user', + content: [{ type: 'text', text: 'Internal context' }], + synthetic_reason: syntheticReason + }) + + expect(decodeGrokTranscriptLine(line, `fb-${syntheticReason}`)).toBeNull() + } + ) + + it.each([ + 'bootstrap context', + [{ type: 'text', text: 'bootstrap context' }], + [ + { + type: 'text', + text: [ + 'Runtime context', + 'Working tree snapshot' + ].join('\n\n') + } + ] + ])('skips a standalone Grok user_info bootstrap row', (content) => { + expect( + decodeGrokTranscriptLine(JSON.stringify({ type: 'user', content }), 'fb-user-info') + ).toBeNull() + }) + + it('keeps a real query that discusses the user_info tag', () => { + const line = JSON.stringify({ + type: 'user', + content: [ + { + type: 'text', + text: 'Explain the tag' + } + ] + }) + + expect(decodeGrokTranscriptLine(line, 'fb-user-info-query')).toMatchObject({ + role: 'user', + blocks: [{ type: 'text', text: 'Explain the tag' }] + }) + }) + + it('filters Grok bootstrap rows from a real-schema transcript prefix', () => { + const rows = [ + { type: 'system', content: 'System context' }, + { + type: 'user', + content: [{ type: 'text', text: 'Runtime context' }] + }, + { + type: 'user', + content: [{ type: 'text', text: 'Project context' }], + synthetic_reason: 'project_instructions' + }, + { + type: 'user', + content: [{ type: 'text', text: 'Reminder context' }], + synthetic_reason: 'system_reminder' + }, + { + type: 'user', + content: [{ type: 'text', text: 'Visible prompt' }] + }, + { type: 'assistant', content: 'Visible answer' } + ] + + const messages = rows + .map((row, index) => decodeGrokTranscriptLine(JSON.stringify(row), `fb-real-schema-${index}`)) + .filter((message) => message !== null) + + expect(messages).toMatchObject([ + { role: 'user', blocks: [{ type: 'text', text: 'Visible prompt' }] }, + { role: 'assistant', blocks: [{ type: 'text', text: 'Visible answer' }] } + ]) + }) + + it('decodes reasoning summaries', () => { + const line = JSON.stringify({ + type: 'reasoning', + id: 'rs-1', + summary: [{ type: 'summary_text', text: 'Planning the change' }] + }) + expect(decodeGrokTranscriptLine(line, 'fb-3')).toMatchObject({ + id: 'fb-3:rs-1', + role: 'reasoning', + blocks: [{ type: 'text', text: 'Planning the change' }], + source: 'transcript' + }) + }) + + it('skips system prompts', () => { + const line = JSON.stringify({ type: 'system', content: 'You are Grok' }) + expect(decodeGrokTranscriptLine(line, 'fb-4')).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 1a428cbb98a..67b1aaf76aa 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -5,181 +5,10 @@ // or null (unknown/empty records are skipped, never thrown — plan KTD risk: // schema drift). `fallbackId` is used only when the record carries no intrinsic // id; the caller supplies a value unique per line. +// +// Why: agent-specific decoders live in dedicated modules so this barrel stays +// under the max-lines limit while callers keep a single import path. -import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' -import { - asRecord, - extractString, - parseJsonObject, - timestampMs -} from '../ai-vault/session-scanner-values' -import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' - -export function decodeClaudeTranscriptLine( - line: string, - fallbackId: string -): NativeChatMessage | null { - const record = parseJsonObject(line) - if (!record) { - return null - } - const role = record.type - if (role !== 'user' && role !== 'assistant') { - return null - } - const message = asRecord(record.message) - const blocks = claudeContentBlocks(message?.content) - if (blocks.length === 0) { - return null - } - const messageId = extractString(record.uuid) ?? extractString(message?.id) - return { - id: messageId ?? fallbackId, - role: claudeMessageRole(role, blocks), - blocks, - timestamp: parseTimestamp(record.timestamp), - source: 'transcript' - } -} - -// Claude marks reasoning via `thinking` content blocks; when a message is made -// up solely of reasoning, surface it as a reasoning-role message. -function claudeMessageRole( - role: 'user' | 'assistant', - blocks: NativeChatBlock[] -): NativeChatMessage['role'] { - if (role === 'user') { - const onlyToolResults = blocks.every((block) => block.type === 'tool-result') - return onlyToolResults && blocks.length > 0 ? 'tool' : 'user' - } - return role -} - -export function decodeCodexTranscriptLine( - line: string, - fallbackId: string -): NativeChatMessage | null { - const record = parseJsonObject(line) - if (!record) { - return null - } - const payload = asRecord(record.payload) - if (!payload) { - return null - } - const timestamp = parseTimestamp(record.timestamp) - const baseId = extractString(payload.id) ?? fallbackId - - if (record.type === 'response_item') { - return codexResponseItem(payload, baseId, timestamp) - } - if (record.type === 'event_msg') { - return codexEventMessage(payload, baseId, timestamp) - } - return null -} - -function codexResponseItem( - payload: Record, - id: string, - timestamp: number | null -): NativeChatMessage | null { - if (payload.type === 'message') { - const blocks = claudeContentBlocks(payload.content) - if (blocks.length === 0) { - return null - } - const role = - payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'system' - return { id, role, blocks, timestamp, source: 'transcript' } - } - if (payload.type === 'reasoning') { - const text = extractString(payload.text) ?? codexSummaryText(payload.summary) - if (!text) { - return null - } - return { - id, - role: 'reasoning', - blocks: [{ type: 'text', text }], - timestamp, - source: 'transcript' - } - } - if (payload.type === 'function_call' || payload.type === 'local_shell_call') { - const name = extractString(payload.name) ?? 'tool' - return { - id, - role: 'assistant', - blocks: [{ type: 'tool-call', name, input: codexCallInput(payload) }], - timestamp, - source: 'transcript' - } - } - if (payload.type === 'function_call_output') { - return { - id, - role: 'tool', - blocks: [codexToolResult(payload.output)], - timestamp, - source: 'transcript' - } - } - return null -} - -function codexEventMessage( - payload: Record, - id: string, - timestamp: number | null -): NativeChatMessage | null { - if (payload.type === 'user_message') { - const text = extractString(payload.message) - return text - ? { id, role: 'user', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } - : null - } - if (payload.type === 'agent_message') { - const text = extractString(payload.message) - return text - ? { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp, source: 'transcript' } - : null - } - return null -} - -function codexCallInput(payload: Record): unknown { - if (payload.arguments !== undefined) { - return payload.arguments - } - return payload.input ?? payload.action ?? null -} - -function codexToolResult(output: unknown): NativeChatBlock { - const record = asRecord(output) - const isError = record?.success === false || record?.is_error === true - return { - type: 'tool-result', - output: toolResultOutput(record?.content ?? record?.output ?? output), - ...(isError ? { isError: true } : {}) - } -} - -function codexSummaryText(summary: unknown): string | null { - if (!Array.isArray(summary)) { - return null - } - const parts: string[] = [] - for (const item of summary) { - const text = extractString(asRecord(item)?.text) ?? extractString(item) - if (text) { - parts.push(text) - } - } - return parts.length ? parts.join('\n') : null -} - -function parseTimestamp(value: unknown): number | null { - const parsed = timestampMs(value) - return Number.isFinite(parsed) ? parsed : null -} +export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' +export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' +export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index c0b5f64bb68..1dbbd7dd82a 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -1,9 +1,13 @@ import { createReadStream } from 'node:fs' -import { createInterface } from 'node:readline' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' import { errorMessage } from '../ai-vault/session-scanner-values' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' -import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { decodeTranscriptStream } from './transcript-stream-lines' export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } @@ -17,8 +21,7 @@ export type ReadTranscriptOptions = ResolveSessionFileOptions & { * the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies * NO message cap. Unknown record types are skipped rather than throwing, so a * single malformed/unrecognized line cannot fail the whole read. The per-line - * record→message mapping is shared with the live tailer (transcript-watch.ts) - * via transcript-line-decoders.ts. + * record-to-message mapping is shared with the live tailer. */ export async function readNativeChatTranscript( agent: AgentType, @@ -36,6 +39,9 @@ export async function readNativeChatTranscript( if (agent === 'codex') { return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) } } + if (agent === 'grok') { + return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) } + } return { error: `Unsupported agent for native chat transcript: ${agent}` } } catch (err) { return { error: errorMessage(err) } @@ -46,21 +52,7 @@ async function readTranscript( filePath: string, decode: (line: string, fallbackId: string) => NativeChatMessage | null ): Promise { - const reader = createInterface({ - input: createReadStream(filePath, { encoding: 'utf-8' }), - crlfDelay: Infinity - }) - const messages: NativeChatMessage[] = [] - let index = 0 - for await (const line of reader) { - // Why: fallback id embeds start offset 0 so it matches the live tailer's id - // for the same record (the tailer's first drain reads from offset 0 too). - // Records that re-emit then collapse by id in the assembler — no dup, no drop. - const message = decode(line, `${filePath}:0:${index}`) - if (message) { - messages.push(message) - } - index++ - } + const stream = createReadStream(filePath, { encoding: 'utf-8' }) + const { messages } = await decodeTranscriptStream(stream, filePath, 0, decode, true) return messages } diff --git a/src/main/native-chat/transcript-stream-lines.test.ts b/src/main/native-chat/transcript-stream-lines.test.ts new file mode 100644 index 00000000000..2bfa6f6f9d2 --- /dev/null +++ b/src/main/native-chat/transcript-stream-lines.test.ts @@ -0,0 +1,49 @@ +import { Readable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { decodeTranscriptStream } from './transcript-stream-lines' + +const decode = (line: string, id: string) => ({ + id, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: line }], + timestamp: null, + source: 'transcript' as const +}) + +describe('decodeTranscriptStream', () => { + it('uses identical absolute byte ids for full and incremental reads', async () => { + const prefix = '{"first":"é"}\r\n' + const appended = '{"second":true}\n' + const full = await decodeTranscriptStream( + Readable.from([prefix + appended]), + '/chat.jsonl', + 0, + decode, + true + ) + const incremental = await decodeTranscriptStream( + Readable.from([appended]), + '/chat.jsonl', + Buffer.byteLength(prefix, 'utf8'), + decode, + false + ) + + expect(incremental.messages[0]?.id).toBe(full.messages[1]?.id) + }) + + it('does not consume a partial trailing JSONL record', async () => { + const complete = '{"first":true}\n' + const partial = '{"second"' + const result = await decodeTranscriptStream( + Readable.from([complete + partial]), + '/chat.jsonl', + 0, + decode, + false + ) + + expect(result.messages).toHaveLength(1) + expect(result.consumedBytes).toBe(Buffer.byteLength(complete, 'utf8')) + }) +}) diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts new file mode 100644 index 00000000000..a764941ad30 --- /dev/null +++ b/src/main/native-chat/transcript-stream-lines.ts @@ -0,0 +1,47 @@ +import type { Readable } from 'node:stream' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { transcriptFallbackId } from './transcript-fallback-id' + +type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null + +export async function decodeTranscriptStream( + stream: Readable, + filePath: string, + start: number, + decode: TranscriptDecoder, + includeTrailingLine: boolean +): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { + const messages: NativeChatMessage[] = [] + let pending = '' + let consumedBytes = 0 + + for await (const chunk of stream) { + pending += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') + let newlineIndex = pending.indexOf('\n') + while (newlineIndex !== -1) { + const segment = pending.slice(0, newlineIndex + 1) + decodeLine(segment.slice(0, -1), consumedBytes) + consumedBytes += Buffer.byteLength(segment, 'utf8') + pending = pending.slice(newlineIndex + 1) + newlineIndex = pending.indexOf('\n') + } + } + + if (includeTrailingLine && pending.length > 0) { + decodeLine(pending, consumedBytes) + consumedBytes += Buffer.byteLength(pending, 'utf8') + } + + return { messages, consumedBytes } + + function decodeLine(rawLine: string, relativeOffset: number): void { + const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine + if (!line) { + return + } + const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset)) + if (message) { + messages.push(message) + } + } +} diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 919cac24e10..4bbd934f0eb 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -10,10 +10,14 @@ import { watch, type FSWatcher } from 'node:fs' import { open, stat } from 'node:fs/promises' -import type { Readable } from 'node:stream' import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' -import { decodeClaudeTranscriptLine, decodeCodexTranscriptLine } from './transcript-line-decoders' +import { + decodeClaudeTranscriptLine, + decodeCodexTranscriptLine, + decodeGrokTranscriptLine +} from './transcript-line-decoders' +import { decodeTranscriptStream } from './transcript-stream-lines' export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { agent: AgentType @@ -57,6 +61,9 @@ function lineDecoderForAgent( if (agent === 'codex') { return decodeCodexTranscriptLine } + if (agent === 'grok') { + return decodeGrokTranscriptLine + } return null } @@ -94,53 +101,19 @@ async function readAppendedMessages( end: end - 1, autoClose: false }) - const { messages, consumedBytes } = await decodeStreamLines(stream, filePath, start, decode) + const { messages, consumedBytes } = await decodeTranscriptStream( + stream, + filePath, + start, + decode, + false + ) return { messages, consumedTo: start + consumedBytes } } finally { await handle.close() } } -async function decodeStreamLines( - stream: Readable, - filePath: string, - start: number, - decode: (line: string, fallbackId: string) => NativeChatMessage | null -): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> { - const text = await readStreamText(stream) - const completeEnd = text.lastIndexOf('\n') - if (completeEnd === -1) { - return { messages: [], consumedBytes: 0 } - } - - // Why: transcript writers can flush mid-record. Only advance through - // newline-terminated JSONL so invalid partial JSON is retried on the next - // append instead of being lost forever. - const completeText = text.slice(0, completeEnd + 1) - const lines = completeText.split('\n') - const messages: NativeChatMessage[] = [] - for (const [index, line] of lines.entries()) { - if (!line) { - continue - } - // Fallback id embeds the byte offset so ids stay stable+unique across - // appends even when a record carries no intrinsic id. - const message = decode(line, `${filePath}:${start}:${index}`) - if (message) { - messages.push(message) - } - } - return { messages, consumedBytes: Buffer.byteLength(completeText, 'utf8') } -} - -async function readStreamText(stream: Readable): Promise { - const chunks: string[] = [] - for await (const chunk of stream) { - chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - } - return chunks.join('') -} - /** * Subscribe to live appends on an agent's transcript file. Returns an * unsubscribe fn that tears the watcher down completely. diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 0974367ef81..91c781bf452 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -7,6 +7,7 @@ type MockMultiplexer = { notify: ReturnType onNotification: ReturnType onNotificationByMethod: ReturnType + onDispose: ReturnType dispose: ReturnType isDisposed: ReturnType } @@ -17,6 +18,9 @@ function createMockMux(): MockMultiplexer { notify: vi.fn(), onNotification: vi.fn(), onNotificationByMethod: vi.fn().mockReturnValue(vi.fn()), + // Why: requestGitStreamable subscribes to onDispose before awaiting the + // response so it can reject in-flight reassembly if the link drops. + onDispose: vi.fn().mockReturnValue(vi.fn()), dispose: vi.fn(), isDisposed: vi.fn().mockReturnValue(false) } @@ -320,7 +324,10 @@ describe('SshGitProvider', () => { 'git.exec', { args: ['clone', '--progress', '--', 'git@example.com:repo.git', 'repo'], - cwd: '/home/user' + cwd: '/home/user', + // Why: exec opts into response streaming so a large stdout is chunked + // onto the bulk lane; old relays ignore the flag. + __streamResponse: true }, { signal: controller.signal, @@ -353,7 +360,8 @@ describe('SshGitProvider', () => { }) expect(mux.request).toHaveBeenCalledWith('git.exec', { args: ['diff', '--cached', '--patch', '--minimal', '--no-color', '--no-ext-diff'], - cwd: '/home/user/repo' + cwd: '/home/user/repo', + __streamResponse: true }) }) @@ -636,7 +644,10 @@ describe('SshGitProvider', () => { expect(mux.request).toHaveBeenCalledWith('git.diff', { worktreePath: '/home/user/repo', filePath: 'src/index.ts', - staged: true + staged: true, + // Why: opts into response streaming; a small result still comes back as a + // single frame (relay decides), and old relays ignore the flag. + __streamResponse: true }) expect(result).toEqual(diffResult) }) @@ -880,7 +891,8 @@ describe('SshGitProvider', () => { const result = await provider.getBranchDiff('/home/user/repo', 'main') expect(mux.request).toHaveBeenCalledWith('git.branchDiff', { worktreePath: '/home/user/repo', - baseRef: 'main' + baseRef: 'main', + __streamResponse: true }) expect(result).toEqual(diffs) }) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 0cde62d534f..76fb163909b 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -21,6 +21,7 @@ import type { import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from '../git/hosted-remote-url' import { JsonRpcErrorCode } from '../ssh/relay-protocol' +import { requestGitStreamable } from '../ssh/ssh-git-response-stream-reader' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' @@ -373,7 +374,7 @@ export class SshGitProvider implements IGitProvider { return this.gitDiffReadDedupe.run( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), async () => - (await this.mux.request('git.diff', { + (await requestGitStreamable(this.mux, 'git.diff', { worktreePath, filePath, staged, @@ -597,7 +598,7 @@ export class SshGitProvider implements IGitProvider { keyOptions.oldPath ?? null ]), async () => - (await this.mux.request('git.branchDiff', { + (await requestGitStreamable(this.mux, 'git.branchDiff', { worktreePath, baseRef, ...options @@ -619,7 +620,7 @@ export class SshGitProvider implements IGitProvider { args.oldPath ?? null ]), async () => - (await this.mux.request('git.commitDiff', { + (await requestGitStreamable(this.mux, 'git.commitDiff', { worktreePath, ...args })) as GitDiffResult @@ -761,8 +762,8 @@ export class SshGitProvider implements IGitProvider { options?: { signal?: AbortSignal; timeoutMs?: number } ): Promise<{ stdout: string; stderr: string }> { const result = options - ? await this.mux.request('git.exec', { args, cwd }, options) - : await this.mux.request('git.exec', { args, cwd }) + ? await requestGitStreamable(this.mux, 'git.exec', { args, cwd }, options) + : await requestGitStreamable(this.mux, 'git.exec', { args, cwd }) return result as { stdout: string stderr: string diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index 27a279cdd55..ca0a9fd8261 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -138,8 +138,39 @@ describe('SshPtyProvider', () => { cols: 120, rows: 40, cwd: undefined, - env: {} + env: {}, + envToDelete: [POWERLEVEL10K_WIZARD_DISABLE_ENV] + }) + }) + + it('preserves explicit TERM and forwards final env deletions to the relay', async () => { + mux.request.mockResolvedValue({ id: 'pty-env-precedence' }) + const envToDelete = ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + + await provider.spawn({ + cols: 120, + rows: 40, + env: { + TERM: 'screen-256color', + TERM_PROGRAM: 'stale-terminal', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/stale-attribution' + }, + envToDelete + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { + TERM: 'screen-256color', + [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' + }, + envToDelete }) + const spawnCall = mux.request.mock.calls.find((call) => call[0] === 'pty.spawn') + expect(spawnCall?.[1]?.env).not.toHaveProperty('TERM_PROGRAM') + expect(spawnCall?.[1]?.env).not.toHaveProperty('ORCA_ATTRIBUTION_SHIM_DIR') }) it('forwards provider command delivery to the relay', async () => { diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index be84c2a6267..e386e89dbba 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -149,6 +149,7 @@ export class SshPtyProvider implements IPtyProvider { rows: opts.rows, cwd: opts.cwd, env: this.withRemoteCliBridgeEnv(opts.env, opts.envToDelete), + ...(opts.envToDelete?.length ? { envToDelete: opts.envToDelete } : {}), // Why: the relay's plugin-overlay env augmenter needs to know which // Pi-compatible agent is being launched, while commandDelivery tells it // whether to submit the command itself for runtime-owned background PTYs. @@ -179,27 +180,28 @@ export class SshPtyProvider implements IPtyProvider { envToDelete?: readonly string[] ): Record { const merged = { ...env } + if (this.remoteCliBridgeEnv) { + const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':' + const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null + if (pathKey) { + const pathValue = merged[pathKey] ?? '' + merged[pathKey] = pathValue.split(pathDelimiter).includes(this.remoteCliBridgeEnv.binDir) + ? pathValue + : pathValue + ? `${this.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}` + : this.remoteCliBridgeEnv.binDir + } + merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir + merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir + merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath + merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath + } + // Why: match local/daemon precedence—managed defaults and augmentations + // cannot resurrect values the caller explicitly removed. for (const key of envToDelete ?? []) { delete merged[key] } seedPowerlevel10kWizardEnv(merged, { envToDelete }) - if (!this.remoteCliBridgeEnv) { - return merged - } - const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':' - const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null - if (pathKey) { - const pathValue = merged[pathKey] ?? '' - merged[pathKey] = pathValue.split(pathDelimiter).includes(this.remoteCliBridgeEnv.binDir) - ? pathValue - : pathValue - ? `${this.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}` - : this.remoteCliBridgeEnv.binDir - } - merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir - merged.ORCA_RELAY_DIR = this.remoteCliBridgeEnv.relayDir - merged.ORCA_RELAY_NODE_PATH = this.remoteCliBridgeEnv.nodePath - merged.ORCA_RELAY_SOCKET_PATH = this.remoteCliBridgeEnv.sockPath return merged } diff --git a/src/main/rate-limits/grok-auth.ts b/src/main/rate-limits/grok-auth.ts index 508e0569bec..10ba629ff34 100644 --- a/src/main/rate-limits/grok-auth.ts +++ b/src/main/rate-limits/grok-auth.ts @@ -1,10 +1,10 @@ import { existsSync, readFileSync } from 'node:fs' -import { homedir } from 'node:os' import { join } from 'node:path' +import { resolveGrokHomeDir } from '../../shared/grok-session-paths' // Why: when GROK_HOME is set, auth.json must be the same path Grok CLI uses. export function getGrokHome(): string { - return process.env.GROK_HOME?.trim() || join(homedir(), '.grok') + return resolveGrokHomeDir() } export function getGrokAuthPath(): string { diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index cbfd8289b7e..5c3243c87be 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -79,6 +79,7 @@ type InternalRateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null } @@ -113,6 +114,7 @@ export class RateLimitService { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null } @@ -1126,7 +1128,7 @@ export class RateLimitService { private withFetchingStatus( current: ProviderRateLimits | null, - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' ): ProviderRateLimits { if (!current) { return { @@ -1200,6 +1202,7 @@ export class RateLimitService { ? this.withFetchingStatus(null, 'opencode-go') : this.withFetchingStatus(previousState.opencodeGo, 'opencode-go'), kimi: this.withFetchingStatus(previousState.kimi, 'kimi'), + antigravity: this.withFetchingStatus(previousState.antigravity, 'antigravity'), minimax: miniMaxConfigChanged ? this.withFetchingStatus(null, 'minimax') : this.withFetchingStatus(previousState.minimax, 'minimax'), @@ -1287,6 +1290,14 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) + // Why: Antigravity shares Google/Gemini usage credentials today; mirror the + // Gemini snapshot under provider 'antigravity' so status-bar UI that checks + // antigravity state receives a real fetch lifecycle instead of staying null. + const antigravity: ProviderRateLimits = { + ...gemini, + provider: 'antigravity' + } + const opencodeGo = opencodeGoResult.status === 'fulfilled' ? opencodeGoResult.value @@ -1365,6 +1376,7 @@ export class RateLimitService { : this.applyStalePolicy(opencodeGo, previousState.opencodeGo) : this.state.opencodeGo, kimi: this.applyStalePolicy(kimi, previousState.kimi), + antigravity: this.applyStalePolicy(antigravity, previousState.antigravity), minimax: shouldApplyMiniMax ? miniMaxConfigChanged ? miniMax diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index 3bbfc0ab1e0..f368ea270e2 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -13,10 +13,10 @@ import type { FileWatcherHostMessage, FileWatcherWorkerMessage } from './file-wa // exclusions silently fail and fseventsd delivers full node_modules churn. import { WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOption + buildParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' -const RUNTIME_FILE_WATCH_IGNORE = buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) +const RUNTIME_FILE_WATCH_IGNORE_OPTIONS = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) // Why: clean teardown is async (the worker awaits subscription.unsubscribe() // before closing its port and exiting). Wait this long for the worker to exit on @@ -66,7 +66,7 @@ export function watchFileExplorerInWorker( ): Promise<() => Promise> { return new Promise((resolve, reject) => { const worker = new Worker(getFileWatcherWorkerPath(), { - workerData: { rootPath, ignore: RUNTIME_FILE_WATCH_IGNORE } + workerData: { rootPath, ignoreOptions: RUNTIME_FILE_WATCH_IGNORE_OPTIONS } }) let ready = false diff --git a/src/main/runtime/file-watcher-worker.ts b/src/main/runtime/file-watcher-worker.ts index ec7e9141b16..9fd8e17cb77 100644 --- a/src/main/runtime/file-watcher-worker.ts +++ b/src/main/runtime/file-watcher-worker.ts @@ -10,13 +10,14 @@ import { stat } from 'node:fs/promises' import { parentPort, workerData } from 'node:worker_threads' import type * as ParcelWatcher from '@parcel/watcher' import type { FsChangeEvent } from '../../shared/types' +import type { ParcelWatcherIgnoreOptions } from '../ipc/filesystem-watcher-ignore' const RUNTIME_FILE_WATCH_EVENT_STAT_LIMIT = 200 const RUNTIME_FILE_WATCH_STAT_CONCURRENCY = 8 type FileWatcherWorkerData = { rootPath: string - ignore: string[] + ignoreOptions: ParcelWatcherIgnoreOptions } // Messages the worker sends back to the host. @@ -117,7 +118,7 @@ async function main(): Promise { // rejection that crashes the worker silently. Surface it instead. .catch((err: unknown) => reportWatchError(err)) }, - { ignore: data.ignore } + data.ignoreOptions as ParcelWatcher.Options ) // The crawl finished and the subscription is live. diff --git a/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts b/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts new file mode 100644 index 00000000000..44216e64d73 --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-notify-coalescer.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createMobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' + +describe('createMobileSessionTabsNotifyCoalescer', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces a rapid burst of title/status flips into one trailing emit (repro)', () => { + // Repro of the churn bug: a spinner-in-title agent flips a PTY title many + // times a second. Without coalescing every flip fans out an emit; with it, + // the whole burst settles into a single trailing-edge emit per worktree. + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + coalescer.schedule('worktree-1') + // Each flip lands well inside the trailing window, resetting the timer. + vi.advanceTimersByTime(10) + } + expect(emit).not.toHaveBeenCalled() + + // Let the trailing window elapse after the last flip. + vi.advanceTimersByTime(50) + + expect(emit).toHaveBeenCalledTimes(1) + expect(emit).toHaveBeenCalledWith('worktree-1') + }) + + it('force-flushes under sustained churn so the emit is never starved', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + // Keep flipping faster than the trailing window forever; the max-wait cap + // (250ms) must force at least one emit within that budget. + for (let i = 0; i < 100; i++) { + coalescer.schedule('worktree-1') + vi.advanceTimersByTime(30) + } + + expect(emit).toHaveBeenCalled() + expect(emit).toHaveBeenCalledWith('worktree-1') + }) + + it('keeps per-worktree windows independent', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.schedule('worktree-2') + vi.advanceTimersByTime(50) + + expect(emit).toHaveBeenCalledTimes(2) + expect(emit).toHaveBeenCalledWith('worktree-1') + expect(emit).toHaveBeenCalledWith('worktree-2') + }) + + it('cancel() drops a pending notify so a structural emit can supersede it', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + // A structural change (tab add/remove) cancels the coalesced notify because + // it emits immediately elsewhere. + coalescer.cancel('worktree-1') + vi.advanceTimersByTime(50) + + expect(emit).not.toHaveBeenCalled() + }) + + it('flush() emits the pending notify immediately', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.flush('worktree-1') + + expect(emit).toHaveBeenCalledTimes(1) + // The timer must not double-fire afterward. + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(1) + }) + + it('flush() is a no-op when nothing is pending', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.flush('worktree-1') + + expect(emit).not.toHaveBeenCalled() + }) + + it('flushAll() drains every pending worktree (subscription close)', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.schedule('worktree-2') + coalescer.flushAll() + + expect(emit).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(50) + expect(emit).toHaveBeenCalledTimes(2) + }) + + it('dispose() drops pending timers without emitting', () => { + const emit = vi.fn() + const coalescer = createMobileSessionTabsNotifyCoalescer(emit) + + coalescer.schedule('worktree-1') + coalescer.dispose() + vi.advanceTimersByTime(50) + + expect(emit).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/mobile-session-tabs-notify-coalescer.ts b/src/main/runtime/mobile-session-tabs-notify-coalescer.ts new file mode 100644 index 00000000000..ac6608b71fb --- /dev/null +++ b/src/main/runtime/mobile-session-tabs-notify-coalescer.ts @@ -0,0 +1,109 @@ +// Why: spinner-in-title agents (e.g. the Claude Code braille spinner) flip a +// PTY title several times per second. Each flip touched every session.tabs +// snapshot and fanned a fresh emit out to every subscriber, where the ws layer +// JSON.stringifies it per client — O(clients × snapshot size) of churn work +// with no debounce. Clients gate on snapshotVersion freshness, so only the +// newest version per worktree matters; coalescing the intermediate emits is +// safe. Structural changes (tab added/removed/activated) bypass this via an +// immediate flush so they still propagate promptly. + +// Trailing-edge window: title/status is latency-sensitive UI, so this is +// tighter than files.watch's 150ms but looser than native-chat's 40ms. +const SESSION_TABS_FLUSH_MS = 50 +// Force a flush after this long even under sustained churn, so a title that +// keeps spinning never starves the emit indefinitely. +const SESSION_TABS_MAX_WAIT_MS = 250 + +export type MobileSessionTabsNotifyCoalescer = { + // Schedule a coalesced (trailing-edge) notify for a worktree. + schedule: (worktreeId: string) => void + // Cancel any pending notify for a worktree without emitting. Use when an + // immediate emit has already superseded the pending state, or the worktree + // was removed and a stale notify must not fire. + cancel: (worktreeId: string) => void + // Flush a worktree's pending notify now (emit if one is pending). + flush: (worktreeId: string) => void + // Flush every pending worktree now. + flushAll: () => void + // Drop all pending state without emitting (runtime teardown). + dispose: () => void +} + +type PendingNotify = { + timer: ReturnType + firstScheduledAt: number +} + +/** + * Coalesces per-worktree session.tabs notifications on a short trailing-edge + * window. `emit` is invoked once per settled worktree and is expected to read + * the latest snapshot itself, so only the freshest snapshotVersion is ever + * published — dropped intermediate versions are exactly what clients discard. + */ +export function createMobileSessionTabsNotifyCoalescer( + emit: (worktreeId: string) => void +): MobileSessionTabsNotifyCoalescer { + const pending = new Map() + + const clear = (worktreeId: string): void => { + const entry = pending.get(worktreeId) + if (!entry) { + return + } + clearTimeout(entry.timer) + pending.delete(worktreeId) + } + + const fire = (worktreeId: string): void => { + clear(worktreeId) + emit(worktreeId) + } + + const arm = (worktreeId: string): ReturnType => { + const timer = setTimeout(() => fire(worktreeId), SESSION_TABS_FLUSH_MS) + if (typeof timer.unref === 'function') { + timer.unref() + } + return timer + } + + return { + schedule(worktreeId: string): void { + const now = Date.now() + const existing = pending.get(worktreeId) + if (existing) { + // Cap total delay so sustained churn can't starve the emit forever. + if (now - existing.firstScheduledAt >= SESSION_TABS_MAX_WAIT_MS) { + fire(worktreeId) + return + } + clearTimeout(existing.timer) + existing.timer = arm(worktreeId) + return + } + pending.set(worktreeId, { timer: arm(worktreeId), firstScheduledAt: now }) + }, + cancel(worktreeId: string): void { + clear(worktreeId) + }, + flush(worktreeId: string): void { + if (pending.has(worktreeId)) { + fire(worktreeId) + } + }, + flushAll(): void { + // Snapshot keys first: fire() deletes from `pending`, and emit may + // schedule new work, so mutating the live map mid-iteration is unsafe. + const worktreeIds = Array.from(pending.keys()) + for (const worktreeId of worktreeIds) { + fire(worktreeId) + } + }, + dispose(): void { + for (const entry of pending.values()) { + clearTimeout(entry.timer) + } + pending.clear() + } + } +} diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index 3d36716dd5e..6c319271088 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import type * as GitUsernameModule from '../git/git-username' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' vi.mock('../git/worktree', () => ({ @@ -983,4 +984,109 @@ describe('mobile subscribe integration', () => { expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false) }) }) + + describe('session.tabs title/status churn coalescing', () => { + type SessionTabsPrivate = { + mobileSessionTabsByWorktree: Map + touchMobileSessionSnapshotsForPty: (ptyId: string) => void + notifyMobileSessionTabsChanged: (worktreeId?: string) => void + } + + function seedPtyBackedSnapshot(runtime: OrcaRuntimeService, ptyId: string): void { + const priv = runtime as unknown as SessionTabsPrivate + priv.mobileSessionTabsByWorktree.set('worktree-a', { + worktree: 'worktree-a', + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: 'tab-1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1', + title: 'agent', + parentTabId: 'group-1', + leafId: 'leaf-1', + ptyId, + isActive: true + } + ] + }) + } + + it('collapses a rapid title-flip burst into a single emit (repro)', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // A spinner-in-title agent flips the title ~20 times within a second. + const FLIPS = 20 + for (let i = 0; i < FLIPS; i++) { + priv.touchMobileSessionSnapshotsForPty('pty-1') + vi.advanceTimersByTime(10) + } + // Nothing has fired yet — all flips landed inside the trailing window. + expect(emits).toHaveLength(0) + + vi.advanceTimersByTime(50) + + // Pre-fix this fanned out one emit per flip; now it is a single emit. + expect(emits).toHaveLength(1) + // The emitted version is the freshest (monotonic, never a stale one). + const stored = priv.mobileSessionTabsByWorktree.get('worktree-a') + expect(emits[0]).toBe(stored?.snapshotVersion) + expect(emits[0]).toBe(1 + FLIPS) + + unsubscribe() + }) + + it('lets a structural change emit immediately and supersede the coalesced notify', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + // Title churn schedules a coalesced notify... + priv.touchMobileSessionSnapshotsForPty('pty-1') + // ...then a structural change (e.g. tab activated) demands a prompt emit. + priv.notifyMobileSessionTabsChanged('worktree-a') + expect(emits).toHaveLength(1) + + // The pending coalesced notify was cancelled by the immediate emit, so no + // duplicate trailing emit fires. + vi.advanceTimersByTime(50) + expect(emits).toHaveLength(1) + + unsubscribe() + }) + + it('flushes a pending notify when the last subscriber closes', () => { + const { runtime } = createRuntime() + seedPtyBackedSnapshot(runtime, 'pty-1') + const priv = runtime as unknown as SessionTabsPrivate + + const emits: number[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => { + emits.push(snapshot.snapshotVersion) + }) + + priv.touchMobileSessionSnapshotsForPty('pty-1') + expect(emits).toHaveLength(0) + + // Closing the subscription flushes the pending window so the final state + // still reaches the listener before it is dropped. + unsubscribe() + expect(emits).toHaveLength(1) + }) + }) }) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index b5fb46e500c..157442e548b 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -89,6 +89,13 @@ import { TERMINAL_METHODS } from './rpc/methods/terminal' const ORIGINAL_PLATFORM = process.platform const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform') +async function waitForMobileSessionTabsEvents( + events: RuntimeMobileSessionTabsResult[], + count: number +): Promise { + await vi.waitFor(() => expect(events).toHaveLength(count)) +} + function setPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { configurable: true, @@ -1363,6 +1370,18 @@ describe('OrcaRuntimeService', () => { expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0) }) + it('reports the configured Windows terminal shell on status', () => { + const runtime = new OrcaRuntimeService({ + ...store, + getSettings: () => ({ + ...store.getSettings(), + terminalWindowsShell: 'wsl.exe' + }) + } as never) + + expect(runtime.getStatus().terminalWindowsShell).toBe('wsl.exe') + }) + it('advertises browser screencast only when a renderer window is available', () => { const runtime = createRuntime() electronMocks.BrowserWindow.fromId.mockReturnValue({ isDestroyed: () => false } as never) @@ -11336,6 +11355,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('laptop-created-pty', '\x1b]0;⠴ - Thinking - grok\x07', 101) runtime.onPtyData('laptop-created-pty', '\x1b]0;⠙ - Responding - grok\x07', 102) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toHaveLength(1) expect(events[0]?.tabs[0]).toEqual( expect.objectContaining({ @@ -11375,6 +11395,7 @@ describe('OrcaRuntimeService', () => { 100 ) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toHaveLength(1) expect(events[0]?.tabs[0]).toEqual( expect.objectContaining({ @@ -11393,6 +11414,7 @@ describe('OrcaRuntimeService', () => { 101 ) + await waitForMobileSessionTabsEvents(events, 2) expect(events).toHaveLength(2) expect(events[1]?.tabs[0]?.type === 'terminal' && events[1].tabs[0].agentStatus).toEqual( expect.objectContaining({ state: 'waiting' }) @@ -11424,6 +11446,7 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('hook-ping-pty', payload, 101) runtime.onPtyData('hook-ping-pty', payload, 102) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toHaveLength(1) unsubscribe() @@ -11456,6 +11479,7 @@ describe('OrcaRuntimeService', () => { // The stuck-spinner guard (#1437) must win over the retained hook row. runtime.onPtyData('hook-exit-pty', '\x1b]0;zsh\x07', 101) + await waitForMobileSessionTabsEvents(events, 1) const last = events.at(-1)?.tabs[0] expect(last?.type).toBe('terminal') expect(last?.type === 'terminal' ? last.agentStatus : null).toBeFalsy() @@ -13681,6 +13705,7 @@ describe('OrcaRuntimeService', () => { foregroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ expect.objectContaining({ @@ -13731,6 +13756,7 @@ describe('OrcaRuntimeService', () => { foregroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ expect.objectContaining({ @@ -13792,6 +13818,7 @@ describe('OrcaRuntimeService', () => { freshForegroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(getForegroundProcess).toHaveBeenCalledTimes(2) expect(events).toEqual([ @@ -13856,6 +13883,7 @@ describe('OrcaRuntimeService', () => { freshForegroundProcess.resolve('omp') await new Promise((resolve) => setImmediate(resolve)) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ expect.objectContaining({ @@ -15942,19 +15970,8 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123) runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude waiting for permission\x07', 124) + await waitForMobileSessionTabsEvents(events, 1) expect(events).toEqual([ - expect.objectContaining({ - tabs: [ - expect.objectContaining({ - type: 'terminal', - title: 'Claude working', - agentStatus: expect.objectContaining({ - state: 'working', - terminalHandle: laptopTerminal.handle - }) - }) - ] - }), expect.objectContaining({ tabs: [ expect.objectContaining({ @@ -15967,7 +15984,6 @@ describe('OrcaRuntimeService', () => { ] }) ]) - expect(events[1]!.snapshotVersion).toBeGreaterThan(events[0]!.snapshotVersion) unsubscribe() }) @@ -15993,19 +16009,14 @@ describe('OrcaRuntimeService', () => { runtime.onPtyData('laptop-created-pty', '\x1b]0;Claude working\x07', 123) runtime.onPtyData('laptop-created-pty', '\x1b]0;claude agents\x07', 124) + await waitForMobileSessionTabsEvents(events, 1) expect(events[0]?.tabs[0]).toEqual( - expect.objectContaining({ - type: 'terminal', - agentStatus: expect.objectContaining({ state: 'working' }) - }) - ) - expect(events[1]?.tabs[0]).toEqual( expect.objectContaining({ type: 'terminal', title: 'claude agents' }) ) - expect(events[1]?.tabs[0]).not.toHaveProperty('agentStatus') + expect(events[0]?.tabs[0]).not.toHaveProperty('agentStatus') unsubscribe() }) @@ -17624,6 +17635,7 @@ describe('OrcaRuntimeService', () => { events.length = 0 runtime.onPtyData('headless-pty-2', '\x1b]0;tab-other running\x07', 200) + await waitForMobileSessionTabsEvents(events, 1) const afterPtyData = events.at(-1) expect(afterPtyData?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`) expect(afterPtyData?.activeTabType).toBe('terminal') @@ -18745,6 +18757,90 @@ describe('OrcaRuntimeService', () => { expect(first.tab).toMatchObject({ parentTabId: 'tab-renderer' }) }) + it('returns the settled success for a retried clientMutationId whose response was lost', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setNotifier({ + focusTerminal: vi.fn(), + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + closeTerminal: vi.fn(), + closeSessionTab: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + const webContents = { send: vi.fn() } + const send = vi.fn((_channel: string, payload: { requestId: string }) => { + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [ + { + tabId: 'tab-renderer', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane:1', + paneRuntimeId: 1, + ptyId: 'pty-renderer', + paneTitle: null + } + ], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [ + { + type: 'terminal', + id: 'tab-renderer::pane:1', + parentTabId: 'tab-renderer', + leafId: 'pane:1', + title: 'Terminal', + isActive: false + } + ] + } + ] + }) + ipcMain.emit( + 'terminal:tabCreateReply', + { sender: webContents }, + { requestId: payload.requestId, tabId: 'tab-renderer', title: 'Terminal' } + ) + }) + webContents.send = send + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + }) + + const first = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + clientMutationId: 'mutation-lost-response' + }) + // Why: the phone retries with the same key when the create response was + // lost in transit; within the retention window it must reuse the terminal. + const retried = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + activate: false, + clientMutationId: 'mutation-lost-response' + }) + + const createRequests = send.mock.calls.filter( + ([channel]) => channel === 'terminal:requestTabCreate' + ) + expect(createRequests).toHaveLength(1) + expect(retried).toBe(first) + }) + it('does not dedupe mobile terminal creates across worktrees with the same clientMutationId', async () => { const otherWorktreeId = `${TEST_REPO_ID}::/tmp/worktree-b` vi.mocked(listWorktrees).mockResolvedValue([ @@ -19968,6 +20064,7 @@ describe('OrcaRuntimeService', () => { workspaceKind: 'git', worktreeId: 'repo-1::/tmp/worktree-a', repoId: 'repo-1', + terminalPlatform: process.platform, repo: 'repo', path: '/tmp/worktree-a', branch: 'feature/foo', @@ -19991,6 +20088,7 @@ describe('OrcaRuntimeService', () => { unread: false, liveTerminalCount: 1, hasAttachedPty: true, + lastActivityAt: 0, lastOutputAt: 321, preview: 'build green', agents: [] @@ -20023,6 +20121,128 @@ describe('OrcaRuntimeService', () => { expect(summary?.linkedPR).toEqual({ number: 42, state: 'merged' }) }) + it('carries persisted worktree host ownership in mobile summaries', async () => { + const metaById = { + [TEST_WORKTREE_ID]: { + ...store.getAllWorktreeMeta()[TEST_WORKTREE_ID], + hostId: 'runtime:owner-runtime' as const + } + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId] + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const { worktrees } = await runtime.getWorktreePs() + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ + repoId: TEST_REPO_ID, + hostId: 'runtime:owner-runtime' + }) + }) + + it('emits only instance-validated lineage parents in mobile summaries', async () => { + // Regression: shipped mobile clients trust parentWorktreeId without instance + // checks, so worktree.ps must not emit stale same-path lineage from the store. + const parentPath = '/tmp/worktree-parent' + const validChildPath = '/tmp/worktree-child-valid' + const staleChildPath = '/tmp/worktree-child-stale' + const parentId = `${TEST_REPO_ID}::${parentPath}` + const validChildId = `${TEST_REPO_ID}::${validChildPath}` + const staleChildId = `${TEST_REPO_ID}::${staleChildPath}` + const metaById: Record = { + [parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }), + [validChildId]: makeWorktreeMeta({ instanceId: 'child-instance' }), + // The stale child path was reused by a replacement checkout. + [staleChildId]: makeWorktreeMeta({ instanceId: 'replacement-instance' }) + } + const makeLineage = (childId: string, worktreeInstanceId: string): WorktreeLineage => ({ + worktreeId: childId, + worktreeInstanceId, + parentWorktreeId: parentId, + parentWorktreeInstanceId: 'parent-instance', + origin: 'manual', + capture: { source: 'manual-action', confidence: 'explicit' }, + createdAt: 1 + }) + const lineageById: Record = { + [validChildId]: makeLineage(validChildId, 'child-instance'), + [staleChildId]: makeLineage(staleChildId, 'old-child-instance') + } + const runtimeStore = { + ...store, + getAllWorktreeMeta: () => metaById, + getWorktreeMeta: (worktreeId: string) => metaById[worktreeId], + setWorktreeMeta: (worktreeId: string, meta: Partial) => { + metaById[worktreeId] = { ...(metaById[worktreeId] ?? makeWorktreeMeta()), ...meta } + return metaById[worktreeId] + }, + getAllWorktreeLineage: () => lineageById, + getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId] + } + vi.mocked(listWorktrees).mockResolvedValue( + [parentPath, validChildPath, staleChildPath].map((path) => ({ + path, + head: 'abc', + branch: `feature/${path.split('/').pop()}`, + isBare: false, + isMainWorktree: false + })) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + + const { worktrees } = await runtime.getWorktreePs() + + expect(worktrees.find((worktree) => worktree.worktreeId === validChildId)).toMatchObject({ + parentWorktreeId: parentId, + worktreeInstanceId: 'child-instance', + lineageWorktreeInstanceId: 'child-instance', + parentWorktreeInstanceId: 'parent-instance' + }) + const staleSummary = worktrees.find((worktree) => worktree.worktreeId === staleChildId) + expect(staleSummary).toMatchObject({ + parentWorktreeId: null, + worktreeInstanceId: 'replacement-instance' + }) + expect(staleSummary?.lineageWorktreeInstanceId).toBeUndefined() + expect(staleSummary?.parentWorktreeInstanceId).toBeUndefined() + expect(worktrees.find((worktree) => worktree.worktreeId === parentId)).toMatchObject({ + childWorktreeIds: [validChildId] + }) + }) + + it('reports the resolved terminal platform for WSL project mobile summaries', async () => { + await withPlatform('win32', async () => { + const runtime = new OrcaRuntimeService({ + ...store, + getProjects: () => [ + { + id: 'project-1', + displayName: 'repo', + badgeColor: 'blue', + sourceRepoIds: [TEST_REPO_ID], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, + createdAt: 0, + updatedAt: 0 + } + ], + getSettings: () => ({ + ...store.getSettings(), + localWindowsRuntimeDefault: { kind: 'windows-host' } + }) + } as never) + + const { worktrees } = await runtime.getWorktreePs() + + expect(worktrees.find((worktree) => worktree.worktreeId === TEST_WORKTREE_ID)).toMatchObject({ + repoId: TEST_REPO_ID, + terminalPlatform: 'linux' + }) + }) + }) + it('omits worktrees hidden by the host visibility policy from mobile summaries', async () => { const hiddenExternalRepo = { ...store.getRepos()[0], diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f420ebd0031..325c72c362e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -247,6 +247,11 @@ import { RUNTIME_PROTOCOL_VERSION, type RuntimeCapability } from '../../shared/protocol-version' +import { + configureAiVaultSessionSources, + listAiVaultSessions +} from '../ai-vault/cached-session-list' +import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import type { WorkspacePortKillRequest, WorkspacePortKillResult, @@ -534,6 +539,7 @@ import { createIssue as createJiraIssue, getIssue as getJiraIssue, getIssueComments as getJiraIssueComments, + getProjectStatusOrder as getJiraProjectStatusOrder, listAssignableUsers as listJiraAssignableUsers, listCreateFields as listJiraCreateFields, listIssueTypes as listJiraIssueTypes, @@ -685,6 +691,10 @@ import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' +import { + createMobileSessionTabsNotifyCoalescer, + type MobileSessionTabsNotifyCoalescer +} from './mobile-session-tabs-notify-coalescer' import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { @@ -1178,6 +1188,9 @@ function getAgentLaunchPlatformForRepo( return isWindowsAbsolutePathLike(repo.path) ? 'win32' : 'linux' } +// Why: long enough for a phone to reconnect and retry a create whose response +// was lost, short enough that an intentional later re-resume forks fresh. +const MOBILE_TERMINAL_CREATE_RESULT_TTL_MS = 60_000 const FOREGROUND_AGENT_WRAPPER_RETRY_INTERVAL_MS = 150 const FOREGROUND_AGENT_WRAPPER_RETRY_TIMEOUT_MS = 6_500 const BRACKETED_PASTE_BEGIN = '\x1b[200~' @@ -2060,6 +2073,13 @@ export class OrcaRuntimeService { { activate: boolean; selectIfNoActiveTab: boolean } >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() + // Why: coalesces title/status-driven session.tabs emits so spinner churn + // doesn't fan out (and per-client JSON.stringify) a snapshot several times a + // second. Emit reads the latest snapshot, so only the freshest version ships. + private readonly mobileSessionTabsNotifyCoalescer: MobileSessionTabsNotifyCoalescer = + createMobileSessionTabsNotifyCoalescer((worktreeId) => + this.notifyMobileSessionTabsChangedNow(worktreeId) + ) private leaves = new Map() // Why: PTY output is a per-keystroke hot path. Looking up affected leaves by // ptyId keeps active TUI redraws independent of the total open terminal count. @@ -2379,6 +2399,11 @@ export class OrcaRuntimeService { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + // Why: codex-home paths for the Agent Session History scan must be sourced + // here, not via the window-only registerCoreHandlers path — that path never + // runs under `orca serve`, so remote/SSH hosts would silently drop + // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. + getAdditionalAiVaultCodexHomePaths?: () => readonly string[] buildAgentHookPtyEnv?: () => Record } ) { @@ -2388,6 +2413,14 @@ export class OrcaRuntimeService { this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + // Why: configure the shared AiVault scan cache from a serve-mode-reachable + // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions + // even on headless `orca serve` hosts where registerCoreHandlers never runs. + if (deps?.getAdditionalAiVaultCodexHomePaths) { + configureAiVaultSessionSources({ + getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths + }) + } // Why: the daemon adapter is installed via `setLocalPtyProvider()` during // attachMainWindowServices, AFTER this service is constructed. Capturing // `getLocalPtyProvider()` at construction time would freeze a reference to @@ -2778,11 +2811,19 @@ export class OrcaRuntimeService { // must not treat browser panes as supported just because runtime RPC is up. capabilities, hostPlatform: process.platform, + terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null, protocolVersion: RUNTIME_PROTOCOL_VERSION, minCompatibleMobileVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION } } + // Why: scans the transcript-owning host's disk (correct by construction over + // RPC — a remote/SSH host scans its own disk). Delegates to the one shared + // cache so the desktop panel and the mobile screen never double-scan. + listAiVaultSessions(args?: AiVaultListArgs): Promise { + return listAiVaultSessions(args) + } + setPtyController(controller: RuntimePtyController | null): void { // Why: CLI terminal writes must go through the main-owned PTY registry // instead of tunneling back through renderer IPC, or live handles could @@ -3532,7 +3573,10 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabsChanged(worktreeId) } - private touchMobileSessionSnapshotsForPty(ptyId: string): void { + private touchMobileSessionSnapshotsForPty( + ptyId: string, + options: { immediate?: boolean } = {} + ): void { for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) { const hasPtyBackedTab = snapshot.tabs.some( (tab) => @@ -3546,7 +3590,15 @@ export class OrcaRuntimeService { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 }) - this.notifyMobileSessionTabsChanged(worktreeId) + if (options.immediate) { + // Why: readiness/lifecycle changes are structural and must not wait + // behind the title/status coalescing window. + this.notifyMobileSessionTabsChanged(worktreeId) + } else { + // Why: title/status flips several times a second under spinner-in-title + // agents. Coalesce the emit instead of fanning out every version. + this.mobileSessionTabsNotifyCoalescer.schedule(worktreeId) + } } } @@ -5208,6 +5260,9 @@ export class OrcaRuntimeService { ): () => void { this.mobileSessionTabListeners.add(listener) return () => { + // Why: flush pending coalesced notifies before dropping this listener so a + // subscriber closing mid-window still receives the latest settled state. + this.mobileSessionTabsNotifyCoalescer.flushAll() this.mobileSessionTabListeners.delete(listener) } } @@ -7240,7 +7295,7 @@ export class OrcaRuntimeService { pty.lastExitCode = exitCode this.resolvePtyExitWaiters(pty, ptyId) this.pruneDisconnectedPtyTranscript(pty) - this.touchMobileSessionSnapshotsForPty(ptyId) + this.touchMobileSessionSnapshotsForPty(ptyId, { immediate: true }) } for (const leaf of this.getLeavesForPty(ptyId)) { @@ -9658,24 +9713,40 @@ export class OrcaRuntimeService { if (!linkedPR && meta?.linkedPR != null) { linkedPR = { number: meta.linkedPR, state: 'unknown' } } + const terminalPlatform = repo ? this.getAgentLaunchPlatformForRepo(repo) : process.platform + // Why: use the instance-validated lineage from attachLineageToResolvedWorktrees, + // not the raw store entry — shipped mobile clients trust parentWorktreeId as-is, + // so a stale same-path entry would nest replacement checkouts under old parents. + const lineage = worktree.lineage summaries.set(worktree.id, { // Why: mobile mirrors desktop workspace grouping/order from persisted // metadata, while older runtimes may not have hydrated every field yet. workspaceKind: 'git', worktreeId: worktree.id, repoId: worktree.repoId, + ...((worktree.hostId ?? meta?.hostId) ? { hostId: worktree.hostId ?? meta?.hostId } : {}), + terminalPlatform, repo: repo?.displayName ?? worktree.repoId, path: worktree.path, branch: worktree.branch, isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, hasHostSidebarActivity: false, + ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), + ...(lineage?.worktreeInstanceId !== undefined + ? { lineageWorktreeInstanceId: lineage.worktreeInstanceId } + : {}), + ...(lineage?.parentWorktreeInstanceId !== undefined + ? { parentWorktreeInstanceId: lineage.parentWorktreeInstanceId } + : {}), parentWorktreeId: worktree.parentWorktreeId, childWorktreeIds: worktree.childWorktreeIds, displayName: worktree.displayName, workspaceStatus: meta?.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, sortOrder: meta?.sortOrder ?? 0, ...(meta?.manualOrder !== undefined ? { manualOrder: meta.manualOrder } : {}), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), linkedIssue: worktree.linkedIssue, linkedPR, linkedLinearIssue: meta?.linkedLinearIssue ?? null, @@ -9715,12 +9786,15 @@ export class OrcaRuntimeService { isArchived: worktree.isArchived, isMainWorktree: worktree.isMainWorktree, hasHostSidebarActivity: false, + ...(worktree.instanceId !== undefined ? { worktreeInstanceId: worktree.instanceId } : {}), parentWorktreeId: null, childWorktreeIds: [], displayName: worktree.displayName, workspaceStatus: worktree.workspaceStatus ?? DEFAULT_WORKSPACE_STATUS_ID, sortOrder: worktree.sortOrder ?? 0, ...(worktree.manualOrder !== undefined ? { manualOrder: worktree.manualOrder } : {}), + lastActivityAt: worktree.lastActivityAt, + ...(worktree.createdAt !== undefined ? { createdAt: worktree.createdAt } : {}), linkedIssue: worktree.linkedIssue ?? null, linkedPR: null, linkedLinearIssue: worktree.linkedLinearIssue ?? null, @@ -16411,21 +16485,23 @@ export class OrcaRuntimeService { const mutationKey = `${worktreeSelector}\0${mutationId}` // Why: a retried create (double-tap, reconnect replay) with the same // idempotency key must return the in-flight operation instead of spawning a - // duplicate terminal. Settled entries are dropped so a later retry — after a - // failure or after the result is consumed — can start a fresh create. + // duplicate terminal. Successes are kept briefly so a retry whose response + // was lost in transit reuses the created terminal; failures are dropped + // immediately so a retry can start a fresh create. const inflight = this.mobileTerminalCreateByMutationId.get(mutationKey) if (inflight) { return inflight } const run = this.runCreateMobileSessionTerminal(worktreeSelector, opts) this.mobileTerminalCreateByMutationId.set(mutationKey, run) - void run - .catch(() => {}) - .finally(() => { - if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { - this.mobileTerminalCreateByMutationId.delete(mutationKey) - } - }) + const drop = (): void => { + if (this.mobileTerminalCreateByMutationId.get(mutationKey) === run) { + this.mobileTerminalCreateByMutationId.delete(mutationKey) + } + } + void run.then(() => { + setTimeout(drop, MOBILE_TERMINAL_CREATE_RESULT_TTL_MS).unref?.() + }, drop) return run } @@ -18884,6 +18960,9 @@ export class OrcaRuntimeService { nextWorktrees.add(worktreeId) } else { this.mobileSessionTabsByWorktree.delete(worktreeId) + // Why: drop any pending coalesced notify so a stale snapshot can't + // land after the removed frame. + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) this.notifyMobileSessionTabsRemoved(worktreeId) } } @@ -19058,6 +19137,14 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabSnapshots() return } + // Why: structural changes (tab add/remove/activate) must propagate promptly, + // so cancel any pending coalesced title/status notify — this immediate emit + // already reflects the latest snapshot and supersedes it. + this.mobileSessionTabsNotifyCoalescer.cancel(worktreeId) + this.notifyMobileSessionTabsChangedNow(worktreeId) + } + + private notifyMobileSessionTabsChangedNow(worktreeId: string): void { if (this.mobileSessionTabListeners.size === 0) { return } @@ -22715,6 +22802,13 @@ export class OrcaRuntimeService { return listJiraTransitions(key, siteId) } + jiraGetProjectStatusOrder( + projectKey: string, + siteId?: string + ): ReturnType { + return getJiraProjectStatusOrder(projectKey, siteId) + } + // ── Browser automation ── private readonly browserCommands = new RuntimeBrowserCommands({ diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 818ddb92fca..24ff44597b0 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -102,7 +102,7 @@ export class RpcDispatcher { signal?: AbortSignal clientId?: string clientKind?: 'mobile' | 'runtime' - sendBinary?: (bytes: Uint8Array) => void + sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( streamId: number, handler: (frame: TerminalStreamFrame) => void diff --git a/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts new file mode 100644 index 00000000000..5d0b2d3da92 --- /dev/null +++ b/src/main/runtime/rpc/e2ee-channel-text-backpressure.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { E2EEChannel, type E2EEChannelOptions } from './e2ee-channel' +import { deriveSharedKey, decrypt, encrypt, generateKeyPair } from './e2ee-crypto' + +// Repro for gap (a): the streaming JSON reply path (encryptedReply) had no +// bufferedAmount gate, so a fast producer over a slow link (legacy +// terminal.subscribe, which has NO seq/resync) ballooned ws.bufferedAmount +// without bound. The fix holds replies in order and drains on recovery — never +// dropping a frame (which would recreate the corruption bug on the legacy path). + +function publicKeyToBase64(key: Uint8Array): string { + return Buffer.from(key).toString('base64') +} + +function createMockWs() { + const sent: string[] = [] + return { + OPEN: 1 as const, + readyState: 1, + bufferedAmount: 0, + send: vi.fn((data: string) => { + sent.push(data) + }), + close: vi.fn(), + sent + } +} + +function setup(overrides?: Partial) { + const serverKeys = generateKeyPair() + const clientKeys = generateKeyPair() + const ws = createMockWs() + const onError = vi.fn() + const channel = new E2EEChannel(ws as unknown as WebSocket, { + serverSecretKey: serverKeys.secretKey, + validateToken: (token) => token === 'valid-token', + onReady: vi.fn(), + onError, + ...overrides + }) + const sharedKey = deriveSharedKey(clientKeys.secretKey, serverKeys.publicKey) + channel.handleRawMessage( + JSON.stringify({ type: 'e2ee_hello', publicKeyB64: publicKeyToBase64(clientKeys.publicKey) }) + ) + channel.handleRawMessage( + encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + ) + return { channel, ws, sharedKey, onError } +} + +/** Fire a streaming reply through the real channel's encryptedReply closure. */ +function emitReply(ctx: ReturnType, payload: string): void { + ctx.channel.onMessage((_plaintext, encryptedReply) => { + encryptedReply(payload) + }) + ctx.channel.handleRawMessage(encrypt('{"id":"x","method":"status.get"}', ctx.sharedKey)) +} + +describe('E2EE text reply backpressure', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('holds text replies while over the buffer cap and drains them in order', () => { + const ctx = setup() + const baseline = ctx.ws.sent.length // ready + authenticated control frames + + // Simulate a congested socket: bufferedAmount pinned over the 8 MiB cap. + ctx.ws.bufferedAmount = 9 * 1024 * 1024 + + emitReply(ctx, '{"seq":1}') + emitReply(ctx, '{"seq":2}') + emitReply(ctx, '{"seq":3}') + + // Not dropped, not sent yet — parked in order on the queue. + expect(ctx.ws.sent.length).toBe(baseline) + + // Link drains; the queue flushes every reply, in order, none lost. + ctx.ws.bufferedAmount = 0 + vi.runOnlyPendingTimers() + + const replies = ctx.ws.sent.slice(baseline).map((frame) => decrypt(frame, ctx.sharedKey)) + expect(replies).toEqual(['{"seq":1}', '{"seq":2}', '{"seq":3}']) + expect(ctx.onError).not.toHaveBeenCalled() + }) + + it('sends straight through when the socket is not congested', () => { + const ctx = setup() + const baseline = ctx.ws.sent.length + emitReply(ctx, '{"ok":true}') + expect(ctx.ws.sent.length).toBe(baseline + 1) + expect(decrypt(ctx.ws.sent[baseline]!, ctx.sharedKey)).toBe('{"ok":true}') + }) +}) diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 97ce9c5c813..63cc2c0dfcd 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -3,6 +3,10 @@ // handler only sees plaintext JSON, identical to the Unix socket path. import type { WebSocket } from 'ws' import { deriveSharedKey, encrypt, decrypt, encryptBytes, decryptBytes } from './e2ee-crypto' +import { + createWsOutboundBackpressureQueue, + type WsOutboundBackpressureQueue +} from '../../../shared/ws-outbound-backpressure-queue' type ChannelState = 'awaiting_hello' | 'awaiting_auth' | 'ready' @@ -48,6 +52,11 @@ export class E2EEChannel { ) => void) | null = null private binaryMessageHandler: ((plaintext: Uint8Array) => void) | null = null + // Why: the streaming JSON reply path (e.g. legacy terminal.subscribe) has no + // seq/resync, so it must never drop frames under backpressure. Hold text + // replies in order while bufferedAmount is over the cap and drain as it + // clears; only a wedged link (hard cap) closes the socket for a clean resync. + private textReplyQueue: WsOutboundBackpressureQueue | null = null deviceToken: string | null = null @@ -128,7 +137,7 @@ export class E2EEChannel { if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { return } - this.ws.send(encrypt(response, this.sharedKey)) + this.ensureTextReplyQueue().enqueue(encrypt(response, this.sharedKey)) } const encryptedBinaryReply = (response: Uint8Array): boolean => { if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) { @@ -215,6 +224,22 @@ export class E2EEChannel { this.onReady(this) } + private ensureTextReplyQueue(): WsOutboundBackpressureQueue { + if (!this.textReplyQueue) { + this.textReplyQueue = createWsOutboundBackpressureQueue({ + send: (frame) => this.ws.send(frame), + // Encrypted replies are base64 ASCII strings, so length === byte count. + byteLengthOf: (frame) => frame.length, + getBufferedAmount: () => this.ws.bufferedAmount, + isWritable: () => Boolean(this.sharedKey) && this.ws.readyState === this.ws.OPEN, + // 1013 (Try Again Later): the link is wedged; drop the channel so the + // client reconnects and replays a full snapshot instead of unbounded RSS. + onOverflow: () => this.onError(1013, 'Outbound reply buffer overflow') + }) + } + return this.textReplyQueue + } + private sendEncryptedControl(message: unknown): void { if (this.ws.readyState === this.ws.OPEN && this.sharedKey) { this.ws.send(encrypt(JSON.stringify(message), this.sharedKey)) @@ -229,5 +254,7 @@ export class E2EEChannel { this.sharedKey = null this.messageHandler = null this.binaryMessageHandler = null + this.textReplyQueue?.dispose() + this.textReplyQueue = null } } diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index c7cc56a6490..d676f35e579 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -1,61 +1,228 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AI_VAULT_METHODS } from './ai-vault' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { OrcaRuntimeService } from '../../orca-runtime' +import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' +import type { AiVaultScanOptions } from '../../../ai-vault/session-scanner-types' -const mocks = vi.hoisted(() => ({ +const { scanAiVaultSessions } = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn() })) vi.mock('../../../ai-vault/session-scanner', () => ({ - scanAiVaultSessions: mocks.scanAiVaultSessions + scanAiVaultSessions })) -beforeEach(() => { - vi.clearAllMocks() - mocks.scanAiVaultSessions.mockResolvedValue({ - sessions: [], - issues: [], - scannedAt: '2026-07-04T00:00:00.000Z' +import { AI_VAULT_METHODS, AiVaultListSessionsParams } from './ai-vault' +import { + configureAiVaultSessionSources, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from '../../../ai-vault/cached-session-list' + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +const SCANNED_AT = '2026-06-29T00:00:00.000Z' + +function makeResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: SCANNED_AT } +} + +function makeSession(): AiVaultSession { + return { + id: 'local:claude:sess-1:/tmp/t.jsonl', + executionHostId: 'local', + agent: 'claude', + sessionId: 'sess-1', + title: 'Test session', + cwd: '/tmp', + branch: null, + model: null, + filePath: '/tmp/t.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: SCANNED_AT, + messageCount: 2, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'claude --resume sess-1', + subagent: null + } +} + +function makeDispatcher(): RpcDispatcher { + // Why: the handler only needs getRuntimeId (envelope) + listAiVaultSessions, + // which delegates to the shared cache module the IPC handler also uses. + const runtime = { + getRuntimeId: () => 'test-runtime', + listAiVaultSessions: (args?: Parameters[0]) => + listAiVaultSessions(args) + } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) +} + +describe('aiVault.listSessions params schema', () => { + it('accepts a bounded request', () => { + const parsed = AiVaultListSessionsParams.safeParse({ + limit: 500, + force: true, + scopePaths: ['/home/user/repo'] + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a limit above the cap', () => { + const parsed = AiVaultListSessionsParams.safeParse({ limit: 5000 }) + expect(parsed.success).toBe(false) + }) + + it('clamps scopePaths past the cap instead of rejecting', () => { + // Why: uncapped producers (web client, pre-cap desktop parents) may exceed + // the bound; scope paths only widen discovery, so truncation is safe. + const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`) + const parsed = AiVaultListSessionsParams.safeParse({ scopePaths }) + expect(parsed.success).toBe(true) + expect(parsed.data?.scopePaths).toEqual(scopePaths.slice(0, 64)) + }) + + it('rejects an over-long scopePath', () => { + const parsed = AiVaultListSessionsParams.safeParse({ scopePaths: ['/'.padEnd(5000, 'a')] }) + expect(parsed.success).toBe(false) + }) + + it('rejects non-runtime execution host ids before dispatch', () => { + expect( + AiVaultListSessionsParams.safeParse({ executionHostId: 'not-a-runtime-host' }).success + ).toBe(false) + expect(AiVaultListSessionsParams.safeParse({ executionHostId: 'local' }).success).toBe(false) + expect(AiVaultListSessionsParams.safeParse({ executionHostId: 'ssh:dev-box' }).success).toBe( + false + ) }) }) -describe('aiVault.listSessions runtime RPC', () => { - it('scans server-local sessions under the caller supplied runtime host id', async () => { - const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') - expect(method).toBeDefined() +describe('aiVault.listSessions handler + shared cache', () => { + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessions.mockReset() + scanAiVaultSessions.mockResolvedValue(makeResult()) + }) - const params = method!.params!.parse({ - limit: 25, - scopePaths: ['/srv/app'], - executionHostId: 'runtime:remote-server' - }) + afterEach(() => { + resetAiVaultSessionListCacheForTests() + }) - await method!.handler(params, {} as never) + it('returns the AiVaultListResult unchanged', async () => { + const dispatcher = makeDispatcher() + const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + expect(response).toMatchObject({ ok: true, result: makeResult() }) + }) - expect(mocks.scanAiVaultSessions).toHaveBeenCalledWith({ - limit: 25, - scopePaths: ['/srv/app'], - executionHostId: 'runtime:remote-server' + it('passes only the first 64 scopePaths to the scanner when a request exceeds the cap', async () => { + const dispatcher = makeDispatcher() + const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`) + const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { scopePaths })) + expect(response).toMatchObject({ ok: true }) + expect(scanAiVaultSessions.mock.calls[0]?.[0]).toMatchObject({ + scopePaths: scopePaths.slice(0, 64) }) }) - it('rejects non-runtime execution host ids before dispatch', () => { - const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') - expect(method).toBeDefined() + it('shares one cache between the IPC entry point and the RPC method', async () => { + const dispatcher = makeDispatcher() + // First call via the shared module (what the desktop IPC handler invokes). + await listAiVaultSessions({ limit: 500 }) + // Second call via the RPC method with the same cache key. + await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) - expect(() => - method!.params!.parse({ - executionHostId: 'not-a-runtime-host' - }) - ).toThrow('Invalid runtime execution host id') - expect(() => - method!.params!.parse({ - executionHostId: 'local' - }) - ).toThrow('Invalid runtime execution host id') - expect(() => - method!.params!.parse({ - executionHostId: 'ssh:dev-box' + it('keeps a newer different-key scan dedupable after an older scan resolves', async () => { + // Why: the resolving scan's cleanup must not clear tracking a concurrent + // different-key scan replaced, or re-requests start a duplicate rescan. + const deferreds: ((result: AiVaultListResult) => void)[] = [] + scanAiVaultSessions.mockImplementation( + () => new Promise((resolve) => deferreds.push(resolve)) + ) + // The scanner is invoked a microtask after the call (WSL-home await), so + // wait for each deferred to register before driving resolution order. + const scanA = listAiVaultSessions({ limit: 100 }) + await vi.waitFor(() => expect(deferreds).toHaveLength(1)) + const scanB = listAiVaultSessions({ limit: 200 }) + await vi.waitFor(() => expect(deferreds).toHaveLength(2)) + deferreds[0]?.(makeResult()) + await scanA + // Re-request key B while its first scan is still pending: must dedupe. + const scanBAgain = listAiVaultSessions({ limit: 200 }) + // Flush a macrotask so that a wrongly-started duplicate scan would reach the + // mock (it fires a microtask after the WSL-home await): guarded stays 2, a + // reverted guard reads 3, so this assertion — not a Promise.all hang — pins + // the fix. + await new Promise((resolve) => setTimeout(resolve)) + expect(scanAiVaultSessions).toHaveBeenCalledTimes(2) + deferreds[1]?.(makeResult()) + await Promise.all([scanB, scanBAgain]) + }) + + it('restamps the shared cached result as the addressed runtime host', async () => { + scanAiVaultSessions.mockResolvedValue({ + sessions: [makeSession()], + issues: [{ executionHostId: 'local', agent: 'claude', path: '/tmp', message: 'boom' }], + scannedAt: SCANNED_AT + }) + const dispatcher = makeDispatcher() + // A mobile-style caller (no executionHostId) primes the shared cache… + const localResponse = (await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { limit: 500 }) + )) as { ok: boolean; result: AiVaultListResult } + // …then a desktop/web caller addressing this host by runtime id reuses it. + const runtimeResponse = (await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { + limit: 500, + executionHostId: 'runtime:remote-server' }) - ).toThrow('Invalid runtime execution host id') + )) as { ok: boolean; result: AiVaultListResult } + + // Why: the host id must never change what is scanned — one host-local scan + // (and one cache entry) serves every caller; only the stamps differ. + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessions.mock.calls[0]?.[0]).toMatchObject({ executionHostId: 'local' }) + + expect(localResponse.result.sessions[0]?.executionHostId).toBe('local') + expect(runtimeResponse.result.sessions[0]?.executionHostId).toBe('runtime:remote-server') + expect(runtimeResponse.result.sessions[0]?.id).toBe( + 'runtime:remote-server:claude:sess-1:/tmp/t.jsonl' + ) + expect(runtimeResponse.result.issues[0]?.executionHostId).toBe('runtime:remote-server') + }) + + it('injects codex-home dirs sourced from the runtime (serve-mode reachable)', async () => { + configureAiVaultSessionSources({ + getAdditionalCodexHomePaths: () => ['/runtime/codex/home'] + }) + const dispatcher = makeDispatcher() + await dispatcher.dispatch(makeRequest('aiVault.listSessions', {})) + const options = scanAiVaultSessions.mock.calls[0]?.[0] as AiVaultScanOptions + // Why: the codex-home is sourced from the runtime, not the window-only + // registerCoreHandlers path, so it survives in serve mode. + expect(options.additionalCodexSessionsDirs).toContain('/runtime/codex/home/sessions') + expect(options.wslHomeDirs).toEqual([]) + }) + + it('forwards codex-home through the real OrcaRuntimeService construction path', async () => { + // Why: the dispatcher test above seeds the cache module directly, so it would + // still pass if OrcaRuntimeService stopped forwarding the codex-home source. + // Construct the real runtime to lock that cross-layer wiring in place. + const runtime = new OrcaRuntimeService(null, undefined, { + getAdditionalAiVaultCodexHomePaths: () => ['/ctor/codex/home'] + }) + await runtime.listAiVaultSessions({}) + const options = scanAiVaultSessions.mock.calls[0]?.[0] as AiVaultScanOptions + expect(options.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions') }) }) diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 49591ea1e40..50ace9d222a 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -1,8 +1,16 @@ import { z } from 'zod' -import { scanAiVaultSessions } from '../../../ai-vault/session-scanner' import { defineMethod, type RpcMethod } from '../core' +import { OptionalBoolean } from '../schemas' +import { restampAiVaultListResult } from '../../../ai-vault/session-list-results' +import { AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types' import { parseExecutionHostId } from '../../../../shared/execution-host' +// Why: bound limit + scopePaths so a client cannot force an unbounded scan. +// Each scopePath is a host-local match prefix (validated/capped, never used for +// traversal); the count/length caps mirror the worktree-schemas bounding style. +const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096 +const AI_VAULT_LIMIT_MAX = 2000 + const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => { const parsed = parseExecutionHostId(value) if (parsed?.kind === 'runtime') { @@ -15,22 +23,43 @@ const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${stri return z.NEVER }) -const listSessionsParamsSchema = z.object({ - limit: z.number().int().positive().optional(), - force: z.boolean().optional(), - scopePaths: z.array(z.string()).optional(), - executionHostId: executionHostIdSchema +export const AiVaultListSessionsParams = z.object({ + limit: z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number().int().max(AI_VAULT_LIMIT_MAX), z.undefined()])) + .optional(), + force: OptionalBoolean, + scopePaths: z + .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) + // Why: clamp instead of reject — scope paths only ever widen discovery, and + // rejecting would hard-break older/uncapped producers (web client, pre-cap + // desktop parents) that send more than the bound. + .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) + .optional(), + // Why: desktop/web callers name the runtime host they are addressing; mobile + // omits it. The scan itself is host-local either way, so the id must never + // change what is scanned — it only restamps the shared cached result. + executionHostId: executionHostIdSchema.optional() }) export const AI_VAULT_METHODS: RpcMethod[] = [ defineMethod({ name: 'aiVault.listSessions', - params: listSessionsParamsSchema, - handler: async (params) => - scanAiVaultSessions({ + params: AiVaultListSessionsParams, + handler: async (params, { runtime }) => { + const result = await runtime.listAiVaultSessions({ limit: params.limit, - scopePaths: params.scopePaths, - executionHostId: params.executionHostId + force: params.force, + scopePaths: params.scopePaths }) + // Why: web clients consume this response directly (no parent-side retag), + // so sessions must come back stamped as the runtime host they addressed. + return params.executionHostId + ? restampAiVaultListResult(result, params.executionHostId) + : result + } }) ] diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 2af71e3abf1..01a3f784e17 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -182,11 +182,12 @@ describe('client UI RPC methods', () => { ...getDefaultUIState(), worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'], + statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'antigravity', 'ports'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', @@ -222,11 +223,12 @@ describe('client UI RPC methods', () => { const payload = { worktreeCardProperties: ['status', 'branch', 'automation', 'inline-agents'], _worktreeCardModeDefaulted: true, - statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'ports'], + statusBarItems: ['codex', 'kimi', 'minimax', 'grok', 'antigravity', 'ports'], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, taskResumeState: { githubMode: 'items', githubItemsQuery: 'is:open', diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 99595922838..9b08e6decd3 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -44,6 +44,7 @@ const StatusBarItem = z.enum([ 'claude', 'codex', 'gemini', + 'antigravity', 'opencode-go', 'kimi', 'minimax', @@ -204,6 +205,7 @@ const UiUpdate = z _portsStatusBarDefaultAdded: z.boolean().optional(), _kimiStatusBarDefaultAdded: z.boolean().optional(), _minimaxStatusBarDefaultAdded: z.boolean().optional(), + _antigravityStatusBarDefaultAdded: z.boolean().optional(), _grokStatusBarDefaultAdded: z.boolean().optional(), statusBarVisible: z.boolean().optional(), dismissedUpdateVersion: NullableString.optional(), diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index 8df96326c0a..8bf82eab593 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -125,7 +125,10 @@ describe('jira RPC methods', () => { jiraListCreateFields: vi.fn().mockResolvedValue([{ key: 'customfield_10010' }]), jiraListPriorities: vi.fn().mockResolvedValue([{ id: 'priority-1' }]), jiraListAssignableUsers: vi.fn().mockResolvedValue([{ accountId: 'user-1' }]), - jiraListTransitions: vi.fn().mockResolvedValue([{ id: 'transition-1' }]) + jiraListTransitions: vi.fn().mockResolvedValue([{ id: 'transition-1' }]), + jiraGetProjectStatusOrder: vi.fn().mockResolvedValue({ + statusIdsByColumn: [['status-1']] + }) } as unknown as OrcaRuntimeService const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) @@ -151,6 +154,9 @@ describe('jira RPC methods', () => { await dispatcher.dispatch( makeRequest('jira.listTransitions', { key: 'ABC-3', siteId: 'site-1' }) ) + await dispatcher.dispatch( + makeRequest('jira.getProjectStatusOrder', { projectKey: 'ALP', siteId: 'site-1' }) + ) expect(runtime.jiraListProjects).toHaveBeenCalledWith('all') expect(runtime.jiraListIssueTypes).toHaveBeenCalledWith('project-1', 'site-1') @@ -158,5 +164,6 @@ describe('jira RPC methods', () => { expect(runtime.jiraListPriorities).toHaveBeenCalledWith('site-1') expect(runtime.jiraListAssignableUsers).toHaveBeenCalledWith('ABC-3', 'Ada', 'site-1') expect(runtime.jiraListTransitions).toHaveBeenCalledWith('ABC-3', 'site-1') + expect(runtime.jiraGetProjectStatusOrder).toHaveBeenCalledWith('ALP', 'site-1') }) }) diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index 9a4c614d9ee..35d1d067ca1 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -88,6 +88,11 @@ const AssignableUsers = z.object({ siteId: OptionalString }) +const ProjectStatusOrder = z.object({ + projectKey: requiredString('Project key is required'), + siteId: OptionalString +}) + export const JIRA_METHODS: RpcMethod[] = [ defineMethod({ name: 'jira.connect', @@ -204,5 +209,11 @@ export const JIRA_METHODS: RpcMethod[] = [ params: IssueKey, handler: async (params, { runtime }) => runtime.jiraListTransitions(params.key.trim(), params.siteId) + }), + defineMethod({ + name: 'jira.getProjectStatusOrder', + params: ProjectStatusOrder, + handler: async (params, { runtime }) => + runtime.jiraGetProjectStatusOrder(params.projectKey.trim(), params.siteId) }) ] diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index a750438e03e..ff553f812a7 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -1083,7 +1083,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.resizeForClient', params: TerminalResizeForClient, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle (pane's PTY replaced under it) + // must fail with terminal_handle_stale instead of resizing the wrong PTY + // (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1137,7 +1140,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.setDisplayMode', params: TerminalSetDisplayMode, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale instead of mutating the wrong PTY's display + // mode/viewport (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1159,7 +1165,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.restoreFit', params: TerminalHandle, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale instead of reclaiming the wrong PTY back to + // desktop dims (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1180,7 +1189,10 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ name: 'terminal.updateViewport', params: TerminalUpdateViewport, handler: async (params, { runtime }) => { - const leaf = runtime.resolveLeafForHandle(params.terminal) + // Why: guarded resolution — a stale handle must fail with + // terminal_handle_stale instead of writing viewport state to the wrong + // PTY (#7718). Clients recover by re-deriving the handle. + const leaf = runtime.resolveLiveLeafForHandle(params.terminal) if (!leaf?.ptyId) { throw new Error('no_connected_pty') } @@ -1221,18 +1233,20 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ opcode: TerminalStreamOpcode, payload: Uint8Array = new Uint8Array(), seq?: number - ): void => { + ): boolean => { if (closed) { - return + return false } - sendBinary( - encodeTerminalStreamFrame({ - opcode, - streamId, - seq: typeof seq === 'number' ? seq : cursor++, - payload - }) + // Why: Output `seq` is a UTF-16 high-water the client uses for frame-drop + // gap detection, so a seq-less Output chunk must carry the sentinel 0 + // (== "no seq") rather than the cursor value that orders control frames; + // a cursor value would poison the client's expected-seq tracker. + const resolvedSeq = + typeof seq === 'number' ? seq : opcode === TerminalStreamOpcode.Output ? 0 : cursor++ + const sent = sendBinary( + encodeTerminalStreamFrame({ opcode, streamId, seq: resolvedSeq, payload }) ) + return sent !== false } const sendStreamError = (streamId: number, message: string): void => { sendFrame(streamId, TerminalStreamOpcode.Error, encodeTerminalStreamText(message)) diff --git a/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts b/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts new file mode 100644 index 00000000000..c34ff54f848 --- /dev/null +++ b/src/main/runtime/rpc/terminal-geometry-stale-handle.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from 'vitest' +import { RpcDispatcher } from './dispatcher' +import type { RpcRequest } from './core' +import type { OrcaRuntimeService } from '../orca-runtime' +import { TERMINAL_METHODS } from './methods/terminal' + +// Why: the terminal geometry family (resize/setDisplayMode/restoreFit/ +// updateViewport) mutates PTY state. A remote client can hold a handle minted +// for a PTY that was later replaced under the pane (restart/re-spawn bumps +// ptyId/generation). The UNGUARDED resolveLeafForHandle returns the pane's +// CURRENT pty, so a stale client would mutate the wrong (new) PTY — visible as +// geometry corruption on the fresh session (#7718). These methods must use the +// guarded resolveLiveLeafForHandle, which throws terminal_handle_stale instead. + +// Models a stale handle exactly as the runtime does: the unguarded resolver +// silently adopts the replacement PTY ('pty-b'); the guarded resolver throws. +const NEW_PTY_UNDER_PANE = 'pty-b' + +function stubStaleHandleRuntime(overrides: Partial = {}): OrcaRuntimeService { + return { + getRuntimeId: () => 'test-runtime', + // Unguarded path: returns the pane's current (replaced) PTY — the misroute. + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: NEW_PTY_UNDER_PANE }), + // Guarded path: surfaces the staleness. + resolveLiveLeafForHandle: vi.fn(() => { + throw new Error('terminal_handle_stale') + }), + ...overrides + } as unknown as OrcaRuntimeService +} + +function makeRequest(method: string, params?: unknown): RpcRequest { + return { id: 'req-1', authToken: 'tok', method, params } +} + +async function expectStale(method: string, params: unknown, mutators: string[]): Promise { + const spies: Record> = {} + for (const name of mutators) { + spies[name] = vi.fn() + } + const runtime = stubStaleHandleRuntime(spies as Partial) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch(makeRequest(method, params)) + + expect(response.ok).toBe(false) + if (response.ok) { + throw new Error(`expected ${method} to reject a stale handle`) + } + expect(response.error.message).toContain('terminal_handle_stale') + // The wrong (replacement) PTY must never be mutated. + for (const name of mutators) { + expect(spies[name]).not.toHaveBeenCalled() + } +} + +describe('terminal geometry family rejects stale handles instead of mutating the wrong PTY', () => { + it('terminal.resizeForClient fails with terminal_handle_stale', async () => { + await expectStale( + 'terminal.resizeForClient', + { terminal: 'stale-terminal', mode: 'restore', clientId: 'client-1' }, + ['resizeForClient'] + ) + }) + + it('terminal.setDisplayMode fails with terminal_handle_stale', async () => { + await expectStale( + 'terminal.setDisplayMode', + { + terminal: 'stale-terminal', + mode: 'auto', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }, + [ + 'setMobileDisplayMode', + 'applyMobileDisplayMode', + 'updateMobileSubscriberViewport', + 'markMobileActor' + ] + ) + }) + + it('terminal.restoreFit fails with terminal_handle_stale', async () => { + await expectStale('terminal.restoreFit', { terminal: 'stale-terminal' }, [ + 'reclaimTerminalForDesktop' + ]) + }) + + it('terminal.updateViewport fails with terminal_handle_stale', async () => { + await expectStale( + 'terminal.updateViewport', + { + terminal: 'stale-terminal', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }, + ['updateMobileViewport', 'updateDesktopViewport'] + ) + }) +}) + +describe('terminal geometry family still mutates the live PTY for a fresh handle', () => { + it('terminal.restoreFit reclaims the resolved PTY when the handle is live', async () => { + const reclaimTerminalForDesktop = vi.fn().mockResolvedValue(true) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + reclaimTerminalForDesktop + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.restoreFit', { terminal: 'live-terminal' }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(response.result).toEqual({ restored: true }) + expect(reclaimTerminalForDesktop).toHaveBeenCalledWith('pty-a') + }) + + it('terminal.resizeForClient resizes the resolved PTY when the handle is live', async () => { + const resizeForClient = vi.fn().mockResolvedValue({ cols: 80, rows: 24 }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + resizeForClient + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.resizeForClient', { + terminal: 'live-terminal', + mode: 'restore', + clientId: 'client-1' + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(resizeForClient).toHaveBeenCalledWith( + 'pty-a', + 'restore', + 'client-1', + undefined, + undefined + ) + }) + + it('terminal.setDisplayMode mutates the resolved PTY when the handle is live', async () => { + const setMobileDisplayMode = vi.fn() + const applyMobileDisplayMode = vi.fn().mockResolvedValue(undefined) + const updateMobileSubscriberViewport = vi.fn() + const markMobileActor = vi.fn() + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + setMobileDisplayMode, + applyMobileDisplayMode, + updateMobileSubscriberViewport, + markMobileActor, + getLayout: vi.fn().mockReturnValue({ seq: 42 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.setDisplayMode', { + terminal: 'live-terminal', + mode: 'auto', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(updateMobileSubscriberViewport).toHaveBeenCalledWith('pty-a', 'client-1', { + cols: 80, + rows: 24 + }) + expect(markMobileActor).toHaveBeenCalledWith('pty-a', 'client-1') + expect(setMobileDisplayMode).toHaveBeenCalledWith('pty-a', 'auto') + expect(applyMobileDisplayMode).toHaveBeenCalledWith('pty-a') + expect(response.result).toEqual({ mode: 'auto', seq: 42 }) + }) + + it('terminal.updateViewport updates the resolved PTY when the handle is live', async () => { + const updateMobileViewport = vi.fn().mockResolvedValue({ updated: true, applied: true }) + const runtime = { + getRuntimeId: () => 'test-runtime', + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-a' }), + updateMobileViewport, + getLayout: vi.fn().mockReturnValue({ seq: 7 }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('terminal.updateViewport', { + terminal: 'live-terminal', + client: { id: 'client-1', type: 'mobile' }, + viewport: { cols: 80, rows: 24 } + }) + ) + + expect(response.ok).toBe(true) + if (!response.ok) { + throw new Error(response.error.message) + } + expect(updateMobileViewport).toHaveBeenCalledWith('pty-a', 'client-1', { + cols: 80, + rows: 24 + }) + expect(response.result).toEqual({ updated: true, applied: true, seq: 7 }) + }) +}) diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 59e4d2d1b29..24367102745 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -70,14 +70,19 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { waitForTerminal: vi.fn(() => new Promise(() => {})), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-1', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index 69e956aa155..edb5afc030a 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -77,14 +77,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-1', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -199,7 +204,10 @@ describe('terminal multiplex RPC', () => { opcode: TerminalStreamOpcode.SnapshotRequest, streamId: 5, seq: 4, - payload: encodeTerminalStreamJson({ requestId: 7, scrollbackRows: 5000 }) + payload: encodeTerminalStreamJson({ + requestId: 7, + scrollbackRows: 5000 + }) }) )! ) @@ -291,14 +299,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-stale-multiplex-resize', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -327,8 +340,20 @@ describe('terminal multiplex RPC', () => { await vi.waitFor(() => expect(resizeListener).toBeDefined()) binaryFrames.splice(0) - resizeListener?.({ cols: 90, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 2 }) - resizeListener?.({ cols: 100, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 3 }) + resizeListener?.({ + cols: 90, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 2 + }) + resizeListener?.({ + cols: 100, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 3 + }) await vi.waitFor(() => expect(restreamResolves).toHaveLength(2)) restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24 }) @@ -402,14 +427,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-multibyte-output-batch', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -478,9 +508,11 @@ describe('terminal multiplex RPC', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -502,14 +534,19 @@ describe('terminal multiplex RPC', () => { waitForTerminal: vi.fn(() => new Promise(() => {})), sendTerminal: vi.fn().mockResolvedValue({ accepted: true }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-multiplex-limited', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -578,8 +615,16 @@ describe('terminal multiplex RPC', () => { serializeTerminalBuffer: vi .fn() .mockResolvedValueOnce({ data: 'initial', cols: 120, rows: 40 }) - .mockResolvedValueOnce({ data: 'x'.repeat(2 * 1024 * 1024 + 1), cols: 120, rows: 40 }) - .mockResolvedValueOnce({ data: 'budgeted snapshot', cols: 120, rows: 40 }), + .mockResolvedValueOnce({ + data: 'x'.repeat(2 * 1024 * 1024 + 1), + cols: 120, + rows: 40 + }) + .mockResolvedValueOnce({ + data: 'budgeted snapshot', + cols: 120, + rows: 40 + }), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -596,14 +641,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-budgeted-request', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -640,7 +690,10 @@ describe('terminal multiplex RPC', () => { opcode: TerminalStreamOpcode.SnapshotRequest, streamId: 14, seq: 2, - payload: encodeTerminalStreamJson({ requestId: 55, scrollbackRows: 5000 }) + payload: encodeTerminalStreamJson({ + requestId: 55, + scrollbackRows: 5000 + }) }) )! ) @@ -704,7 +757,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), @@ -805,7 +861,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), @@ -892,7 +951,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -981,7 +1043,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -992,7 +1057,9 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-subscribe-output-chunking', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1067,7 +1134,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -1078,13 +1148,18 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered-output-on-subscribe', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: vi.fn(() => vi.fn()) } ) await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) - dataListenerRef.current?.('starting shell\r\n', { seq: 16, rawLength: 16 }) + dataListenerRef.current?.('starting shell\r\n', { + seq: 16, + rawLength: 16 + }) resolveSnapshot({ data: '', cols: 120, rows: 40 }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) @@ -1125,7 +1200,12 @@ describe('terminal multiplex RPC', () => { readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => - new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + new Promise<{ + data: string + cols: number + rows: number + seq: number + }>((resolve) => { resolveSnapshot = resolve }) ), @@ -1151,7 +1231,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -1162,7 +1245,9 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered-output-covered-by-snapshot', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: vi.fn(() => vi.fn()) } ) @@ -1222,7 +1307,12 @@ describe('terminal multiplex RPC', () => { readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => - new Promise<{ data: string; cols: number; rows: number; seq: number }>((resolve) => { + new Promise<{ + data: string + cols: number + rows: number + seq: number + }>((resolve) => { resolveSnapshot = resolve }) ), @@ -1248,7 +1338,10 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -1259,7 +1352,9 @@ describe('terminal multiplex RPC', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered-output-partially-covered-by-snapshot', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: vi.fn(() => vi.fn()) } ) @@ -1319,7 +1414,10 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), @@ -1327,7 +1425,9 @@ describe('terminal multiplex RPC', () => { { signal: controller.signal, connectionId: 'conn-phone-multiplex', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1393,14 +1493,19 @@ describe('terminal multiplex RPC', () => { cleanups.set(id, cleanup) }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-stale-handle', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1484,14 +1589,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-buffered', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1593,14 +1703,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-buffered-multibyte', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1690,7 +1805,11 @@ describe('terminal multiplex RPC', () => { resolveRequestedSnapshot = resolve }) ) - .mockResolvedValueOnce({ data: 'retry snapshot', cols: 120, rows: 40 }), + .mockResolvedValueOnce({ + data: 'retry snapshot', + cols: 120, + rows: 40 + }), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), @@ -1710,14 +1829,19 @@ describe('terminal multiplex RPC', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateDesktopViewport: vi.fn().mockResolvedValue(true) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.multiplex', {}), (msg) => messages.push(msg), { connectionId: 'conn-request-overflow', - sendBinary: (bytes) => binaryFrames.push(bytes), + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, registerBinaryStreamHandler: (streamId, handler) => { handlers.set(streamId, handler) return () => handlers.delete(streamId) @@ -1754,7 +1878,10 @@ describe('terminal multiplex RPC', () => { opcode: TerminalStreamOpcode.SnapshotRequest, streamId: 12, seq: 2, - payload: encodeTerminalStreamJson({ requestId: 44, scrollbackRows: 5000 }) + payload: encodeTerminalStreamJson({ + requestId: 44, + scrollbackRows: 5000 + }) }) )! ) diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index 2df3dbc7173..df81e05e057 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -52,7 +52,10 @@ describe('terminal output batching', () => { }), waitForTerminal: vi.fn(() => new Promise(() => {})) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -78,7 +81,9 @@ describe('terminal output batching', () => { .map((msg) => JSON.parse(msg)) .filter((message) => message.result?.type === 'data') expect(dataMessages).toHaveLength(1) - expect(dataMessages[0]).toMatchObject({ result: { type: 'data', chunk: 'ab' } }) + expect(dataMessages[0]).toMatchObject({ + result: { type: 'data', chunk: 'ab' } + }) runtime.cleanupSubscription('terminal-1:desktop-1') await dispatchPromise @@ -123,7 +128,10 @@ describe('terminal output batching', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -134,7 +142,9 @@ describe('terminal output batching', () => { (msg) => messages.push(msg), { connectionId: 'conn-1', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -144,7 +154,10 @@ describe('terminal output batching', () => { const subscribed = messages .map((msg) => JSON.parse(msg)) .find((msg) => msg.result?.type === 'subscribed') - expect(subscribed?.result).toMatchObject({ type: 'subscribed', streamId: expect.any(Number) }) + expect(subscribed?.result).toMatchObject({ + type: 'subscribed', + streamId: expect.any(Number) + }) await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) const emitData = dataListenerRef.current @@ -206,7 +219,10 @@ describe('terminal output batching', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -286,7 +302,10 @@ describe('terminal output batching', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const messages: string[] = [] const dispatchPromise = dispatcher.dispatchStreaming( diff --git a/src/main/runtime/rpc/terminal-send.test.ts b/src/main/runtime/rpc/terminal-send.test.ts index b778f814977..c426513c9f5 100644 --- a/src/main/runtime/rpc/terminal-send.test.ts +++ b/src/main/runtime/rpc/terminal-send.test.ts @@ -417,7 +417,7 @@ describe('terminal send RPC', () => { it('routes terminal restore fit through the runtime driver state machine', async () => { const runtime = stubRuntime({ - resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), reclaimTerminalForDesktop: vi.fn().mockResolvedValue(true) }) const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index adad88dded0..f4a70cbeebd 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -40,7 +40,10 @@ describe('terminal subscribe buffering', () => { ), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -77,11 +80,16 @@ describe('terminal subscribe buffering', () => { const messages: string[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: null }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }) + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }) + }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) await dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { terminal: 'terminal-1' }), @@ -104,9 +112,11 @@ describe('terminal subscribe buffering', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -122,7 +132,10 @@ describe('terminal subscribe buffering', () => { }), waitForTerminal: vi.fn(() => new Promise(() => {})) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -172,7 +185,10 @@ describe('terminal subscribe buffering', () => { cleanupSubscription: vi.fn(), waitForTerminal: vi.fn(() => new Promise(() => {})) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -209,9 +225,11 @@ describe('terminal subscribe buffering', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue(null), getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), @@ -230,7 +248,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -241,7 +262,9 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-binary-limited', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -273,9 +296,11 @@ describe('terminal subscribe buffering', () => { const cleanups = new Map void>() const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), - readTerminal: vi - .fn() - .mockResolvedValue({ tail: ['line 120'], truncated: false, limited: true }), + readTerminal: vi.fn().mockResolvedValue({ + tail: ['line 120'], + truncated: false, + limited: true + }), serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'serialized snapshot\r\n', cols: 100, @@ -298,7 +323,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -309,7 +337,9 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-binary-serialized-limited', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -375,7 +405,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue(false) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -386,7 +419,9 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-buffered', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) @@ -446,7 +481,12 @@ describe('terminal subscribe buffering', () => { data: string cols: number rows: number - oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[] + oscLinks?: { + row: number + startCol: number + endCol: number + uri: string + }[] }) => void)[] = [] const serializeTerminalBuffer = vi .fn() @@ -457,7 +497,12 @@ describe('terminal subscribe buffering', () => { data: string cols: number rows: number - oscLinks?: { row: number; startCol: number; endCol: number; uri: string }[] + oscLinks?: { + row: number + startCol: number + endCol: number + uri: string + }[] }>((resolve) => { restreamResolves.push(resolve) }) @@ -490,7 +535,10 @@ describe('terminal subscribe buffering', () => { sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), updateMobileViewport: vi.fn().mockResolvedValue({ updated: true, applied: true }) }) - const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: TERMINAL_METHODS + }) const dispatchPromise = dispatcher.dispatchStreaming( makeRequest('terminal.subscribe', { @@ -501,19 +549,38 @@ describe('terminal subscribe buffering', () => { (msg) => messages.push(msg), { connectionId: 'conn-stale-resize', - sendBinary: (bytes) => binaryFrames.push(bytes) + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } } ) await vi.waitFor(() => expect(resizeListener).toBeDefined()) binaryFrames.splice(0) - resizeListener?.({ cols: 90, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 2 }) - resizeListener?.({ cols: 100, rows: 24, displayMode: 'auto', reason: 'apply-layout', seq: 3 }) + resizeListener?.({ + cols: 90, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 2 + }) + resizeListener?.({ + cols: 100, + rows: 24, + displayMode: 'auto', + reason: 'apply-layout', + seq: 3 + }) await vi.waitFor(() => expect(restreamResolves).toHaveLength(2)) const newerOscLinks = [{ row: 0, startCol: 4, endCol: 9, uri: 'https://example.com' }] - restreamResolves[1]?.({ data: 'newer', cols: 100, rows: 24, oscLinks: newerOscLinks }) + restreamResolves[1]?.({ + data: 'newer', + cols: 100, + rows: 24, + oscLinks: newerOscLinks + }) await vi.waitFor(() => expect( binaryFrames.some((frame) => { diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index f8b3c75c7cf..ace969138c4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -124,6 +124,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'accounts.selectCodex', 'accounts.subscribe', 'accounts.unsubscribe', + 'aiVault.listSessions', 'browser.back', 'browser.dialogAccept', 'browser.dialogDismiss', @@ -160,6 +161,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'files.readTerminalArtifactPreview', 'files.resolveTerminalPath', 'files.writeTerminalArtifact', + 'folderWorkspace.list', 'git.abortMerge', 'git.abortRebase', 'git.bulkStage', @@ -282,6 +284,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'preflight.check', 'preflight.detectAgents', 'preflight.detectRemoteAgents', + 'projectGroup.list', 'repo.baseRefDefault', 'repo.gitAvailable', 'repo.hooks', diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index c4e9f089743..459eaef88df 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -8,10 +8,27 @@ import { FrameDecoder, parseJsonRpcMessage, parseUnameToRelayPlatform, + isGitResponseStreamMarker, type JsonRpcRequest, type DecodedFrame } from './relay-protocol' +describe('git response stream marker', () => { + it('accepts only complete non-negative integer metadata', () => { + expect( + isGitResponseStreamMarker({ + __orcaGitResponseStream: { streamId: 1, totalBytes: 1024, chunkCount: 2 } + }) + ).toBe(true) + expect(isGitResponseStreamMarker({ __orcaGitResponseStream: {} })).toBe(false) + expect( + isGitResponseStreamMarker({ + __orcaGitResponseStream: { streamId: -1, totalBytes: 1024, chunkCount: 2 } + }) + ).toBe(false) + }) +}) + describe('frame encoding', () => { it('encodes a frame with 13-byte header', () => { const payload = Buffer.from('hello') diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index db57c4d19aa..c05b6c33bfe 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -55,6 +55,45 @@ export const STREAM_CHUNK_SIZE = 256 * 1024 * client. */ export const MAX_CONCURRENT_STREAMS = 16 +// ── Git response streaming (see docs/relay-git-response-stream-design.md) ── + +/** Serialized-JSON size above which the relay chunks a streamable git response + * (diff family + exec) onto the bulk lane instead of one JSON-RPC frame. Mirror + * of the relay-side constant; the client only opts in — the relay owns the + * decision — so this is documentation of the shared contract. */ +export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 + +/** Per-chunk size (serialized-result UTF-8 bytes) for git response streaming. + * The client reassembles by concatenation and does not depend on this value, + * so it stays cross-version safe. */ +export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 + +/** Sentinel the relay returns as the RPC result when the real payload streams + * as git.responseChunk frames. Absent from old relays, so a new client falls + * back to the plain result they return. */ +export type GitResponseStreamMarker = { + __orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number } +} + +export function isGitResponseStreamMarker(value: unknown): value is GitResponseStreamMarker { + if (typeof value !== 'object' || value === null || !('__orcaGitResponseStream' in value)) { + return false + } + const marker = (value as { __orcaGitResponseStream?: unknown }).__orcaGitResponseStream + if (typeof marker !== 'object' || marker === null) { + return false + } + const fields = marker as Record + return ( + Number.isInteger(fields.streamId) && + (fields.streamId as number) > 0 && + Number.isInteger(fields.totalBytes) && + (fields.totalBytes as number) >= 0 && + Number.isInteger(fields.chunkCount) && + (fields.chunkCount as number) >= 0 + ) +} + // ── JSON-RPC types ────────────────────────────────────────────────── export type JsonRpcRequest = { diff --git a/src/main/ssh/ssh-git-response-stream-reader.ts b/src/main/ssh/ssh-git-response-stream-reader.ts new file mode 100644 index 00000000000..ef63c7a6dc0 --- /dev/null +++ b/src/main/ssh/ssh-git-response-stream-reader.ts @@ -0,0 +1,311 @@ +import type { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { RelayErrorCode, isGitResponseStreamMarker } from './relay-protocol' + +const SENTINEL_STREAM_ID = -1 + +/** Reject if no stream frame (chunk/end/error) arrives within this window, + * reset on each frame. mux.request's own timeout only bounds the fast sentinel + * response; without this, a relay pump that breaks on staleness (which sends no + * responseEnd) while the SSH channel stays up would hang the client forever. */ +const STREAM_INACTIVITY_TIMEOUT_MS = 30_000 + +/** Bound transient buffering of other concurrent streams' chunks while this + * reader awaits its sentinel: every reader sees all git.responseChunk frames + * and can't filter by streamId until its own sentinel resolves. Foreign frames + * are dropped on drain anyway; this just caps the pre-sentinel backlog. */ +const MAX_PENDING_FRAMES = 64 + +export class GitResponseStreamError extends Error { + readonly code = RelayErrorCode.StreamProtocolError + constructor(message: string) { + super(message) + } +} + +type PendingFrame = + | { kind: 'chunk'; params: Record } + | { kind: 'end'; params: Record } + | { kind: 'error'; params: Record } + +/** + * Request a git method that may return a large payload, opting into response + * streaming so a big diff/exec response is chunked onto the relay's bulk lane + * instead of one JSON-RPC frame (which would head-of-line-block pty.data echo + * on the shared SSH channel). + * + * Cross-version behavior: + * - New relay + big result → returns the stream sentinel; we reassemble chunks. + * - New relay + small result, or old client → plain single-frame result. + * - Old relay (ignores `__streamResponse`) → returns the plain result; the + * marker check fails and we return it directly, i.e. today's behavior. + */ +export function requestGitStreamable( + mux: SshChannelMultiplexer, + method: string, + params: Record, + options?: { + signal?: AbortSignal + /** Bounds only the sentinel request (forwarded to mux.request), like today. */ + timeoutMs?: number + /** Bounds the post-sentinel reassembly stall; resets on each chunk. */ + inactivityTimeoutMs?: number + } +): Promise { + // Why: subscribe to chunk/end/error BEFORE awaiting the sentinel response so a + // chunk that lands in the same dispatch tick as the response is not dropped + // (mirrors readFileViaStream). streamIdRef stays SENTINEL until the sentinel + // resolves; frames are queued until then and drained. + const streamIdRef = { current: SENTINEL_STREAM_ID } + const unsubscribers: (() => void)[] = [] + const cleanup = (): void => { + while (unsubscribers.length > 0) { + try { + unsubscribers.pop()?.() + } catch { + // best-effort + } + } + } + + return new Promise((resolve, reject) => { + const parts: Buffer[] = [] + let expectedSeq = 0 + let receivedBytes = 0 + let totalBytes = 0 + let chunkCount = 0 + let settled = false + let metadataReady = false + const pending: PendingFrame[] = [] + + const inactivityMs = options?.inactivityTimeoutMs ?? STREAM_INACTIVITY_TIMEOUT_MS + let inactivityTimer: ReturnType | null = null + const clearInactivity = (): void => { + if (inactivityTimer) { + clearTimeout(inactivityTimer) + inactivityTimer = null + } + } + // Why: reset on every stream frame so a legitimately long stream is not + // killed, but a wedged stream (no frames arriving) rejects instead of + // hanging the caller forever. + const armInactivity = (): void => { + clearInactivity() + inactivityTimer = setTimeout(() => { + fail( + new GitResponseStreamError( + `Git response stream stalled (>${inactivityMs}ms without data)` + ) + ) + }, inactivityMs) + inactivityTimer.unref?.() + } + + const cancel = (): void => { + if (streamIdRef.current !== SENTINEL_STREAM_ID && !mux.isDisposed()) { + try { + mux.notify('git.cancelResponseStream', { streamId: streamIdRef.current }) + } catch { + // best-effort + } + } + } + const fail = (err: Error): void => { + if (settled) { + return + } + settled = true + clearInactivity() + cancel() + cleanup() + reject(err) + } + const succeed = (value: unknown): void => { + if (settled) { + return + } + settled = true + clearInactivity() + cleanup() + resolve(value) + } + + const handleChunk = (p: Record): void => { + if (settled || p.streamId !== streamIdRef.current) { + return + } + const seq = p.seq as number + const data = p.data as string + if (typeof seq !== 'number' || typeof data !== 'string') { + fail(new GitResponseStreamError(`Malformed chunk for git stream ${streamIdRef.current}`)) + return + } + if (seq !== expectedSeq) { + fail( + new GitResponseStreamError( + `Out-of-order chunk for git stream ${streamIdRef.current}: expected ${expectedSeq}, got ${seq}` + ) + ) + return + } + const decoded = Buffer.from(data, 'base64') + parts.push(decoded) + receivedBytes += decoded.length + expectedSeq += 1 + armInactivity() + // Why: credit-based flow control — the relay caps unacked chunks so a big + // response cannot queue unbounded ahead of interactive pty.data frames. + if (!mux.isDisposed()) { + try { + mux.notify('git.responseAck', { streamId: streamIdRef.current, seq }) + } catch { + // Disposal can race the check; the ACK is best-effort during teardown. + } + } + } + + const handleEnd = (p: Record): void => { + if (settled || p.streamId !== streamIdRef.current) { + return + } + if (expectedSeq !== chunkCount || receivedBytes !== totalBytes) { + fail( + new GitResponseStreamError( + `Git stream ${streamIdRef.current} incomplete: chunks ${expectedSeq}/${chunkCount}, bytes ${receivedBytes}/${totalBytes}` + ) + ) + return + } + try { + succeed(JSON.parse(Buffer.concat(parts).toString('utf-8'))) + } catch (err) { + fail( + new GitResponseStreamError( + `Git stream ${streamIdRef.current} JSON parse failed: ${String(err)}` + ) + ) + } + } + + const handleStreamError = (p: Record): void => { + if (settled || p.streamId !== streamIdRef.current) { + return + } + fail(new Error((p.message as string | undefined) ?? 'git response stream error')) + } + + const drainPending = (): void => { + while (!settled && pending.length > 0) { + const frame = pending.shift()! + if (frame.kind === 'chunk') { + handleChunk(frame.params) + } else if (frame.kind === 'end') { + handleEnd(frame.params) + } else { + handleStreamError(frame.params) + } + } + } + + // Why: pre-sentinel we cannot filter by streamId (our id is unknown yet), so + // every concurrent reader transiently buffers all readers' chunks. Cap the + // backlog by dropping the oldest; foreign frames are dropped on drain anyway, + // and if our own seq-0 were ever dropped the seq check fails loudly rather + // than corrupting. The sentinel normally resolves long before this cap. + const pushPending = (frame: PendingFrame): void => { + pending.push(frame) + if (pending.length > MAX_PENDING_FRAMES) { + pending.shift() + } + } + + unsubscribers.push( + mux.onNotificationByMethod('git.responseChunk', (p) => { + if (!metadataReady) { + pushPending({ kind: 'chunk', params: p }) + return + } + handleChunk(p) + }) + ) + unsubscribers.push( + mux.onNotificationByMethod('git.responseEnd', (p) => { + if (!metadataReady) { + pushPending({ kind: 'end', params: p }) + return + } + handleEnd(p) + }) + ) + unsubscribers.push( + mux.onNotificationByMethod('git.responseError', (p) => { + if (!metadataReady) { + pushPending({ kind: 'error', params: p }) + return + } + handleStreamError(p) + }) + ) + unsubscribers.push( + mux.onDispose((reason) => { + const err = new Error( + reason === 'connection_lost' + ? 'SSH connection lost, reconnecting...' + : 'Multiplexer disposed' + ) as Error & { code: string } + err.code = reason === 'connection_lost' ? 'CONNECTION_LOST' : 'DISPOSED' + fail(err) + }) + ) + + if (options?.signal) { + const signal = options.signal + if (signal.aborted) { + const err = new Error('Request was cancelled') as Error & { name: string } + err.name = 'AbortError' + fail(err) + return + } + const onAbort = (): void => { + const err = new Error('Request was cancelled') as Error & { name: string } + err.name = 'AbortError' + fail(err) + } + signal.addEventListener('abort', onAbort, { once: true }) + unsubscribers.push(() => signal.removeEventListener('abort', onAbort)) + } + + // Why: forward only the mux-request options (signal/timeoutMs) and omit them + // entirely when absent, so callers that previously issued a 2-arg + // mux.request keep the same call shape (and their tests). inactivityTimeoutMs + // governs reassembly here, not the sentinel request. + const streamParams = { ...params, __streamResponse: true } + const requestOptions = + options?.signal !== undefined || options?.timeoutMs !== undefined + ? { signal: options.signal, timeoutMs: options.timeoutMs } + : undefined + const requestPromise = requestOptions + ? mux.request(method, streamParams, requestOptions) + : mux.request(method, streamParams) + void requestPromise + .then((result) => { + if (settled) { + return + } + // Old relay / small result: plain single-frame value, no stream follows. + if (!isGitResponseStreamMarker(result)) { + succeed(result) + return + } + const marker = result.__orcaGitResponseStream + totalBytes = marker.totalBytes + chunkCount = marker.chunkCount + streamIdRef.current = marker.streamId + metadataReady = true + // Why: start the inactivity deadline now — mux.request's timeout only + // covered the sentinel; the reassembly phase needs its own guard. + armInactivity() + drainPending() + }) + .catch((err) => fail(err as Error)) + }) +} diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 78e7dec4443..26501a8371b 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -756,7 +756,12 @@ async function launchRelay( // Fire-and-forget via conn.exec: we don't need the output — the socket // poll below detects readiness. const logFile = `${remoteDir}/relay.log` - const launchCmd = `cd ${escapedDir} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} > ${shellEscape(logFile)} 2>&1 ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) @@ -1052,6 +1057,10 @@ function windowsRelayLaunchCommand( quoted(sockPath), '--endpoint-dir', quoted(endpointDir), + // Why: in-process rotation owns relay.log (the tail-diagnostics target); + // the shell redirects remain for pre-JS boot/crash output. + '--log-file', + quoted(logFile), `1>${quoted(logFile)}`, `2>${quoted(errFile)}` ].join(' ') diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 614c121d56a..aebfd234b5c 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -172,6 +172,39 @@ describe('SshRelaySession', () => { expect(registerSshGitProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) + it.each([ + ['/bin/bash', "'/bin/bash' -lc 'printenv GROK_HOME | head -c 4097'"], + ['/usr/bin/fish', "'/usr/bin/fish' -lc 'printenv GROK_HOME | head -c 4097'"], + ['/bin/tcsh', "'/bin/tcsh' -lc 'printenv GROK_HOME | head -c 4097'"], + ['/bin/sh', "'/bin/sh' -c 'printenv GROK_HOME | head -c 4097'"] + ])('uses a shell-independent GROK_HOME probe with login shell %s', async (shell, command) => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + muxRequestMock.mockImplementation(async (method: string) => + method === 'session.resolveHome' ? { resolvedPath: '/home/orca' } : { ok: true } + ) + vi.mocked(execCommand).mockResolvedValueOnce(`${shell}\n`).mockResolvedValueOnce('/srv/grok\n') + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = { sftp: vi.fn().mockResolvedValue(sftp) } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + expect(execCommand).toHaveBeenNthCalledWith( + 1, + mockConn, + "printenv SHELL || printf '/bin/sh\\n'", + { timeoutMs: 8_000 } + ) + expect(execCommand).toHaveBeenNthCalledWith(2, mockConn, command, { + wrapCommand: false, + timeoutMs: 8_000 + }) + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', { + grokHomeDir: '/srv/grok' + }) + }) + it('installs remote managed hooks and relay-owned plugin assets before registering the SSH PTY provider', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' muxRequestMock.mockImplementation(async (method: string) => { @@ -181,6 +214,9 @@ describe('SshRelaySession', () => { return { ok: true } }) const sftp = { end: vi.fn() } + vi.mocked(execCommand) + .mockResolvedValueOnce('/bin/bash\n') + .mockResolvedValueOnce('/srv/grok profile\n') const { mockStore, mockPortForward, getMainWindow } = createMockDeps() const mockConn = { sftp: vi.fn().mockResolvedValue(sftp) @@ -199,7 +235,9 @@ describe('SshRelaySession', () => { ompExtensionSource: expect.stringContaining('/hook/omp') }) expect(mockConn.sftp).toHaveBeenCalledTimes(1) - expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca') + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', { + grokHomeDir: '/srv/grok profile' + }) expect(sftp.end).toHaveBeenCalledTimes(1) expect(installRemoteManagedAgentHooksMock.mock.invocationCallOrder[0]).toBeLessThan( muxRequestMock.mock.invocationCallOrder[installPluginsCallIndex] @@ -209,6 +247,26 @@ describe('SshRelaySession', () => { ) }) + it('falls back to login-home Grok config when the remote env probe is invalid', async () => { + process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' + muxRequestMock.mockImplementation(async (method: string) => + method === 'session.resolveHome' ? { resolvedPath: '/home/orca' } : { ok: true } + ) + vi.mocked(execCommand) + .mockResolvedValueOnce('/bin/bash\n') + .mockResolvedValueOnce('relative/grok-home\n') + const sftp = { end: vi.fn() } + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const mockConn = { sftp: vi.fn().mockResolvedValue(sftp) } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(mockConn) + + expect(installRemoteManagedAgentHooksMock).toHaveBeenCalledWith(sftp, '/home/orca', { + grokHomeDir: '/home/orca/.grok' + }) + }) + it('does not run POSIX managed hook installers on Windows remotes', async () => { process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1' const { mockStore, mockPortForward, getMainWindow } = createMockDeps() diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 4b29d7f7ea3..b2564259ffd 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -70,6 +70,7 @@ import { runRemoteOrcaCli } from './ssh-remote-orca-cli' import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' +import { shellEscape } from './ssh-connection-utils' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -85,6 +86,69 @@ type RemoteCliBridgeEnv = { type ExpectedPtyIdentity = { paneKey?: string; tabId?: string } +const REMOTE_GROK_HOME_MAX_LENGTH = 4096 +const REMOTE_GROK_HOME_PROBE_TIMEOUT_MS = 8_000 + +function defaultRemoteGrokHome(remoteHome: string): string { + const home = remoteHome.replace(/\/+$/, '') || remoteHome + return `${home}/.grok` +} + +function normalizeRemoteGrokHome(candidate: string): string | null { + if ( + candidate.length === 0 || + candidate.length > REMOTE_GROK_HOME_MAX_LENGTH || + candidate !== candidate.trim() || + !candidate.startsWith('/') || + candidate.includes('\\') || + hasControlCharacter(candidate) + ) { + return null + } + return candidate.replace(/\/+$/, '') || '/' +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +function loginShellCommand(shell: string, command: string): string { + const shellName = shell.split('/').at(-1) + const mode = shellName === 'sh' || shellName === 'dash' ? '-c' : '-lc' + return `${shellEscape(shell)} ${mode} ${shellEscape(command)}` +} + +async function resolveRemoteGrokHome( + connection: SshConnection, + remoteHome: string +): Promise { + const fallback = defaultRemoteGrokHome(remoteHome) + try { + // Why: remote PTYs start login shells, so probe the same profile-derived + // environment instead of the relay service or local Electron environment. + const shellOutput = await execCommand(connection, "printenv SHELL || printf '/bin/sh\\n'", { + timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS + }) + const shell = shellOutput.trim().split(/\r?\n/, 1)[0] + if (!shell?.startsWith('/') || hasControlCharacter(shell)) { + return fallback + } + // Why: this runs inside the user's actual login shell, which may be fish or + // tcsh. External commands avoid shell-specific variable/conditional syntax. + const probe = `printenv GROK_HOME | head -c ${REMOTE_GROK_HOME_MAX_LENGTH + 1}` + const output = await execCommand(connection, loginShellCommand(shell, probe), { + wrapCommand: false, + timeoutMs: REMOTE_GROK_HOME_PROBE_TIMEOUT_MS + }) + return normalizeRemoteGrokHome(output.split(/\r?\n/, 1)[0] ?? '') ?? fallback + } catch { + return fallback + } +} + function expectedIdentityForLease(lease: { tabId?: string leafId?: string @@ -673,8 +737,10 @@ export class SshRelaySession { let sftp: Awaited> | null = null try { - sftp = await this.requireReadyConnection().sftp() - await installRemoteManagedAgentHooks(sftp, remoteHome) + const connection = this.requireReadyConnection() + const remoteGrokHome = await resolveRemoteGrokHome(connection, remoteHome) + sftp = await connection.sftp() + await installRemoteManagedAgentHooks(sftp, remoteHome, { grokHomeDir: remoteGrokHome }) } catch (error) { console.warn( `[ssh-relay-session] remote managed hook install failed for ${this.targetId}: ${ diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 633ecc45be1..100b89b97f4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -121,6 +121,7 @@ import type { JiraIssue, JiraIssueFilter, JiraIssueType, + JiraProjectStatusOrder, JiraIssueUpdate, JiraPriority, JiraProject, @@ -1233,6 +1234,9 @@ export type PreloadApi = { kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void ackData: (id: string, charCount: number) => void + /** One-shot signal that this page's pty:data dispatcher is registered, so + * main can release sends held during the load/reload boot window. */ + rendererDispatcherReady: () => void setActiveRendererPty: (id: string, active: boolean) => void setRendererPtyVisible: (id: string, visible: boolean) => void hasChildProcesses: (id: string) => Promise @@ -1268,6 +1272,10 @@ export type PreloadApi = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number }> resetRendererDeliveryDebug: () => Promise onData: ( @@ -1924,6 +1932,10 @@ export type PreloadApi = { siteId?: string }) => Promise listTransitions: (args: { key: string; siteId?: string }) => Promise + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }) => Promise } starNag: { onShow: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index 5cd262973fb..9f63a71d985 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -25,6 +25,7 @@ import type { GitHubAssignableUser, GitHubCommentResult, GitHubWorkItem, + JiraProjectStatusOrder, GitPushTarget, GitStagingArea, GitForkSyncExpectedUpstream, @@ -852,6 +853,9 @@ const api = { ackData: (id: string, charCount: number): void => { ipcRenderer.send('pty:ackData', { id, charCount }) }, + rendererDispatcherReady: (): void => { + ipcRenderer.send('pty:rendererDispatcherReady') + }, setActiveRendererPty: (id: string, active: boolean): void => { ipcRenderer.send('pty:setActiveRendererPty', { id, active }) }, @@ -894,6 +898,10 @@ const api = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + rendererLifecycleResetCount: number + lastLifecycleResetClearedChars: number + rendererPtyDispatcherReady: boolean + rendererDispatcherReadyForcedCount: number }> => ipcRenderer.invoke('pty:getRendererDeliveryDebugSnapshot'), resetRendererDeliveryDebug: (): Promise => @@ -1658,7 +1666,11 @@ const api = { }): Promise => ipcRenderer.invoke('jira:listAssignableUsers', args), listTransitions: (args: { key: string; siteId?: string }): Promise => - ipcRenderer.invoke('jira:listTransitions', args) + ipcRenderer.invoke('jira:listTransitions', args), + getProjectStatusOrder: (args: { + projectKey: string + siteId?: string + }): Promise => ipcRenderer.invoke('jira:getProjectStatusOrder', args) }, starNag: { diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 442052c1e96..4edb3dde409 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' import { makePaneKey } from '../shared/stable-pane-id' +import * as agentHookListener from '../shared/agent-hook-listener' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) @@ -257,6 +258,8 @@ describe('RelayAgentHookServer', () => { }) expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + // Let the first 50ms retry miss so continuation across SessionEnd is proven. + await new Promise((resolve) => setTimeout(resolve, 70)) writeFileSync( transcriptPath, `${JSON.stringify({ @@ -378,4 +381,111 @@ describe('RelayAgentHookServer', () => { server.stop() } }, 30_000) + + it('forwards a Grok result when discovery finishes after the old retry window', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf534' + const cwd = join(dir, 'workspace') + const sessionDir = join(dir, '.grok', 'sessions', encodeURIComponent(cwd), sessionId) + mkdirSync(sessionDir, { recursive: true }) + const history = join(sessionDir, 'chat_history.jsonl') + writeFileSync(history, '') + vi.stubEnv('HOME', dir) + vi.stubEnv('USERPROFILE', dir) + await server.start() + try { + const { port, token } = server.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload + }) + }) + + await post({ hookEventName: 'UserPromptSubmit', prompt: 'delayed relay result' }) + await post({ hookEventName: 'Stop', sessionId, cwd }) + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBeUndefined() + + writeFileSync( + history, + `${JSON.stringify({ type: 'assistant', content: 'Relay found after discovery.' })}\n` + ) + releaseDiscovery() + + await vi.waitFor(() => { + expect(forward.mock.calls.at(-1)?.[0].payload.lastAssistantMessage).toBe( + 'Relay found after discovery.' + ) + }) + } finally { + server.stop() + vi.unstubAllEnvs() + } + }) + + it('does not forward an old result over a newer same-text Grok turn', async () => { + let releaseDiscovery!: () => void + const discovery = new Promise((resolve) => { + releaseDiscovery = resolve + }) + vi.spyOn(agentHookListener, 'preparePendingGrokResultDiscovery').mockReturnValue(discovery) + const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() + const server = new RelayAgentHookServer({ endpointDir: dir, forward }) + await server.start() + try { + const { port, token } = server.getCoordinates() + const post = (payload: Record): Promise => + fetch(`http://127.0.0.1:${port}/hook/grok`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ + paneKey: PANE_KEY, + tabId: 'tab-1', + env: 'remote', + version: '1', + payload + }) + }) + + await post({ hookEventName: 'UserPromptSubmit', prompt: 'repeat me' }) + await post({ + hookEventName: 'Stop', + sessionId: '019e37f4-5135-7b63-a4ab-6d13aa6bf535', + cwd: join(dir, 'workspace') + }) + await post({ hookEventName: 'UserPromptSubmit', prompt: 'repeat me' }) + const forwardsBeforeDiscovery = forward.mock.calls.length + + releaseDiscovery() + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(forward).toHaveBeenCalledTimes(forwardsBeforeDiscovery) + expect(forward.mock.calls.at(-1)?.[0].payload).toMatchObject({ + state: 'working', + prompt: 'repeat me', + agentType: 'grok' + }) + } finally { + server.stop() + } + }) }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 741a2a5ca01..f5200a2b178 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -25,6 +25,7 @@ import { hasPendingAgentResultText, HOOK_REQUEST_SLOWLORIS_MS, normalizeHookPayload, + preparePendingGrokResultDiscovery, readRequestBody, resolveHookSource, writeEndpointFile, @@ -406,7 +407,8 @@ export class RelayAgentHookServer { original: AgentHookEventPayload, env?: string, version?: string, - attempt = 1 + attempt = 1, + discoveryReady = false ): void { if ( original.payload.lastAssistantMessage || @@ -416,26 +418,37 @@ export class RelayAgentHookServer { return } this.clearAssistantMessageRetry(original.paneKey) + if (!discoveryReady) { + const discovery = preparePendingGrokResultDiscovery(source, body) + if (discovery) { + // Why: remote slug-group discovery can outlive the bounded transcript- + // flush timers, so completion itself drives the first retry. + void discovery + .then(() => { + if (this.server) { + this.applyAssistantMessageRetry(source, body, original, env, version, 1, true) + } + }) + .catch((err) => { + process.stderr.write( + `[relay-hook-server] Grok result discovery failed: ${err instanceof Error ? err.message : String(err)}\n` + ) + }) + return + } + } const timer = setTimeout(() => { try { this.assistantMessageRetryTimers.delete(original.paneKey) - const current = this.state.lastStatusByPaneKey.get(original.paneKey) - if ( - !current || - current.payload.agentType !== original.payload.agentType || - current.payload.prompt !== original.payload.prompt || - current.payload.lastAssistantMessage - ) { - return - } - const event = normalizeHookPayload(this.state, source, body, this.env) - if (!event?.payload.lastAssistantMessage) { - this.scheduleAssistantMessageRetry(source, body, original, env, version, attempt + 1) - return - } - // Why: the relay runs on SSH targets too; retry from a timer so a delayed - // transcript/chat-history write does not block the remote hook server. - this.applyEvent(event, source, env, version) + this.applyAssistantMessageRetry( + source, + body, + original, + env, + version, + attempt + 1, + discoveryReady + ) } catch (err) { process.stderr.write( `[relay-hook-server] assistant message retry failed: ${err instanceof Error ? err.message : String(err)}\n` @@ -448,6 +461,41 @@ export class RelayAgentHookServer { } } + private applyAssistantMessageRetry( + source: AgentHookSource, + body: unknown, + original: AgentHookEventPayload, + env: string | undefined, + version: string | undefined, + nextAttempt: number, + requireExactOriginal: boolean + ): void { + const current = this.state.lastStatusByPaneKey.get(original.paneKey) + if ( + !current || + (requireExactOriginal && current !== original) || + current.payload.agentType !== original.payload.agentType || + current.payload.prompt !== original.payload.prompt || + current.payload.lastAssistantMessage + ) { + return + } + const event = normalizeHookPayload(this.state, source, body, this.env) + if (!event?.payload.lastAssistantMessage) { + this.scheduleAssistantMessageRetry( + source, + body, + original, + env, + version, + nextAttempt, + requireExactOriginal + ) + return + } + this.applyEvent(event, source, env, version) + } + private bodyEnv(body: unknown): string | undefined { if (typeof body !== 'object' || body === null) { return undefined diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 5ab997c9d21..0a9a3908172 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -396,6 +396,73 @@ describe('RelayDispatcher', () => { expect(listener).toHaveBeenCalledWith(1) }) + it('detaches the primary client when its write throws (frame lost, trigger reconnect)', () => { + // Regression: a primary-client write throw dropped the frame (possibly + // pty.data/pty.exit) with no resend AND without notifying detach, so the + // owning Orca's reconnect + PTY-reattach path never engaged until the ~20s + // keepalive timeout — output/pane-death were silently lost in the meantime. + let throwOnWrite = false + const detachDispatcher = new RelayDispatcher((data) => { + if (throwOnWrite) { + throw new Error('socket closed') + } + written.push(Buffer.from(data)) + }) + try { + const detachListener = vi.fn() + detachDispatcher.onClientDetached(detachListener) + + // A frame the owning Orca must not silently miss (e.g. a pane exit). + throwOnWrite = true + detachDispatcher.notify('pty.exit', { id: 'pty-1', code: 0 }) + + // Fix: the write failure detaches the primary so the reconnect/reattach + // machinery runs promptly instead of waiting for keepalive timeout. + expect(detachListener).toHaveBeenCalledWith(1) + + // Recovery: a reconnecting socket swaps the write via setWrite; the client + // is usable again and later frames flow to the new sink. + throwOnWrite = false + const recovered: Buffer[] = [] + detachDispatcher.setWrite((data) => { + recovered.push(Buffer.from(data)) + }) + detachDispatcher.notify('pty.data', { id: 'pty-1', data: 'x' }) + expect(recovered.length).toBeGreaterThan(0) + } finally { + detachDispatcher.dispose() + } + }) + + it('aborts in-flight primary requests when a client write throws', () => { + let throwOnWrite = false + const detachDispatcher = new RelayDispatcher(() => { + if (throwOnWrite) { + throw new Error('socket closed') + } + }) + let requestSignal: AbortSignal | undefined + try { + detachDispatcher.onRequest('slow.method', async (_params, context) => { + requestSignal = context.signal + await new Promise((resolve) => { + context.signal?.addEventListener('abort', () => resolve(), { once: true }) + }) + }) + detachDispatcher.feed( + encodeJsonRpcFrame({ jsonrpc: '2.0', id: 9, method: 'slow.method' }, 1, 0) + ) + + expect(requestSignal?.aborted).toBe(false) + throwOnWrite = true + detachDispatcher.notify('pty.exit', { id: 'pty-1', code: 0 }) + + expect(requestSignal?.aborted).toBe(true) + } finally { + detachDispatcher.dispose() + } + }) + describe('notifyBulk (bulk lane backpressure)', () => { it('resolves immediately when the sink accepts the frame', async () => { const frames: Buffer[] = [] diff --git a/src/relay/dispatcher.ts b/src/relay/dispatcher.ts index 427d90e6ac0..7dfdf5dc5f4 100644 --- a/src/relay/dispatcher.ts +++ b/src/relay/dispatcher.ts @@ -536,11 +536,21 @@ export class RelayDispatcher { } catch (err) { client.closed = true client.generation++ + this.requestAborts.abortClient(client.id) this.flushDrainWaiters(client) + // Why: a write throw means this frame (possibly pty.data or pty.exit) was + // lost with no resend — the framing carries seq/ack but no retransmit + // buffer. Detach so the owning Orca's reconnect + PTY-reattach path runs + // promptly (regenerating dropped pty.data from the replay buffer and pane + // death via reattach-not-found), instead of silently dropping frames until + // the ~20s keepalive timeout notices. The primary client stays in the map + // (its object is reused across setWrite reconnects) but detach listeners + // must still fire, exactly as invalidateClient() does for stdin/stdout + // death — this makes the socket-write-throw path consistent with those. if (client !== this.primaryClient) { this.clients.delete(client.id) - this.notifyClientDetached(client.id) } + this.notifyClientDetached(client.id) process.stderr.write( `[relay] Client write failed: ${err instanceof Error ? err.message : String(err)}\n` ) diff --git a/src/relay/fs-handler.ts b/src/relay/fs-handler.ts index f6417b38d3c..6074fad2e2b 100644 --- a/src/relay/fs-handler.ts +++ b/src/relay/fs-handler.ts @@ -36,7 +36,7 @@ import { buildRelayCommandEnv } from './relay-command-env' import { assertNoClobberRenameDestinationAvailable } from '../shared/filesystem-rename-collision' import { WATCHER_IGNORE_DIRS, - buildParcelWatcherIgnoreOption + buildParcelWatcherIgnoreOptions } from '../main/ipc/filesystem-watcher-ignore' type WatchState = { @@ -461,9 +461,9 @@ export class FsHandler { })) this.dispatcher.notify('fs.changed', { events: mapped }) }, - // Why: align remote-Linux watchers with the shared nested-glob exclusion - // so nested node_modules/.git don't exhaust inotify on large codebases. - { ignore: buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS) } + // Why: align remote watchers with the shared nested exclusion so + // generated trees neither exhaust inotify nor trigger slow glob regexes. + buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) ) watchState.unwatchFn = () => { void subscription.unsubscribe() diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index c878dfd563e..3bdeb860c61 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -61,6 +61,8 @@ import { getGitCloneFailureMessage } from '../shared/git-clone-failure-message' import { syncForkDefaultBranch, validateGitForkSyncExpectedUpstream } from '../shared/git-fork-sync' import { InFlightPromiseDedupe, stableInFlightKey } from '../shared/in-flight-promise-dedupe' import { GIT_FETCH_SKIP_AUTO_MAINTENANCE_CONFIG_ARGS } from '../shared/git-fetch-auto-maintenance' +import { GitResponseStreamRegistry } from './git-response-stream' +import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -171,6 +173,9 @@ function execFileWithStdin( export class GitHandler { private dispatcher: RelayDispatcher private readonly gitDiffReadDedupe = new InFlightPromiseDedupe() + // Why: large diff/exec responses are chunked onto the bulk lane so they do + // not head-of-line-block interactive pty.data echo on the shared SSH channel. + private readonly responseStreams = new GitResponseStreamRegistry() // Why: configured submodule paths change rarely; an instance-level TTL cache // avoids re-reading `.gitmodules` on every diff click over SSH, and being @@ -182,6 +187,13 @@ export class GitHandler { constructor(dispatcher: RelayDispatcher, _context: RelayContext) { this.dispatcher = dispatcher this.registerHandlers() + // Why: a detached client's git.responseAck frames will never arrive; wake + // any pump parked on the ack window so it re-checks staleness and exits. + this.dispatcher.onClientDetached?.(() => this.responseStreams.wakeAll()) + } + + dispose(): void { + this.responseStreams.disposeAll() } private registerHandlers(): void { @@ -190,7 +202,7 @@ export class GitHandler { this.dispatcher.onRequest('git.checkIgnored', (p) => this.checkIgnored(p)) this.dispatcher.onRequest('git.history', (p) => this.history(p)) this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) - this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) + this.dispatcher.onRequest('git.diff', (p, context) => this.getDiff(p, context)) this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p)) this.dispatcher.onRequest('git.bulkStage', (p) => this.bulkStage(p)) @@ -215,8 +227,8 @@ export class GitHandler { this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.fastForward', (p) => this.fastForward(p)) this.dispatcher.onRequest('git.rebaseFromBase', (p) => this.rebaseFromBase(p)) - this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) - this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) + this.dispatcher.onRequest('git.branchDiff', (p, context) => this.branchDiff(p, context)) + this.dispatcher.onRequest('git.commitDiff', (p, context) => this.commitDiff(p, context)) this.dispatcher.onRequest('git.listWorktrees', (p, context) => this.listWorktrees(p, context)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) @@ -231,6 +243,45 @@ export class GitHandler { this.dispatcher.onRequest('git.exec', (p, context) => this.exec(p, context)) this.dispatcher.onRequest('git.clone', (p, context) => this.clone(p, context)) this.dispatcher.onRequest('git.isGitRepo', (p) => this.isGitRepo(p)) + this.dispatcher.onNotification('git.responseAck', (p, context) => this.responseAck(p, context)) + this.dispatcher.onNotification('git.cancelResponseStream', (p, context) => + this.cancelResponseStream(p, context) + ) + } + + private responseAck(params: Record, context: RequestContext): void { + const streamId = params.streamId + const seq = params.seq + if (typeof streamId === 'number' && typeof seq === 'number') { + this.responseStreams.recordAck(streamId, seq, context.clientId) + } + } + + private cancelResponseStream(params: Record, context: RequestContext): void { + const streamId = params.streamId + if (typeof streamId === 'number') { + this.responseStreams.abort(streamId, context.clientId) + } + } + + // Why: when the client opted into response streaming and the serialized result + // exceeds the threshold, chunk it onto the bulk lane and return a small + // sentinel as the RPC result. Old clients omit the flag (single-frame, as + // today); old relays never call this, so a new client falls back to the plain + // result they return. + private maybeStreamResponse( + result: unknown, + params: Record, + context: RequestContext | undefined + ): unknown { + if (params.__streamResponse !== true || !context) { + return result + } + const payload = Buffer.from(JSON.stringify(result ?? null), 'utf-8') + if (payload.length <= GIT_RESPONSE_STREAM_THRESHOLD) { + return result + } + return this.responseStreams.startStream(payload, this.dispatcher, context) } private async runWithDiffDedupeClear(run: () => Promise): Promise { @@ -352,7 +403,7 @@ export class GitHandler { }) } - private async getDiff(params: Record) { + private async getDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const filePath = params.filePath as string // Why: filePath is relative to worktreePath and used in readWorkingFile via @@ -366,7 +417,7 @@ export class GitHandler { const compareAgainstHead = params.compareAgainstHead as boolean | undefined // Why: register the in-flight dedupe synchronously (before any await) so // concurrent identical reads coalesce; submodule routing happens inside. - return this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.run( stableInFlightKey(['diff', worktreePath, filePath, staged, compareAgainstHead]), async () => { // Why: gitlink paths can't be read as blobs and submodule working dirs @@ -431,6 +482,7 @@ export class GitHandler { ) } ) + return this.maybeStreamResponse(result, params, context) } private async stage(params: Record) { @@ -1027,7 +1079,7 @@ export class GitHandler { } } - private async branchDiff(params: Record) { + private async branchDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const baseRef = params.baseRef as string if (baseRef.startsWith('-')) { @@ -1038,7 +1090,7 @@ export class GitHandler { filePath: params.filePath as string | undefined, oldPath: params.oldPath as string | undefined } - return this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.run( stableInFlightKey([ 'branchDiff', worktreePath, @@ -1056,9 +1108,10 @@ export class GitHandler { options ) ) + return this.maybeStreamResponse(result, params, context) } - private async commitDiff(params: Record) { + private async commitDiff(params: Record, context?: RequestContext) { const worktreePath = params.worktreePath as string const args = { commitOid: params.commitOid as string, @@ -1066,7 +1119,7 @@ export class GitHandler { filePath: params.filePath as string, oldPath: params.oldPath as string | undefined } - return this.gitDiffReadDedupe.run( + const result = await this.gitDiffReadDedupe.run( stableInFlightKey([ 'commitDiff', worktreePath, @@ -1077,6 +1130,7 @@ export class GitHandler { ]), () => commitDiffEntry(this.gitBuffer.bind(this), worktreePath, args) ) + return this.maybeStreamResponse(result, params, context) } private async exec(params: Record, context?: RequestContext) { @@ -1085,7 +1139,7 @@ export class GitHandler { validateGitExecArgs(args) const { stdout, stderr } = await this.git(args, cwd, { signal: context?.signal }) - return { stdout, stderr } + return this.maybeStreamResponse({ stdout, stderr }, params, context) } private async clone(params: Record, context?: RequestContext) { diff --git a/src/relay/git-response-pty-echo-backpressure.integration.test.ts b/src/relay/git-response-pty-echo-backpressure.integration.test.ts new file mode 100644 index 00000000000..09e6bf38192 --- /dev/null +++ b/src/relay/git-response-pty-echo-backpressure.integration.test.ts @@ -0,0 +1,309 @@ +/** + * Regression: SSH typing latency under large git responses. + * + * The relay and the client share ONE ordered SSH channel. A large git diff/exec + * response sent as a single JSON-RPC frame queues megabytes into the outbound + * pipe at once; an interactive pty.data echo emitted mid-response then sits + * behind all of it and typing feels seconds-slow. + * + * These tests model the SSH channel as a congestible in-memory pipe and assert + * deterministic byte bounds instead of wall-clock latency: + * - WITHOUT the fix (client does not opt in), the whole response queues ahead + * of a pty echo; + * - WITH the fix (client opts in), the response streams on the bulk lane and + * at most ~1 chunk frame sits ahead of the echo; + * - both paths reassemble the identical git result. + */ +import { describe, expect, it, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import { execFileSync } from 'node:child_process' +import * as path from 'node:path' +import { tmpdir } from 'node:os' + +import { + SshChannelMultiplexer, + type MultiplexerTransport +} from '../main/ssh/ssh-channel-multiplexer' +import { requestGitStreamable } from '../main/ssh/ssh-git-response-stream-reader' + +import { RelayDispatcher } from './dispatcher' +import { RelayContext } from './context' +import { GitHandler } from './git-handler' +import { GIT_RESPONSE_CHUNK_SIZE } from './protocol' + +// One framed git.responseChunk: base64 (4/3) + JSON envelope + header slack. +const FRAMED_CHUNK_BYTES = Math.ceil((GIT_RESPONSE_CHUNK_SIZE * 4) / 3) + 512 +const SINK_HIGH_WATER_MARK = 64 * 1024 + +async function waitUntil( + predicate: () => boolean, + what: string, + timeoutMs = 10_000 +): Promise { + const deadline = Date.now() + timeoutMs + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error(`waitUntil timed out: ${what}`) + } + await new Promise((r) => setImmediate(r)) + } +} + +async function waitUntilSettled(read: () => number, stableTurns = 25): Promise { + let last = read() + let stable = 0 + while (stable < stableTurns) { + await new Promise((r) => setImmediate(r)) + const current = read() + if (current === last) { + stable += 1 + } else { + stable = 0 + last = current + } + } +} + +type Harness = { + mux: SshChannelMultiplexer + dispatcher: RelayDispatcher + gitHandler: GitHandler + queuedBytes: () => number + deliverAll: () => void + startAutoDeliver: () => void + dispose: () => void +} + +function createHarness(opts: { congested: boolean }): Harness { + let relayFeed: ((data: Buffer) => void) | null = null + const clientDataCallbacks: ((data: Buffer) => void)[] = [] + + const clientTransport: MultiplexerTransport = { + write: (data: Buffer) => { + // Client → relay (keystrokes, acks) is the opposite duplex direction and + // is not blocked by relay→client congestion. + setImmediate(() => relayFeed?.(data)) + }, + onData: (cb) => { + clientDataCallbacks.push(cb) + }, + onClose: () => {} + } + + const outQueue: Buffer[] = [] + let queuedBytes = 0 + const drainWaiters = new Set<() => void>() + const fireDrainIfIdle = (): void => { + if (queuedBytes > 0) { + return + } + for (const cb of Array.from(drainWaiters)) { + drainWaiters.delete(cb) + cb() + } + } + + const dispatcher = new RelayDispatcher( + (data: Buffer) => { + outQueue.push(data) + queuedBytes += data.length + if (!opts.congested) { + return true + } + return queuedBytes < SINK_HIGH_WATER_MARK + }, + { + waitWriteDrain: (cb: () => void) => { + drainWaiters.add(cb) + fireDrainIfIdle() + } + } + ) + relayFeed = (data: Buffer) => dispatcher.feed(data) + + const deliverAll = (): void => { + while (outQueue.length > 0) { + const buf = outQueue.shift()! + queuedBytes -= buf.length + for (const cb of clientDataCallbacks) { + cb(buf) + } + } + fireDrainIfIdle() + } + + let autoDeliverTimer: ReturnType | null = null + const startAutoDeliver = (): void => { + if (autoDeliverTimer) { + return + } + autoDeliverTimer = setInterval(deliverAll, 1) + } + + const context = new RelayContext() + const gitHandler = new GitHandler(dispatcher, context) + const mux = new SshChannelMultiplexer(clientTransport) + + return { + mux, + dispatcher, + gitHandler, + queuedBytes: () => queuedBytes, + deliverAll, + startAutoDeliver, + dispose: () => { + if (autoDeliverTimer) { + clearInterval(autoDeliverTimer) + } + mux.dispose() + dispatcher.dispose() + gitHandler.dispose() + } + } +} + +// Build a repo whose staged diff for `big.txt` is several MB so git.diff +// returns a payload well over the stream threshold. +function makeRepoWithLargeStagedDiff(dir: string): void { + const env = { ...process.env } + const run = (args: string[]): void => { + execFileSync('git', args, { cwd: dir, stdio: 'pipe', env }) + } + run(['init']) + run(['config', 'user.email', 'test@test.com']) + run(['config', 'user.name', 'Test']) + // Distinct non-repeating lines keep the diff from compressing to nothing. + // Stay under the render limits (120k lines / 6M chars) so the diff result + // carries the full ~4MB content and exceeds the stream threshold. + const lines: string[] = [] + for (let i = 0; i < 12_000; i += 1) { + lines.push(`line ${i} ${'x'.repeat(100)}`) + } + writeFileSync(path.join(dir, 'big.txt'), lines.join('\n')) + run(['add', 'big.txt']) +} + +describe('large git response vs pty.data echo head-of-line blocking', () => { + let tmpDir: string + let repoDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-hol-')) + repoDir = path.join(tmpDir, 'repo') + mkdirSync(repoDir, { recursive: true }) + makeRepoWithLargeStagedDiff(repoDir) + }) + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('WITHOUT opt-in: the whole diff queues ahead of a pty echo (single-frame HOL)', async () => { + const harness = createHarness({ congested: true }) + try { + let queuedBytesAheadOfEcho = -1 + harness.dispatcher.onNotification('pty.data', (params) => { + queuedBytesAheadOfEcho = harness.queuedBytes() + harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) + }) + + // Plain request WITHOUT __streamResponse: single-frame response, as today. + const diffPromise = harness.mux.request('git.diff', { + worktreePath: repoDir, + filePath: 'big.txt', + staged: true + }) + // Let the whole single JSON-RPC frame land in the congested pipe. + await waitUntil(() => harness.queuedBytes() > SINK_HIGH_WATER_MARK, 'diff frame queued') + await waitUntilSettled(() => harness.queuedBytes()) + + harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') + + // The echo sits behind the entire (multi-hundred-KB) diff frame — far + // more than a single bulk chunk would be. + expect(queuedBytesAheadOfEcho).toBeGreaterThan(512 * 1024) + + harness.startAutoDeliver() + const result = (await diffPromise) as { diff?: string } + expect(typeof result).toBe('object') + } finally { + harness.dispose() + } + }, 30_000) + + it('WITH opt-in: the diff streams on the bulk lane and the echo stays bounded', async () => { + const harness = createHarness({ congested: true }) + try { + let queuedBytesAheadOfEcho = -1 + harness.dispatcher.onNotification('pty.data', (params) => { + queuedBytesAheadOfEcho = harness.queuedBytes() + harness.dispatcher.notify('pty.data', { id: params.id, data: params.data }) + }) + + const diffPromise = requestGitStreamable(harness.mux, 'git.diff', { + worktreePath: repoDir, + filePath: 'big.txt', + staged: true + }) + // Deliver the sentinel response, then let the pump run into congestion. + await waitUntil(() => harness.queuedBytes() > 0, 'sentinel queued') + harness.deliverAll() + await waitUntil(() => harness.queuedBytes() > 0, 'first chunk queued') + await waitUntilSettled(() => harness.queuedBytes()) + + harness.mux.notify('pty.data', { id: 'pty-1', data: 'x' }) + await waitUntil(() => queuedBytesAheadOfEcho >= 0, 'echo emitted by relay') + + // At most one in-flight bulk frame (the write that saturated the sink) + // plus slack sits ahead of the echo. + expect(queuedBytesAheadOfEcho).toBeLessThan(2 * FRAMED_CHUNK_BYTES) + + harness.startAutoDeliver() + const streamed = (await diffPromise) as Record + expect(typeof streamed).toBe('object') + expect(streamed).not.toHaveProperty('__orcaGitResponseStream') + } finally { + harness.dispose() + } + }, 30_000) + + it('streamed result equals the single-frame result', async () => { + const harness = createHarness({ congested: false }) + try { + harness.startAutoDeliver() + const params = { worktreePath: repoDir, filePath: 'big.txt', staged: true } + // Why: request both concurrently so the shared 1ms delivery pump drives + // the plain frame and the streamed chunk/ack round-trips without either + // starving the other on a slow CI event loop. + const [plain, streamed] = await Promise.all([ + harness.mux.request('git.diff', params), + requestGitStreamable(harness.mux, 'git.diff', params) + ]) + expect(streamed).toEqual(plain) + } finally { + harness.dispose() + } + }, 30_000) + + it('rejects with an inactivity timeout when the stream stalls after the sentinel', async () => { + const harness = createHarness({ congested: false }) + try { + // Deliver the sentinel so reassembly begins, then stop delivering chunks + // to model a relay pump that wedged while the channel stayed up. Without + // the client-side inactivity deadline this promise would hang forever. + const streamedPromise = requestGitStreamable( + harness.mux, + 'git.diff', + { worktreePath: repoDir, filePath: 'big.txt', staged: true }, + { inactivityTimeoutMs: 250 } + ) + await waitUntil(() => harness.queuedBytes() > 0, 'sentinel queued') + harness.deliverAll() // sentinel only; chunks stay undelivered + await expect(streamedPromise).rejects.toThrow(/stalled/) + } finally { + harness.dispose() + } + }, 30_000) +}) diff --git a/src/relay/git-response-stream-ownership.test.ts b/src/relay/git-response-stream-ownership.test.ts new file mode 100644 index 00000000000..c6340265238 --- /dev/null +++ b/src/relay/git-response-stream-ownership.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { GitResponseStreamRegistry } from './git-response-stream' +import { GIT_RESPONSE_CHUNK_SIZE, STREAM_ACK_WINDOW_CHUNKS } from './protocol' + +async function flushPump(): Promise { + await new Promise((resolve) => setImmediate(resolve)) +} + +describe('GitResponseStreamRegistry client ownership', () => { + const registries: GitResponseStreamRegistry[] = [] + + afterEach(() => { + for (const registry of registries) { + registry.disposeAll() + } + registries.length = 0 + }) + + it('ignores acknowledgements and cancellation from a different relay client', async () => { + const ownerClientId = 7 + const notifyBulk = vi.fn().mockResolvedValue(undefined) + const dispatcher = { + notifyBulk, + notify: vi.fn() + } as unknown as RelayDispatcher + const context: RequestContext = { + clientId: ownerClientId, + isStale: () => false + } + const registry = new GitResponseStreamRegistry() + registries.push(registry) + const payload = Buffer.alloc(GIT_RESPONSE_CHUNK_SIZE * (STREAM_ACK_WINDOW_CHUNKS * 3)) + const marker = registry.startStream(payload, dispatcher, context) + const streamId = marker.__orcaGitResponseStream.streamId + + await flushPump() + expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + + registry.recordAck(streamId, 10_000, ownerClientId + 1) + await flushPump() + expect(notifyBulk).toHaveBeenCalledTimes(STREAM_ACK_WINDOW_CHUNKS) + + registry.abort(streamId, ownerClientId + 1) + registry.recordAck(streamId, STREAM_ACK_WINDOW_CHUNKS - 1, ownerClientId) + await flushPump() + expect(notifyBulk.mock.calls.length).toBeGreaterThan(STREAM_ACK_WINDOW_CHUNKS) + }) + + it('contains a secondary failure while reporting a stream error', async () => { + const notifyBulk = vi.fn().mockRejectedValue(new Error('socket closed')) + const dispatcher = { notifyBulk, notify: vi.fn() } as unknown as RelayDispatcher + const registry = new GitResponseStreamRegistry() + registries.push(registry) + + registry.startStream(Buffer.from('payload'), dispatcher, { + clientId: 7, + isStale: () => false + }) + + await flushPump() + await flushPump() + + expect(notifyBulk).toHaveBeenCalledTimes(2) + expect(notifyBulk.mock.calls[0]?.[0]).toBe('git.responseChunk') + expect(notifyBulk.mock.calls[1]?.[0]).toBe('git.responseError') + }) +}) diff --git a/src/relay/git-response-stream.ts b/src/relay/git-response-stream.ts new file mode 100644 index 00000000000..bced3b47e46 --- /dev/null +++ b/src/relay/git-response-stream.ts @@ -0,0 +1,211 @@ +// Streams large git RPC responses (diff family + exec) onto the bulk lane in +// chunks instead of one JSON-RPC frame, so a big diff cannot head-of-line-block +// interactive pty.data echo on the shared SSH channel. Mirrors the fs +// read-stream credit-window pattern (see fs-handler-file-read.ts) but the +// payload is an in-memory serialized string rather than a file handle. +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { + GIT_RESPONSE_CHUNK_SIZE, + STREAM_ACK_WINDOW_CHUNKS, + STREAM_ACK_STALL_RECHECK_MS, + type GitResponseStreamMarker +} from './protocol' + +type GitResponseStreamEntry = { + ownerClientId: number + aborted: boolean + /** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */ + ackedThroughSeq: number + ackWaiters: Set<() => void> +} + +/** Serialized git responses are chunked as base64 so multi-byte UTF-8 + * sequences never split across a chunk boundary (the client concatenates the + * decoded bytes and parses once). */ +function encodeChunks(payload: Buffer): string[] { + const chunks: string[] = [] + for (let offset = 0; offset < payload.length; offset += GIT_RESPONSE_CHUNK_SIZE) { + chunks.push(payload.subarray(offset, offset + GIT_RESPONSE_CHUNK_SIZE).toString('base64')) + } + return chunks +} + +export class GitResponseStreamRegistry { + private streams = new Map() + private nextId = 1 + + private register(ownerClientId: number): number { + const streamId = this.nextId++ + this.streams.set(streamId, { + ownerClientId, + aborted: false, + ackedThroughSeq: -1, + ackWaiters: new Set() + }) + return streamId + } + + recordAck(streamId: number, seq: number, clientId: number): void { + const entry = this.streams.get(streamId) + if ( + !entry || + entry.ownerClientId !== clientId || + typeof seq !== 'number' || + !Number.isFinite(seq) + ) { + return + } + if (seq > entry.ackedThroughSeq) { + entry.ackedThroughSeq = seq + } + this.wake(entry) + } + + abort(streamId: number, clientId: number): void { + const entry = this.streams.get(streamId) + if (entry?.ownerClientId === clientId) { + entry.aborted = true + this.wake(entry) + } + } + + /** Wake every parked pump so it re-checks staleness — used when a client + * detaches and its acks will never arrive. */ + wakeAll(): void { + for (const entry of this.streams.values()) { + this.wake(entry) + } + } + + private wake(entry: GitResponseStreamEntry): void { + for (const waiter of Array.from(entry.ackWaiters)) { + waiter() + } + } + + private waitForAck(streamId: number): Promise { + const entry = this.streams.get(streamId) + if (!entry || entry.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + entry.ackWaiters.delete(finish) + resolve() + } + const timer = setTimeout(finish, STREAM_ACK_STALL_RECHECK_MS) + timer.unref?.() + entry.ackWaiters.add(finish) + }) + } + + /** + * Register a stream for `payload`, kick off the bulk-lane pump on a later + * task (so the sentinel response reaches the client first), and return the + * sentinel marker to send as the RPC result. + */ + startStream( + payload: Buffer, + dispatcher: RelayDispatcher, + context: RequestContext + ): GitResponseStreamMarker { + const streamId = this.register(context.clientId) + const chunks = encodeChunks(payload) + // Why: kick the pump off the response task so the client sees the sentinel + // (and can subscribe/reassemble) before the first chunk frame arrives. + setImmediate(() => { + void this.pump(streamId, chunks, dispatcher, context) + }) + return { + __orcaGitResponseStream: { streamId, totalBytes: payload.length, chunkCount: chunks.length } + } + } + + private async pump( + streamId: number, + chunks: string[], + dispatcher: RelayDispatcher, + context: RequestContext + ): Promise { + const entry = this.streams.get(streamId) + if (!entry) { + return + } + const clientId = context.clientId + let seq = 0 + let endReason: 'end' | 'aborted' | 'stale' = 'end' + try { + for (seq = 0; seq < chunks.length; seq += 1) { + if (context.isStale()) { + endReason = 'stale' + break + } + if (entry.aborted) { + endReason = 'aborted' + break + } + // Why: credit window — the client acks each chunk, bounding how many + // bulk bytes a keystroke echo can queue behind on the shared channel. + while ( + seq - entry.ackedThroughSeq > STREAM_ACK_WINDOW_CHUNKS && + !context.isStale() && + !entry.aborted + ) { + await this.waitForAck(streamId) + } + if (context.isStale()) { + endReason = 'stale' + break + } + if (entry.aborted) { + endReason = 'aborted' + break + } + // Why: notifyBulk waits out sink saturation so chunk frames never pile + // up in the outbound pipe ahead of interactive pty.data frames. + await dispatcher.notifyBulk( + 'git.responseChunk', + { streamId, seq, data: chunks[seq] }, + { + clientId + } + ) + } + if (endReason === 'end') { + await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId }) + } + } catch (err) { + if (!context.isStale() && !entry.aborted) { + try { + await dispatcher.notifyBulk( + 'git.responseError', + { + streamId, + message: err instanceof Error ? err.message : String(err) + }, + { clientId } + ) + } catch { + // Why: the original failure may be the owning channel closing; a + // second send failure must not escape this detached pump. + } + } + } finally { + this.streams.delete(streamId) + } + } + + disposeAll(): void { + for (const entry of this.streams.values()) { + entry.aborted = true + this.wake(entry) + } + this.streams.clear() + } +} diff --git a/src/relay/protocol.ts b/src/relay/protocol.ts index 267f6ef480f..4df0f7bd9b5 100644 --- a/src/relay/protocol.ts +++ b/src/relay/protocol.ts @@ -59,6 +59,27 @@ export const STREAM_ACK_WINDOW_CHUNKS = 4 * (and its open file handle) forever. */ export const STREAM_ACK_STALL_RECHECK_MS = 1_000 +// ── Git response streaming (see docs/relay-git-response-stream-design.md) ── + +/** Serialized-JSON size above which a streamable git response (diff family + + * exec) is chunked onto the bulk lane instead of one JSON-RPC frame, so a large + * diff cannot head-of-line-block interactive pty.data echo on the shared SSH + * channel. Below this, single-frame is cheaper and avoids stream overhead. */ +export const GIT_RESPONSE_STREAM_THRESHOLD = 256 * 1024 + +/** Per-chunk size (UTF-8 bytes of the serialized result) for git response + * streaming. Independent from STREAM_CHUNK_SIZE — this offset math is not + * shared with fs streams, so tuning it here is cross-version safe as long as + * the client reassembles by concatenation (it does not depend on chunk size). */ +export const GIT_RESPONSE_CHUNK_SIZE = 128 * 1024 + +/** Sentinel result returned in place of a large git response: the real payload + * follows as git.responseChunk frames on the bulk lane. Old relays never emit + * this, so a new client falls back to the plain result they return. */ +export type GitResponseStreamMarker = { + __orcaGitResponseStream: { streamId: number; totalBytes: number; chunkCount: number } +} + export const RelayErrorCode = { TooManyStreams: -33006, StreamProtocolError: -33007 diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 7d5198e87a9..332828408bd 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -1290,11 +1290,78 @@ describe('PtyHandler', () => { } } - const spawnEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('xterm-256color') expect(spawnEnv.env.SEEN_OPENCODE_CONFIG_DIR).toBe('/remote/renderer-opencode') expect(spawnEnv.env.SEEN_PI_CODING_AGENT_DIR).toBe('/remote/pi') }) + it('applies identity defaults, then deletions, while preserving explicit TERM', async () => { + handler.addEnvAugmenter(() => ({ + TERM: 'augmenter-term', + TERM_PROGRAM: 'augmenter-terminal', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/augmenter-attribution' + })) + + await dispatcher.callRequest('pty.spawn', { + env: { + TERM: 'screen-256color', + TERM_PROGRAM: 'renderer-terminal', + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/renderer-attribution' + }, + envToDelete: ['TERM_PROGRAM', 'ORCA_ATTRIBUTION_SHIM_DIR'] + }) + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('screen-256color') + expect(spawnEnv.env.TERM).toBe('screen-256color') + expect(spawnEnv.env.COLORTERM).toBe('truecolor') + expect(spawnEnv.env.FORCE_HYPERLINK).toBe('1') + expect(spawnEnv.env.TERM_PROGRAM).toBeUndefined() + expect(spawnEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + + it('replaces an ambient TERM=dumb when no explicit TERM is supplied', async () => { + const previousTerm = process.env.TERM + process.env.TERM = 'dumb' + try { + await dispatcher.callRequest('pty.spawn', {}) + } finally { + if (previousTerm === undefined) { + delete process.env.TERM + } else { + process.env.TERM = previousTerm + } + } + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('xterm-256color') + expect(spawnEnv.env.TERM).toBe('xterm-256color') + expect(spawnEnv.env.TERM_PROGRAM).toBe('Orca') + }) + + it('uses the safe terminal default when TERM is deleted without a custom value', async () => { + await dispatcher.callRequest('pty.spawn', { + envToDelete: ['TERM'] + }) + + const spawnEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(spawnEnv.name).toBe('xterm-256color') + expect(spawnEnv.env.TERM).toBe('xterm-256color') + }) + it('lets relay env augmenters resolve the original sequenced startup command hint', async () => { handler.addEnvAugmenter((ctx) => ({ SEEN_LAUNCH_COMMAND_HINT: resolveSetupAgentSequenceLaunchCommand(ctx.env, ctx.command) ?? '' @@ -1402,6 +1469,172 @@ describe('PtyHandler', () => { expect(callArgs.env.ORCA_WORKTREE_ID).toBe('wt-5') expect(callArgs.env.ORCA_AGENT_HOOK_PORT).toBe('12345') expect(callArgs.env.ORCA_AGENT_HOOK_TOKEN).toBe('abc-uuid') + expect(callArgs.env.TERM).toBe('xterm-256color') + expect(callArgs.env.TERM_PROGRAM).toBe('Orca') + }) + + it('normalizes an explicit empty TERM and preserves sanitized env deletions on revive', async () => { + await dispatcher.callRequest('pty.spawn', { + env: { TERM: '' }, + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR', '', 42] + }) + + const initialEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(initialEnv.name).toBe('xterm-256color') + expect(initialEnv.env.TERM).toBe('xterm-256color') + + const state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + const [serialized] = JSON.parse(state) as { + explicitTerm?: string + envToDelete?: string[] + }[] + expect(serialized.explicitTerm).toBeUndefined() + expect(serialized.envToDelete).toEqual(['ORCA_ATTRIBUTION_SHIM_DIR']) + + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/revived-attribution' + })) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const revivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(revivedEnv.name).toBe('xterm-256color') + expect(revivedEnv.env.TERM).toBe('xterm-256color') + expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + + it('drops legacy empty explicit TERM metadata after revive', async () => { + const state = JSON.stringify([ + { + id: 'pty-8', + pid: process.pid, + cols: 80, + rows: 24, + cwd: process.cwd(), + explicitTerm: '', + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'] + } + ]) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/legacy-empty-attribution' + })) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const revivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(revivedEnv.name).toBe('xterm-256color') + expect(revivedEnv.env.TERM).toBe('xterm-256color') + expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + + const serializedState = (await dispatcher.callRequest('pty.serialize', { + ids: ['pty-8'] + })) as string + const [serialized] = JSON.parse(serializedState) as { + explicitTerm?: string + envToDelete?: string[] + }[] + expect(serialized.explicitTerm).toBeUndefined() + expect(serialized.envToDelete).toEqual(['ORCA_ATTRIBUTION_SHIM_DIR']) + }) + + it('preserves explicit TERM and env deletions through repeated revive cycles', async () => { + await dispatcher.callRequest('pty.spawn', { + env: { TERM: 'screen-256color' }, + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'] + }) + let state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/first-revive' + })) + await dispatcher.callRequest('pty.revive', { state }) + + const firstRevivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(firstRevivedEnv.name).toBe('screen-256color') + expect(firstRevivedEnv.env.TERM).toBe('screen-256color') + expect(firstRevivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + state = (await dispatcher.callRequest('pty.serialize', { ids: ['pty-1'] })) as string + expect(JSON.parse(state)).toMatchObject([ + { + explicitTerm: 'screen-256color', + envToDelete: ['ORCA_ATTRIBUTION_SHIM_DIR'] + } + ]) + + handler.dispose() + mockPtySpawn.mockClear() + dispatcher = createMockDispatcher() + handler = new PtyHandler(dispatcher as unknown as RelayDispatcher) + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/second-revive' + })) + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const secondRevivedEnv = mockPtySpawn.mock.calls[0][2] as { + name: string + env: Record + } + expect(secondRevivedEnv.name).toBe('screen-256color') + expect(secondRevivedEnv.env.TERM).toBe('screen-256color') + expect(secondRevivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBeUndefined() + }) + + it('revives legacy serialized entries with default TERM and no env deletions', async () => { + handler.addEnvAugmenter(() => ({ + ORCA_ATTRIBUTION_SHIM_DIR: '/tmp/legacy-attribution' + })) + const state = JSON.stringify([ + { + id: 'pty-7', + pid: process.pid, + cols: 80, + rows: 24, + cwd: process.cwd() + } + ]) + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + try { + await dispatcher.callRequest('pty.revive', { state }) + } finally { + killSpy.mockRestore() + } + + const revivedEnv = mockPtySpawn.mock.calls[0][2] as { env: Record } + expect(revivedEnv.env.TERM).toBe('xterm-256color') + expect(revivedEnv.env.ORCA_ATTRIBUTION_SHIM_DIR).toBe('/tmp/legacy-attribution') }) it('revive preserves attach identity metadata without exporting hook identity env', async () => { diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 3a812415909..965d8cbdc30 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -68,6 +68,8 @@ type ManagedPty = { attachIdentity?: PtyIdentity worktreeId?: string terminalHandle?: string + explicitTerm?: string + envToDelete: string[] startupCommand?: ManagedStartupCommand } @@ -180,6 +182,16 @@ type SerializedPtyEntry = { attachIdentity?: PtyIdentity worktreeId?: string terminalHandle?: string + explicitTerm?: string + envToDelete?: string[] +} + +function sanitizeEnvToDelete(value: unknown): string[] { + return Array.isArray(value) + ? value + .filter((key): key is string => typeof key === 'string' && key.length > 0) + .slice(0, 1_024) + : [] } export type PtyExitListener = (event: { id: string; paneKey?: string }) => void @@ -283,9 +295,19 @@ export class PtyHandler { * otherwise agent-status over SSH silently breaks on every revive. */ private buildSpawnEnv( rendererEnv: Record | undefined, - ctx: { id: string; paneKey?: string; shell: string; command?: string } + ctx: { id: string; paneKey?: string; shell: string; command?: string }, + envToDelete: readonly string[] = [] ): Record { - const baseEnv = { ...process.env, ...rendererEnv } as Record + const baseEnv = { + ...process.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + TERM_PROGRAM_VERSION: + rendererEnv?.ORCA_APP_VERSION || process.env.ORCA_APP_VERSION || '0.0.0-dev', + FORCE_HYPERLINK: '1', + ...rendererEnv + } as Record const augmented: Record = {} for (const augmenter of this.envAugmenters) { try { @@ -296,7 +318,25 @@ export class PtyHandler { ) } } - return { ...baseEnv, ...augmented } + const result = { ...baseEnv, ...augmented } + // Why: match local/daemon precedence so relay defaults and augmenters + // cannot resurrect attribution or identity values explicitly removed. + for (const key of envToDelete) { + delete result[key] + } + if ( + !envToDelete.includes('TERM') && + rendererEnv && + Object.prototype.hasOwnProperty.call(rendererEnv, 'TERM') + ) { + result.TERM = rendererEnv.TERM + } + // Why: node-pty treats missing/empty TERM as its own platform-specific + // default. Normalize here so POSIX and Windows relay children agree. + if (!result.TERM) { + result.TERM = 'xterm-256color' + } + return result } private clearStartupCommandTimer(managed: ManagedPty): void { @@ -562,6 +602,15 @@ export class PtyHandler { const rows = (params.rows as number) || 24 const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined + const envToDelete = sanitizeEnvToDelete(params.envToDelete) + const explicitTerm = + !envToDelete.includes('TERM') && + env && + Object.prototype.hasOwnProperty.call(env, 'TERM') && + typeof env.TERM === 'string' && + env.TERM.length > 0 + ? env.TERM + : undefined const shellOverride = typeof params.shellOverride === 'string' ? params.shellOverride.trim() : '' const resolvedShellOverride = resolvePtyShellOverride(shellOverride) @@ -585,7 +634,7 @@ export class PtyHandler { typeof params.terminalWindowsWslDistro === 'string' ? params.terminalWindowsWslDistro : null const commandDelivery = params.commandDelivery === 'provider' ? 'provider' : 'renderer' const shouldProviderDeliverCommand = commandDelivery === 'provider' && command !== undefined - const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }) + const spawnEnv = this.buildSpawnEnv(env, { id, paneKey, shell, command }, envToDelete) const launchCommandHint = resolveSetupAgentSequenceLaunchCommand(spawnEnv, command) const shouldEmitShellReadyMarker = launchCommandHint !== undefined && @@ -607,7 +656,9 @@ export class PtyHandler { // When overlays are injected, the launch wrapper keeps those paths after // user startup files re-export their defaults. const term = pty.spawn(shell, shellLaunch.args, { - name: 'xterm-256color', + // Why: node-pty overwrites env.TERM with `name`; keep caller-selected + // terminal identities instead of losing them at the final spawn boundary. + name: spawnEnv.TERM ?? 'xterm-256color', cols, rows, cwd, @@ -635,6 +686,8 @@ export class PtyHandler { tabId, ...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}), worktreeId, + ...(explicitTerm !== undefined ? { explicitTerm } : {}), + envToDelete, ...(terminalHandle ? { terminalHandle } : {}), ...(shouldProviderDeliverCommand ? { @@ -935,6 +988,8 @@ export class PtyHandler { tabId: managed.tabId, attachIdentity: managed.attachIdentity, worktreeId: managed.worktreeId, + ...(managed.explicitTerm !== undefined ? { explicitTerm: managed.explicitTerm } : {}), + envToDelete: managed.envToDelete, ...(managed.terminalHandle ? { terminalHandle: managed.terminalHandle } : {}) }) } @@ -976,6 +1031,16 @@ export class PtyHandler { if (entry.terminalHandle) { revivedEnv.ORCA_TERMINAL_HANDLE = entry.terminalHandle } + const explicitTerm = + typeof entry.explicitTerm === 'string' && entry.explicitTerm.length > 0 + ? entry.explicitTerm + : undefined + if (explicitTerm !== undefined) { + revivedEnv.TERM = explicitTerm + } + // Why: serialized state can come from an older or untrusted client, so + // revive reapplies the same bounds as a fresh spawn before retaining it. + const envToDelete = sanitizeEnvToDelete(entry.envToDelete) const shell = resolveDefaultShell() // Why: `command` is intentionally absent from this revive path because // SerializedPtyEntry (see line 99) does not persist it — ManagedPty @@ -984,14 +1049,19 @@ export class PtyHandler { // undefined` for revived PTYs and prepares the Pi default plus OMP's // typed-command wrapper. Plumbing `command` through serialization is a // separate, larger change. - const spawnEnv = this.buildSpawnEnv(revivedEnv, { - id: entry.id, - paneKey: entry.paneKey, - shell - }) + const spawnEnv = this.buildSpawnEnv( + revivedEnv, + { + id: entry.id, + paneKey: entry.paneKey, + shell + }, + envToDelete + ) const shellLaunch = getRelayShellLaunchConfig(shell, spawnEnv) const term = ptyMod.spawn(shell, shellLaunch.args, { - name: 'xterm-256color', + // Why: revive must preserve the same terminal identity as fresh spawn. + name: spawnEnv.TERM ?? 'xterm-256color', cols: entry.cols, rows: entry.rows, cwd: entry.cwd, @@ -1008,6 +1078,8 @@ export class PtyHandler { tabId: entry.tabId, attachIdentity: entry.attachIdentity, worktreeId: entry.worktreeId, + ...(explicitTerm !== undefined ? { explicitTerm } : {}), + envToDelete, ...(entry.terminalHandle ? { terminalHandle: entry.terminalHandle } : {}) }) diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 65f90e88628..d47c2019582 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -37,6 +37,7 @@ import { RelayDispatcher } from './dispatcher' import { RelayContext } from './context' import { PtyHandler } from './pty-handler' import { FsHandler } from './fs-handler' +import { installRelayLogRotation } from './rotating-log-writer' import { GitHandler } from './git-handler' import { PreflightHandler } from './preflight-handler' import { ExternalAutomationsHandler } from './external-automations-handler' @@ -114,6 +115,7 @@ function parseArgs(argv: string[]): { cliMode: boolean sockPath: string endpointDir?: string + logFile?: string } { let graceTimeMs = DEFAULT_GRACE_MS let connectMode = false @@ -121,6 +123,7 @@ function parseArgs(argv: string[]): { let cliMode = false let sockPath = '' let endpointDir: string | undefined + let logFile: string | undefined for (let i = 2; i < argv.length; i++) { if (argv[i] === '--grace-time' && argv[i + 1]) { const parsed = Number.parseInt(argv[i + 1], 10) @@ -143,12 +146,15 @@ function parseArgs(argv: string[]): { } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { endpointDir = argv[i + 1] i++ + } else if (argv[i] === '--log-file' && argv[i + 1]) { + logFile = argv[i + 1] + i++ } } if (!sockPath) { sockPath = join(process.cwd(), SOCK_NAME) } - return { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir } + return { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir, logFile } } // ── Connect mode ───────────────────────────────────────────────────── @@ -318,7 +324,7 @@ async function readOrcaCliStdin(): Promise { // ── Normal mode ────────────────────────────────────────────────────── async function main(): Promise { - const { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir } = parseArgs( + const { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir, logFile } = parseArgs( process.argv ) @@ -332,6 +338,13 @@ async function main(): Promise { return } + // Why: only the long-lived detached daemon accumulates relay.log; --connect + // bridges and --orca-cli are short-lived and already returned above. Route all + // relay logging through a size-capped rotator so relay.log can't grow forever. + if (detached && logFile) { + installRelayLogRotation(logFile) + } + let ownsSocketPath = false let ownedSocketIdentity: SocketIdentity | null = null const ownsCurrentSocketPath = (): boolean => { @@ -454,10 +467,7 @@ async function main(): Promise { const ptyHandler = new PtyHandler(dispatcher, graceTimeMs) const fsHandler = new FsHandler(dispatcher, context) - // Why: GitHandler registers its own request handlers on construction, - // so we hold the reference only for potential future disposal. - const _gitHandler = new GitHandler(dispatcher, context) - void _gitHandler + const gitHandler = new GitHandler(dispatcher, context) const _preflightHandler = new PreflightHandler(dispatcher) const _externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher) @@ -1013,6 +1023,7 @@ async function main(): Promise { dispatcher.dispose() ptyHandler.dispose() fsHandler.dispose() + gitHandler.dispose() hookServer.stop() // Why: Node's Unix server.close() can unlink the listen path. If the path // was externally removed and rebound by a newer relay, closing this older diff --git a/src/relay/rotating-log-writer.test.ts b/src/relay/rotating-log-writer.test.ts new file mode 100644 index 00000000000..61c988766bb --- /dev/null +++ b/src/relay/rotating-log-writer.test.ts @@ -0,0 +1,161 @@ +/** + * Regression: relay.log grows unbounded on long-lived relays. + * + * The relay is launched detached with `> relay.log 2>&1`, which truncates only + * at relaunch; a relay that stays up for days accumulates per-stream stderr + * lines forever. These tests assert the in-process rotator caps size, keeps one + * archived generation, and always leaves the CURRENT log at relay.log so the + * `tail -100 relay.log` diagnostics workflow keeps working. + */ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { mkdtempSync, mkdirSync, readFileSync, existsSync, statSync, writeFileSync } from 'node:fs' +import { rm } from 'node:fs/promises' +import * as path from 'node:path' +import { tmpdir } from 'node:os' + +import { RotatingLogWriter, installRelayLogRotation } from './rotating-log-writer' + +describe('RotatingLogWriter', () => { + let dir: string + let logPath: string + + beforeEach(() => { + dir = mkdtempSync(path.join(tmpdir(), 'relay-log-rot-')) + logPath = path.join(dir, 'relay.log') + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('rotates relay.log -> relay.log.1 at the cap and keeps the current log tail-able', () => { + const cap = 4 * 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + const line = `${'a'.repeat(200)}\n` + // Write well past the cap so at least one rotation happens. + for (let i = 0; i < 60; i += 1) { + writer.write(line) + } + + // Current log exists at relay.log (tail target) and is under the cap. + expect(existsSync(logPath)).toBe(true) + expect(statSync(logPath).size).toBeLessThanOrEqual(cap) + // Exactly one archived generation. + expect(existsSync(`${logPath}.1`)).toBe(true) + expect(existsSync(`${logPath}.2`)).toBe(false) + + // The most recent lines are in the current log (tail-ability). + writer.write('MARKER-LAST\n') + expect(readFileSync(logPath, 'utf-8')).toContain('MARKER-LAST') + } finally { + writer.dispose() + } + }) + + it('preserves pre-existing boot output already in relay.log (append, not truncate)', () => { + writeFileSync(logPath, 'BOOT-LINE-FROM-SHELL-REDIRECT\n') + const writer = new RotatingLogWriter(logPath, 1024 * 1024) + try { + writer.write('runtime line\n') + const contents = readFileSync(logPath, 'utf-8') + expect(contents).toContain('BOOT-LINE-FROM-SHELL-REDIRECT') + expect(contents).toContain('runtime line') + } finally { + writer.dispose() + } + }) + + it('bounds a single oversized log write and keeps its newest tail', () => { + const cap = 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + writer.write(`${'old'.repeat(1000)}LATEST-CONTEXT`) + expect(statSync(logPath).size).toBeLessThanOrEqual(cap) + expect(readFileSync(logPath, 'utf-8')).toContain('LATEST-CONTEXT') + } finally { + writer.dispose() + } + }) + + it('caps total footprint to ~2x maxBytes (current + one archive)', () => { + const cap = 8 * 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + const line = `${'z'.repeat(256)}\n` + for (let i = 0; i < 500; i += 1) { + writer.write(line) + } + const currentSize = statSync(logPath).size + const archiveSize = existsSync(`${logPath}.1`) ? statSync(`${logPath}.1`).size : 0 + // Never more than the current file + a single archived generation. + expect(currentSize).toBeLessThanOrEqual(cap) + expect(archiveSize).toBeLessThanOrEqual(cap) + expect(existsSync(`${logPath}.2`)).toBe(false) + } finally { + writer.dispose() + } + }) + + it('caps size via truncate-in-place when rename cannot succeed (Windows shell-handle case)', () => { + // Model the platform where renameSync fails (e.g. Windows, where the launch + // shell's own `1>relay.log` handle blocks renaming the live file): make the + // archive path a directory so renameSync(logPath, `${logPath}.1`) throws. + mkdirSync(`${logPath}.1`, { recursive: true }) + const cap = 4 * 1024 + const writer = new RotatingLogWriter(logPath, cap) + try { + const line = `${'q'.repeat(200)}\n` + for (let i = 0; i < 100; i += 1) { + writer.write(line) + } + // The cap still holds via truncate-in-place even though no archive was made. + expect(statSync(logPath).size).toBeLessThanOrEqual(cap) + writer.write('MARKER-LAST\n') + expect(readFileSync(logPath, 'utf-8')).toContain('MARKER-LAST') + } finally { + writer.dispose() + } + }) + + it('installRelayLogRotation routes stdout/stderr through the rotator and restores', () => { + const cap = 2 * 1024 + const { restore } = installRelayLogRotation(logPath, cap) + try { + process.stderr.write('via-stderr-line\n') + process.stdout.write('via-stdout-line\n') + expect(readFileSync(logPath, 'utf-8')).toContain('via-stderr-line') + expect(readFileSync(logPath, 'utf-8')).toContain('via-stdout-line') + } finally { + restore() + } + // After restore, process.stderr no longer targets the rotator file. Spy so + // the assertion write does not leak to the real test-runner stderr. + const sizeAfterRestore = statSync(logPath).size + const spy = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + process.stderr.write('should-not-be-in-relay-log\n') + } finally { + spy.mockRestore() + } + expect(statSync(logPath).size).toBe(sizeAfterRestore) + }) + + it('leaves the original streams active when the log cannot be opened', () => { + mkdirSync(logPath) + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const { writer, restore } = installRelayLogRotation(logPath) + expect(writer.active).toBe(false) + process.stdout.write('stdout fallback\n') + process.stderr.write('stderr fallback\n') + expect(stdout).toHaveBeenCalledWith('stdout fallback\n') + expect(stderr).toHaveBeenCalledWith('stderr fallback\n') + restore() + } finally { + stdout.mockRestore() + stderr.mockRestore() + } + }) +}) diff --git a/src/relay/rotating-log-writer.ts b/src/relay/rotating-log-writer.ts new file mode 100644 index 00000000000..e685e6fd520 --- /dev/null +++ b/src/relay/rotating-log-writer.ts @@ -0,0 +1,174 @@ +// Size-capped, crash-safe rotation for the relay's diagnostic log. +// +// Why: the relay is launched detached with `> relay.log 2>&1`, which only +// truncates at relaunch — a long-lived relay's relay.log grows unbounded +// (per-stream stderr lines, reconnect flaps, etc.). Rotation must live in the +// relay process because it owns its own stderr; the shell redirect cannot cap +// size. Constraints honored: +// - keeps working when the launch fd is a pipe/redirect on Linux/macOS/Windows +// (append-only, no fd tricks; a rotation failure falls back to the prior fd); +// - the diagnostics tail (`tail -100 ~/.orca-remote/relay-*/relay.log`) keeps +// working because the CURRENT log is always at relay.log; +// - crash-safe: writes and rotation are guarded so logging never throws, and +// rotation renames (never deletes the live file) so no window loses logging. +import { closeSync, openSync, renameSync, statSync, writeSync } from 'node:fs' + +/** Default size cap before rotating relay.log → relay.log.1. 10 MB balances + * keeping enough history for diagnosis against bounding disk use on small + * remote hosts (e.g. a Raspberry Pi); one archived generation is kept. */ +export const DEFAULT_RELAY_LOG_MAX_BYTES = 10 * 1024 * 1024 + +type StreamWrite = typeof process.stderr.write + +export class RotatingLogWriter { + private readonly logPath: string + private readonly rotatedPath: string + private readonly maxBytes: number + private fd: number | null = null + private currentBytes = 0 + private failed = false + + constructor(logPath: string, maxBytes: number = DEFAULT_RELAY_LOG_MAX_BYTES) { + this.logPath = logPath + this.rotatedPath = `${logPath}.1` + this.maxBytes = maxBytes + this.open() + } + + private open(mode: 'a' | 'w' = 'a'): void { + try { + // 'a' preserves pre-JS boot output already in relay.log and keeps + // concurrent appends atomic; 'w' truncates in place (used as the rotation + // fallback when a rename cannot succeed — e.g. Windows, where the launch + // shell's own redirect handle blocks renaming the live file). + this.fd = openSync(this.logPath, mode) + try { + this.currentBytes = mode === 'w' ? 0 : statSync(this.logPath).size + } catch { + this.currentBytes = 0 + } + } catch { + // Cannot open the log file (permission/full disk): disable rotation and + // let callers fall back to the original stream. + this.failed = true + this.fd = null + } + } + + /** True when the writer is usable; false means fall back to raw stderr. */ + get active(): boolean { + return !this.failed && this.fd !== null + } + + write(chunk: string | Uint8Array): void { + if (!this.active || this.fd === null) { + return + } + const encoded = typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : Buffer.from(chunk) + // Why: one pathological log write must not bypass the disk cap. Preserve + // the newest tail because it carries the most useful failure context. + const buf = + encoded.length > this.maxBytes ? encoded.subarray(encoded.length - this.maxBytes) : encoded + try { + // Rotate BEFORE writing when the incoming write would cross the cap, so a + // single large line still lands wholly in the fresh file. + if (this.currentBytes > 0 && this.currentBytes + buf.length > this.maxBytes) { + this.rotate() + } + writeSync(this.fd, buf) + this.currentBytes += buf.length + } catch { + // A write failure must never crash the relay; disable and fall back. + this.failed = true + this.closeQuietly() + } + } + + private rotate(): void { + if (this.fd !== null) { + closeSync(this.fd) + this.fd = null + } + let renamed = false + try { + // Preferred path: archive one generation. rename() replaces any existing + // relay.log.1 atomically on POSIX; the live file is renamed (never + // deleted) so no log window is lost, then we reopen a fresh relay.log. + renameSync(this.logPath, this.rotatedPath) + renamed = true + } catch { + // Why: on Windows the launch shell holds its own `1>relay.log` handle + // WITHOUT FILE_SHARE_DELETE, so renaming the live file fails (EPERM/EBUSY) + // — and a cross-device relay.log.1 would fail too. Fall back to truncating + // relay.log in place ('w') so the size cap STILL holds and we don't churn a + // failing rename on every subsequent line. The shell's fd keeps appending + // at its own offset, but the truncation bounds total growth. Trade-off: no + // archived generation on this platform/path. + } + // Successful rename → fresh append file (size 0). Failed rename → truncate. + this.open(renamed ? 'a' : 'w') + } + + private closeQuietly(): void { + if (this.fd !== null) { + try { + closeSync(this.fd) + } catch { + // best-effort + } + this.fd = null + } + } + + dispose(): void { + this.closeQuietly() + } +} + +/** + * Route process.stdout/stderr writes through a RotatingLogWriter that owns + * `logPath`. Returns a restore function. If the writer cannot open the file, + * the original streams are left untouched (logging still works, just uncapped). + */ +export function installRelayLogRotation( + logPath: string, + maxBytes: number = DEFAULT_RELAY_LOG_MAX_BYTES +): { writer: RotatingLogWriter; restore: () => void } { + const writer = new RotatingLogWriter(logPath, maxBytes) + const originalStdout = process.stdout.write.bind(process.stdout) + const originalStderr = process.stderr.write.bind(process.stderr) + + if (!writer.active) { + return { writer, restore: () => {} } + } + + const wrap = + (original: StreamWrite): StreamWrite => + (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown): boolean => { + writer.write(chunk) + // Why: preserve the Writable.write callback contract so callers awaiting + // the write (rare, but e.g. flush-before-exit) are not left hanging. + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb + if (typeof callback === 'function') { + ;(callback as (err?: Error | null) => void)(null) + } + // If the writer went inactive mid-run (write failure), fall back so logs + // are not silently dropped for the rest of the session. + if (!writer.active) { + return (original as (c: string | Uint8Array) => boolean)(chunk) + } + return true + } + + process.stdout.write = wrap(originalStdout as StreamWrite) as typeof process.stdout.write + process.stderr.write = wrap(originalStderr as StreamWrite) as typeof process.stderr.write + + return { + writer, + restore: () => { + process.stdout.write = originalStdout as typeof process.stdout.write + process.stderr.write = originalStderr as typeof process.stderr.write + writer.dispose() + } + } +} diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 395b18131a7..ccf79ad3638 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -1381,8 +1381,9 @@ /* ── Link Bubble ──────────────────────────────────────── */ .rich-markdown-link-bubble { - position: absolute; - z-index: 30; + position: fixed; + z-index: 60; + -webkit-app-region: no-drag; display: flex; align-items: center; gap: 2px; diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 810063e3929..5e74b874b55 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -159,6 +159,12 @@ import { LinearProjectTable } from '@/components/linear-project-view-surfaces' import JiraIssueWorkspace from '@/components/JiraIssueWorkspace' +import { TaskPageJiraIssueList } from '@/components/task-page-jira-issue-list' +import { + getSingleJiraProjectScope, + getTaskPageJiraStatusOrderScopeKey, + loadTaskPageJiraProjectStatusOrder +} from '@/components/task-page-jira-status-order' import { JiraIcon } from '@/components/icons/JiraIcon' import { cn } from '@/lib/utils' import { @@ -277,6 +283,7 @@ import type { JiraIssue, JiraIssueType, JiraProject, + JiraProjectStatusOrder, LinearIssue, LinearProjectDetail, LinearProjectSummary, @@ -3501,6 +3508,9 @@ export default function TaskPage(): React.JSX.Element { selectedJiraSiteId ] ) + const jiraTaskSourceScopeKey = jiraTaskSourceContext + ? getTaskSourceCacheScope(jiraTaskSourceContext) + : providerRuntimeContextKey const accountBackedTaskSourceHostAvailability = useMemo(() => { if (taskSource !== 'linear' && taskSource !== 'jira') { return [] @@ -4575,6 +4585,10 @@ export default function TaskPage(): React.JSX.Element { const [appliedJiraSearch, setAppliedJiraSearch] = useState('') const [activeJiraPreset, setActiveJiraPreset] = useState('assigned') const [jiraRefreshNonce, setJiraRefreshNonce] = useState(0) + const [jiraProjectStatusOrder, setJiraProjectStatusOrder] = useState<{ + order: JiraProjectStatusOrder + scopeKey: string + } | null>(null) useEffect(() => { if (taskResumeAppliedRef.current || !persistedUIReady || !settings) { @@ -5595,6 +5609,17 @@ export default function TaskPage(): React.JSX.Element { ), [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache, jiraTaskSourceContext] ) + const displayedJiraProjectScope = useMemo( + () => getSingleJiraProjectScope(displayedJiraIssues), + [displayedJiraIssues] + ) + const displayedJiraStatusOrderScopeKey = displayedJiraProjectScope + ? getTaskPageJiraStatusOrderScopeKey(jiraTaskSourceScopeKey, displayedJiraProjectScope) + : null + const displayedJiraStatusOrder = + jiraProjectStatusOrder && displayedJiraStatusOrderScopeKey === jiraProjectStatusOrder.scopeKey + ? jiraProjectStatusOrder.order + : null // New Linear project dialog state const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false) @@ -7792,6 +7817,26 @@ export default function TaskPage(): React.JSX.Element { } setJiraIssues(issues) setJiraLoading(false) + const projectScope = getSingleJiraProjectScope(issues) + if (!projectScope) { + return + } + const statusOrderScopeKey = getTaskPageJiraStatusOrderScopeKey( + jiraTaskSourceScopeKey, + projectScope + ) + void loadTaskPageJiraProjectStatusOrder( + jiraTaskSourceContext ?? settings, + jiraTaskSourceScopeKey, + projectScope + ).then((order) => { + if (!cancelled) { + setJiraProjectStatusOrder({ + order, + scopeKey: statusOrderScopeKey + }) + } + }) }) .catch((err) => { if (cancelled) { @@ -7815,7 +7860,8 @@ export default function TaskPage(): React.JSX.Element { activeJiraPreset, jiraRefreshNonce, taskResumeApplied, - jiraTaskSourceContext + jiraTaskSourceContext, + jiraTaskSourceScopeKey ]) useEffect(() => { @@ -10026,185 +10072,16 @@ export default function TaskPage(): React.JSX.Element { ) : null} -
- {displayedJiraIssues.map((issue) => { - const selected = issue.key === selectedJiraIssueKey - const labels = issue.labels.slice(0, 3) - const contextLabel = - selectedJiraSiteId === 'all' && issue.siteName - ? `${issue.siteName} / ${issue.project.key}` - : issue.project.key - return ( -
openJiraDetailPage(issue)} - onKeyDown={(e) => { - if (e.target !== e.currentTarget) { - return - } - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - openJiraDetailPage(issue) - } - }} - className={cn( - 'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring md:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px] lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]', - selected && 'bg-accent' - )} - > - - {issue.key} - - -
-
- - {issue.key} - -

- {issue.title} -

-
-
- - {issue.status.name} - - - {issue.priority?.name ?? - translate('auto.components.TaskPage.713179dfdc', 'No priority')} - - - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} - -
-
- - {contextLabel} - - {labels.map((label) => ( - - {label} - - ))} - {issue.labels.length > labels.length ? ( - - +{issue.labels.length - labels.length} - - ) : null} -
-
- -
- - {issue.status.name} - -
- - - {issue.priority?.name ?? - translate('auto.components.TaskPage.713179dfdc', 'No priority')} - - -
- {issue.assignee?.avatarUrl ? ( - {issue.assignee.displayName} - ) : ( - - {issue.assignee?.displayName?.slice(0, 1) ?? '-'} - - )} - - {issue.assignee?.displayName ?? - translate('auto.components.TaskPage.42a9160321', 'Unassigned')} - -
- - - -
- {formatRelativeTime(issue.updatedAt)} -
-
- - {new Date(issue.updatedAt).toLocaleString()} - -
- -
- - - - - - {translate( - 'auto.components.TaskPage.9497f2787c', - 'Start workspace' - )} - - - - - - - - {translate('auto.components.TaskPage.eee68073b2', 'Open in Jira')} - - -
-
- ) - })} -
+ { + setLinkBubble(null) + setIsEditingLink(false) + }, handleLinkSave, handleLinkRemove, handleLinkEditCancel, diff --git a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx index 92fe187e823..f5283616abb 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditorSurface.tsx @@ -37,6 +37,7 @@ function shouldFocusEmptyEditorFromSurfaceClick( type RichMarkdownEditorSurfaceProps = { editor: Editor | null editorFontZoomLevel: number + rootElement: HTMLDivElement | null rootRef: (node: HTMLDivElement | null) => void scrollContainerRef: React.RefObject headerSlot?: React.ReactNode @@ -91,6 +92,7 @@ type RichMarkdownEditorSurfaceProps = { toggleWholeWord: () => void } linkBubbleActions: { + dismissLinkBubble: () => void handleLinkSave: (href: string) => void handleLinkRemove: () => void handleLinkEditCancel: () => void @@ -119,6 +121,7 @@ type RichMarkdownEditorSurfaceProps = { export function RichMarkdownEditorSurface({ editor, editorFontZoomLevel, + rootElement, rootRef, scrollContainerRef, headerSlot, @@ -249,8 +252,11 @@ export function RichMarkdownEditorSurface({ {linkBubble ? ( linkBubbleActions.setIsEditingLink(true)} diff --git a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx index 7a144d446d6..7ff8464cec3 100644 --- a/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx +++ b/src/renderer/src/components/editor/RichMarkdownLinkBubble.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import type { Editor } from '@tiptap/react' import { ExternalLink, Pencil, Unlink } from 'lucide-react' import { translate } from '@/i18n/i18n' @@ -9,6 +10,66 @@ export type LinkBubbleState = { top: number } +const LINK_BUBBLE_VIEWPORT_MARGIN = 8 +const LINK_BUBBLE_MAX_WIDTH = 344 +const LINK_BUBBLE_HEIGHT = 40 +const LINK_BUBBLE_LAYOUT_ATTRIBUTES = ['aria-hidden', 'class', 'hidden', 'inert', 'style'] + +function hasRectChanged(initialRect: DOMRect, nextRect: DOMRect): boolean { + return ( + Math.abs(nextRect.left - initialRect.left) > 0.5 || + Math.abs(nextRect.top - initialRect.top) > 0.5 || + Math.abs(nextRect.width - initialRect.width) > 0.5 || + Math.abs(nextRect.height - initialRect.height) > 0.5 + ) +} + +function getStableAnchorClassName(anchorElement: HTMLElement): string { + return [...anchorElement.classList] + .filter((className) => className !== 'rich-markdown-mod-held') + .sort() + .join(' ') +} + +function isAnchorVisible(anchorElement: HTMLElement): boolean { + if (!anchorElement.isConnected || anchorElement.getClientRects().length === 0) { + return false + } + for (let element: HTMLElement | null = anchorElement; element; element = element.parentElement) { + const style = window.getComputedStyle(element) + if ( + element.hidden || + element.inert || + element.getAttribute('aria-hidden') === 'true' || + style.display === 'none' || + style.visibility === 'hidden' || + style.visibility === 'collapse' || + Number.parseFloat(style.opacity) === 0 + ) { + return false + } + } + return true +} + +function clampDocumentBubblePosition(linkBubble: LinkBubbleState): React.CSSProperties { + // Why: the body portal no longer inherits editor clipping, so keep every + // action reachable when the selected link sits at a window edge. + const maxLeft = Math.max( + LINK_BUBBLE_VIEWPORT_MARGIN, + window.innerWidth - LINK_BUBBLE_MAX_WIDTH - LINK_BUBBLE_VIEWPORT_MARGIN + ) + const maxTop = Math.max( + LINK_BUBBLE_VIEWPORT_MARGIN, + window.innerHeight - LINK_BUBBLE_HEIGHT - LINK_BUBBLE_VIEWPORT_MARGIN + ) + return { + position: 'fixed', + left: Math.min(Math.max(linkBubble.left, LINK_BUBBLE_VIEWPORT_MARGIN), maxLeft), + top: Math.min(Math.max(linkBubble.top, LINK_BUBBLE_VIEWPORT_MARGIN), maxTop) + } +} + export function getLinkBubblePosition( editor: Editor, rootEl: HTMLElement | null @@ -16,13 +77,12 @@ export function getLinkBubblePosition( const { from } = editor.state.selection try { const coords = editor.view.coordsAtPos(from) - const rootRect = rootEl?.getBoundingClientRect() - if (!rootRect) { + if (!rootEl) { return null } return { - left: coords.left - rootRect.left, - top: coords.bottom - rootRect.top + 4 + left: coords.left, + top: coords.bottom + 4 } } catch { return null @@ -91,28 +151,126 @@ function LinkEditInput({ } type RichMarkdownLinkBubbleProps = { + anchorElement: HTMLElement | null linkBubble: LinkBubbleState isEditing: boolean + onDismiss: () => void onSave: (href: string) => void onRemove: () => void onEditStart: () => void onEditCancel: () => void onOpen: () => void + portalToDocument?: boolean } export function RichMarkdownLinkBubble({ + anchorElement, linkBubble, isEditing, + onDismiss, onSave, onRemove, onEditStart, onEditCancel, - onOpen + onOpen, + portalToDocument = false }: RichMarkdownLinkBubbleProps): React.JSX.Element { - return ( + const bubbleRef = useRef(null) + const onDismissRef = useRef(onDismiss) + onDismissRef.current = onDismiss + + useEffect(() => { + if (!anchorElement || !isAnchorVisible(anchorElement)) { + onDismissRef.current() + return + } + + const dismiss = (): void => onDismissRef.current() + const dismissOutside = (event: Event): void => { + const target = event.target + if ( + target instanceof Node && + !anchorElement.contains(target) && + !bubbleRef.current?.contains(target) + ) { + dismiss() + } + } + const initialRect = anchorElement.getBoundingClientRect() + const initialAnchorClassName = getStableAnchorClassName(anchorElement) + const dismissIfLayoutInvalidated = (mutations: MutationRecord[] = []): void => { + const anchorStyleChanged = mutations.some( + (mutation) => mutation.target === anchorElement && mutation.attributeName === 'style' + ) + if ( + !isAnchorVisible(anchorElement) || + hasRectChanged(initialRect, anchorElement.getBoundingClientRect()) || + getStableAnchorClassName(anchorElement) !== initialAnchorClassName || + anchorStyleChanged + ) { + dismiss() + } + } + const resizeObserver = new ResizeObserver(() => { + dismissIfLayoutInvalidated() + }) + const intersectionObserver = new IntersectionObserver(([entry]) => { + if (!entry?.isIntersecting || !isAnchorVisible(anchorElement)) { + dismiss() + } + }) + const mutationObserver = new MutationObserver(dismissIfLayoutInvalidated) + const dismissOnScroll = (event: Event): void => { + const target = event.target + // Why: long URL inputs scroll horizontally to keep the caret visible; + // only scrolling outside the bubble invalidates its document position. + if (target instanceof Node && bubbleRef.current?.contains(target)) { + return + } + dismiss() + } + + resizeObserver.observe(anchorElement) + intersectionObserver.observe(anchorElement) + for ( + let element: HTMLElement | null = anchorElement; + element; + element = element.parentElement + ) { + mutationObserver.observe(element, { + attributes: true, + attributeFilter: LINK_BUBBLE_LAYOUT_ATTRIBUTES + }) + } + window.addEventListener('pointerdown', dismissOutside, true) + window.addEventListener('focusin', dismissOutside, true) + window.addEventListener('scroll', dismissOnScroll, true) + window.addEventListener('resize', dismiss) + return () => { + resizeObserver.disconnect() + intersectionObserver.disconnect() + mutationObserver.disconnect() + window.removeEventListener('pointerdown', dismissOutside, true) + window.removeEventListener('focusin', dismissOutside, true) + window.removeEventListener('scroll', dismissOnScroll, true) + window.removeEventListener('resize', dismiss) + } + }, [anchorElement]) + + const anchorRect = anchorElement?.getBoundingClientRect() + const positionStyle: React.CSSProperties = portalToDocument + ? clampDocumentBubblePosition(linkBubble) + : { + position: 'absolute', + left: linkBubble.left - (anchorRect?.left ?? 0), + top: linkBubble.top - (anchorRect?.top ?? 0) + } + + const bubble = (
{ // Prevent editor blur when clicking bubble buttons, but let inputs // receive focus normally. @@ -166,4 +324,8 @@ export function RichMarkdownLinkBubble({ )}
) + + // Why: editor panes clip overflow at the workbench boundary, so the URL + // actions must portal to the app layer to remain above the right sidebar. + return portalToDocument ? createPortal(bubble, document.body) : bubble } diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 9a4019a3f8b..3159408be66 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -385,8 +385,13 @@ export function GitHubMarkdownComposer({ {attachmentFooter} {linkBubble ? ( { + setLinkBubble(null) + setIsEditingLink(false) + }} onSave={handleLinkSave} onRemove={handleLinkRemove} onEditStart={() => setIsEditingLink(true)} diff --git a/src/renderer/src/components/native-chat/NativeChatView.test.tsx b/src/renderer/src/components/native-chat/NativeChatView.test.tsx index 0aedcc811bf..968ceb6a5a8 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.test.tsx @@ -76,7 +76,7 @@ describe('NativeChatSessionGate', () => { renderResolution({ paneKey: 'tab-1:leaf-1', launchAgent: null, - resolvedAgent: 'grok', + resolvedAgent: 'gemini', ptyId: 'pty-1' }) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index fe2e3d6de8f..8956355fa51 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest' import { canToggleNativeChat } from './native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' describe('canToggleNativeChat', () => { it('allows a terminal launched with a supported coding agent', () => { @@ -55,16 +56,53 @@ describe('canToggleNativeChat', () => { ).toBe(true) }) - it('rejects an unsupported launch agent (Grok)', () => { + it('accepts local Grok once native chat can parse its transcript', () => { expect( canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal', - launchAgent: 'grok' + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(null) + }) + ).toBe(true) + }) + + it('accepts runtime-owned Grok because Model B reads the transcript locally', () => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + 'runtime-ssh-env-1' + ) + }) + ).toBe(true) + }) + + it('rejects Model-A SSH Grok when its transcript is remote-only', () => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable('ssh-target-1') }) ).toBe(false) }) + it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => { + expect( + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: 'grok', + nativeChatTranscriptIsLocalReadable: false, + isChatViewMode: true + }) + ).toBe(true) + }) + it('rejects an unsupported agent detected live (Gemini)', () => { expect( canToggleNativeChat({ @@ -76,15 +114,16 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) - it('rejects an unsupported agent resolved from the title', () => { + it('accepts Grok when resolved from the title', () => { expect( canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal', launchAgent: null, - resolvedAgent: 'grok' + resolvedAgent: 'grok', + nativeChatTranscriptIsLocalReadable: true }) - ).toBe(false) + ).toBe(true) }) it('rejects a stale supported title when live detection found an unsupported agent', () => { @@ -115,7 +154,7 @@ describe('canToggleNativeChat', () => { canToggleNativeChat({ experimentalNativeChatEnabled: true, contentType: 'terminal', - launchAgent: 'grok', + launchAgent: 'gemini', resolvedAgent: 'claude' }) ).toBe(false) diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 4e520af1b9a..3d2be38e376 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -22,6 +22,8 @@ export type NativeChatAvailabilityInput = { * terminal title resolver) when it identifies the foreground as an agent * before hooks arrive. */ resolvedAgent?: TuiAgent | null + /** Whether this renderer's native-chat reader can access the agent transcript. */ + nativeChatTranscriptIsLocalReadable?: boolean /** Already-chat tabs must always be allowed to toggle back to terminal, even * if live hook state was lost during a dev/app restart. */ isChatViewMode?: boolean @@ -30,9 +32,9 @@ export type NativeChatAvailabilityInput = { /** Native chat is a rendering of a coding-agent conversation, so the toggle is * only meaningful on terminals that actually run an agent we can parse. Plain * shells, non-terminal surfaces (editor, browser, …), and unsupported agents - * (Grok, Gemini, …) never qualify. Live identity is authoritative when present; + * (Gemini, …) never qualify. Live identity is authoritative when present; * launch metadata is next, and title resolution only fills the pre-hook gap for - * manually-started Claude/Codex sessions. */ + * manually-started Claude/Codex/Grok sessions. */ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean { if (input.experimentalNativeChatEnabled !== true) { return false @@ -44,5 +46,8 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean return true } const agent = input.detectedAgent ?? input.launchAgent ?? input.resolvedAgent + if (agent === 'grok' && input.nativeChatTranscriptIsLocalReadable !== true) { + return false + } return isNativeChatSupportedAgent(agent) } diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts index 2ded9d310b3..35fdc107695 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.test.ts @@ -19,6 +19,14 @@ describe('image paste agent map', () => { }) }) + it('grok attaches image paths like other vision-capable TUIs', () => { + expect(getAgentImageHandling('grok')).toBe('attachment') + expect(resolveImagePaste('grok', '/tmp/orca-paste-1.png')).toEqual({ + kind: 'attach', + path: '/tmp/orca-paste-1.png' + }) + }) + it('unknown/custom agent is unsupported', () => { expect(getAgentImageHandling('some-custom-agent')).toBe('unsupported') expect(resolveImagePaste('some-custom-agent', '/tmp/x.png')).toEqual({ diff --git a/src/renderer/src/components/native-chat/native-chat-image-paste.ts b/src/renderer/src/components/native-chat/native-chat-image-paste.ts index fb5adf67d87..792d10445ed 100644 --- a/src/renderer/src/components/native-chat/native-chat-image-paste.ts +++ b/src/renderer/src/components/native-chat/native-chat-image-paste.ts @@ -19,7 +19,11 @@ const IMAGE_ATTACHMENT_AGENTS: ReadonlySet = new Set([ 'gemini', 'cursor', 'copilot', - 'droid' + 'droid', + // Why: Grok CLI pastes images via bracketed path / image chips (see xAI + // terminal docs + pager paste.rs). Keep it on the same attachment path as + // Claude/Codex rather than treating path paste as unsupported text. + 'grok' ]) export function getAgentImageHandling(agent: AgentType): AgentImageHandling { diff --git a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts index 73c291bb4e2..1193450e3cb 100644 --- a/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pane-resolution.test.ts @@ -160,19 +160,31 @@ describe('resolveNativeChatSession', () => { } ) - it.each(['gemini', 'grok'] as TuiAgent[])( - 'does not resolve unsupported title fallback %s', - (resolvedAgent) => { - expect( - resolveNativeChatSession({ - paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', - launchAgent: null, - resolvedAgent, - ptyId: 'pty-1' - }) - ).toBeNull() - } - ) + it('does not resolve unsupported title fallback gemini', () => { + expect( + resolveNativeChatSession({ + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + launchAgent: null, + resolvedAgent: 'gemini', + ptyId: 'pty-1' + }) + ).toBeNull() + }) + + it('resolves Grok from title fallback once native chat supports its transcript', () => { + expect( + resolveNativeChatSession({ + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + launchAgent: null, + resolvedAgent: 'grok', + ptyId: 'pty-1' + }) + ).toMatchObject({ + agent: 'grok', + sessionId: null, + ptyId: 'pty-1' + }) + }) it('does not resolve an unsupported live status entry', () => { const paneKey = 'tab-1:11111111-1111-4111-8111-111111111111' @@ -224,17 +236,21 @@ describe('resolveNativeChatSession', () => { ).toBeNull() }) - it('does not resolve an unsupported launch agent', () => { + it('resolves a Grok launch agent', () => { expect( resolveNativeChatSession({ paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', launchAgent: 'grok', ptyId: 'pty-1' }) - ).toBeNull() + ).toMatchObject({ + agent: 'grok', + sessionId: null, + ptyId: 'pty-1' + }) }) - it('does not fall back to a supported title agent when launchAgent is unsupported', () => { + it('keeps Grok launch identity ahead of a different title agent', () => { expect( resolveNativeChatSession({ paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', @@ -242,7 +258,11 @@ describe('resolveNativeChatSession', () => { resolvedAgent: 'codex', ptyId: 'pty-1' }) - ).toBeNull() + ).toMatchObject({ + agent: 'grok', + sessionId: null, + ptyId: 'pty-1' + }) }) it('keeps launch identity ahead of the title fallback', () => { diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 5ae84dde522..c53b5263de0 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -4,7 +4,9 @@ import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalPaneLayoutNode } from '../../../../shared/types' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' +import { getConnectionIdFromState } from '@/lib/connection-context' export function isNativeChatShortcutTitleFallbackSafe( root: TerminalPaneLayoutNode | null | undefined @@ -61,7 +63,7 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive (candidate) => candidate.id === tab.entityId ) // Carry the agent identity (not just "an agent exists") so the chord stays - // inert on unsupported agents like Grok, matching the menu/header gate. + // inert on unsupported agents (e.g. Gemini), matching the menu/header gate. // Pane keys are `${entityId}:${leafId}` — the backing terminal tab id, not // the unified tab id. const terminalLayout = state.terminalLayoutsByTabId[tab.entityId] @@ -82,6 +84,9 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive launchAgent: detectedAgent ? null : terminalTab?.launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : titleFallbackAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(state, worktreeId) + ), isChatViewMode: tab.viewMode === 'chat' }) ) { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts index a7ab45e936e..e22a64e3a8a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-display.ts @@ -1,111 +1,10 @@ -import type { - AiVaultSession, - AiVaultSessionPreviewMessage -} from '../../../../shared/ai-vault-types' - -const CONVERSATION_ROLES = new Set(['user', 'assistant']) - -export type AiVaultSessionDisplayTurn = { - role: AiVaultSessionPreviewMessage['role'] - text: string - timestamp: string | null -} - -export function latestSessionConversationTurn( - session: AiVaultSession -): AiVaultSessionDisplayTurn | null { - return recentSessionConversationTurns(session, 1)[0] ?? null -} - -export function recentSessionConversationTurns( - session: AiVaultSession, - limit: number -): AiVaultSessionDisplayTurn[] { - if (limit <= 0) { - return [] - } - - return displayableSessionPreviewMessages(session).slice(-limit).map(toDisplayTurn) -} - -export function sessionDetailConversationTurns( - session: AiVaultSession, - limit: number -): AiVaultSessionDisplayTurn[] { - if (limit <= 0) { - return [] - } - - const turns = displayableSessionPreviewMessages(session) - .map(toDisplayTurn) - .filter((turn) => !turnTextMatchesSessionTitle(session.title, turn.text)) - - return dedupeAdjacentConversationTurns(turns).slice(-limit) -} - -function turnTextMatchesSessionTitle(title: string, turnText: string): boolean { - const sessionText = normalizeSessionDisplayText(title) - const candidateText = normalizeSessionDisplayText(turnText) - if (!sessionText || !candidateText) { - return false - } - if (sessionText === candidateText) { - return true - } - const longEnough = sessionText.length >= 24 && candidateText.length >= 24 - return ( - longEnough && (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText)) - ) -} - -function dedupeAdjacentConversationTurns( - turns: AiVaultSessionDisplayTurn[] -): AiVaultSessionDisplayTurn[] { - const deduped: AiVaultSessionDisplayTurn[] = [] - for (const turn of turns) { - const previous = deduped.at(-1) - if ( - previous && - previous.role === turn.role && - normalizeSessionDisplayText(previous.text) === normalizeSessionDisplayText(turn.text) - ) { - continue - } - deduped.push(turn) - } - return deduped -} - -function normalizeSessionDisplayText(value: string): string { - return value.trim().replace(/\s+/g, ' ').toLowerCase() -} - -export function sessionModelLabel(session: AiVaultSession): string | null { - return session.model || null -} - -export function sessionPreviewSearchText(session: AiVaultSession): string { - return displayableSessionPreviewMessages(session) - .map((message) => message.text) - .join(' ') -} - -function displayableSessionPreviewMessages( - session: AiVaultSession -): AiVaultSessionPreviewMessage[] { - const conversationTurns = session.previewMessages.filter((message) => - CONVERSATION_ROLES.has(message.role) - ) - - // Why: search hits should be explainable by the preview UI; tool/system text is - // only searchable when it is the fallback preview shown for the session. - return conversationTurns.length > 0 ? conversationTurns : session.previewMessages -} - -function toDisplayTurn(message: AiVaultSessionPreviewMessage): AiVaultSessionDisplayTurn { - return { - role: message.role, - text: message.text, - timestamp: message.timestamp - } -} +// Why: the pure preview/search-text core now lives in /shared so mobile can +// reuse it (Metro can't import renderer). Re-export for renderer import parity. +export type { AiVaultSessionDisplayTurn } from '../../../../shared/ai-vault-session-display' +export { + latestSessionConversationTurn, + recentSessionConversationTurns, + sessionDetailConversationTurns, + sessionModelLabel, + sessionPreviewSearchText +} from '../../../../shared/ai-vault-session-display' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts index 61dd7119ffa..0ea9338c39f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-filters.ts @@ -1,276 +1,19 @@ -import { - isPathInsideOrEqual, - normalizeRuntimePathSeparators -} from '../../../../shared/cross-platform-path' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' -import { parseWslUncPath } from '../../../../shared/wsl-paths' -import type { - AiVaultAgent, - AiVaultGroup, - AiVaultScope, - AiVaultSession, - AiVaultSort -} from '../../../../shared/ai-vault-types' -import { - aiVaultAgentLabel, - isAiVaultSessionRecoverableEmpty, - isAiVaultSessionResumableContent -} from '../../../../shared/ai-vault-types' -import { sessionPreviewSearchText } from './ai-vault-session-display' -import type { AiVaultSessionProject } from './ai-vault-session-projects' - -export type AiVaultSessionFilterState = { - query: string - agents: readonly AiVaultAgent[] - scope: AiVaultScope - sort: AiVaultSort - activeWorktreePaths: readonly string[] - activeProjectKey?: string | null - sessionProjectById?: ReadonlyMap - projectLabelByKey?: ReadonlyMap - hideEmptySessions: boolean -} - -export type AiVaultSessionGroup = { - key: string - label: string - sessions: AiVaultSession[] -} - -type ParsedQuery = { - terms: string[] - repoTerms: string[] - pathTerms: string[] -} - -export const AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES = 2 * 1024 - -export function isAiVaultSessionFilterQueryTooLarge( - query: string, - maxBytes = AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES -): boolean { - return isClipboardTextByteLengthOverLimit(query, maxBytes) -} - -export function filterAiVaultSessions( - sessions: readonly AiVaultSession[], - filters: AiVaultSessionFilterState -): AiVaultSession[] { - if (isAiVaultSessionFilterQueryTooLarge(filters.query)) { - return [] - } - - const agentSet = new Set(filters.agents) - const parsedQuery = parseVaultQuery(filters.query) - - return sessions - .filter((session) => { - if (!agentSet.has(session.agent)) { - return false - } - // Hide plain empty sessions, but keep sessions with resumable content - // (some parsers only learn turns from previews, e.g. Grok) and zero-turn - // sessions that still carry recoverable content (queued prompts / - // subagent transcripts) so a lost conversation is surfaced distinctly. - if ( - filters.hideEmptySessions && - !isAiVaultSessionResumableContent(session) && - !isAiVaultSessionRecoverableEmpty(session) - ) { - return false - } - if (filters.scope === 'workspace') { - const cwd = session.cwd - if ( - !cwd || - !filters.activeWorktreePaths.some((pathValue) => - isAiVaultSessionInWorkspacePath(pathValue, cwd) - ) - ) { - return false - } - } - if (filters.scope === 'project') { - if (!filters.activeProjectKey) { - return false - } - if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { - return false - } - } - return matchesQuery(session, parsedQuery, filters) - }) - .sort((left, right) => compareSessions(left, right, filters.sort)) -} - -export function groupAiVaultSessions( - sessions: readonly AiVaultSession[], - group: AiVaultGroup, - options: { - sessionProjectById?: ReadonlyMap - projectLabelByKey?: ReadonlyMap - } = {} -): AiVaultSessionGroup[] { - const groups = new Map() - - for (const session of sessions) { - const { key, label } = getGroupIdentity(session, group, options) - const existing = groups.get(key) - if (existing) { - existing.sessions.push(session) - } else { - groups.set(key, { key, label, sessions: [session] }) - } - } - - return [...groups.values()] -} - -export function folderLabel(pathValue: string | null): string { - if (!pathValue) { - return 'Unknown location' - } - const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) - if (parts.length >= 2) { - return parts.slice(-2).join('/') - } - return parts[0] ?? pathValue -} - -export function agentLabel(agent: AiVaultAgent): string { - return aiVaultAgentLabel(agent) -} - -export function parseVaultQuery(query: string): ParsedQuery { - const terms: string[] = [] - const repoTerms: string[] = [] - const pathTerms: string[] = [] - - for (const rawToken of tokenizeQuery(query)) { - const token = rawToken.toLowerCase() - if (token.startsWith('repo:')) { - const value = token.slice('repo:'.length) - if (value) { - repoTerms.push(value) - } - continue - } - if (token.startsWith('path:')) { - const value = token.slice('path:'.length) - if (value) { - pathTerms.push(value) - } - continue - } - terms.push(token) - } - - return { terms, repoTerms, pathTerms } -} - -function matchesQuery( - session: AiVaultSession, - parsed: ParsedQuery, - filters: Pick -): boolean { - const searchable = [ - session.title, - session.sessionId, - session.agent, - session.branch, - session.model, - session.cwd, - session.filePath, - sessionPreviewSearchText(session) - ] - .filter(Boolean) - .join(' ') - .toLowerCase() - - if (parsed.terms.some((term) => !searchable.includes(term))) { - return false - } - - const sessionProject = filters.sessionProjectById?.get(session.id) - const repoLabel = ( - sessionProject?.kind === 'repo' - ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) - : folderLabel(session.cwd) - ).toLowerCase() - if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { - return false - } - - const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() - if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { - return false - } - - return true -} - -function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVaultSort): number { - const leftValue = sort === 'created' ? left.createdAt : left.updatedAt - const rightValue = sort === 'created' ? right.createdAt : right.updatedAt - const leftTime = Date.parse(leftValue ?? left.modifiedAt) - const rightTime = Date.parse(rightValue ?? right.modifiedAt) - return rightTime - leftTime -} - -function getGroupIdentity( - session: AiVaultSession, - group: AiVaultGroup, - options: { - sessionProjectById?: ReadonlyMap - projectLabelByKey?: ReadonlyMap - } -): Pick { - if (group === 'agent') { - return { key: session.agent, label: agentLabel(session.agent) } - } - if (group === 'project') { - const sessionProject = options.sessionProjectById?.get(session.id) - if (sessionProject) { - return { - key: sessionProject.key, - label: - options.projectLabelByKey?.get(sessionProject.key) || - sessionProject.label || - folderLabel(session.cwd) - } - } - } - return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } -} - -function getFolderGroupKey(pathValue: string | null): string { - return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' -} - -function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { - if (isPathInsideOrEqual(workspacePath, sessionCwd)) { - return true - } - - const workspaceWslPath = parseWslUncPath(workspacePath) - if (!workspaceWslPath) { - return false - } - - // WSL agent transcripts record Linux cwd values even when Orca stores the - // active worktree as a Windows UNC path. - return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd) -} - -function tokenizeQuery(query: string): string[] { - const tokens: string[] = [] - const pattern = /"([^"]+)"|'([^']+)'|(\S+)/g - let match: RegExpExecArray | null - while ((match = pattern.exec(query)) !== null) { - const token = match[1] ?? match[2] ?? match[3] - if (token?.trim()) { - tokens.push(token.trim()) - } - } - return tokens -} +// Why: the pure filter/group/query core now lives in /shared so the mobile +// package can reuse it (Metro can't import renderer). Re-export for renderer +// import parity. Not a byte-for-byte move: tokenizeQuery gained quoted +// repo:/path: operator values (e.g. path:"/a/My Project"), which the old +// renderer tokenizer split on spaces. +export type { + AiVaultSessionProject, + AiVaultSessionFilterState, + AiVaultSessionGroup +} from '../../../../shared/ai-vault-session-filters' +export { + AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES, + agentLabel, + filterAiVaultSessions, + folderLabel, + groupAiVaultSessions, + isAiVaultSessionFilterQueryTooLarge, + parseVaultQuery +} from '../../../../shared/ai-vault-session-filters' diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index c0734a11881..f9f13dc9c7c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -12,15 +12,11 @@ import { normalizeRuntimePathForComparison, normalizeRuntimePathSeparators } from '../../../../shared/cross-platform-path' +import type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' -export type AiVaultSessionProject = { - kind: 'repo' | 'folder' | 'unknown' - key: string - label: string - projectId?: string - repoId?: string - hostKey?: ExecutionHostId -} +// Why: the plain project descriptor moved to /shared (so the lifted filter core +// stays renderer-free). Re-export it here for renderer import parity. +export type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' export type AiVaultProjectContext = { activeProjectKey: string | null diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 5fe949cea39..127df935ff7 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -412,6 +412,31 @@ describe('AppearancePane', () => { expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('minimax') }) + it('records Antigravity status bar toggles as usage tracking interactions', async () => { + mocks.state.availableStatusBarToggles = [ + { + id: 'antigravity', + title: 'Antigravity Usage', + description: 'Show Antigravity subscription usage in the status bar.', + toggleDescription: 'Show Antigravity subscription usage for the active workspace.', + keywords: ['status bar', 'antigravity', 'usage'] + } + ] + mocks.state.settingsSearchQuery = 'antigravity' + const container = await renderAppearancePane(getDefaultSettings('/tmp')) + const antigravitySwitch = container.querySelector( + 'button[role="switch"][aria-label="Antigravity Usage"]' + ) + + expect(antigravitySwitch).not.toBeNull() + await act(async () => { + antigravitySwitch?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + + expect(mocks.state.recordFeatureInteraction).toHaveBeenCalledWith('usage-tracking') + expect(mocks.state.toggleStatusBarItem).toHaveBeenCalledWith('antigravity') + }) + it('collapses sibling sections so only the Interface section is expanded by default', async () => { mocks.state.settingsSearchQuery = '' const container = await renderAppearancePane(getDefaultSettings('/tmp')) diff --git a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx index 97d53297e0a..1690832ee18 100644 --- a/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx +++ b/src/renderer/src/components/settings/AppearanceWindowSidebarSection.tsx @@ -43,6 +43,7 @@ function recordStatusBarToggleInteraction( id === 'gemini' || id === 'opencode-go' || id === 'kimi' || + id === 'antigravity' || id === 'minimax' || id === 'grok' ) { diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 1b95beecf5b..a8c25191172 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -26,7 +26,7 @@ export function NativeChatExperimentalSetting({ title={translate('auto.components.settings.ExperimentalPane.nativeChat.title', 'Native chat')} description={translate( 'auto.components.settings.ExperimentalPane.nativeChat.description', - 'Preview the desktop chat surface for Claude and Codex terminal sessions.' + 'Preview the desktop chat surface for supported agent terminal sessions.' )} keywords={getExperimentalSearchEntry().nativeChat.keywords} className="space-y-3 py-2" @@ -40,7 +40,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.copy', - 'Adds a native chat view you can switch to from supported Claude and Codex terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' + 'Adds a native chat view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.' )}

@@ -70,7 +70,7 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.defaultCopy', - 'Choose how new Claude and Codex terminal tabs open.' + 'Choose how new supported agent terminal tabs open.' )}

diff --git a/src/renderer/src/components/settings/TerminalInteractionSection.tsx b/src/renderer/src/components/settings/TerminalInteractionSection.tsx index b97b053ad9c..2d4f55bec61 100644 --- a/src/renderer/src/components/settings/TerminalInteractionSection.tsx +++ b/src/renderer/src/components/settings/TerminalInteractionSection.tsx @@ -329,7 +329,7 @@ export function TerminalInteractionSection({ )} description={translate( 'auto.components.settings.TerminalPane.69c64a479c', - 'Let tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).' + 'Let Grok, tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).' )} keywords={[ 'osc 52', @@ -339,6 +339,7 @@ export function TerminalInteractionSection({ 'neovim', 'nvim', 'fzf', + 'grok', 'ssh', 'remote', 'copy', @@ -352,7 +353,7 @@ export function TerminalInteractionSection({ )} description={translate( 'auto.components.settings.TerminalPane.6e6480a7df', - 'Let programs in the terminal (tmux, Neovim, fzf, SSH) copy to your system clipboard.' + 'Let programs in the terminal (Grok, tmux, Neovim, fzf, SSH) copy to your system clipboard.' )} checked={settings.terminalAllowOsc52Clipboard} onChange={() => diff --git a/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts new file mode 100644 index 00000000000..68c6513bb72 --- /dev/null +++ b/src/renderer/src/components/settings/appearance-status-bar-antigravity-toggle-search.ts @@ -0,0 +1,43 @@ +import type { StatusBarItem } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { translateSearchKeyword } from './settings-search-keywords' + +export function getAntigravityStatusBarToggleSearchEntry(): { + id: StatusBarItem + title: string + description: string + keywords: string[] + toggleDescription: string +} { + return { + id: 'antigravity', + title: translate( + 'auto.components.settings.appearance.search.antigravityUsageTitle', + 'Antigravity Usage' + ), + description: translate( + 'auto.components.settings.appearance.search.antigravityUsageDescription', + 'Show Antigravity subscription usage in the status bar.' + ), + keywords: [ + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.896eb53fd4', + 'status bar' + ), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.antigravityKeyword', + 'antigravity' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'), + ...translateSearchKeyword( + 'auto.components.settings.appearance.search.de586def95', + 'subscription' + ), + ...translateSearchKeyword('auto.components.settings.appearance.search.51b0ccd6a2', 'google') + ], + toggleDescription: translate( + 'settings.appearance.statusBar.antigravityToggleDescription', + 'Show Antigravity subscription usage for the active workspace.' + ) + } +} diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts index 18a218aca4c..3518bcba8aa 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.test.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.test.ts @@ -18,6 +18,19 @@ vi.mock('./settings-search-keywords', () => ({ import { getStatusBarToggles } from './appearance-status-bar-search' describe('getStatusBarToggles', () => { + it('includes Antigravity usage so Appearance can toggle the default-on status item', () => { + const antigravityToggle = getStatusBarToggles().find((entry) => entry.id === 'antigravity') + + expect(antigravityToggle).toMatchObject({ + title: 'Antigravity Usage', + description: 'Show Antigravity subscription usage in the status bar.', + toggleDescription: 'Show Antigravity subscription usage for the active workspace.' + }) + expect(antigravityToggle?.keywords).toEqual( + expect.arrayContaining(['status bar', 'antigravity', 'usage', 'subscription', 'google']) + ) + }) + it('includes MiniMax usage so Appearance can toggle the default-on status item', () => { const miniMaxToggle = getStatusBarToggles().find((entry) => entry.id === 'minimax') diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts index 9044c084fa6..02403c24f4b 100644 --- a/src/renderer/src/components/settings/appearance-status-bar-search.ts +++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts @@ -2,6 +2,7 @@ import type { StatusBarItem } from '../../../../shared/types' import { createLocalizedCatalog } from '@/i18n/localized-catalog' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' +import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-bar-antigravity-toggle-search' import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search' export const getStatusBarToggles = createLocalizedCatalog( @@ -99,6 +100,7 @@ export const getStatusBarToggles = createLocalizedCatalog( 'Show Gemini token and cost usage for the active workspace.' ) }, + getAntigravityStatusBarToggleSearchEntry(), { id: 'opencode-go', title: translate( diff --git a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts index a1b92429180..2d74f8dd1d9 100644 --- a/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts +++ b/src/renderer/src/components/settings/native-chat-experimental-search-entry.ts @@ -10,7 +10,7 @@ export function getNativeChatExperimentalSearchEntry(): SettingsSearchEntry { ), description: translate( 'auto.components.settings.experimental.search.nativeChat.description', - 'Preview the desktop chat surface for Claude and Codex terminal sessions.' + 'Preview the desktop chat surface for supported agent terminal sessions.' ), keywords: [ ...translateSearchKeyword( @@ -33,6 +33,10 @@ export function getNativeChatExperimentalSearchEntry(): SettingsSearchEntry { 'auto.components.settings.experimental.search.nativeChat.codex', 'codex' ), + ...translateSearchKeyword( + 'auto.components.settings.experimental.search.nativeChat.grok', + 'grok' + ), ...translateSearchKeyword( 'auto.components.settings.experimental.search.nativeChat.terminal', 'terminal' diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx index 6e942363112..d1ef3700f83 100644 --- a/src/renderer/src/components/stats/GrokUsagePane.test.tsx +++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx @@ -22,6 +22,7 @@ const mockStoreState = { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: { provider: 'grok', diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 3db1b663f24..71cc71ffe40 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1760,11 +1760,13 @@ export function ProviderDetailsMenu({ ? 'O' : provider.provider === 'kimi' ? 'K' - : provider.provider === 'minimax' - ? 'M' - : provider.provider === 'grok' - ? 'R' - : 'X'} + : provider.provider === 'antigravity' + ? 'A' + : provider.provider === 'minimax' + ? 'M' + : provider.provider === 'grok' + ? 'R' + : 'X'} ) : ( @@ -1910,17 +1912,26 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele return null } - const { claude, codex, gemini, opencodeGo, kimi, minimax, grok } = rateLimits + const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits // Why: a provider earns a bar from either a usable live snapshot or durable // setup in Settings. The durable path keeps account switchers visible while // usage snapshots hydrate, fail, or temporarily report unavailable. // Detection-gating (see status-bar-agent-gating) additionally hides per-CLI // bars when the agent isn't installed on PATH. - // Why: thread the cookie durability flag from RateLimitState so the - // MiniMax bar stays visible after a reload and between snapshot refreshes. + // Why: Antigravity usage has no separate persisted credential. A checked + // status item plus detected CLI is the durable signal that the user wants + // its usage slot visible while the first snapshot is still pending. The + // visibility module additionally requires geminiCliOAuthEnabled (already in + // settings) because the snapshot mirrors the Gemini fetch. + const antigravityUsageConfigured = + statusBarItems.includes('antigravity') && + isStatusBarItemAvailable('antigravity', detectedAgentIds) + // Why: thread non-GlobalSettings durability flags from renderer/main state so + // bars stay visible after a reload and between snapshot refreshes. const usageSettings = { ...settings, + antigravityUsageConfigured, minimaxCookieConfigured: rateLimits.minimaxCookieConfigured, grokAuthConfigured: rateLimits.grokAuthConfigured } @@ -1928,6 +1939,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele const visibleCodex = getVisibleUsageProvider('codex', codex, usageSettings) const visibleGemini = getVisibleUsageProvider('gemini', gemini, usageSettings) const visibleKimi = getVisibleUsageProvider('kimi', kimi, usageSettings) + const visibleAntigravity = getVisibleUsageProvider('antigravity', antigravity, usageSettings) const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings) const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings) const showClaude = @@ -1946,6 +1958,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele visibleKimi !== null && statusBarItems.includes('kimi') && isStatusBarItemAvailable('kimi', detectedAgentIds) + const showAntigravity = + visibleAntigravity !== null && + statusBarItems.includes('antigravity') && + isStatusBarItemAvailable('antigravity', detectedAgentIds) // Why: MiniMax is a cookie-auth provider, not a CLI on PATH, so detection-gating // doesn't apply (same rationale as OpenCode Go below). const showMiniMax = visibleMiniMax !== null && statusBarItems.includes('minimax') @@ -1968,6 +1984,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele showGemini || showOpencodeGo || showKimi || + showAntigravity || showMiniMax || showGrok || showResourceUsage @@ -1976,7 +1993,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele // included because managed accounts are durable even when live usage // snapshots are still hydrating or unavailable after an update. const isEmptyUsageState = isUsageEmptyState( - { claude, codex, gemini, opencodeGo, kimi, minimax, grok }, + { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok }, usageSettings ) // Why: the teaching CTA is a one-time nudge — once the user hides it, keep it @@ -1988,6 +2005,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele gemini?.status === 'fetching' || opencodeGo?.status === 'fetching' || kimi?.status === 'fetching' || + antigravity?.status === 'fetching' || minimax?.status === 'fetching' || grok?.status === 'fetching' @@ -2045,6 +2063,17 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele )} /> )} + {showAntigravity && ( + + )} {showOpencodeGo && ( )} + {isStatusBarItemAvailable('antigravity', detectedAgentIds) && ( + { + recordFeatureInteraction('usage-tracking') + toggleStatusBarItem('antigravity') + }} + > + + {translate( + 'auto.components.status.bar.StatusBar.antigravityUsage', + 'Antigravity Usage' + )} + + )} { diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts index b888a52fb05..cd98315df50 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts @@ -18,6 +18,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', null)).toBe(true) expect(isStatusBarItemAvailable('codex', null)).toBe(true) expect(isStatusBarItemAvailable('gemini', null)).toBe(true) + expect(isStatusBarItemAvailable('antigravity', null)).toBe(true) expect(isStatusBarItemAvailable('grok', null)).toBe(true) }) @@ -25,6 +26,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', [])).toBe(false) expect(isStatusBarItemAvailable('codex', ['claude'])).toBe(false) expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false) + expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false) expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false) }) @@ -32,6 +34,7 @@ describe('isStatusBarItemAvailable', () => { expect(isStatusBarItemAvailable('claude', ['claude'])).toBe(true) expect(isStatusBarItemAvailable('codex', ['codex', 'claude'])).toBe(true) expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true) + expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true) expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true) }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts index 805d7079cee..4ded63147db 100644 --- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts +++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts @@ -1,6 +1,6 @@ import type { StatusBarItem, TuiAgent } from '../../../../shared/types' -// Why: Claude/Codex/Gemini usage bars are surface noise when the underlying +// Why: CLI-backed usage bars are surface noise when the underlying // CLI isn't installed (e.g. a fresh Ubuntu install showing "Gemini Usage" // when no Gemini CLI is on PATH). We hide both the bar and its toggle when // PATH detection reports the agent as missing. Pre-detection (null) keeps @@ -11,6 +11,7 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([ 'codex', 'gemini', 'kimi', + 'antigravity', 'grok' ]) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts index 2dbba23155a..7389a372f80 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts @@ -70,6 +70,7 @@ function usageSettings(overrides: Partial = {}): UsagePro claudeManagedAccounts: [], opencodeSessionCookie: '', geminiCliOAuthEnabled: false, + antigravityUsageConfigured: false, minimaxCookieConfigured: false, grokAuthConfigured: false, ...overrides @@ -119,6 +120,11 @@ describe('hasUsageProviderSettings', () => { expect( hasUsageProviderSettings(usageSettings({ opencodeSessionCookie: ' session=abc ' })) ).toBe(true) + // Why: antigravity durability requires the Gemini OAuth opt-in; the + // checked item alone must not suppress the usage setup CTA. + expect(hasUsageProviderSettings(usageSettings({ antigravityUsageConfigured: true }))).toBe( + false + ) expect(hasUsageProviderSettings(usageSettings({ minimaxCookieConfigured: true }))).toBe(true) expect(hasUsageProviderSettings(usageSettings({ grokAuthConfigured: true }))).toBe(true) }) @@ -150,6 +156,32 @@ describe('hasUsageProviderSettingsForProvider', () => { ).toBe(true) expect(hasUsageProviderSettingsForProvider('claude', usageSettings())).toBe(false) expect(hasUsageProviderSettingsForProvider('kimi', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('grok', usageSettings())).toBe(false) + }) + + it('requires both a checked Antigravity item and Gemini OAuth as the durable Antigravity signal', () => { + expect( + hasUsageProviderSettingsForProvider( + 'antigravity', + usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + ) + ).toBe(true) + // Why: the snapshot mirrors the Gemini fetch — without the OAuth opt-in it + // is permanently unavailable, so the checked item alone is not durable. + expect( + hasUsageProviderSettingsForProvider( + 'antigravity', + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(false) + expect( + hasUsageProviderSettingsForProvider( + 'antigravity', + usageSettings({ geminiCliOAuthEnabled: true }) + ) + ).toBe(false) + expect(hasUsageProviderSettingsForProvider('antigravity', usageSettings())).toBe(false) + expect(hasUsageProviderSettingsForProvider('antigravity', null)).toBe(false) }) it('treats minimaxCookieConfigured as the durable signal for MiniMax', () => { @@ -283,6 +315,42 @@ describe('getVisibleUsageProvider', () => { ) ).toBe(null) }) + + it('keeps Antigravity visible while the snapshot is pending when checked and Gemini OAuth is on', () => { + const visible = getVisibleUsageProvider( + 'antigravity', + null, + usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + ) + expect(visible).toMatchObject({ + provider: 'antigravity', + status: 'fetching', + session: null, + weekly: null + }) + }) + + it('hides Antigravity while Gemini OAuth is off even when its status item is checked', () => { + // Why: without the OAuth opt-in the mirrored snapshot is permanently + // 'unavailable'; the default-on item must not pin a dead bar. + expect( + getVisibleUsageProvider( + 'antigravity', + null, + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(null) + expect( + getVisibleUsageProvider( + 'antigravity', + provider('unavailable', { + provider: 'antigravity', + error: 'Gemini CLI OAuth is disabled in settings' + }), + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(null) + }) }) describe('isUsageEmptyState', () => { @@ -295,6 +363,7 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null }, @@ -312,6 +381,7 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }) }, @@ -329,6 +399,7 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: provider('unavailable', { provider: 'antigravity' }), minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }) }, @@ -357,6 +428,7 @@ describe('isUsageEmptyState', () => { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null }, @@ -374,6 +446,7 @@ describe('isUsageEmptyState', () => { gemini: provider('unavailable'), opencodeGo: provider('unavailable', { provider: 'opencode-go' }), kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, minimax: provider('unavailable', { provider: 'minimax' }), grok: provider('unavailable', { provider: 'grok' }) }, @@ -381,4 +454,42 @@ describe('isUsageEmptyState', () => { ) ).toBe(true) }) + + it('does not show the setup CTA while checked Antigravity usage is awaiting a snapshot', () => { + expect( + isUsageEmptyState( + { + claude: provider('unavailable', { provider: 'claude' }), + codex: provider('unavailable', { provider: 'codex' }), + gemini: provider('unavailable'), + opencodeGo: provider('unavailable', { provider: 'opencode-go' }), + kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, + grok: provider('unavailable', { provider: 'grok' }), + minimax: provider('unavailable', { provider: 'minimax' }) + }, + usageSettings({ antigravityUsageConfigured: true, geminiCliOAuthEnabled: true }) + ) + ).toBe(false) + }) + + it('still shows the setup CTA when Antigravity is checked but Gemini OAuth is off', () => { + // Why: the default-on Antigravity item is not configured usage on its own; + // it must not hide the teaching CTA from users who set nothing up. + expect( + isUsageEmptyState( + { + claude: provider('unavailable', { provider: 'claude' }), + codex: provider('unavailable', { provider: 'codex' }), + gemini: provider('unavailable'), + opencodeGo: provider('unavailable', { provider: 'opencode-go' }), + kimi: provider('unavailable', { provider: 'kimi' }), + antigravity: null, + grok: provider('unavailable', { provider: 'grok' }), + minimax: provider('unavailable', { provider: 'minimax' }) + }, + usageSettings({ antigravityUsageConfigured: true }) + ) + ).toBe(true) + }) }) diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts index 54e7ad1a362..1da3dc0f757 100644 --- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts +++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts @@ -8,6 +8,12 @@ export type UsageProviderSettings = Pick< | 'opencodeSessionCookie' | 'geminiCliOAuthEnabled' > & { + // Why: Antigravity has no separate persisted usage credential in Orca. The + // checked status-bar item is the durable user signal; StatusBar only sets + // this after PATH detection says the agent is available. Durability further + // requires geminiCliOAuthEnabled — the snapshot mirrors the Gemini fetch, + // which never yields data while that opt-in is off. + antigravityUsageConfigured: boolean // Why: MiniMax/Grok sign-in live on disk, not in settings; main sets these each poll. minimaxCookieConfigured: boolean grokAuthConfigured: boolean @@ -19,6 +25,7 @@ type UsageProviderSnapshots = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null } @@ -65,6 +72,8 @@ export function hasUsageProviderSettings( (settings?.claudeManagedAccounts?.length ?? 0) > 0 || settings?.geminiCliOAuthEnabled === true || Boolean(settings?.opencodeSessionCookie?.trim()) || + // Antigravity's durable signal requires geminiCliOAuthEnabled, so it is + // already covered by the gemini term above. settings?.minimaxCookieConfigured === true || settings?.grokAuthConfigured === true ) @@ -89,6 +98,12 @@ export function hasUsageProviderSettingsForProvider( if (providerId === 'opencode-go') { return Boolean(settings.opencodeSessionCookie?.trim()) } + if (providerId === 'antigravity') { + // Why: the Antigravity snapshot mirrors the Gemini fetch, which stays + // 'unavailable' until the user opts into Gemini CLI OAuth. Without that + // gate the default-on checked item would pin a permanently dead bar. + return settings.antigravityUsageConfigured === true && settings.geminiCliOAuthEnabled === true + } if (providerId === 'minimax') { return settings.minimaxCookieConfigured === true } @@ -137,12 +152,16 @@ export function isUsageEmptyState( // Why: system-default Claude/Codex accounts have no persisted account row; // their first durable signal is the usage snapshot, so wait for snapshots to // settle before teaching the user to connect an account. + const antigravitySnapshotPending = + hasUsageProviderSettingsForProvider('antigravity', settings) && + isProviderSnapshotPending(providers.antigravity) if ( isProviderSnapshotPending(providers.claude) || isProviderSnapshotPending(providers.codex) || isProviderSnapshotPending(providers.gemini) || isProviderSnapshotPending(providers.opencodeGo) || isProviderSnapshotPending(providers.kimi) || + antigravitySnapshotPending || isProviderSnapshotPending(providers.minimax) || isProviderSnapshotPending(providers.grok) ) { @@ -155,6 +174,7 @@ export function isUsageEmptyState( !isProviderConfigured(providers.gemini) && !isProviderConfigured(providers.opencodeGo) && !isProviderConfigured(providers.kimi) && + !isProviderConfigured(providers.antigravity) && !isProviderConfigured(providers.minimax) && !isProviderConfigured(providers.grok) ) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index 510e3eea66f..dea9af3abd7 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -1,10 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' +import type * as ReactModule from 'react' import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' -vi.mock('@/lib/agent-catalog', () => ({ - AgentIcon: () => null -})) +vi.mock('@/lib/agent-catalog', async () => { + const ReactActual = await vi.importActual('react') + return { + AgentIcon: ({ agent }: { agent: string }) => + ReactActual.createElement('span', { 'data-agent-icon': agent }) + } +}) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string, values?: Record) => { @@ -448,6 +453,11 @@ describe('ProviderPanel reset rendering', () => { }) describe('ProviderIcon', () => { + it('renders the Antigravity agent icon for the antigravity provider', () => { + const markup = renderToStaticMarkup(ProviderIcon({ provider: 'antigravity' })) + expect(markup).toContain('data-agent-icon="antigravity"') + }) + it('renders the official MiniMax icon asset for the minimax provider', () => { // Why: the icon must travel to the status bar / tooltip unchanged so the // user recognises the brand. We pin it to an with a non-empty diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx index c013c110463..be174965b4f 100644 --- a/src/renderer/src/components/status-bar/tooltip.tsx +++ b/src/renderer/src/components/status-bar/tooltip.tsx @@ -93,6 +93,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem if (provider === 'kimi') { return } + if (provider === 'antigravity') { + return + } if (provider === 'minimax') { return } diff --git a/src/renderer/src/components/status-bar/usage-error-copy.test.ts b/src/renderer/src/components/status-bar/usage-error-copy.test.ts index 3fc29780adc..c91d0704ace 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.test.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.test.ts @@ -7,6 +7,10 @@ vi.mock('@/i18n/i18n', () => ({ import { getProviderDisplayName } from './usage-error-copy' describe('getProviderDisplayName', () => { + it('returns the Antigravity brand name', () => { + expect(getProviderDisplayName('antigravity')).toBe('Antigravity') + }) + it('returns the MiniMax brand name', () => { expect(getProviderDisplayName('minimax')).toBe('MiniMax') }) @@ -17,6 +21,7 @@ describe('getProviderDisplayName', () => { expect(getProviderDisplayName('gemini')).toBe('Gemini') expect(getProviderDisplayName('opencode-go')).toBe('OpenCode Go') expect(getProviderDisplayName('kimi')).toBe('Kimi') + expect(getProviderDisplayName('grok')).toBe('Grok') }) it('falls back to the raw provider id when no mapping exists', () => { diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts index f6f9b8d699c..83fc1888beb 100644 --- a/src/renderer/src/components/status-bar/usage-error-copy.ts +++ b/src/renderer/src/components/status-bar/usage-error-copy.ts @@ -17,6 +17,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider']) if (provider === 'kimi') { return 'Kimi' } + if (provider === 'antigravity') { + return 'Antigravity' + } if (provider === 'minimax') { return 'MiniMax' } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index e5e9c328a6e..2632fffe8bd 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -79,8 +79,10 @@ import { useTabStripOverflowNavigation } from './tab-strip-overflow-navigation' import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { getConnectionIdFromState } from '@/lib/connection-context' const isWindows = navigator.userAgent.includes('Windows') const isMacOs = navigator.userAgent.includes('Mac') @@ -463,6 +465,9 @@ function TabBarInner({ useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {})) ) const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true) + const nativeChatTranscriptIsLocalReadable = useAppStore((s) => + isNativeChatTranscriptLocalReadable(getConnectionIdFromState(s, worktreeId)) + ) // Why: Electron elements run in a separate process, so clicking // inside one never dispatches a pointerdown on the renderer document. @@ -1124,6 +1129,7 @@ function TabBarInner({ launchAgent: terminalTab.launchAgent, detectedAgent, resolvedAgent, + nativeChatTranscriptIsLocalReadable, isChatViewMode: unifiedTabForItem.viewMode === 'chat' }) return ( diff --git a/src/renderer/src/components/task-page-jira-grouping.test.ts b/src/renderer/src/components/task-page-jira-grouping.test.ts new file mode 100644 index 00000000000..2627b62a4fa --- /dev/null +++ b/src/renderer/src/components/task-page-jira-grouping.test.ts @@ -0,0 +1,171 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import React from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { TooltipProvider } from '@/components/ui/tooltip' +import { groupJiraIssuesByStatus, TaskPageJiraIssueList } from './task-page-jira-issue-list' +import { + getSingleJiraProjectScope, + loadTaskPageJiraProjectStatusOrder +} from './task-page-jira-status-order' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' + +const { jiraGetProjectStatusOrderMock } = vi.hoisted(() => ({ + jiraGetProjectStatusOrderMock: vi.fn() +})) + +vi.mock('@/runtime/runtime-jira-client', () => ({ + jiraGetProjectStatusOrder: (...args: unknown[]) => jiraGetProjectStatusOrderMock(...args) +})) + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + jiraGetProjectStatusOrderMock.mockReset() +}) + +function jiraIssue( + key: string, + title: string, + statusId: string, + statusName: string, + options: { projectKey?: string; siteId?: string } = {} +): JiraIssue { + const siteId = options.siteId ?? 'site-1' + const projectKey = options.projectKey ?? 'ALP' + return { + id: `${siteId}:${key}`, + key, + title, + url: `https://example.atlassian.net/browse/${key}`, + siteId, + siteName: 'Example Jira', + project: { id: projectKey, key: projectKey, name: projectKey, siteId }, + issueType: { id: '10001', name: 'Bug' }, + status: { id: statusId, name: statusName, categoryKey: 'new', categoryName: statusName }, + labels: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z' + } +} + +function statusOrder(statusIdsByColumn: string[][]): JiraProjectStatusOrder { + return { statusIdsByColumn } +} + +describe('Jira issue status grouping', () => { + it('groups issues through the production implementation and preserves row order', () => { + const first = jiraIssue('ALP-1', 'First', '1', 'To Do') + const second = jiraIssue('ALP-2', 'Second', '2', 'In Progress') + const third = jiraIssue('ALP-3', 'Third', '1', 'To Do') + + const sections = groupJiraIssuesByStatus([first, second, third], null) + + expect(sections.map((section) => section.label)).toEqual(['In Progress', 'To Do']) + expect(sections[1]?.issues).toEqual([first, third]) + }) + + it('uses Jira column order while sorting statuses in the same column alphabetically', () => { + const sections = groupJiraIssuesByStatus( + [ + jiraIssue('ALP-1', 'Done issue', '3', 'Done'), + jiraIssue('ALP-2', 'To do issue', '1', 'To Do'), + jiraIssue('ALP-3', 'Progress issue', '2', 'In Progress') + ], + statusOrder([['1', '2'], ['3']]) + ) + + expect(sections.map((section) => section.label)).toEqual(['In Progress', 'To Do', 'Done']) + }) + + it('places statuses missing from board configuration last in alphabetical order', () => { + const sections = groupJiraIssuesByStatus( + [ + jiraIssue('ALP-1', 'Done issue', '3', 'Done'), + jiraIssue('ALP-2', 'To do issue', '1', 'To Do'), + jiraIssue('ALP-3', 'Progress issue', '2', 'In Progress') + ], + statusOrder([['2']]) + ) + + expect(sections.map((section) => section.label)).toEqual(['In Progress', 'Done', 'To Do']) + }) + + it('only selects a board-order scope for one Jira site and project', () => { + const singleScope = getSingleJiraProjectScope([ + jiraIssue('ALP-1', 'First', '1', 'To Do'), + jiraIssue('ALP-2', 'Second', '2', 'In Progress') + ]) + const multipleSites = getSingleJiraProjectScope([ + jiraIssue('ALP-1', 'First', '1', 'To Do', { siteId: 'site-1' }), + jiraIssue('ALP-2', 'Second', '2', 'In Progress', { siteId: 'site-2' }) + ]) + const multipleProjects = getSingleJiraProjectScope([ + jiraIssue('ALP-1', 'First', '1', 'To Do', { projectKey: 'ALP' }), + jiraIssue('BRV-1', 'Second', '2', 'In Progress', { projectKey: 'BRV' }) + ]) + const missingSite = jiraIssue('ALP-3', 'Third', '3', 'Done') + delete missingSite.siteId + delete missingSite.project.siteId + + expect(singleScope).toMatchObject({ projectKey: 'ALP', siteId: 'site-1' }) + expect(multipleSites).toBeNull() + expect(multipleProjects).toBeNull() + expect(getSingleJiraProjectScope([missingSite])).toBeNull() + }) + + it('uses alphabetical fallback when status-order metadata is unavailable', async () => { + const error = new Error('Unknown RPC method') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + jiraGetProjectStatusOrderMock.mockRejectedValueOnce(error) + const scope = getSingleJiraProjectScope([jiraIssue('ALP-1', 'First', '1', 'To Do')]) + if (!scope) { + throw new Error('Expected one Jira project scope') + } + + await expect(loadTaskPageJiraProjectStatusOrder(null, 'runtime:old', scope)).resolves.toEqual({ + statusIdsByColumn: [] + }) + expect(warn).toHaveBeenCalledWith('[jira] Failed to load project status order:', error) + }) + + it('collapses and expands a status group through its accessible trigger', async () => { + const user = userEvent.setup() + render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskPageJiraIssueList, { + formatUpdatedAt: () => 'today', + getStatusTone: () => 'border-border', + issues: [ + jiraIssue('ALP-1', 'First issue', '1', 'To Do'), + jiraIssue('ALP-2', 'Second issue', '1', 'To Do') + ], + onOpenIssue: vi.fn(), + onStartWorkspace: vi.fn(), + selectedIssue: null, + showSiteContext: false, + statusOrder: null + }) + ) + ) + + const trigger = screen.getByRole('button', { name: 'To Do 2' }) + expect(trigger).toHaveAttribute('data-variant', 'ghost') + expect(trigger).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('First issue')).toBeInTheDocument() + + await user.click(trigger) + expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('First issue')).not.toBeInTheDocument() + + await user.click(trigger) + expect(screen.getByText('First issue')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/task-page-jira-issue-list.tsx b/src/renderer/src/components/task-page-jira-issue-list.tsx new file mode 100644 index 00000000000..f8b67d1c20e --- /dev/null +++ b/src/renderer/src/components/task-page-jira-issue-list.tsx @@ -0,0 +1,349 @@ +import React, { useMemo, useState } from 'react' +import { ArrowRight, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' + +export type TaskPageJiraIssueSection = { + key: string + label: string + issues: JiraIssue[] +} + +type TaskPageJiraIssueListProps = { + formatUpdatedAt: (updatedAt: string) => string + getStatusTone: (categoryKey: string) => string + issues: JiraIssue[] + onOpenIssue: (issue: JiraIssue) => void + onStartWorkspace: (issue: JiraIssue) => void + selectedIssue: JiraIssue | null + showSiteContext: boolean + statusOrder: JiraProjectStatusOrder | null +} + +function statusColumnRanks(order: JiraProjectStatusOrder | null): Map { + const ranks = new Map() + for (const [columnIndex, statusIds] of (order?.statusIdsByColumn ?? []).entries()) { + for (const statusId of statusIds) { + if (!ranks.has(statusId)) { + ranks.set(statusId, columnIndex) + } + } + } + return ranks +} + +function sectionColumnRank( + section: TaskPageJiraIssueSection, + ranks: ReadonlyMap +): number { + let rank = Number.POSITIVE_INFINITY + for (const issue of section.issues) { + rank = Math.min(rank, ranks.get(issue.status.id) ?? Number.POSITIVE_INFINITY) + } + return rank +} + +export function groupJiraIssuesByStatus( + issues: readonly JiraIssue[], + statusOrder: JiraProjectStatusOrder | null +): TaskPageJiraIssueSection[] { + const sections = new Map() + for (const issue of issues) { + const key = `status:${issue.status.name}` + const section = sections.get(key) + if (section) { + section.issues.push(issue) + } else { + sections.set(key, { key, label: issue.status.name, issues: [issue] }) + } + } + + const ranks = statusColumnRanks(statusOrder) + const sectionRanks = new Map( + [...sections.values()].map((section) => [section.key, sectionColumnRank(section, ranks)]) + ) + return [...sections.values()].sort((a, b) => { + const rankA = sectionRanks.get(a.key) ?? Number.POSITIVE_INFINITY + const rankB = sectionRanks.get(b.key) ?? Number.POSITIVE_INFINITY + return rankA === rankB ? a.label.localeCompare(b.label) : rankA - rankB + }) +} + +function isSelectedIssue(issue: JiraIssue, selectedIssue: JiraIssue | null): boolean { + if (!selectedIssue || issue.key !== selectedIssue.key) { + return false + } + return !selectedIssue.siteId || !issue.siteId || selectedIssue.siteId === issue.siteId +} + +function JiraIssueRow({ + formatUpdatedAt, + getStatusTone, + issue, + onOpenIssue, + onStartWorkspace, + selected, + showSiteContext +}: { + formatUpdatedAt: (updatedAt: string) => string + getStatusTone: (categoryKey: string) => string + issue: JiraIssue + onOpenIssue: (issue: JiraIssue) => void + onStartWorkspace: (issue: JiraIssue) => void + selected: boolean + showSiteContext: boolean +}): React.JSX.Element { + const labels = issue.labels.slice(0, 3) + const contextLabel = + showSiteContext && issue.siteName + ? `${issue.siteName} / ${issue.project.key}` + : issue.project.key + + return ( + // Why: the row contains action buttons, so a native button wrapper would + // create invalid nested buttons; role + keyboard handling preserves access. +
onOpenIssue(issue)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) { + return + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onOpenIssue(issue) + } + }} + className={cn( + 'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring md:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px] lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]', + selected && 'bg-accent' + )} + > + + {issue.key} + + +
+
+ + {issue.key} + +

+ {issue.title} +

+
+
+ + {issue.status.name} + + + {issue.priority?.name ?? + translate('auto.components.TaskPage.713179dfdc', 'No priority')} + + + {issue.assignee?.displayName ?? + translate('auto.components.TaskPage.42a9160321', 'Unassigned')} + +
+
+ + {contextLabel} + + {labels.map((label) => ( + + {label} + + ))} + {issue.labels.length > labels.length ? ( + + +{issue.labels.length - labels.length} + + ) : null} +
+
+ +
+ + {issue.status.name} + +
+ + + {issue.priority?.name ?? translate('auto.components.TaskPage.713179dfdc', 'No priority')} + + +
+ {issue.assignee?.avatarUrl ? ( + {issue.assignee.displayName} + ) : ( + + {issue.assignee?.displayName?.slice(0, 1) ?? '-'} + + )} + + {issue.assignee?.displayName ?? + translate('auto.components.TaskPage.42a9160321', 'Unassigned')} + +
+ + + +
+ {formatUpdatedAt(issue.updatedAt)} +
+
+ + {new Date(issue.updatedAt).toLocaleString()} + +
+ +
+ + + + + + {translate('auto.components.TaskPage.9497f2787c', 'Start workspace')} + + + + + + + + {translate('auto.components.TaskPage.eee68073b2', 'Open in Jira')} + + +
+
+ ) +} + +export function TaskPageJiraIssueList({ + formatUpdatedAt, + getStatusTone, + issues, + onOpenIssue, + onStartWorkspace, + selectedIssue, + showSiteContext, + statusOrder +}: TaskPageJiraIssueListProps): React.JSX.Element { + const [collapsedGroups, setCollapsedGroups] = useState>(() => new Set()) + const sections = useMemo( + () => groupJiraIssuesByStatus(issues, statusOrder), + [issues, statusOrder] + ) + + return ( +
+ {sections.map((section) => { + const open = !collapsedGroups.has(section.key) + return ( + { + setCollapsedGroups((current) => { + const next = new Set(current) + if (nextOpen) { + next.delete(section.key) + } else { + next.add(section.key) + } + return next + }) + }} + > + + + + + {section.issues.map((issue) => ( + + ))} + + + ) + })} +
+ ) +} diff --git a/src/renderer/src/components/task-page-jira-status-order.ts b/src/renderer/src/components/task-page-jira-status-order.ts new file mode 100644 index 00000000000..8cb6ed39c37 --- /dev/null +++ b/src/renderer/src/components/task-page-jira-status-order.ts @@ -0,0 +1,63 @@ +import type { JiraIssue, JiraProjectStatusOrder } from '../../../shared/types' +import { jiraGetProjectStatusOrder, type RuntimeJiraSettings } from '@/runtime/runtime-jira-client' +import { createMetadataRequestStore, loadMetadata } from '@/hooks/metadata-request-cache' + +export type TaskPageJiraProjectScope = { + key: string + projectKey: string + siteId: string +} + +const jiraProjectStatusOrderStore = createMetadataRequestStore() + +export function getTaskPageJiraStatusOrderScopeKey( + runtimeScopeKey: string, + projectScope: TaskPageJiraProjectScope +): string { + return `${encodeURIComponent(runtimeScopeKey)}:${projectScope.key}` +} + +function issueProjectScope(issue: JiraIssue): TaskPageJiraProjectScope | null { + const projectKey = issue.project.key.trim() + const siteId = issue.siteId?.trim() || issue.project.siteId?.trim() + if (!projectKey || !siteId) { + return null + } + return { + key: `${encodeURIComponent(siteId)}:${encodeURIComponent(projectKey)}`, + projectKey, + siteId + } +} + +export function getSingleJiraProjectScope( + issues: readonly JiraIssue[] +): TaskPageJiraProjectScope | null { + let onlyScope: TaskPageJiraProjectScope | null = null + for (const issue of issues) { + const scope = issueProjectScope(issue) + if (!scope || (onlyScope && onlyScope.key !== scope.key)) { + return null + } + onlyScope = scope + } + return onlyScope +} + +export function loadTaskPageJiraProjectStatusOrder( + settings: RuntimeJiraSettings, + runtimeScopeKey: string, + projectScope: TaskPageJiraProjectScope +): Promise { + // Why: a project key can exist on multiple Jira sites and runtimes; all three + // identities must participate in caching to preserve SSH and multi-site parity. + const cacheKey = getTaskPageJiraStatusOrderScopeKey(runtimeScopeKey, projectScope) + return loadMetadata(jiraProjectStatusOrderStore, cacheKey, () => + jiraGetProjectStatusOrder(settings, projectScope.projectKey, projectScope.siteId) + ).catch((error: unknown) => { + // Why: older remote runtimes do not expose this optional metadata method; + // an empty order preserves the deterministic alphabetical fallback. + console.warn('[jira] Failed to load project status order:', error) + return { statusIdsByColumn: [] } + }) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 7bdb5d20392..297b28da36d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -43,6 +43,7 @@ import { serializeTerminalLayout } from './layout-serialization' import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { applyExpandedLayoutTo, cancelPendingPaneSizeRefreshFrames, @@ -99,6 +100,7 @@ import { } from '@/lib/pane-manager/mobile-driver-state' import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send-eligibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import type { AgentType } from '../../../../shared/agent-status-types' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' import { safeFit } from '@/lib/pane-manager/pane-tree-ops' @@ -295,6 +297,10 @@ export default function TerminalPane({ // read this map at dispatch time to pass cwd into splitPane. const paneCwdRef = useRef>(new Map()) const paneMode2031Ref = useRef>(new Map()) + // Why: per-pane mirror of the kitty keyboard flags negotiated by the pane's + // application (fed from PTY output in pty-connection). The keyboard policy + // reads it to encode Option chords as kitty CSI-u for opted-in TUIs. + const paneKittyKeyboardModesRef = useRef>(new Map()) const paneLastThemeModeRef = useRef>(new Map()) const panePtyBindingsRef = useRef>(new Map()) // Why: tracks panes currently replaying recorded PTY bytes into xterm @@ -316,6 +322,9 @@ export default function TerminalPane({ } return connectionId }) + const nativeChatTranscriptIsLocalReadable = useAppStore((store) => + isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) + ) // Which machine's SSH store this target belongs to: a remote Orca server's // per-environment bucket, or null for this machine's local SSH maps. The // explicit-owner resolver never lets a merely focused runtime make a @@ -719,6 +728,7 @@ export default function TerminalPane({ launchAgent: detectedAgent ? null : terminalTab?.launchAgent, detectedAgent, resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId), + nativeChatTranscriptIsLocalReadable, isChatViewMode: isChatViewForLeaf }) }, @@ -727,6 +737,7 @@ export default function TerminalPane({ effectiveChatViewMode, chatLeafId, nativeChatEnabled, + nativeChatTranscriptIsLocalReadable, terminalTab?.launchAgent, resolveTitleAgentForLeaf ] @@ -1466,6 +1477,7 @@ export default function TerminalPane({ paneTransportsRef, paneCwdRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, panePtyBindingsRef, replayingPanesRef, @@ -1690,6 +1702,7 @@ export default function TerminalPane({ startup: { command: 'codex' }, paneTransportsRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, replayingPanesRef, isActiveRef, @@ -1789,6 +1802,7 @@ export default function TerminalPane({ searchOpenRef, searchStateRef, macOptionAsAltRef, + paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy: settings?.terminalShortcutPolicy ?? 'orca-first' }) diff --git a/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-path-fragments.ts b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-path-fragments.ts new file mode 100644 index 00000000000..fed1fc1ac5e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hard-wrapped-terminal-path-fragments.ts @@ -0,0 +1,68 @@ +export type HardWrappedPathFragmentRow = { + text: string + sourceText: string + columns: number[] + isWrapped: boolean + lineLength: number +} + +const HARD_WRAPPED_PATH_FRAGMENT_PATTERN = /^[A-Za-z0-9._~@%+=:,/\\-]+$/ + +export function isHardWrappedPathFragment(text: string): boolean { + return HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(text) && /[A-Za-z0-9]/.test(text) +} + +export function isIncompleteHardWrappedPathStart(text: string): boolean { + // Why: a terminal row can end immediately after a complete root, drive, or + // relative prefix, before the continuation contributes path-name characters. + return /^(?:[/\\]|\.{1,2}\/|~\/|[A-Za-z]:)$/.test(text) +} + +export function isHardWrappedPathContinuation(text: string): boolean { + return isHardWrappedPathFragment(text) || isIncompleteHardWrappedPathStart(text) +} + +export function canStartHardWrappedPath(text: string): boolean { + if (!isHardWrappedPathFragment(text)) { + return /(?:^|[\s•*>-])(?:\/|\.{1,2}\/|[A-Za-z0-9._-]+\/)[A-Za-z0-9._~@%+=:,/\\-]*$/.test(text) + } + + return /(?:\/|\\)/.test(text) +} + +function sliceHardWrappedPathFragmentRow( + row: HardWrappedPathFragmentRow, + startIndex: number, + endIndex: number +): HardWrappedPathFragmentRow { + return { + ...row, + text: row.text.slice(startIndex, endIndex), + columns: row.columns.slice(startIndex, endIndex + 1) + } +} + +export function getHardWrappedPathSuffix( + row: HardWrappedPathFragmentRow +): HardWrappedPathFragmentRow | null { + let startIndex = row.text.length + while (startIndex > 0 && HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(row.text[startIndex - 1])) { + startIndex-- + } + const suffix = sliceHardWrappedPathFragmentRow(row, startIndex, row.text.length) + return isHardWrappedPathContinuation(suffix.text) ? suffix : null +} + +export function getHardWrappedPathPrefix( + row: HardWrappedPathFragmentRow +): HardWrappedPathFragmentRow | null { + let endIndex = 0 + while ( + endIndex < row.text.length && + HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(row.text[endIndex]) + ) { + endIndex++ + } + const prefix = sliceHardWrappedPathFragmentRow(row, 0, endIndex) + return isHardWrappedPathContinuation(prefix.text) ? prefix : null +} diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index ed78a5d6500..b5bf46b7d0c 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -14,7 +14,12 @@ import { type TerminalShortcutPolicy } from '../../../../shared/keybindings' import type { PaneCwdMap } from './resolve-split-cwd' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { keyboardEventBelongsToScope } from './terminal-keyboard-scope' +import { + getLayoutBaseCharacterForCode, + prefetchLayoutBaseCharacters +} from '@/lib/keyboard-layout/layout-base-character' import { normalizeSelectedTextForFileSearch } from '@/lib/file-search-selection' import { isFindQueryTooLarge } from '@/lib/find-query-bounds' import { handleEmptyFloatingWorkspacePanelCloseShortcut } from '@/lib/floating-workspace-terminal-actions' @@ -161,6 +166,7 @@ type KeyboardHandlersDeps = { searchOpenRef: React.RefObject searchStateRef: React.RefObject macOptionAsAltRef: React.RefObject + paneKittyKeyboardModesRef?: React.RefObject> keybindings?: KeybindingOverrides terminalShortcutPolicy?: TerminalShortcutPolicy } @@ -194,6 +200,7 @@ export function useTerminalKeyboardShortcuts({ searchOpenRef, searchStateRef, macOptionAsAltRef, + paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy = 'orca-first' }: KeyboardHandlersDeps): void { @@ -206,6 +213,12 @@ export function useTerminalKeyboardShortcuts({ const isWindows = navigator.userAgent.includes('Windows') const shortcutPlatform: KeybindingPlatform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' + // Why: kitty Option-chord encoding resolves base keys through the async + // KeyboardLayoutMap; prefetch so the map is cached before the first chord. + if (isMac) { + prefetchLayoutBaseCharacters() + } + // Why: KeyboardEvent.location on a character key (e.g. Period) always // reports that key's own position (0 = standard), not which modifier is // held. To distinguish left vs right Option, we record the Option key's @@ -293,6 +306,17 @@ export function useTerminalKeyboardShortcuts({ }) } + // Why: the pane's TUI opted into kitty keyboard reporting via CSI > u; + // the tracker mirrors that from PTY output so the policy can encode + // Option chords the way the application negotiated. + const isKittyKeyboardActivePane = (): boolean => { + const activePane = manager.getActivePane() ?? manager.getPanes()[0] + if (!activePane) { + return false + } + return (paneKittyKeyboardModesRef?.current.get(activePane.id)?.flags ?? 0) > 0 + } + const action = resolveTerminalShortcutAction( e, isMac, @@ -300,7 +324,9 @@ export function useTerminalKeyboardShortcuts({ optionKeyLocation, isWindows, keybindings, - isLocalWindowsConptyPane + isLocalWindowsConptyPane, + isKittyKeyboardActivePane, + getLayoutBaseCharacterForCode ) if (!action) { return @@ -537,6 +563,7 @@ export function useTerminalKeyboardShortcuts({ searchOpenRef, searchStateRef, macOptionAsAltRef, + paneKittyKeyboardModesRef, keybindings, terminalShortcutPolicy, tabId, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 01c98e84557..70abd4fae3f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -7,6 +7,7 @@ import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color- import type { StartupCommandDelivery } from '../../../../shared/codex-startup-delivery' import type { SetupSplitDirection, TuiAgent } from '../../../../shared/types' import type { SleepingAgentLaunchConfig } from '../../../../shared/agent-session-resume' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' export type PtyConnectionDeps = { tabId: string @@ -37,6 +38,10 @@ export type PtyConnectionDeps = { restoredPtyIdByLeafId?: Record paneTransportsRef: React.RefObject> paneMode2031Ref: React.RefObject> + /** Per-pane mirror of the kitty keyboard flags the pane's application + * negotiated. Fed from PTY output here; read by the keyboard policy. */ + paneKittyKeyboardModesRef: React.RefObject> + paneLastThemeModeRef: React.RefObject> replayingPanesRef: ReplayingPanesRef restoredViewportBlankingPanesRef?: RestoredViewportBlankingPanesRef 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 9368f810cbf..72aac62a8e8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -486,6 +486,7 @@ function createDeps(overrides: Record = {}) { restoredPtyIdByLeafId: {}, paneTransportsRef: { current: new Map() }, paneMode2031Ref: { current: new Map() }, + paneKittyKeyboardModesRef: { current: new Map() }, paneLastThemeModeRef: { current: new Map() }, replayingPanesRef: { current: new Map() }, isActiveRef: { current: true }, @@ -921,6 +922,32 @@ describe('connectPanePty', () => { ) }) + // Why: a late exit from a replaced PTY takes the stale-transport early + // return in onExit and skips the kitty mirror reset there — a fresh spawn + // must therefore reset the reused per-pane tracker itself, or a + // restart-in-place leaks the old TUI's kitty flags into a fresh shell. + it('resets a stale kitty keyboard mirror when spawning a fresh PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + const { TerminalKittyKeyboardModeTracker } = + await import('../../../../shared/terminal-kitty-keyboard-mode-tracker') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const staleTracker = new TerminalKittyKeyboardModeTracker() + staleTracker.scan('\x1b[>1u') + expect(staleTracker.flags).toBe(1) + // Why: a unique tab id keeps this pane's key clear of pendingSpawnByPaneKey + // entries from other tests, so the connect deterministically fresh-spawns. + const deps = createDeps({ + tabId: 'tab-kitty-fresh-spawn', + paneKittyKeyboardModesRef: { current: new Map([[91, staleTracker]]) } + }) + + connectPanePty(createPane(91) as never, createManager(91) as never, deps as never) + await flushAsyncTicks() + + expect(staleTracker.flags).toBe(0) + }) + it('threads the resolved local project runtime into IPC terminal transport options', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -5630,6 +5657,24 @@ describe('connectPanePty', () => { expect(transport.sendInput).toHaveBeenCalledWith('yes') expect(transport.sendInput).toHaveBeenCalledWith('\x1b[A') expect(transport.sendInputImmediate).not.toHaveBeenCalled() + + // terminal-query-reply.test proves real xterm emits this as one fully framed + // onData reply; this pins that production-shaped reply to the immediate path. + transport.sendInputImmediate.mockClear() + const xtversionReply = '\x1bP>|xterm.js(6.1.0-beta.287)\x1b\\' + sendTerminalInputThroughPane(pane, xtversionReply) + expect(transport.sendInputImmediate).toHaveBeenCalledWith(xtversionReply) + + // Printable input is user-owned. Remote cooked echo comes back through PTY + // output, not onData, so xterm/OSC-looking text must stay on normal input. + transport.sendInput.mockClear() + transport.sendInputImmediate.mockClear() + const printableInputs = [']10;hello', '>|xterm.js(6.1.0-beta.287)', ']|literal-text'] + for (const data of printableInputs) { + sendTerminalInputThroughPane(pane, data) + expect(transport.sendInput).toHaveBeenCalledWith(data) + } + expect(transport.sendInputImmediate).not.toHaveBeenCalled() }) it('writes the onReplayData pendingEscapeTailAnsi meta last, after the replayed bytes (#7329)', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index e86afd8018e..94fd3774fa4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -9,6 +9,7 @@ import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' @@ -1125,6 +1126,19 @@ export function connectPanePty( // Why: paneKey crosses PTY env, hook IPC, retained rows, and reload/replay. // Use the stable layout leaf UUID, not the renderer-local numeric pane id. const cacheKey = makePaneKey(deps.tabId, pane.leafId) + // Why: mirrors the kitty keyboard flags the pane's application negotiates. + // Fed only from application output (live PTY bytes + daemon replay + // payloads), never from renderer-generated resets, so it reflects what the + // application expects even after defensive renderer-side kitty wipes. + const kittyKeyboardModes = (() => { + const existing = deps.paneKittyKeyboardModesRef.current.get(pane.id) + if (existing) { + return existing + } + const created = new TerminalKittyKeyboardModeTracker() + deps.paneKittyKeyboardModesRef.current.set(pane.id, created) + return created + })() const getSleepingRecordForPane = ( state: ReturnType ): { paneKey: string; record: SleepingAgentSessionRecord } | null => { @@ -2178,6 +2192,9 @@ export function connectPanePty( handledExitPtyId = ptyId agentCompletionCoordinator.dispose() clearPanePtyFitBinding() + // Why: the negotiating application died with its PTY; any replacement + // session starts with kitty keyboard flags at zero. + kittyKeyboardModes.reset() const isSuppressedExit = deps.consumeSuppressedPtyExit(ptyId) if (!isSuppressedExit) { deps.clearExitedPanePtyLayoutBinding(pane.id, ptyId) @@ -3819,6 +3836,11 @@ export function connectPanePty( ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() + // Why: a fresh spawn is a new process with kitty keyboard flags at + // zero. The exit-handler reset alone is not enough: a late exit from a + // replaced PTY takes the stale-transport early return and skips it, so + // a restart-in-place would leak the old TUI's flags into a fresh shell. + kittyKeyboardModes.reset() prepareFreshShellViewportForSpawn(options) if (connectionId && startupOverride?.command) { // Why: SSH providers use `command` only as spawn metadata; the renderer @@ -4183,6 +4205,11 @@ export function connectPanePty( // must clear a stale agent signal from an earlier payload. rememberReattachPayloadAgentSignal(data, { fullScreenReplay: clearBeforeReplay }) } + // Why: replayed application bytes carry the live TUI's kitty keyboard + // negotiation; the mirror must re-arm from them after a reload. Replay + // semantics: relay reconnects redeliver the same window, so pushes + // apply as sets to keep the mirrored stack from accumulating frames. + kittyKeyboardModes.scanReplay(data) await writeReplayDataAsync(data) if (clearBeforeReplay || data.length > 0) { await writeReplayDataAsync(reattachReplayResetSequence(data)) @@ -4466,6 +4493,10 @@ export function connectPanePty( foreground: boolean, opts?: { hiddenStartupRendererQuery?: boolean } ): void { + // Why: every application byte funnels through here (foreground, hidden, + // and background writes), so this is the one place the kitty keyboard + // mirror observes the pane's protocol negotiation. + kittyKeyboardModes.scan(data) if (foreground) { resetHiddenOutputRestoreIfPtyChanged() resetHiddenRendererRiskState() @@ -5571,6 +5602,10 @@ export function connectPanePty( } } writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: the daemon snapshot's rehydrate preamble carries the live + // session's kitty keyboard flags; re-arm the mirror from it so Option + // chords keep their kitty encoding after a window reload. + kittyKeyboardModes.scanReplay(connectResult.snapshot) writeReplayData(connectResult.snapshot) // Snapshot reattach keeps a live session, so avoid the broader mode // reset. We only drop renderer-owned state that should not leak from @@ -5597,6 +5632,10 @@ export function connectPanePty( // duplication. The reattach reset clears renderer-owned state without // tearing down the still-running TUI's live modes. writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: raw relay replay contains the application's own kitty pushes + // when they fall inside the retained window; re-arm the mirror with + // replay (set) semantics so redelivery cannot grow the stack. + kittyKeyboardModes.scanReplay(connectResult.replay) writeReplayData(connectResult.replay) writeReplayData(reattachReplayResetSequence(connectResult.replay)) sendFocusedReattachFocusInAfterReplay() @@ -5624,6 +5663,9 @@ export function connectPanePty( // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so // reset them to match the fresh shell's expectations. writeReplayData(POST_REPLAY_MODE_RESET) + // Why: the dead run's scrollback was never scanned, and any kitty + // flags it pushed died with it — the fresh shell starts at zero. + kittyKeyboardModes.reset() consumeRestoredViewportBlankingMarker() writeFreshShellViewportBlanking() if (!isRemoteRuntimePtyId(ptyId)) { diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index 896e823d127..bce9767bd85 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -41,6 +41,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { resize: vi.fn(), kill: vi.fn(), ackData: vi.fn(), + rendererDispatcherReady: vi.fn(), onData: vi.fn( ( cb: (payload: { @@ -95,6 +96,19 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { ptyDataHandlers.delete('pty-pi') }) + it('signals main that the pty:data listener is live exactly once per page load', async () => { + // Why: main holds every pty send until this handshake arrives, so a send that + // silently became a no-op (it is optional-chained) would pin the delivery gate + // until the 10s self-heal watchdog. Assert it fires, and fires only once — the + // second transport attach must not re-signal (ptyDispatcherAttached guard). + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + + ensurePtyDispatcher() + ensurePtyDispatcher() + + expect(window.api.pty.rendererDispatcherReady).toHaveBeenCalledTimes(1) + }) + it('routes Pi OSC title frames from pty:data → onTitleChange via the dispatcher', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const onTitleChange = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index f8333edac3e..4c3857d9a09 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -174,6 +174,11 @@ export function ensurePtyDispatcher(): void { } } }) + // Why: tell main the pty:data listener is live now. Before this fires (fresh + // load or post-reload boot window) main holds all sends — bytes sent into a + // listener-less page are silently dropped yet counted in-flight, which + // permanently pins the delivery gate. Fires once per page load. + window.api.pty.rendererDispatcherReady?.() } export function subscribeToPtyExit(ptyId: string, watcher: (code: number) => void): () => void { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts index 881dfa435b0..d07bd2900af 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../../../shared/clipboard-text' -import { createRemoteRuntimePtyTextBatcher } from './remote-runtime-pty-batching' +import { + createRemoteRuntimePtyTextBatcher, + createRemoteRuntimeViewportBatcher +} from './remote-runtime-pty-batching' describe('createRemoteRuntimePtyTextBatcher', () => { it('coalesces small input until the debounce flush', async () => { @@ -139,3 +142,42 @@ describe('createRemoteRuntimePtyTextBatcher', () => { } }) }) + +describe('createRemoteRuntimeViewportBatcher', () => { + it('drops the queued viewport on clear so a later flush emits nothing', () => { + vi.useFakeTimers() + try { + const resizes: { cols: number; rows: number }[] = [] + const batcher = createRemoteRuntimeViewportBatcher(33, (cols, rows) => { + resizes.push({ cols, rows }) + }) + + batcher.queue(120, 40) + batcher.clear() + // A stale pending viewport left behind by clear() would leak out here. + batcher.flush() + + expect(resizes).toEqual([]) + } finally { + vi.useRealTimers() + } + }) + + it('does not emit a cleared viewport when the debounce timer would have fired', () => { + vi.useFakeTimers() + try { + const resizes: { cols: number; rows: number }[] = [] + const batcher = createRemoteRuntimeViewportBatcher(33, (cols, rows) => { + resizes.push({ cols, rows }) + }) + + batcher.queue(90, 30) + batcher.clear() + vi.advanceTimersByTime(100) + + expect(resizes).toEqual([]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts index 48a37e4b9d7..33561b06953 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-batching.ts @@ -173,6 +173,9 @@ export function createRemoteRuntimeViewportBatcher( clearTimeout(timer) timer = null } + // Why: also drop the queued viewport so a later flush()/reuse can't emit a + // stale resize after the batcher was cleared on teardown/resubscribe. + pending = null } const flush = (): void => { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 69805980928..de741deedeb 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -1990,9 +1990,9 @@ describe('createRemoteRuntimePtyTransport', () => { emitOutput(streamId, 'live-after-overflow') expect(onReplayData).not.toHaveBeenCalled() - expect(onError).toHaveBeenCalledWith( - 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' - ) + // Why: an oversized snapshot is skipped but live output continues, so the + // transport classifies it as benign and never surfaces a fatal red banner. + expect(onError).not.toHaveBeenCalled() expect(onConnect).toHaveBeenCalled() expect(onData).toHaveBeenCalledWith('live-after-overflow', expect.objectContaining({ seq: 1 })) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index e9a8f361f5b..b4eea73e0d7 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -21,6 +21,7 @@ import { } from '../../runtime/runtime-terminal-stream' import { getRemoteRuntimeTerminalMultiplexer, + REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE, type RemoteRuntimeMultiplexedTerminal } from '../../runtime/remote-runtime-terminal-multiplexer' import { @@ -373,6 +374,11 @@ export function createRemoteRuntimePtyTransport( function handleRemoteTerminalError(error: unknown): void { const message = runtimeTerminalErrorMessage(error) + if (message === REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE) { + // Why: an oversized initial snapshot is skipped but live output keeps + // flowing — informational, not fatal, so never surface a red xterm banner. + return + } if (isRemoteTerminalGoneMessage(message)) { // Why: paired web clients consume host-published PTY handles. If the host // retires one between snapshots, clear this mirror and wait for the next diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts index 228ff9ddca6..c0112fa2c41 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts @@ -55,11 +55,15 @@ describe('terminal IME candidate key release guard', () => { it('does not guard fresh keydowns, other keys, modified keypresses, or expired keypresses', () => { const releases = createTerminalImePendingCandidateKeyReleases() armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) - expect(shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20)).toBe( - false - ) expect( - shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + shouldApplyTerminalImePendingCandidateKeyRelease(event({ key: '2' }), releases, 20) + ).toBe(false) + expect( + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '3' }), + releases, + 20 + ) ).toBe(false) expect( shouldApplyTerminalImePendingCandidateKeyRelease( @@ -157,10 +161,18 @@ describe('terminal IME candidate key release guard', () => { armTerminalImePendingCandidateKeyRelease(releases, event({ key: '2' }), 10) armTerminalImePendingCandidateKeyRelease(releases, event({ key: '3' }), 12) expect( - shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '2' }), releases, 20) + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '2' }), + releases, + 20 + ) ).toBe(true) expect( - shouldApplyTerminalImePendingCandidateKeyRelease(event({ type: 'keyup', key: '3' }), releases, 20) + shouldApplyTerminalImePendingCandidateKeyRelease( + event({ type: 'keyup', key: '3' }), + releases, + 20 + ) ).toBe(true) // The first key's keyup no longer strands the second key's pending release. clearTerminalImePendingCandidateKeyRelease(releases, event({ type: 'keyup', key: '2' })) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts index 1e363adcec4..858745afb3d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts @@ -11,7 +11,10 @@ type TrackerHarness = { tracker: TerminalImeCompositionTracker element: HTMLElement advance: (ms: number) => void - composition: (type: 'compositionstart' | 'compositionupdate' | 'compositionend', data: string) => void + composition: ( + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data: string + ) => void input: (inputType: string) => void blur: () => void } diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts new file mode 100644 index 00000000000..45131aeb513 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' +import { buildDefaultTerminalOptions } from '@/lib/pane-manager/pane-terminal-options' +import { resolvePaneKeyboardProtocolAgent } from './terminal-keyboard-protocol-pane-agent' + +const localWindowsConpty = { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe', + executionHostId: 'local' as const +} + +function resolvedKittyKeyboard( + startup: { launchAgent?: 'grok' } | null | undefined, + tabLaunchAgent: 'grok' | null = null +): boolean | undefined { + const tuiAgent = resolvePaneKeyboardProtocolAgent(startup, tabLaunchAgent) + const options = { + ...buildDefaultTerminalOptions(), + ...buildTerminalKeyboardProtocolOptions({ ...localWindowsConpty, tuiAgent }) + } + return options.vtExtensions?.kittyKeyboard +} + +describe('pane-scoped terminal keyboard protocol agent', () => { + it('keeps KKP for the Grok startup pane but withholds it from a later shell split', () => { + expect(resolvedKittyKeyboard({ launchAgent: 'grok' })).toBe(true) + + // The lifecycle consumes its one-shot startup before a user-created split. + expect(resolvedKittyKeyboard(null)).toBe(false) + }) + + it('uses persisted Grok identity for a restored root but not its later sibling', () => { + expect(resolvedKittyKeyboard(undefined, 'grok')).toBe(true) + + // onPaneCreated changes the lifecycle startup sentinel from undefined to null. + expect(resolvedKittyKeyboard(null, 'grok')).toBe(false) + }) + + it('keeps explicit pane startup identity ahead of a stale tab launch agent', () => { + expect(resolvePaneKeyboardProtocolAgent({ launchAgent: 'claude' }, 'grok')).toBe('claude') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts new file mode 100644 index 00000000000..89fb02cbd67 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-protocol-pane-agent.ts @@ -0,0 +1,16 @@ +import type { TuiAgent } from '../../../../shared/types' + +type PaneKeyboardProtocolStartup = { + launchAgent?: TuiAgent +} + +/** Resolves only the agent owned by the startup payload for the pane being created. */ +export function resolvePaneKeyboardProtocolAgent( + startup: PaneKeyboardProtocolStartup | null | undefined, + tabLaunchAgent?: TuiAgent | null +): TuiAgent | null { + if (startup === undefined) { + return tabLaunchAgent ?? null + } + return startup?.launchAgent ?? null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts index c75dd3943e7..3840c7c962a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts @@ -1987,6 +1987,84 @@ describe('createFilePathLinkProvider range bounds', () => { expect(continuationLink!.range).toEqual(firstRowLink!.range) }) + it('returns all three sibling links and the same boundary link from either row over SSH', async () => { + const firstPath = 'validation-screenshots/01-before-white-terminal-scrollbar-gutter.png' + const middleStart = 'validation-screenshots/02-after-' + const middleEnd = 'transparent-terminal-scrollbar-gutter.png' + const middlePath = middleStart + middleEnd + const thirdPath = 'validation-screenshots/03-after-light-theme.png' + const rows = [ + makeBufferLine(`${firstPath} · ${middleStart}`), + makeBufferLine(`${middleEnd} · ${thirdPath}`) + ] + const completePaths = new Set([firstPath, middlePath, thirdPath].map((path) => `/repo/${path}`)) + vi.mocked(getConnectionId).mockReturnValue('ssh-wrapped') + fsPathExistsMock.mockImplementation(async ({ filePath }) => completePaths.has(filePath)) + const { provider } = createProviderSetup(rows, new Map()) + const provide = (line: number): Promise => + new Promise((resolve) => provider.provideLinks(line, (links) => resolve(links ?? []))) + + const firstRowLinks = await provide(1) + const secondRowLinks = await provide(2) + const firstMiddle = firstRowLinks.find((link) => link.text === middlePath) + const secondMiddle = secondRowLinks.find((link) => link.text === middlePath) + + expect(firstRowLinks.map((link) => link.text)).toEqual([firstPath, middlePath]) + expect(secondRowLinks.map((link) => link.text)).toEqual([middlePath, thirdPath]) + expect(new Set([...firstRowLinks, ...secondRowLinks].map((link) => link.text))).toEqual( + new Set([firstPath, middlePath, thirdPath]) + ) + expect(firstMiddle?.range).toEqual({ + start: { x: firstPath.length + ' · '.length + 1, y: 1 }, + end: { x: middleEnd.length, y: 2 } + }) + expect(secondMiddle?.range).toEqual(firstMiddle?.range) + expect([...firstRowLinks, ...secondRowLinks].every((link) => !link.text.includes(' · '))).toBe( + true + ) + expect(fsPathExistsMock).toHaveBeenCalledWith({ + filePath: `/repo/${middlePath}`, + connectionId: 'ssh-wrapped' + }) + expect(window.api.shell.pathExists).not.toHaveBeenCalled() + }) + + it('opens the same boundary path from direct clicks on both physical halves', async () => { + setPlatform('Macintosh') + const firstPath = 'validation-screenshots/01-before-white-terminal-scrollbar-gutter.png' + const middleStart = 'validation-screenshots/02-after-' + const middleEnd = 'transparent-terminal-scrollbar-gutter.png' + const middlePath = middleStart + middleEnd + const thirdPath = 'validation-screenshots/03-after-light-theme.png' + const rows = [ + makeBufferLine(`${firstPath} · ${middleStart}`), + makeBufferLine(`${middleEnd} · ${thirdPath}`) + ] + const pathExistsCache = new Map([[`active\0/repo/${middlePath}`, true]]) + const positions = [ + { x: firstPath.length + ' · '.length + 2, y: 1 }, + { x: 2, y: 2 } + ] + + for (const position of positions) { + const opened = openFilePathLinkAtBufferPosition(makeBuffer(rows), position, 133, { + startupCwd: '/repo', + worktreeId: 'wt-1', + worktreePath: '/repo', + runtimeEnvironmentId: null, + pathExistsCache + }) + await flushDoubleRaf() + + expect(opened).toBe(true) + expect(openFileMock).toHaveBeenLastCalledWith( + expect.objectContaining({ filePath: `/repo/${middlePath}` }), + { forceContentReload: true } + ) + } + expect(openFileMock).toHaveBeenCalledTimes(2) + }) + it('maps file link columns through multi-code-unit characters before the path', async () => { const text = 'e\u0301 src/main.ts' const columns = [0, 0, 1] diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index 0f0c4cec3d1..8bf8159d9d1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -510,3 +510,174 @@ describe('resolveTerminalShortcutAction', () => { ).toEqual({ type: 'splitActivePane', direction: 'horizontal' }) }) }) + +describe('kitty keyboard protocol panes', () => { + const kittyActive = (): boolean => true + const kittyInactive = (): boolean => false + + const resolveKitty = ( + input: TerminalShortcutEvent, + macOptionAsAlt: 'true' | 'false' | 'left' | 'right' = 'false', + optionKeyLocation = 0, + active: () => boolean = kittyActive + ) => + resolveTerminalShortcutAction( + input, + true, + macOptionAsAlt, + optionKeyLocation, + false, + undefined, + undefined, + active + ) + + it('encodes Option+letter as kitty CSI-u with the physical base key in compose mode', () => { + // macOS composition reports key='π' for Option+P on ABC/compose layouts; + // OMP binds alt+p (temporary model) and alt+m (model selector). + expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[112;3u' + }) + expect(resolveKitty(event({ key: 'µ', code: 'KeyM', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[109;3u' + }) + }) + + it('includes shift in the kitty modifier field', () => { + expect(resolveKitty(event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[112;4u' + }) + }) + + it('encodes Option+digit and mapped Option+punctuation', () => { + expect(resolveKitty(event({ key: '¡', code: 'Digit1', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[49;3u' + }) + expect(resolveKitty(event({ key: '≥', code: 'Period', altKey: true }))).toEqual({ + type: 'sendInput', + data: '\x1b[46;3u' + }) + }) + + it('exempts dead keys so Option composition still starts', () => { + expect(resolveKitty(event({ key: 'Dead', code: 'KeyE', altKey: true }))).toBeNull() + }) + + it('defers to xterm in macOptionAsAlt=true mode (native kitty encoding is correct there)', () => { + expect(resolveKitty(event({ key: 'p', code: 'KeyP', altKey: true }), 'true')).toBeNull() + }) + + it('keeps shift+Option composition untouched in non-kitty panes', () => { + expect( + resolveKitty( + event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }), + 'false', + 0, + kittyInactive + ) + ).toBeNull() + // Meta-side Option in 'left' mode stays shift-exempt without kitty. + expect( + resolveKitty( + event({ key: '∏', code: 'KeyP', altKey: true, shiftKey: true }), + 'left', + 1, + kittyInactive + ) + ).toBeNull() + }) + + it('keeps compose-mode behavior unchanged when the pane is not kitty-active', () => { + expect( + resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true }), 'false', 0, kittyInactive) + ).toBeNull() + // The B/F/D readline patches still apply without kitty. + expect( + resolveKitty(event({ key: '∫', code: 'KeyB', altKey: true }), 'false', 0, kittyInactive) + ).toEqual({ type: 'sendInput', data: '\x1bb' }) + }) + + it('encodes the compose-side Option key as kitty CSI-u in left/right modes', () => { + // In 'left' mode the right Option normally composes; a kitty pane asked + // for modifier-accurate keys, so it gets alt-encoded too. + expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 2)).toEqual({ + type: 'sendInput', + data: '\x1b[108;3u' + }) + // The designated meta side upgrades from legacy Esc+letter to CSI-u. + expect(resolveKitty(event({ key: '¬', code: 'KeyL', altKey: true }), 'left', 1)).toEqual({ + type: 'sendInput', + data: '\x1b[108;3u' + }) + }) + + it('yields Alt+Arrow and Alt+Backspace to xterm kitty encoding', () => { + expect(resolveKitty(event({ key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }))).toBeNull() + expect(resolveKitty(event({ key: 'Backspace', code: 'Backspace', altKey: true }))).toBeNull() + // Without kitty, the readline translations still apply. + expect( + resolveKitty( + event({ key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }), + 'false', + 0, + kittyInactive + ) + ).toEqual({ type: 'sendInput', data: '\x1bb' }) + expect( + resolveKitty( + event({ key: 'Backspace', code: 'Backspace', altKey: true }), + 'false', + 0, + kittyInactive + ) + ).toEqual({ type: 'sendInput', data: '\x1b\x7f' }) + }) + + it('does not intercept Option chords with Cmd or Ctrl held', () => { + expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true, metaKey: true }))).toBeNull() + expect(resolveKitty(event({ key: 'π', code: 'KeyP', altKey: true, ctrlKey: true }))).toBeNull() + }) + + it('resolves the kitty base key through the active layout map when provided', () => { + const resolveWithLayout = ( + input: TerminalShortcutEvent, + layoutBaseCharacterForCode: (code: string) => string | undefined + ) => + resolveTerminalShortcutAction( + input, + true, + 'false', + 0, + false, + undefined, + undefined, + kittyActive, + layoutBaseCharacterForCode + ) + + // AZERTY types M at the physical Semicolon position; the layout map must + // win over the US punctuation table so the chord reports alt+m, not alt+;. + const azerty = (code: string): string | undefined => (code === 'Semicolon' ? 'm' : undefined) + expect(resolveWithLayout(event({ key: 'µ', code: 'Semicolon', altKey: true }), azerty)).toEqual( + { type: 'sendInput', data: '\x1b[109;3u' } + ) + + // Colemak types P at the physical KeyR position. + const colemak = (code: string): string | undefined => (code === 'KeyR' ? 'p' : undefined) + expect(resolveWithLayout(event({ key: 'π', code: 'KeyR', altKey: true }), colemak)).toEqual({ + type: 'sendInput', + data: '\x1b[112;3u' + }) + + // Falls back to the US table when the layout map has no entry. + const empty = (): string | undefined => undefined + expect(resolveWithLayout(event({ key: 'π', code: 'KeyP', altKey: true }), empty)).toEqual({ + type: 'sendInput', + data: '\x1b[112;3u' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts index 35cc6c00ffe..69bc914d048 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts @@ -42,6 +42,26 @@ export type TerminalShortcutAction = | { type: 'scrollViewport'; position: 'top' | 'bottom' } | { type: 'sendInput'; data: string } +/** Kitty keyboard protocol modifier field: 1 + shift(1) + alt(2). */ +function kittyAltModifiers(shiftKey: boolean): number { + return shiftKey ? 4 : 3 +} + +/** The un-shifted ASCII character for a physical key code (letters, digits, + * and the punctuation map above), or undefined for unmapped codes. */ +function resolveUnshiftedCharacterForCode(code: string | undefined): string | undefined { + if (!code) { + return undefined + } + if (code.startsWith('Key') && code.length === 4) { + return code.charAt(3).toLowerCase() + } + if (code.startsWith('Digit') && code.length === 6) { + return code.charAt(5) + } + return PUNCTUATION_CODE_MAP[code] +} + /** * Resolves terminal keyboard events before xterm receives them. * Keeps configurable Orca shortcuts and terminal byte fallbacks in one @@ -58,7 +78,16 @@ export function resolveTerminalShortcutAction( // ConPTY (PowerShell/cmd via PSReadLine). Only consulted for the Ctrl+Arrow // word-nav rule below, so the execution-host lookup it performs stays off the // hot path for every other keystroke. - isLocalWindowsConptyPane?: () => boolean + isLocalWindowsConptyPane?: () => boolean, + // Why: lazily reports whether the active pane's application has enabled the + // kitty keyboard protocol (CSI > u). Gates the Option-as-Alt compensation + // below on the application's own opt-in, so shells keep composition. + isKittyKeyboardActivePane?: () => boolean, + // Why: kitty key reports carry the key's unshifted codepoint in the active + // layout; the physical-code table above is US QWERTY and reports the wrong + // key on Dvorak/Colemak/AZERTY-class layouts. This resolves through + // Chromium's KeyboardLayoutMap when it is available. + layoutBaseCharacterForCode?: (code: string) => string | undefined ): TerminalShortcutAction | null { const platform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' if (!event.repeat) { @@ -184,6 +213,11 @@ export function resolveTerminalShortcutAction( !event.shiftKey && event.key === 'Backspace' ) { + // Why: a kitty-protocol TUI binds the CSI 127;3u that xterm's kitty + // encoder emits natively; the legacy \x1b\x7f fallback would bypass it. + if (isKittyKeyboardActivePane?.()) { + return null + } return { type: 'sendInput', data: '\x1b\x7f' } } @@ -194,6 +228,11 @@ export function resolveTerminalShortcutAction( !event.shiftKey && (event.key === 'ArrowLeft' || event.key === 'ArrowRight') ) { + // Why: a kitty-protocol TUI binds alt+arrow via the CSI 1;3D / 1;3C that + // xterm's kitty encoder emits natively; \eb/\ef would reach it as alt+b/f. + if (isKittyKeyboardActivePane?.()) { + return null + } // Why: xterm.js would otherwise emit \e[1;3D / \e[1;3C for option/alt+arrow, // which default readline (bash, zsh) does not bind to backward-word / // forward-word — so word navigation silently doesn't work without a custom @@ -240,46 +279,61 @@ export function resolveTerminalShortcutAction( // // The handling depends on the macOptionAsAlt setting (mirrors Ghostty): // - 'true': xterm handles all Option as Meta natively; nothing to do here. + // - kitty-protocol pane (any other mode): the TUI asked for modifier-accurate + // keys, so every Option chord is encoded as kitty CSI-u with the physical + // base key (Option+P → \x1b[112;3u). Without this, xterm's kitty encoder + // reports the composed codepoint (alt+π), which no TUI binds — the chord + // neither triggers the hotkey nor types the character (issue: OMP Alt+P / + // Alt+M dead on compose layouts). Dead keys are exempt so composition + // (Option+E → ´) keeps working. // - 'false': compensate the three most critical readline shortcuts (B/F/D). // - 'left'/'right': the designated Option key acts as full Meta (emit Esc+ // for any single letter); the other key composes, with B/F/D compensated. - if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && !event.shiftKey) { - // Why: event.location on a character key reports that key's position (always - // 0 for standard keys), NOT which modifier is held. The caller must track - // the Option key's own keydown location and pass it as optionKeyLocation. - const isLeftOption = optionKeyLocation === 1 - const isRightOption = optionKeyLocation === 2 - - const shouldActAsMeta = - (macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption) - - if (shouldActAsMeta) { - // Emit Esc+key for letter keys (e.g. Option+B → \x1bb) - if (event.code?.startsWith('Key') && event.code.length === 4) { - const letter = event.code.charAt(3).toLowerCase() - return { type: 'sendInput', data: `\x1b${letter}` } - } - // Emit Esc+digit for number keys (e.g. Option+1 → \x1b1) - if (event.code?.startsWith('Digit') && event.code.length === 6) { - return { type: 'sendInput', data: `\x1b${event.code.charAt(5)}` } - } - const punct = event.code ? PUNCTUATION_CODE_MAP[event.code] : undefined - if (punct) { - return { type: 'sendInput', data: `\x1b${punct}` } + if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && macOptionAsAlt !== 'true') { + if (event.key !== 'Dead' && isKittyKeyboardActivePane?.()) { + const baseCharacter = + (event.code ? layoutBaseCharacterForCode?.(event.code) : undefined) ?? + resolveUnshiftedCharacterForCode(event.code) + if (baseCharacter) { + return { + type: 'sendInput', + data: `\x1b[${baseCharacter.codePointAt(0)};${kittyAltModifiers(event.shiftKey)}u` + } } } - // In 'false', 'left', or 'right' mode, the compose-side Option key still - // needs the three most critical readline shortcuts patched. - if (macOptionAsAlt !== 'true' && !shouldActAsMeta) { - if (event.code === 'KeyB') { - return { type: 'sendInput', data: '\x1bb' } - } - if (event.code === 'KeyF') { - return { type: 'sendInput', data: '\x1bf' } + if (!event.shiftKey) { + // Why: event.location on a character key reports that key's position + // (always 0 for standard keys), NOT which modifier is held. The caller + // must track the Option key's own keydown location and pass it as + // optionKeyLocation. + const isLeftOption = optionKeyLocation === 1 + const isRightOption = optionKeyLocation === 2 + + const shouldActAsMeta = + (macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption) + + if (shouldActAsMeta) { + // Emit Esc+key (e.g. Option+B → \x1bb) for letters, digits, and + // mapped punctuation. + const character = resolveUnshiftedCharacterForCode(event.code) + if (character) { + return { type: 'sendInput', data: `\x1b${character}` } + } } - if (event.code === 'KeyD') { - return { type: 'sendInput', data: '\x1bd' } + + // In 'false', 'left', or 'right' mode, the compose-side Option key still + // needs the three most critical readline shortcuts patched. + if (!shouldActAsMeta) { + if (event.code === 'KeyB') { + return { type: 'sendInput', data: '\x1bb' } + } + if (event.code === 'KeyF') { + return { type: 'sendInput', data: '\x1bf' } + } + if (event.code === 'KeyD') { + return { type: 'sendInput', data: '\x1bd' } + } } } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index f8355d8b31b..4587aac9354 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines -- Why: these hook tests share a mocked React lifecycle harness with global event cases. */ import type * as ReactModule from 'react' +import type * as StoreModule from '@/store' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { PASTE_TERMINAL_TEXT_EVENT, SYNC_FIT_PANES_EVENT } from '@/constants/terminal' import { registerLivePaneManager, unregisterLivePaneManager } from '@/lib/pane-manager/pane-manager-registry' +import { useAppStore } from '@/store' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' @@ -95,6 +97,20 @@ vi.mock('./terminal-input-activity', () => ({ recordTerminalUserInputForLeaf: mocks.recordTerminalUserInputForLeaf })) +// Why: this suite invokes the hook outside a real React render (the react mock +// above runs effects synchronously and manages refs by hand), so a reactive +// useAppStore(selector) call would throw an "Invalid hook call". Read the +// current snapshot synchronously instead; getState/setState stay real so tests +// can seed terminalLayoutsByTabId. +vi.mock('@/store', async (importOriginal) => { + const actual = await importOriginal() + const realHook = actual.useAppStore + const testHook = ((selector?: (state: ReturnType) => unknown) => + selector ? selector(realHook.getState()) : realHook.getState()) as typeof realHook + Object.assign(testHook, realHook) + return { ...actual, useAppStore: testHook } +}) + class MockResizeObserver { observe = vi.fn() disconnect = vi.fn() @@ -190,6 +206,7 @@ describe('useTerminalPaneGlobalEffects', () => { beforeEach(() => { resetHookRefs() vi.clearAllMocks() + useAppStore.setState({ terminalLayoutsByTabId: {} }) ;(globalThis as unknown as { window: unknown }).window = { addEventListener: vi.fn(), removeEventListener: vi.fn(), @@ -588,8 +605,29 @@ describe('useTerminalPaneGlobalEffects', () => { expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) }) - it('reports the active local PTY to the main output scheduler', () => { - const manager = { + function seedActiveLeafPty(tabId: string, activeLeafId: string, ptyId: string): void { + useAppStore.setState({ + terminalLayoutsByTabId: { + [tabId]: { + root: null, + activeLeafId, + expandedLeafId: null, + ptyIdsByLeafId: { [activeLeafId]: ptyId } + } + } + }) + } + + function makeActivePtyManager(): { + getPanes: ReturnType + resumeRendering: ReturnType + resetWebglTextureAtlases: ReturnType + scheduleRevealRepaint: ReturnType + scheduleRevealPresent: ReturnType + suspendRendering: ReturnType + getActivePane: ReturnType + } { + return { getPanes: vi.fn(() => [{ id: 1, terminal: { name: 'terminal-a' } }]), resumeRendering: vi.fn(), resetWebglTextureAtlases: vi.fn(), @@ -598,8 +636,11 @@ describe('useTerminalPaneGlobalEffects', () => { suspendRendering: vi.fn(), getActivePane: vi.fn(() => ({ id: 1, terminal: { name: 'terminal-a' } })) } - const transport = { getPtyId: vi.fn(() => 'pty-active') } - const paneTransports = new Map([[1, transport]]) + } + + it('reports the active local PTY to the main output scheduler', () => { + const manager = makeActivePtyManager() + seedActiveLeafPty('tab-1', 'leaf-1', 'pty-active') beginHookRender() useTerminalPaneGlobalEffects({ @@ -611,7 +652,7 @@ describe('useTerminalPaneGlobalEffects', () => { paneCount: 1, managerRef: { current: manager as never }, containerRef: { current: null }, - paneTransportsRef: { current: paneTransports as never }, + paneTransportsRef: { current: new Map() }, isActiveRef: { current: false }, isVisibleRef: { current: false }, toggleExpandPane: vi.fn() @@ -620,6 +661,62 @@ describe('useTerminalPaneGlobalEffects', () => { expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true) }) + it('re-reports the active PTY when the active leaf rebinds to a new PTY without visibility changing', () => { + const manager = makeActivePtyManager() + const mountArgs = { + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: true, + isVisible: true, + isWorktreeActive: true, + isSyncFitEnabled: true, + paneCount: 1, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + toggleExpandPane: vi.fn() + } + + seedActiveLeafPty('tab-1', 'leaf-1', 'pty-old') + beginHookRender() + useTerminalPaneGlobalEffects(mountArgs) + expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-old', true) + + // Deferred reattach rebinds the active leaf to a new PTY; isActive/isVisible/ + // isWorktreeActive never flip, so only the reactive leaf→PTY binding changes. + seedActiveLeafPty('tab-1', 'leaf-1', 'pty-new') + beginHookRender() + useTerminalPaneGlobalEffects(mountArgs) + + expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-new', true) + }) + + it('does not report a backgrounded tab active even when its active leaf has a live PTY', () => { + const manager = makeActivePtyManager() + seedActiveLeafPty('tab-1', 'leaf-1', 'pty-active') + + beginHookRender() + useTerminalPaneGlobalEffects({ + tabId: 'tab-1', + worktreeId: 'wt-1', + isActive: false, + isVisible: false, + isWorktreeActive: false, + isSyncFitEnabled: false, + paneCount: 1, + managerRef: { current: manager as never }, + containerRef: { current: null }, + paneTransportsRef: { current: new Map() }, + isActiveRef: { current: false }, + isVisibleRef: { current: false }, + toggleExpandPane: vi.fn() + }) + + expect(window.api.pty.setActiveRendererPty).not.toHaveBeenCalled() + }) + it('enforces scroll intent after hidden layout changes the viewport', () => { const terminalA = { name: 'terminal-a' } const manager = { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index a72235b13ca..e021ced6b6a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -84,6 +84,17 @@ export function useTerminalPaneGlobalEffects({ const renderingSuspendedByVisibilityRef = useRef(false) const hiddenReasonRef = useRef(null) const rendererVisible = isVisible && isWorktreeActive + // Why: the active pane can rebind to a new PTY (deferred reattach / eager + // adopt) or switch active leaf without isActive/isVisible/isWorktreeActive + // flipping. Derive the active leaf's live PTY reactively from the same + // leaf→PTY binding the reattach path writes, so the active-renderer-pty report + // below re-fires on rebind — otherwise main keeps the stale id and the live + // PTY loses its interactive reserve. + const activeLeafPtyId = useAppStore((state) => { + const layout = state.terminalLayoutsByTabId[tabId] + const activeLeafId = layout?.activeLeafId + return activeLeafId ? (layout.ptyIdsByLeafId?.[activeLeafId] ?? null) : null + }) const { captureViewportPositions, withSuppressedScrollTracking, @@ -162,19 +173,16 @@ export function useTerminalPaneGlobalEffects({ }, [isActive, isWorktreeActive, rendererVisible]) useEffect(() => { - const manager = managerRef.current - const activePane = isActive && isVisible && isWorktreeActive ? manager?.getActivePane() : null - const ptyId = activePane - ? (paneTransportsRef.current.get(activePane.id)?.getPtyId() ?? null) - : null + const ptyId = isActive && isVisible && isWorktreeActive ? activeLeafPtyId : null if (!ptyId || ptyId.startsWith('remote:')) { return } // Why: main uses this as a scheduler hint only, so the foreground pane's - // renderer output gets first chance at the bounded ACK reserve. + // renderer output gets first chance at the bounded ACK reserve. The cleanup + // reports the old PTY inactive before the effect re-runs for a rebind. window.api.pty.setActiveRendererPty?.(ptyId, true) return () => window.api.pty.setActiveRendererPty?.(ptyId, false) - }, [isActive, isVisible, isWorktreeActive, managerRef, paneTransportsRef]) + }, [isActive, isVisible, isWorktreeActive, activeLeafPtyId]) useEffect(() => { const onToggleExpand = (event: Event): void => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 132a52c36d0..7999db503ca 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react' import type { IDisposable, Terminal } from '@xterm/xterm' import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types' +import type { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' import { PaneManager, type PaneExternalDropHandler, @@ -18,6 +19,7 @@ import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-he import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' import { buildTerminalKeyboardProtocolOptions } from '@/lib/pane-manager/terminal-keyboard-protocol' +import { resolvePaneKeyboardProtocolAgent } from './terminal-keyboard-protocol-pane-agent' import { useAppStore } from '@/store' import { createFilePathLinkProvider, @@ -241,6 +243,7 @@ type UseTerminalPaneLifecycleDeps = { * context-menu split handlers can read it synchronously for cache hits. */ paneCwdRef: React.RefObject paneMode2031Ref: React.RefObject> + paneKittyKeyboardModesRef: React.RefObject> paneLastThemeModeRef: React.RefObject> panePtyBindingsRef: React.RefObject> replayingPanesRef: ReplayingPanesRef @@ -515,6 +518,7 @@ export function useTerminalPaneLifecycle({ paneTransportsRef, paneCwdRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, panePtyBindingsRef, replayingPanesRef, @@ -735,6 +739,7 @@ export function useTerminalPaneLifecycle({ startup: startupWithSetupSplitWait, paneTransportsRef, paneMode2031Ref, + paneKittyKeyboardModesRef, paneLastThemeModeRef, replayingPanesRef, restoredViewportBlankingPanesRef, @@ -1200,6 +1205,7 @@ export function useTerminalPaneLifecycle({ mode2031DisposablesRef.current.delete(paneId) } paneMode2031Ref.current.delete(paneId) + paneKittyKeyboardModesRef.current.delete(paneId) paneLastThemeModeRef.current.delete(paneId) const osc52Disposable = osc52DisposablesRef.current.get(paneId) if (osc52Disposable) { @@ -1374,19 +1380,27 @@ export function useTerminalPaneLifecycle({ (candidate) => candidate.id === tabId ) const platformInfo = window.api.platform?.get?.() + // Why: launch identity belongs only to the pane consuming this one-shot + // startup. A tab-wide hint would leak Grok's KKP exception to shell splits. + const knownTuiAgent = resolvePaneKeyboardProtocolAgent( + ptyDeps.startup, + currentTab?.launchAgent + ) const ptyBackendContext = { userAgent: navigator.userAgent, osRelease: platformInfo?.osRelease, connectionId: getConnectionId(worktreeId), cwd: startupCwd, shellOverride: currentTab?.shellOverride, - executionHostId: getExecutionHostIdForWorktree(storeState, worktreeId) + executionHostId: getExecutionHostIdForWorktree(storeState, worktreeId), + tuiAgent: knownTuiAgent } const windowsPtyCompatibilityOptions = buildWindowsPtyCompatibilityOptions(ptyBackendContext) // Why: local Windows ConPTY CLIs read the Kitty keyboard advertisement but // do not decode CSI-u, so withhold it there to restore Enter/Up/Down nav // (issue #2434); SSH and macOS/Linux panes keep enhanced reporting. + // Exception: Grok (tuiAgent) keeps the advertisement — see protocol module. const keyboardProtocolOptions = buildTerminalKeyboardProtocolOptions(ptyBackendContext) return { ...windowsPtyCompatibilityOptions, diff --git a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts index b39e73df1af..c8e50554b21 100644 --- a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts +++ b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { buildWrappedLogicalLine } from './wrapped-terminal-link-ranges' +import { + buildHardWrappedPathLogicalLineCandidates, + buildWrappedLogicalLine, + rangeForParsedFileLink +} from './wrapped-terminal-link-ranges' type TestBufferLine = { isWrapped: boolean @@ -13,9 +17,14 @@ type TestBufferLine = { ) => string } -function makeBufferLine(text: string, isWrapped = false): TestBufferLine { +function makeBufferLine( + text: string, + options: { isWrapped?: boolean; columns?: number[] } = {} +): TestBufferLine { + const columns = + options.columns ?? Array.from({ length: text.length + 1 }, (_value, index) => index) return { - isWrapped, + isWrapped: options.isWrapped ?? false, length: text.length, getCell: () => undefined, translateToString: ( @@ -27,7 +36,7 @@ function makeBufferLine(text: string, isWrapped = false): TestBufferLine { if (outColumns) { outColumns.length = 0 for (let index = startColumn; index <= endColumn; index++) { - outColumns.push(index) + outColumns.push(columns[index] ?? index) } } return text.slice(startColumn, endColumn) @@ -37,7 +46,7 @@ function makeBufferLine(text: string, isWrapped = false): TestBufferLine { describe('buildWrappedLogicalLine', () => { it('joins ordinary soft-wrapped terminal rows', () => { - const rows = [makeBufferLine('src/'), makeBufferLine('file.ts', true)] + const rows = [makeBufferLine('src/'), makeBufferLine('file.ts', { isWrapped: true })] const logicalLine = buildWrappedLogicalLine({ getLine: (y) => rows[y] }, 2) @@ -47,7 +56,7 @@ describe('buildWrappedLogicalLine', () => { it('caps pathological soft-wrapped lines before scanning the whole run', () => { const rows = Array.from({ length: 1_000 }, (_value, index) => - makeBufferLine('b'.repeat(80), index > 0) + makeBufferLine('b'.repeat(80), { isWrapped: index > 0 }) ) const observedRows: number[] = [] @@ -65,3 +74,223 @@ describe('buildWrappedLogicalLine', () => { expect(Math.max(...observedRows)).toBeLessThan(250) }) }) + +describe('buildHardWrappedPathLogicalLineCandidates', () => { + const firstPath = 'validation-screenshots/01-before-white-terminal-scrollbar-gutter.png' + const middleStart = 'validation-screenshots/02-after-' + const middleEnd = 'transparent-terminal-scrollbar-gutter.png' + const thirdPath = 'validation-screenshots/03-after-light-theme.png' + + function makeThreeLinkRows(): TestBufferLine[] { + return [ + makeBufferLine(`${firstPath} · ${middleStart}`), + makeBufferLine(`${middleEnd} · ${thirdPath}`) + ] + } + + it('reconstructs one boundary path without merging its sibling links', () => { + const rows = makeThreeLinkRows() + const buffer = { getLine: (y: number) => rows[y] } + const firstRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 1) + const secondRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 2) + const expectedText = middleStart + middleEnd + const firstBoundary = firstRowCandidates.filter((candidate) => candidate.text === expectedText) + const secondBoundary = secondRowCandidates.filter( + (candidate) => candidate.text === expectedText + ) + + expect(firstBoundary).toHaveLength(1) + expect(secondBoundary).toHaveLength(1) + expect(firstRowCandidates.filter((candidate) => candidate.rows.length > 1)).toHaveLength(1) + expect(secondBoundary[0].fingerprint).toBe(firstBoundary[0].fingerprint) + expect(firstBoundary[0].rows.map((row) => row.text)).toEqual([middleStart, middleEnd]) + expect(firstBoundary[0].text).not.toContain(' · ') + expect(rangeForParsedFileLink(firstBoundary[0], 0, expectedText.length)).toEqual({ + start: { x: firstPath.length + ' · '.length + 1, y: 1 }, + end: { x: middleEnd.length, y: 2 } + }) + }) + + it.each([ + { + name: 'POSIX root', + firstFragment: '/', + secondFragment: 'home/alice/file.ts', + expectedPath: '/home/alice/file.ts' + }, + { + name: 'Windows drive', + firstFragment: 'C:', + secondFragment: '\\Users\\Alice\\repo\\file.ts', + expectedPath: 'C:\\Users\\Alice\\repo\\file.ts' + }, + { + name: 'UNC root', + firstFragment: '\\', + secondFragment: '\\server\\share\\file.ts', + expectedPath: '\\\\server\\share\\file.ts' + }, + { + name: 'current-directory prefix', + firstFragment: './', + secondFragment: 'src/file.ts', + expectedPath: './src/file.ts' + }, + { + name: 'parent-directory prefix', + firstFragment: '../', + secondFragment: 'src/file.ts', + expectedPath: '../src/file.ts' + }, + { + name: 'home-directory prefix', + firstFragment: '~/', + secondFragment: 'src/file.ts', + expectedPath: '~/src/file.ts' + } + ])( + 'reconstructs a boundary path split after its $name', + ({ firstFragment, secondFragment, expectedPath }) => { + const firstRow = `first.ts · ${firstFragment}` + const rows = [makeBufferLine(firstRow), makeBufferLine(`${secondFragment} · third.ts`)] + const buffer = { getLine: (y: number) => rows[y] } + + const firstRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 1) + const secondRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 2) + const firstBoundary = firstRowCandidates.filter( + (candidate) => candidate.text === expectedPath + ) + const secondBoundary = secondRowCandidates.filter( + (candidate) => candidate.text === expectedPath + ) + + expect(firstBoundary).toHaveLength(1) + expect(secondBoundary).toHaveLength(1) + expect(secondBoundary[0].fingerprint).toBe(firstBoundary[0].fingerprint) + expect(firstBoundary[0].rows.map((row) => row.text)).toEqual([firstFragment, secondFragment]) + expect(rangeForParsedFileLink(firstBoundary[0], 0, expectedPath.length)).toEqual({ + start: { x: firstRow.indexOf(firstFragment) + 1, y: 1 }, + end: { x: secondFragment.length, y: 2 } + }) + } + ) + + it('rejects an incomplete drive prefix whose joined text is not a path start', () => { + const rows = [makeBufferLine('first.ts · C:'), makeBufferLine('readme · third.ts')] + + const candidates = buildHardWrappedPathLogicalLineCandidates( + { getLine: (y: number) => rows[y] }, + 1 + ) + + expect(candidates.some((candidate) => candidate.text === 'C:readme')).toBe(false) + expect(candidates.filter((candidate) => candidate.rows.length > 1)).toEqual([]) + }) + + it.each([ + { + name: 'POSIX root', + firstFragment: '/', + secondFragment: 'home/alice/file.ts', + expectedPath: '/home/alice/file.ts' + }, + { + name: 'Windows drive', + firstFragment: 'C:', + secondFragment: '\\Users\\Alice\\repo\\file.ts', + expectedPath: 'C:\\Users\\Alice\\repo\\file.ts' + }, + { + name: 'UNC root', + firstFragment: '\\', + secondFragment: '\\server\\share\\file.ts', + expectedPath: '\\\\server\\share\\file.ts' + } + ])( + 'reconstructs a boundary path after its $name at end of output', + ({ firstFragment, secondFragment, expectedPath }) => { + const rows = [makeBufferLine(`first.ts · ${firstFragment}`), makeBufferLine(secondFragment)] + const buffer = { getLine: (y: number) => rows[y] } + + const firstRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 1) + const secondRowCandidates = buildHardWrappedPathLogicalLineCandidates(buffer, 2) + + expect( + firstRowCandidates.filter((candidate) => candidate.text === expectedPath) + ).toHaveLength(1) + expect( + secondRowCandidates.filter((candidate) => candidate.text === expectedPath) + ).toHaveLength(1) + } + ) + + it('does not duplicate an end-of-output candidate emitted by whole-row reconstruction', () => { + const rows = [makeBufferLine('/'), makeBufferLine('home/alice/file.ts')] + + const candidates = buildHardWrappedPathLogicalLineCandidates( + { getLine: (y: number) => rows[y] }, + 2 + ) + + expect(candidates.filter((candidate) => candidate.text === '/home/alice/file.ts')).toHaveLength( + 1 + ) + }) + + it('fingerprints full source rows outside the selected boundary fragments', () => { + const rows = makeThreeLinkRows() + const buffer = { getLine: (y: number) => rows[y] } + const expectedText = middleStart + middleEnd + const before = buildHardWrappedPathLogicalLineCandidates(buffer, 1).find( + (candidate) => candidate.text === expectedText + ) + + rows[1] = makeBufferLine(`${middleEnd} · validation-screenshots/03-after-dark-theme.png`) + const after = buildHardWrappedPathLogicalLineCandidates(buffer, 1).find( + (candidate) => candidate.text === expectedText + ) + + expect(before).toBeDefined() + expect(after).toBeDefined() + expect(after!.rows.map((row) => row.text)).toEqual(before!.rows.map((row) => row.text)) + expect(after!.fingerprint).not.toBe(before!.fingerprint) + }) + + it('rejects non-path starts before scanning their possible continuations', () => { + const rows = Array.from({ length: 20 }, () => makeBufferLine('a'.repeat(80))) + const observedRows: number[] = [] + + const candidates = buildHardWrappedPathLogicalLineCandidates( + { + getLine: (y: number) => { + observedRows.push(y) + return rows[y] + } + }, + 20 + ) + + expect(candidates).toEqual([]) + expect(observedRows).toHaveLength(21) + }) + + it('preserves Windows drive paths and original continuation columns', () => { + const firstRow = 'result: C:\\Users\\Alice\\Project\\src\\very-' + const firstFragment = 'C:\\Users\\Alice\\Project\\src\\very-' + const secondFragment = 'long\\file.ts' + const secondRow = `${secondFragment} · C:\\other.ts` + const secondColumns = Array.from({ length: secondRow.length + 1 }, (_value, index) => index * 2) + const rows = [makeBufferLine(firstRow), makeBufferLine(secondRow, { columns: secondColumns })] + const candidates = buildHardWrappedPathLogicalLineCandidates( + { getLine: (y: number) => rows[y] }, + 2 + ) + const candidate = candidates.find((item) => item.text === `${firstFragment}${secondFragment}`) + + expect(candidate).toBeDefined() + expect(rangeForParsedFileLink(candidate!, 0, candidate!.text.length)).toEqual({ + start: { x: firstRow.indexOf('C:') + 1, y: 1 }, + end: { x: secondFragment.length * 2, y: 2 } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts index 18be0543b5b..b648950bcd0 100644 --- a/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts +++ b/src/renderer/src/components/terminal-pane/wrapped-terminal-link-ranges.ts @@ -1,4 +1,13 @@ import type { IBufferLine, IBufferRange } from '@xterm/xterm' +import { + canStartHardWrappedPath, + getHardWrappedPathPrefix, + getHardWrappedPathSuffix, + isHardWrappedPathContinuation, + isHardWrappedPathFragment, + isIncompleteHardWrappedPathStart, + type HardWrappedPathFragmentRow +} from './hard-wrapped-terminal-path-fragments' type TerminalBufferLineWithColumns = IBufferLine & { translateToString( @@ -12,6 +21,7 @@ type TerminalBufferLineWithColumns = IBufferLine & { type WrappedLogicalRow = { y: number text: string + sourceText: string columns: number[] startIndex: number isWrapped: boolean @@ -79,7 +89,7 @@ function translateLineWithColumns(line: IBufferLine): { text: string; columns: n } } -function trimHardWrappedPathRow(line: IBufferLine): { text: string; columns: number[] } | null { +function trimHardWrappedPathRow(line: IBufferLine): HardWrappedPathFragmentRow | null { const translated = translateLineWithColumns(line) const startIndex = translated.text.search(/\S/) if (startIndex === -1) { @@ -93,22 +103,39 @@ function trimHardWrappedPathRow(line: IBufferLine): { text: string; columns: num return { text: translated.text.slice(startIndex, endIndex), - columns: translated.columns.slice(startIndex, endIndex + 1) + sourceText: translated.text, + columns: translated.columns.slice(startIndex, endIndex + 1), + isWrapped: line.isWrapped, + lineLength: line.length } } -const HARD_WRAPPED_PATH_FRAGMENT_PATTERN = /^[A-Za-z0-9._~@%+=:,/\\-]+$/ - -function isHardWrappedPathFragment(text: string): boolean { - return HARD_WRAPPED_PATH_FRAGMENT_PATTERN.test(text) && /[A-Za-z0-9]/.test(text) +function toWrappedLogicalRow( + row: HardWrappedPathFragmentRow, + y: number, + startIndex: number +): WrappedLogicalRow { + return { + y, + text: row.text, + sourceText: row.sourceText, + columns: row.columns, + startIndex, + isWrapped: row.isWrapped, + lineLength: row.lineLength + } } -function canStartHardWrappedPath(text: string): boolean { - if (!isHardWrappedPathFragment(text)) { - return /(?:^|[\s•*>-])(?:\/|\.{1,2}\/|[A-Za-z0-9._-]+\/)[A-Za-z0-9._~@%+=:,/\\-]*$/.test(text) - } +function getWrappedRowsFingerprint(rows: WrappedLogicalRow[]): string { + return rows + .map( + (row) => `${row.y}:${row.isWrapped ? 1 : 0}:${row.lineLength}:${row.sourceText}\0${row.text}` + ) + .join('\n') +} - return /(?:\/|\\)/.test(text) +function toWrappedLogicalLine(rows: WrappedLogicalRow[], text: string): WrappedLogicalLine { + return { text, rows: [...rows], fingerprint: getWrappedRowsFingerprint(rows) } } export function buildWrappedLogicalLine( @@ -155,6 +182,7 @@ export function buildWrappedLogicalLine( rows.push({ y: rowY, text: translated.text, + sourceText: translated.text, columns: translated.columns, startIndex: text.length, isWrapped: line.isWrapped, @@ -163,10 +191,7 @@ export function buildWrappedLogicalLine( text += translated.text } - const fingerprint = rows - .map((row) => `${row.y}:${row.isWrapped ? 1 : 0}:${row.lineLength}:${row.text}`) - .join('\n') - return { text, rows, fingerprint } + return toWrappedLogicalLine(rows, text) } export function buildHardWrappedPathLogicalLineCandidates( @@ -186,38 +211,90 @@ export function buildHardWrappedPathLogicalLineCandidates( for (let startY = currentY; startY >= minY; startY--) { const startLine = buffer.getLine(startY) const start = startLine ? trimHardWrappedPathRow(startLine) : null - if (!start || !canStartHardWrappedPath(start.text)) { + if (!start) { + continue + } + const canStartWholeRow = canStartHardWrappedPath(start.text) + const startSuffix = getHardWrappedPathSuffix(start) + const canStartBoundary = Boolean( + startSuffix && + (canStartHardWrappedPath(startSuffix.text) || + isIncompleteHardWrappedPathStart(startSuffix.text)) + ) + // Why: hover calls this for every terminal row; reject non-path starts + // before translating their possible continuation rows. + if (!canStartWholeRow && !canStartBoundary) { continue } - let text = '' - const rows: WrappedLogicalRow[] = [] - for (let rowY = startY; rowY < startY + maxRows; rowY++) { + const sourceRows: { row: HardWrappedPathFragmentRow; y: number }[] = [{ row: start, y: startY }] + for (let rowY = startY + 1; rowY < startY + maxRows; rowY++) { const line = buffer.getLine(rowY) - const translated = line ? trimHardWrappedPathRow(line) : null - if (!translated) { + const row = line ? trimHardWrappedPathRow(line) : null + if (!row) { break } - if (rowY > startY && !isHardWrappedPathFragment(translated.text)) { + sourceRows.push({ row, y: rowY }) + if (!isHardWrappedPathContinuation(row.text)) { break } + } + + let lastWholeCandidateText: string | null = null + if (canStartWholeRow) { + let text = '' + const rows: WrappedLogicalRow[] = [] + for (const sourceRow of sourceRows) { + if (sourceRow.y > startY && !isHardWrappedPathFragment(sourceRow.row.text)) { + break + } + rows.push(toWrappedLogicalRow(sourceRow.row, sourceRow.y, text.length)) + text += sourceRow.row.text + if (sourceRow.y >= currentY) { + candidates.push(toWrappedLogicalLine(rows, text)) + lastWholeCandidateText = text + } + } + } + + if (!startSuffix || !canStartBoundary) { + continue + } + let boundaryText = startSuffix.text + const boundaryRows = [toWrappedLogicalRow(startSuffix, startY, 0)] + let reachedMixedContinuation = false + for (let rowIndex = 1; rowIndex < sourceRows.length; rowIndex++) { + const sourceRow = sourceRows[rowIndex] + if (isHardWrappedPathContinuation(sourceRow.row.text)) { + boundaryRows.push(toWrappedLogicalRow(sourceRow.row, sourceRow.y, boundaryText.length)) + boundaryText += sourceRow.row.text + continue + } - rows.push({ - y: rowY, - text: translated.text, - columns: translated.columns, - startIndex: text.length, - isWrapped: line?.isWrapped ?? false, - lineLength: line?.length ?? translated.text.length - }) - text += translated.text - - if (rowY >= currentY) { - const fingerprint = rows - .map((row) => `${row.y}:${row.isWrapped ? 1 : 0}:${row.lineLength}:${row.text}`) - .join('\n') - candidates.push({ text, rows: [...rows], fingerprint }) + reachedMixedContinuation = true + const finalPrefix = getHardWrappedPathPrefix(sourceRow.row) + if (finalPrefix && finalPrefix.text.length < sourceRow.row.text.length) { + boundaryRows.push(toWrappedLogicalRow(finalPrefix, sourceRow.y, boundaryText.length)) + boundaryText += finalPrefix.text + if (sourceRow.y >= currentY && canStartHardWrappedPath(boundaryText)) { + // Why: only the first mixed continuation can close a hard-wrapped path; + // emitting more boundary combinations can merge sibling links. + candidates.push(toWrappedLogicalLine(boundaryRows, boundaryText)) + } } + break + } + + const lastBoundaryRow = boundaryRows.at(-1)! + if ( + !reachedMixedContinuation && + isIncompleteHardWrappedPathStart(startSuffix.text) && + boundaryRows.length >= 2 && + lastBoundaryRow.y >= currentY && + canStartHardWrappedPath(boundaryText) && + lastWholeCandidateText !== boundaryText + ) { + candidates.push(toWrappedLogicalLine(boundaryRows, boundaryText)) } } diff --git a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts index 7e5d55e4bff..1f3a78326cd 100644 --- a/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts +++ b/src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts @@ -166,7 +166,11 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { // Post-compositionend guard: the tracker is already inactive but the // committing key's trailing press/release must still be absorbed. const linuxPostCompositionGuard = { ...linuxIdle, candidateKeyGuardActive: true } - const windowsComposing = { ...windowsIdle, compositionActive: true, candidateKeyGuardActive: true } + const windowsComposing = { + ...windowsIdle, + compositionActive: true, + candidateKeyGuardActive: true + } it('suppresses keyboard events while Chromium reports active IME composition', () => { for (const options of [windowsIdle, linuxIdle]) { @@ -268,9 +272,9 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { it('suppresses Space and digit keydowns and keyups while the candidate guard is active', () => { for (const options of [linuxComposing, linuxPostCompositionGuard]) { for (const key of [' ', '0', '2', '9']) { - expect( - shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options) - ).toBe(true) + expect(shouldSuppressTerminalImeKeyboardEvent(event({ key, code: '' }), options)).toBe( + true + ) expect( shouldSuppressTerminalImeKeyboardEvent(event({ type: 'keyup', key, code: '' }), options) ).toBe(true) @@ -296,7 +300,10 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { it('leaves Space and digits alone once the guard has expired', () => { for (const type of ['keydown', 'keyup', 'keypress']) { expect( - shouldSuppressTerminalImeKeyboardEvent(event({ type, key: ' ', code: 'Space' }), linuxIdle) + shouldSuppressTerminalImeKeyboardEvent( + event({ type, key: ' ', code: 'Space' }), + linuxIdle + ) ).toBe(false) expect( shouldSuppressTerminalImeKeyboardEvent( @@ -336,10 +343,7 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { it('does not apply the Linux/Sogou candidate guard to Windows', () => { expect( - shouldSuppressTerminalImeKeyboardEvent( - event({ key: ' ', code: 'Space' }), - windowsComposing - ) + shouldSuppressTerminalImeKeyboardEvent(event({ key: ' ', code: 'Space' }), windowsComposing) ).toBe(false) expect( shouldSuppressTerminalImeKeyboardEvent( @@ -390,7 +394,10 @@ describe('shouldSuppressTerminalImeKeyboardEvent — Windows/Linux', () => { shouldPreventDefaultTerminalImeCandidateKey(event({ key: ' ', code: 'Space' }), linuxIdle) ).toBe(false) expect( - shouldPreventDefaultTerminalImeCandidateKey(event({ key: 'a', code: 'KeyA' }), linuxComposing) + shouldPreventDefaultTerminalImeCandidateKey( + event({ key: 'a', code: 'KeyA' }), + linuxComposing + ) ).toBe(false) expect( shouldPreventDefaultTerminalImeCandidateKey( diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index fab0f38e013..49867868e90 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -126,6 +126,8 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { translate } from '@/i18n/i18n' import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' function getShortcutPlatform(): NodeJS.Platform { if (navigator.userAgent.includes('Mac')) { @@ -1484,7 +1486,11 @@ export function useIpcEvents(): void { ? { launchAgent, ...initialAgentTabViewModeProps(store.settings, { - agent: launchAgent + agent: launchAgent, + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) }) } : {}), @@ -1666,7 +1672,10 @@ export function useIpcEvents(): void { ...(shouldActivate ? {} : { activate: false, recordInteraction: false }), launchAgent: data.launchAgent, ...initialAgentTabViewModeProps(store.settings, { - agent: data.launchAgent + agent: data.launchAgent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionIdFromState(store, worktreeId) + ) }), ...(data.cwd ? { startupCwd: data.cwd } : {}) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7c8045033f8..8a91f79653c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -29,6 +29,7 @@ "sshToggleDescription": "Show configured SSH and remote Orca hosts when any are available.", "resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.", "portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.", + "antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.", "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI." } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "Open MiniMax usage details", "3bbf140864": "MiniMax Usage", "remoteServerLabel": "Remote server", + "antigravityUsage": "Antigravity Usage", + "antigravityUsageDetails": "Open Antigravity usage details", "grokUsageAria": "Open Grok usage details", "grokUsageMenu": "Grok Usage" }, @@ -5305,11 +5308,11 @@ "0e89a574ae": "Floating animated pet in the bottom-right corner.", "nativeChat": { "title": "Native chat", - "description": "Preview the desktop chat surface for Claude and Codex terminal sessions.", - "copy": "Adds a native chat view you can switch to from supported Claude and Codex terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.", + "description": "Preview the desktop chat surface for supported agent terminal sessions.", + "copy": "Adds a native chat view you can switch to from supported agent terminal panes. Experimental while we tune transcript fidelity, streaming, and terminal parity.", "toggleLabel": "Toggle native chat", "defaultTitle": "Default view", - "defaultCopy": "Choose how new Claude and Codex terminal tabs open.", + "defaultCopy": "Choose how new supported agent terminal tabs open.", "defaultViewLabel": "Default native chat view", "defaultViewTerminal": "Terminal chat", "defaultViewNative": "Native chat" @@ -6720,9 +6723,9 @@ "d23b43c5be": "Setup Script Location", "34a0dfa06e": "Where the repository setup script runs when a new workspace is created.", "21f8da2078": "Workspace Setup Script", - "6e6480a7df": "Let programs in the terminal (tmux, Neovim, fzf, SSH) copy to your system clipboard.", + "6e6480a7df": "Let programs in the terminal (Grok, tmux, Neovim, fzf, SSH) copy to your system clipboard.", "3338dcf8c1": "Allow TUI Clipboard Writes (OSC 52)", - "69c64a479c": "Let tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).", + "69c64a479c": "Let Grok, tmux, Neovim, and fzf copy to the system clipboard over the PTY (including over SSH).", "4729c645fc": "Automatically copy terminal selections to the clipboard.", "902f5dee1f": "Copy on Select", "9129b7e805": "Hovering a terminal pane activates it without needing to click.", @@ -7223,6 +7226,9 @@ "workspaceOptions": "workspace options", "detailed": "detailed" }, + "antigravityUsageTitle": "Antigravity Usage", + "antigravityUsageDescription": "Show Antigravity subscription usage in the status bar.", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok Usage", "e7d1b0f3a5": "Show Grok weekly credit usage from Grok CLI OAuth.", "d6c0a9e2f4": "grok", @@ -7511,7 +7517,8 @@ "87d99e634b": "Pet", "nativeChat": { "title": "Native chat", - "description": "Preview the desktop chat surface for Claude and Codex terminal sessions." + "description": "Preview the desktop chat surface for supported agent terminal sessions.", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ffd31bdd327..694ea52f824 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -29,6 +29,7 @@ "sshToggleDescription": "Muestra hosts SSH configurados y hosts remotos de Orca cuando haya alguno disponible.", "resourceUsageToggleDescription": "Muestra el Administrador de recursos. Haz clic para ver CPU, memoria, sesiones, controles del servicio en segundo plano y análisis de disco de los espacios de trabajo.", "portsToggleDescription": "Muestra los puertos activos de los espacios de trabajo. Haz clic para ver los puertos de cada espacio de trabajo y los puertos externos.", + "antigravityToggleDescription": "Muestra el uso de suscripción de Antigravity para el espacio de trabajo activo.", "grokToggleDescription": "Muestra el uso de créditos de suscripción de Grok cuando has iniciado sesión con Grok CLI." } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "Abrir detalles de uso de MiniMax", "3bbf140864": "Uso de MiniMax", "remoteServerLabel": "Servidor remoto", + "antigravityUsage": "Uso de Antigravity", + "antigravityUsageDetails": "Abrir detalles de uso de Antigravity", "grokUsageAria": "Abrir detalles de uso de Grok", "grokUsageMenu": "Uso de Grok" }, @@ -5305,11 +5308,11 @@ }, "nativeChat": { "title": "Chat nativo", - "description": "Previsualiza la superficie de chat de escritorio para sesiones de terminal de Claude y Codex.", - "copy": "Agrega una vista de chat nativa a la que puedes cambiar desde paneles de terminal compatibles de Claude y Codex. Experimental mientras ajustamos la fidelidad de transcripción, streaming y paridad con el terminal.", + "description": "Previsualiza la interfaz de chat de escritorio para sesiones de terminal de agentes compatibles.", + "copy": "Añade una vista de chat nativo a la que puedes cambiar desde paneles de terminal de agentes compatibles. Es experimental mientras ajustamos la fidelidad de las transcripciones, la transmisión y la paridad con el terminal.", "toggleLabel": "Alternar chat nativo", "defaultTitle": "Vista predeterminada", - "defaultCopy": "Elige cómo se abren las nuevas pestañas de terminal de Claude y Codex.", + "defaultCopy": "Elige cómo se abren las nuevas pestañas de terminal de agentes compatibles.", "defaultViewLabel": "Vista predeterminada de chat nativo", "defaultViewTerminal": "Chat en terminal", "defaultViewNative": "Chat nativo" @@ -6683,9 +6686,9 @@ "d23b43c5be": "Ubicación del script de configuración", "34a0dfa06e": "Dónde se ejecuta el script de configuración del repositorio cuando se crea un nuevo espacio de trabajo.", "21f8da2078": "Script de configuración del espacio de trabajo", - "6e6480a7df": "Permite que los programas del terminal (tmux, Neovim, fzf, SSH) copien al portapapeles del sistema.", + "6e6480a7df": "Permite que los programas del terminal (Grok, tmux, Neovim, fzf, SSH) copien al portapapeles del sistema.", "3338dcf8c1": "Permitir escrituras en el portapapeles TUI (OSC 52)", - "69c64a479c": "Permite que tmux, Neovim y fzf copien al portapapeles del sistema a través de PTY (incluso por SSH).", + "69c64a479c": "Permite que Grok, tmux, Neovim y fzf copien al portapapeles del sistema a través de PTY (incluso por SSH).", "4729c645fc": "Copia automáticamente las selecciones del terminal al portapapeles.", "902f5dee1f": "Copiar al seleccionar", "9129b7e805": "Al pasar el cursor sobre un panel de terminal, se activa sin necesidad de hacer clic.", @@ -7186,6 +7189,9 @@ }, "9a115966d3": "Minimizar a la bandeja al cerrar", "4d5b9427b5": "Cuando está activado, cerrar la ventana mantiene Orca en ejecución en la bandeja del sistema en lugar de salir.", + "antigravityUsageTitle": "Uso de Antigravity", + "antigravityUsageDescription": "Muestra el uso de suscripción de Antigravity en la barra de estado.", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Uso de Grok", "e7d1b0f3a5": "Muestra el uso semanal de créditos de Grok desde OAuth de Grok CLI.", "d6c0a9e2f4": "grok", @@ -7474,7 +7480,8 @@ }, "nativeChat": { "title": "Chat nativo", - "description": "Previsualiza la vista de chat de escritorio para sesiones de terminal de Claude y Codex." + "description": "Previsualiza la interfaz de chat de escritorio para sesiones de terminal de agentes compatibles.", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 360123c88e7..59b3e7640c2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -29,6 +29,7 @@ "sshToggleDescription": "設定済み SSH ホストとリモート Orca ホストがある場合に表示します。", "resourceUsageToggleDescription": "リソースマネージャーを表示します。これをクリックすると、CPU、メモリ、セッション、デーモン コントロール、およびワークスペース ディスク スキャンが行われます。", "portsToggleDescription": "ライブワークスペースポートを表示します。ワークスペーススコープのポートと外部リスナーの場合はこれをクリックします。", + "antigravityToggleDescription": "アクティブなワークスペースの Antigravity サブスクリプション使用量を表示します。", "grokToggleDescription": "Grok CLI でサインインしている場合に Grok サブスクリプションのクレジット使用量を表示します。" } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "MiniMax の使用量詳細を開く", "3bbf140864": "MiniMax 使用量", "remoteServerLabel": "リモートサーバー", + "antigravityUsage": "Antigravity の使用状況", + "antigravityUsageDetails": "Antigravity の使用状況詳細を開く", "grokUsageAria": "Grok の使用状況詳細を開く", "grokUsageMenu": "Grok の使用状況" }, @@ -5290,11 +5293,11 @@ }, "nativeChat": { "title": "ネイティブチャット", - "description": "Claude と Codex の terminal セッションのデスクトップ チャット サーフェスをプレビューします。", - "copy": "サポートされている Claude および Codex の terminal ペインから切り替えることができるネイティブ チャット ビューを追加します。トランスクリプトの忠実度、ストリーミング、ターミナルのパリティを調整するための実験です。", + "description": "対応エージェントのターミナルセッション用デスクトップチャット画面をプレビューします。", + "copy": "対応エージェントのターミナルペインから切り替えられるネイティブチャットビューを追加します。トランスクリプトの忠実度、ストリーミング、ターミナルとの同等性を調整しているため、試験運用中です。", "toggleLabel": "ネイティブチャットの切り替え", "defaultTitle": "デフォルトのビュー", - "defaultCopy": "新規 Claude および Codex の terminal タブを開く方法を選択します。", + "defaultCopy": "対応エージェントの新しいターミナルタブを開く方法を選択します。", "defaultViewLabel": "デフォルトのネイティブ チャット ビュー", "defaultViewTerminal": "Terminal チャット", "defaultViewNative": "ネイティブチャット" @@ -6705,9 +6708,9 @@ "d23b43c5be": "セットアップスクリプトの場所", "34a0dfa06e": "新規ワークスペースの作成時に repos セットアップ スクリプトが実行される場所。", "21f8da2078": "ワークスペースセットアップスクリプト", - "6e6480a7df": "terminal 内のプログラム (tmux、Neovim、fzf、SSH) をシステムのクリップボードにコピーします。", + "6e6480a7df": "terminal 内のプログラム (Grok、tmux、Neovim、fzf、SSH) をシステムのクリップボードにコピーします。", "3338dcf8c1": "TUI クリップボードへの書き込みを許可する (OSC 52)", - "69c64a479c": "tmux、Neovim、および fzf が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", + "69c64a479c": "Grok、tmux、Neovim、および fzf が PTY 経由 (SSH 経由を含む) でシステム クリップボードにコピーできるようにします。", "4729c645fc": "terminal の選択内容をクリップボードに自動的にコピーします。", "902f5dee1f": "選択時にコピー", "9129b7e805": "terminal ペインにマウスを移動すると、クリックしなくても terminal ペインがアクティブになります。", @@ -7208,6 +7211,9 @@ }, "9a115966d3": "閉じるときにトレイへ最小化", "4d5b9427b5": "有効にすると、ウィンドウを閉じてもOrcaは終了せず、システムトレイで実行を続けます。", + "antigravityUsageTitle": "Antigravity の使用状況", + "antigravityUsageDescription": "ステータスバーに Antigravity サブスクリプションの使用量を表示します。", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok の使用状況", "e7d1b0f3a5": "Grok CLI OAuth から Grok の週次クレジット使用量を表示します。", "d6c0a9e2f4": "grok", @@ -7496,7 +7502,8 @@ }, "nativeChat": { "title": "ネイティブチャット", - "description": "Claude と Codex の terminal セッションのデスクトップ チャット サーフェスをプレビューします。" + "description": "対応エージェントのターミナルセッション用デスクトップチャット画面をプレビューします。", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c74f362a770..3a81820ec53 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -29,6 +29,7 @@ "sshToggleDescription": "사용 가능한 SSH 및 원격 Orca 호스트가 있으면 표시합니다.", "resourceUsageToggleDescription": "리소스 관리자를 표시합니다. 클릭하면 CPU, 메모리, 세션, 데몬 제어, 워크스페이스 디스크 스캔을 볼 수 있습니다.", "portsToggleDescription": "라이브 워크스페이스 포트를 표시합니다. 워크스페이스 범위 포트 및 외부 수신기를 보려면 클릭하세요.", + "antigravityToggleDescription": "활성 워크스페이스에 대한 Antigravity 구독 사용량을 표시합니다.", "grokToggleDescription": "Grok CLI로 로그인한 경우 Grok 구독 크레딧 사용량을 표시합니다." } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "MiniMax 사용량 세부 정보 열기", "3bbf140864": "MiniMax 사용량", "remoteServerLabel": "원격 서버", + "antigravityUsage": "Antigravity 사용량", + "antigravityUsageDetails": "Antigravity 사용량 세부 정보 열기", "grokUsageAria": "Grok 사용량 세부 정보 열기", "grokUsageMenu": "Grok 사용량" }, @@ -5290,11 +5293,11 @@ }, "nativeChat": { "title": "네이티브 채팅", - "description": "Claude 및 Codex terminal 세션에 대한 데스크톱 채팅 화면을 미리 봅니다.", - "copy": "지원되는 Claude 및 Codex terminal 창에서 전환할 수 있는 기본 채팅 보기를 추가합니다. 성적표 충실도, 스트리밍 및 terminal 패리티를 조정하는 동안 실험적입니다.", + "description": "지원되는 에이전트 터미널 세션의 데스크톱 채팅 화면을 미리 봅니다.", + "copy": "지원되는 에이전트 터미널 창에서 전환할 수 있는 네이티브 채팅 보기를 추가합니다. 대화 기록 충실도, 스트리밍, 터미널 동작 일치를 조정하는 동안 실험적으로 제공됩니다.", "toggleLabel": "기본 채팅 전환", "defaultTitle": "기본 보기", - "defaultCopy": "새로운 Claude 및 Codex terminal 탭이 열리는 방식을 선택하세요.", + "defaultCopy": "지원되는 에이전트의 새 터미널 탭을 여는 방식을 선택합니다.", "defaultViewLabel": "기본 기본 채팅 보기", "defaultViewTerminal": "Terminal 채팅", "defaultViewNative": "네이티브 채팅" @@ -6668,9 +6671,9 @@ "d23b43c5be": "설정 스크립트 위치", "34a0dfa06e": "새 워크스페이스를 만들 때 repos 설정 스크립트를 실행할 위치입니다.", "21f8da2078": "워크스페이스 설정 스크립트", - "6e6480a7df": "terminal의 프로그램(tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", + "6e6480a7df": "terminal의 프로그램(Grok, tmux, Neovim, fzf, SSH)이 시스템 클립보드에 복사할 수 있게 합니다.", "3338dcf8c1": "TUI 클립보드 쓰기 허용(OSC 52)", - "69c64a479c": "tmux, Neovim 및 fzf가 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", + "69c64a479c": "Grok, tmux, Neovim 및 fzf가 PTY(SSH 포함)를 통해 시스템 클립보드에 복사하도록 합니다.", "4729c645fc": "terminal 선택 사항을 클립보드에 자동으로 복사합니다.", "902f5dee1f": "선택 시 복사", "9129b7e805": "terminal 패널에 마우스를 가리키면 클릭하지 않고도 활성화됩니다.", @@ -7171,6 +7174,9 @@ "workspaceOptions": "워크스페이스 옵션", "detailed": "상세" }, + "antigravityUsageTitle": "Antigravity 사용량", + "antigravityUsageDescription": "상태 표시줄에 Antigravity 구독 사용량을 표시합니다.", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok 사용량", "e7d1b0f3a5": "Grok CLI OAuth에서 Grok 주간 크레딧 사용량을 표시합니다.", "d6c0a9e2f4": "grok", @@ -7459,7 +7465,8 @@ }, "nativeChat": { "title": "네이티브 채팅", - "description": "Claude 및 Codex terminal 세션에 대한 데스크톱 채팅 화면을 미리 봅니다." + "description": "지원되는 에이전트 터미널 세션의 데스크톱 채팅 화면을 미리 봅니다.", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3796138b5fd..ec6987a5999 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -29,6 +29,7 @@ "sshToggleDescription": "当有可用的 SSH 和远程 Orca 主机时显示它们。", "resourceUsageToggleDescription": "显示资源管理器。点击可查看 CPU、内存、会话、守护进程控制和工作区磁盘扫描。", "portsToggleDescription": "显示实时工作区端口。单击它可获取工作区范围的端口和外部侦听器。", + "antigravityToggleDescription": "显示当前工作区的 Antigravity 订阅使用量。", "grokToggleDescription": "通过 Grok CLI 登录后显示 Grok 订阅额度使用量。" } } @@ -3034,6 +3035,8 @@ "06741a2f3d": "打开 MiniMax 使用量详情", "3bbf140864": "MiniMax 使用量", "remoteServerLabel": "远程服务器", + "antigravityUsage": "Antigravity 使用量", + "antigravityUsageDetails": "打开 Antigravity 使用详情", "grokUsageAria": "打开 Grok 使用详情", "grokUsageMenu": "Grok 使用量" }, @@ -5290,11 +5293,11 @@ }, "nativeChat": { "title": "原生聊天", - "description": "预览 Claude 和 Codex 终端会话的桌面聊天界面。", - "copy": "添加一个原生聊天视图,可从受支持的 Claude 和 Codex 终端窗格切换进入。在我们调校记录保真度、流式输出和终端一致性期间,此功能仍为实验性。", + "description": "预览受支持的智能体终端会话的桌面聊天界面。", + "copy": "添加可从受支持的智能体终端窗格切换的原生聊天视图。在我们调整记录保真度、流式传输和终端一致性期间,此功能仍处于实验阶段。", "toggleLabel": "切换原生聊天", "defaultTitle": "默认视图", - "defaultCopy": "选择新的 Claude 和 Codex 终端标签页如何打开。", + "defaultCopy": "选择如何打开受支持智能体的新终端标签页。", "defaultViewLabel": "默认原生聊天视图", "defaultViewTerminal": "终端聊天", "defaultViewNative": "原生聊天" @@ -6668,9 +6671,9 @@ "d23b43c5be": "安装脚本位置", "34a0dfa06e": "创建新工作区时运行存储库设置脚本的位置。", "21f8da2078": "工作区设置脚本", - "6e6480a7df": "让终端中的程序(tmux、Neovim、fzf、SSH)复制到系统剪贴板。", + "6e6480a7df": "让终端中的程序(Grok、tmux、Neovim、fzf、SSH)复制到系统剪贴板。", "3338dcf8c1": "允许 TUI 剪贴板写入 (OSC 52)", - "69c64a479c": "让 tmux、Neovim 和 fzf 通过 PTY(包括通过 SSH)复制到系统剪贴板。", + "69c64a479c": "让 Grok、tmux、Neovim 和 fzf 通过 PTY(包括通过 SSH)复制到系统剪贴板。", "4729c645fc": "自动将终端选择复制到剪贴板。", "902f5dee1f": "选择时复制", "9129b7e805": "将鼠标悬停在终端窗格上即可将其激活,无需单击。", @@ -7171,6 +7174,9 @@ }, "9a115966d3": "关闭时最小化到托盘", "4d5b9427b5": "启用后,关闭窗口会让 Orca 继续在系统托盘中运行,而不是退出。", + "antigravityUsageTitle": "Antigravity 使用量", + "antigravityUsageDescription": "在状态栏中显示 Antigravity 订阅使用量。", + "antigravityKeyword": "antigravity", "f8e2a1c4b6": "Grok 使用量", "e7d1b0f3a5": "显示来自 Grok CLI OAuth 的 Grok 每周额度使用量。", "d6c0a9e2f4": "grok", @@ -7459,7 +7465,8 @@ }, "nativeChat": { "title": "原生聊天", - "description": "预览 Claude 和 Codex 终端会话的桌面聊天界面。" + "description": "预览受支持的智能体终端会话的桌面聊天界面。", + "grok": "grok" } } }, diff --git a/src/renderer/src/i18n/native-chat-locales.test.ts b/src/renderer/src/i18n/native-chat-locales.test.ts new file mode 100644 index 00000000000..faf341c957b --- /dev/null +++ b/src/renderer/src/i18n/native-chat-locales.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' +import es from './locales/es.json' +import ja from './locales/ja.json' +import ko from './locales/ko.json' +import zh from './locales/zh.json' + +const localizedCatalogs = { es, ja, ko, zh } +const englishSetting = en.auto.components.settings.ExperimentalPane.nativeChat +const englishSearch = en.auto.components.settings.experimental.search.nativeChat + +describe('native chat locale copy', () => { + it.each(Object.entries(localizedCatalogs))('%s keeps provider-neutral copy localized', (_code, catalog) => { + const setting = catalog.auto.components.settings.ExperimentalPane.nativeChat + const search = catalog.auto.components.settings.experimental.search.nativeChat + for (const [localized, english] of [ + [setting.description, englishSetting.description], + [setting.copy, englishSetting.copy], + [setting.defaultCopy, englishSetting.defaultCopy], + [search.description, englishSearch.description] + ]) { + expect(localized.trim()).not.toBe('') + expect(localized).not.toBe(english) + } + expect(search.grok).toBe('grok') + }) +}) diff --git a/src/renderer/src/lib/agent-followup-delivery.test.ts b/src/renderer/src/lib/agent-followup-delivery.test.ts new file mode 100644 index 00000000000..207c70ada36 --- /dev/null +++ b/src/renderer/src/lib/agent-followup-delivery.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { sendFollowupPromptWhenAgentReady } from './agent-followup-delivery' +import { + inspectRuntimeTerminalProcess, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: vi.fn(), + sendRuntimePtyInputVerified: vi.fn() +})) + +// The interpreter-wrapped agents that deliver their prompt over stdin after the +// process starts. These are pip console-scripts, so the PTY foreground comm is +// python/python3 — never the agent's own name. +const INTERPRETER_WRAPPED_AGENTS = [ + { agent: 'aider', expectedProcess: TUI_AGENT_CONFIG.aider.expectedProcess }, + { agent: 'mistral-vibe', expectedProcess: TUI_AGENT_CONFIG['mistral-vibe'].expectedProcess } +] as const + +describe('sendFollowupPromptWhenAgentReady — interpreter-wrapped agents', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('globalThis', globalThis) + // Deliver the prompt write eagerly so the test does not depend on retries. + vi.mocked(sendRuntimePtyInputVerified).mockResolvedValue(true) + }) + + it('sanity: config keeps aider/vibe as stdin-after-start with python-style expected process', () => { + expect(TUI_AGENT_CONFIG.aider.promptInjectionMode).toBe('stdin-after-start') + expect(TUI_AGENT_CONFIG['mistral-vibe'].promptInjectionMode).toBe('stdin-after-start') + }) + + for (const { agent, expectedProcess } of INTERPRETER_WRAPPED_AGENTS) { + it(`types the prompt once ${agent} is up behind a python3 wrapper with a live child`, async () => { + // The console-script agent is running: foreground comm is python3 and the + // PTY has a non-shell child. The exact agent name never appears. + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'python3', + hasChildProcesses: true + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess, + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + }) + + it(`still refuses to type into a bare ${agent} shell foreground`, async () => { + // No agent yet: foreground is a plain shell with no non-shell child. The + // guard must NOT write user text into an arbitrary shell. + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess, + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(false) + expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + + it(`refuses to type into a ${agent} wrapper without a live child`, async () => { + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'python3', + hasChildProcesses: false + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess, + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(false) + expect(sendRuntimePtyInputVerified).not.toHaveBeenCalled() + }) + } + + it('types immediately when the resolver already returns the agent name (local ps path)', async () => { + // On local desktop the ps-table resolver usually resolves python3 → aider + // before we poll; the exact-match path must keep working. + vi.mocked(inspectRuntimeTerminalProcess).mockResolvedValue({ + foregroundProcess: 'aider', + hasChildProcesses: true + }) + + const delivered = await sendFollowupPromptWhenAgentReady({ + ptyId: 'pty-1', + expectedProcess: 'aider', + prompt: 'ship it', + settings: null + }) + + expect(delivered).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(null, 'pty-1', 'ship it\r') + }) +}) diff --git a/src/renderer/src/lib/agent-followup-delivery.ts b/src/renderer/src/lib/agent-followup-delivery.ts index d5a4c6ff9b9..0042cb0f2e4 100644 --- a/src/renderer/src/lib/agent-followup-delivery.ts +++ b/src/renderer/src/lib/agent-followup-delivery.ts @@ -2,7 +2,11 @@ import { inspectRuntimeTerminalProcess, sendRuntimePtyInputVerified } from '@/runtime/runtime-terminal-inspection' -import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' +import { + isAgentForegroundWrapperProcess, + isExpectedAgentProcess +} from '../../../shared/agent-process-recognition' +import { isShellProcess } from '../../../shared/shell-process-detection' import type { GlobalSettings } from '../../../shared/types' type RuntimeOwnerSettings = Pick | null | undefined @@ -25,7 +29,7 @@ export async function sendFollowupPromptWhenAgentReady(args: { } // Why: delayed follow-ups must not type into an arbitrary shell. Require a -// positive expected-process match before writing user/task text to the PTY. +// positive readiness signal before writing user/task text to the PTY. async function waitForAgentForeground( ptyId: string, expectedProcess: string, @@ -41,6 +45,20 @@ async function waitForAgentForeground( if (isExpectedAgentProcess(foreground, expectedProcess)) { return true } + // Why: interpreter-wrapped agents (aider, mistral-vibe are pip console + // scripts) surface a python/node foreground comm, so the exact-name check + // never matches — locally when the ps-table resolver can't pin the child, + // and over SSH when the relay falls back to the bare interpreter name. If + // the foreground is a known agent wrapper (not a shell) with a live + // non-shell child, the agent has taken over the PTY and can accept input. + if ( + attempt >= 4 && + isAgentForegroundWrapperProcess(foreground) && + !isShellProcess(foreground) && + process.hasChildProcesses + ) { + return true + } } catch { // Ignore transient PTY inspection failures and keep polling. } diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 577db1e534e..5ce6ea89740 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -65,7 +65,7 @@ describe('deliverLaunchPromptToAgentTab', () => { }) await deliverLaunchPromptToAgentTab({ tabId: 'unsupported-tab', - agent: 'grok', + agent: 'gemini', content: 'Fix failing checks', submit: true, forcePaste: true @@ -121,7 +121,7 @@ describe('deliverLaunchPromptToAgentTab', () => { await deliverLaunchPromptToAgentTab({ tabId: 'tab-1', - agent: 'grok', + agent: 'gemini', content: 'Large generated prompt', submit: true, forcePaste: true diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 4d5db5d7237..fb703349029 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -123,6 +123,27 @@ describe('ai vault resume command runtime', () => { ).toBe("cd 'C:\\Users\\alice\\repo' && claude '--resume' 'session one'") }) + it('follows the live Windows shell for non-resumable agents in the fallback path', () => { + // Why: agents without a TUI startup plan (e.g. cursor) queue through the + // shared-builder fallback, which must quote for the live shell too (#6152). + const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + + expect( + buildAiVaultResumeCommandForWorktree({ + state, + worktreeId: 'repo-1::worktree-1', + session: { + agent: 'cursor', + sessionId: 'session one', + cwd: 'C:\\Users\\alice\\repo', + codexHome: null + } + }) + ).toBe( + "Set-Location -LiteralPath 'C:\\Users\\alice\\repo'; cursor-agent --resume 'session one'" + ) + }) + it('queues a PowerShell-valid local OMP resume by absolute transcript path', () => { // Regression: local rebuilds must forward session.filePath so OMP resumes by // path, and queued Windows commands must match the live tab shell. diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index d71f3653fbb..f5ea23d3a55 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -137,6 +137,8 @@ export function buildAiVaultResumeStartupForWorktree(args: { platform, commandOverride: args.commandOverride, codexHome, + // Why: non-resumable agents queue through this fallback too, so it must + // quote for the live Windows shell like the startup-plan branch above. shell: queuedShell }) } diff --git a/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts b/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts new file mode 100644 index 00000000000..f4cf3fb943e --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/layout-base-character.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _setLayoutMapForTests, + getLayoutBaseCharacterForCode, + normalizeLayoutBaseCharacter +} from './layout-base-character' + +describe('normalizeLayoutBaseCharacter', () => { + it('accepts a single printable codepoint, lowercased', () => { + expect(normalizeLayoutBaseCharacter('p')).toBe('p') + expect(normalizeLayoutBaseCharacter('P')).toBe('p') + expect(normalizeLayoutBaseCharacter('ö')).toBe('ö') + expect(normalizeLayoutBaseCharacter(';')).toBe(';') + }) + + it('rejects empty, named-key, multi-codepoint, and control values', () => { + expect(normalizeLayoutBaseCharacter(undefined)).toBeUndefined() + expect(normalizeLayoutBaseCharacter('')).toBeUndefined() + expect(normalizeLayoutBaseCharacter('Dead')).toBeUndefined() + expect(normalizeLayoutBaseCharacter('\t')).toBeUndefined() + expect(normalizeLayoutBaseCharacter(' ')).toBeUndefined() + }) +}) + +describe('getLayoutBaseCharacterForCode', () => { + afterEach(() => { + _setLayoutMapForTests(null) + }) + + it('returns undefined without a cached map, and resolves through one', () => { + expect(getLayoutBaseCharacterForCode('KeyP')).toBeUndefined() + + const azertyEntries = new Map([ + ['Semicolon', 'm'], + ['KeyE', 'Dead'] + ]) + _setLayoutMapForTests({ + get: (code) => azertyEntries.get(code), + size: azertyEntries.size + }) + expect(getLayoutBaseCharacterForCode('Semicolon')).toBe('m') + expect(getLayoutBaseCharacterForCode('KeyE')).toBeUndefined() + expect(getLayoutBaseCharacterForCode('KeyZ')).toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/keyboard-layout/layout-base-character.ts b/src/renderer/src/lib/keyboard-layout/layout-base-character.ts new file mode 100644 index 00000000000..d712d897ebd --- /dev/null +++ b/src/renderer/src/lib/keyboard-layout/layout-base-character.ts @@ -0,0 +1,78 @@ +/** + * Synchronous lookup of the active keyboard layout's base (unshifted) + * character for a physical `KeyboardEvent.code`. + * + * Why: kitty keyboard CSI-u reports must carry the codepoint of the key in + * the *current layout* with no modifiers. Deriving it from the physical code + * alone assumes US QWERTY and reports the wrong key on Dvorak, Colemak, + * AZERTY, QWERTZ, etc. — misfiring TUI hotkeys. Chromium's KeyboardLayoutMap + * is the layout-true source, but it resolves asynchronously, so this module + * prefetches it and refreshes on window focus-in (every macOS layout-switch + * path blurs and refocuses the window; Chromium has no layoutchange event — + * see option-as-alt-probe.ts). + */ +import type { LayoutMapLike } from './detect-option-as-alt' + +type NavigatorWithKeyboard = Navigator & { + keyboard?: { + getLayoutMap: () => Promise + } +} + +let cachedLayoutMap: LayoutMapLike | null = null +let focusListenerAttached = false + +async function refreshLayoutMap(): Promise { + const keyboard = (window.navigator as NavigatorWithKeyboard).keyboard + if (!keyboard?.getLayoutMap) { + return + } + try { + cachedLayoutMap = await keyboard.getLayoutMap() + } catch { + // Why: getLayoutMap can transiently reject; keep the last known map + // instead of dropping layout awareness mid-session. + } +} + +/** Idempotent. Kicks off the initial fetch and keeps the cache fresh across + * layout switches. Call from terminal keyboard setup so the map is resolved + * before the first Option chord. */ +export function prefetchLayoutBaseCharacters(): void { + if (focusListenerAttached || typeof window === 'undefined') { + return + } + focusListenerAttached = true + window.addEventListener('focus', () => { + void refreshLayoutMap() + }) + void refreshLayoutMap() +} + +/** A layout map entry is usable as a kitty base key only if it is a single + * printable codepoint (dead keys report names like 'Dead'; some entries are + * empty). Exposed for tests. */ +export function normalizeLayoutBaseCharacter(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + const lowered = value.toLowerCase() + const codePoints = [...lowered] + if (codePoints.length !== 1) { + return undefined + } + const codePoint = lowered.codePointAt(0) as number + return codePoint <= 0x20 ? undefined : lowered +} + +/** The active layout's unshifted character for a physical key code, or + * undefined when the map is unavailable or the key has no single printable + * base character (callers fall back to the US table). */ +export function getLayoutBaseCharacterForCode(code: string): string | undefined { + return normalizeLayoutBaseCharacter(cachedLayoutMap?.get(code)) +} + +/** Test-only: replace or clear the cached layout map. */ +export function _setLayoutMapForTests(map: LayoutMapLike | null): void { + cachedLayoutMap = map +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index fd25fc85df6..fa7283725c4 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -201,7 +201,7 @@ describe('launchAgentInNewTab', () => { }) }) - it('keeps unsupported submit-after-ready launches in terminal mode and does not seed chat', async () => { + it('opens local Grok submit-after-ready launches in native chat', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, @@ -220,9 +220,36 @@ describe('launchAgentInNewTab', () => { }) expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { - launchAgent: 'grok' + launchAgent: 'grok', + quickCommandLabel: undefined, + viewMode: 'chat' + }) + expect(mockSeedNativeChatLaunchPrompt).toHaveBeenCalledWith({ + tabId: 'tab-1', + agent: 'grok', + text: 'large generated prompt', + createdAt: expect.any(Number) + }) + }) + + it('keeps Model-A SSH Grok launches in terminal mode', async () => { + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null, + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + store.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1', path: '/repo' }] + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'grok', worktreeId: 'wt-1' }) + + expect(mockCreateTab).toHaveBeenCalledWith('wt-1', undefined, undefined, { + launchAgent: 'grok', + quickCommandLabel: undefined }) - expect(mockSeedNativeChatLaunchPrompt).not.toHaveBeenCalled() }) it('passes quick command labels only to locally-created agent tabs', async () => { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 50397347791..174fdd86ff0 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -11,6 +11,7 @@ import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order' import { track, tuiAgentToAgentKind } from '@/lib/telemetry' import { deliverLaunchPromptToAgentTab } from '@/lib/agent-launch-prompt-delivery' import { initialAgentTabViewModeProps } from '@/lib/native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { @@ -29,6 +30,7 @@ import { seedCommandCodeSubmittedPromptStatus } from '@/lib/command-code-prompt- import type { TuiAgent } from '../../../shared/types' import type { LaunchSource } from '../../../shared/telemetry-events' import { translate } from '@/i18n/i18n' +import { getConnectionIdFromState } from '@/lib/connection-context' export type LaunchAgentInNewTabArgs = { agent: TuiAgent @@ -264,14 +266,14 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI // stays false), so gate the initial chat view like a `draft` launch — // otherwise a default `auto-submit` followup would open native chat with no // submitted turn to render. - const viewModePromptDelivery = - hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery + const viewModePromptDelivery = hasPrompt && isFollowupPath && promptDelivery === 'auto-submit' ? 'draft' : promptDelivery const tab = store.createTab(worktreeId, groupId, undefined, { launchAgent: agent, quickCommandLabel, ...initialAgentTabViewModeProps(store.settings, { agent, - promptDelivery: viewModePromptDelivery + promptDelivery: viewModePromptDelivery, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(getConnectionIdFromState(store, worktreeId)) }) }) store.queueTabStartupCommand(tab.id, { diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index bc8d1553f2b..10996951048 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -3,6 +3,7 @@ import { decideInitialAgentTabViewMode, initialAgentTabViewModeProps } from './native-chat-initial-view-mode' +import { isNativeChatTranscriptLocalReadable } from './native-chat-transcript-readability' describe('decideInitialAgentTabViewMode', () => { it("returns 'chat' when native chat and the opt-in default setting are on", () => { @@ -50,11 +51,47 @@ describe('decideInitialAgentTabViewMode', () => { decideInitialAgentTabViewMode({ experimentalNativeChat: true, openAgentTabsInChatByDefault: true, - agent: 'grok' + agent: 'gemini' }) ).toBeUndefined() }) + it.each([ + ['local', null], + ['runtime-owned', 'runtime-ssh-env-1'] + ] as const)('opens %s Grok in chat when configured', (_host, connectionId) => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'grok', + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable(connectionId) + }) + ).toBe('chat') + }) + + it('keeps Model-A SSH Grok in the terminal view', () => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent: 'grok', + nativeChatTranscriptIsLocalReadable: + isNativeChatTranscriptLocalReadable('ssh-target-1') + }) + ).toBeUndefined() + expect( + initialAgentTabViewModeProps( + { + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + }, + { agent: 'grok', nativeChatTranscriptIsLocalReadable: false } + ) + ).toEqual({}) + }) + it('returns undefined for draft delivery', () => { expect( decideInitialAgentTabViewMode({ diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.ts b/src/renderer/src/lib/native-chat-initial-view-mode.ts index a79b60962e9..fd0e71c6d41 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.ts @@ -16,6 +16,7 @@ export function decideInitialAgentTabViewMode(args: { openAgentTabsInChatByDefault?: boolean agent?: TuiAgent | null promptDelivery?: NativeChatLaunchPromptDelivery + nativeChatTranscriptIsLocalReadable?: boolean }): Tab['viewMode'] { if (args.experimentalNativeChat !== true || args.openAgentTabsInChatByDefault !== true) { return undefined @@ -23,6 +24,9 @@ export function decideInitialAgentTabViewMode(args: { if (!isNativeChatSupportedAgent(args.agent)) { return undefined } + if (args.agent === 'grok' && args.nativeChatTranscriptIsLocalReadable !== true) { + return undefined + } if (args.promptDelivery === 'draft') { return undefined } @@ -37,13 +41,15 @@ export function initialAgentTabViewModeProps( options: { agent?: TuiAgent | null promptDelivery?: NativeChatLaunchPromptDelivery + nativeChatTranscriptIsLocalReadable?: boolean } = {} ): { viewMode?: Tab['viewMode'] } { const viewMode = decideInitialAgentTabViewMode({ experimentalNativeChat: settings?.experimentalNativeChat, openAgentTabsInChatByDefault: settings?.openAgentTabsInChatByDefault, agent: options.agent, - promptDelivery: options.promptDelivery + promptDelivery: options.promptDelivery, + nativeChatTranscriptIsLocalReadable: options.nativeChatTranscriptIsLocalReadable }) return viewMode ? { viewMode } : {} } diff --git a/src/renderer/src/lib/native-chat-supported-agent.ts b/src/renderer/src/lib/native-chat-supported-agent.ts index ec24cd3968d..a376fe0d5ec 100644 --- a/src/renderer/src/lib/native-chat-supported-agent.ts +++ b/src/renderer/src/lib/native-chat-supported-agent.ts @@ -5,7 +5,11 @@ import type { TuiAgent } from '../../../shared/types' export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set([ 'claude', 'openclaude', - 'codex' + 'codex', + // Why: Grok writes `~/.grok/sessions///chat_history.jsonl` and + // Orca already reads that for hooks/AI Vault — native chat reuses the same + // path + a Grok JSONL decoder rather than leaving Grok TUI-only. + 'grok' ]) export function isNativeChatSupportedAgent( diff --git a/src/renderer/src/lib/native-chat-transcript-readability.test.ts b/src/renderer/src/lib/native-chat-transcript-readability.test.ts new file mode 100644 index 00000000000..29d41bcc7bd --- /dev/null +++ b/src/renderer/src/lib/native-chat-transcript-readability.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import { isNativeChatTranscriptLocalReadable } from './native-chat-transcript-readability' + +describe('native chat transcript readability', () => { + it('allows local and runtime-owned hosts but rejects Model-A SSH and unresolved hosts', () => { + expect(isNativeChatTranscriptLocalReadable(null)).toBe(true) + expect(isNativeChatTranscriptLocalReadable('runtime-ssh-env-1')).toBe(true) + expect(isNativeChatTranscriptLocalReadable('ssh-target-1')).toBe(false) + expect(isNativeChatTranscriptLocalReadable(undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/lib/native-chat-transcript-readability.ts b/src/renderer/src/lib/native-chat-transcript-readability.ts new file mode 100644 index 00000000000..165b2a36a1a --- /dev/null +++ b/src/renderer/src/lib/native-chat-transcript-readability.ts @@ -0,0 +1,8 @@ +import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' + +/** Model-A SSH stores Grok transcripts on a host this renderer cannot read. */ +export function isNativeChatTranscriptLocalReadable( + connectionId: string | null | undefined +): boolean { + return connectionId === null || isRuntimeOwnedSshTargetId(connectionId) +} diff --git a/src/renderer/src/lib/new-workspace.test.ts b/src/renderer/src/lib/new-workspace.test.ts index 8306ca0d62c..dc70d24841f 100644 --- a/src/renderer/src/lib/new-workspace.test.ts +++ b/src/renderer/src/lib/new-workspace.test.ts @@ -4,6 +4,7 @@ const { mockInspectRuntimeTerminalProcess, mockSendRuntimePtyInputVerified, mockPasteDraftToAgentPtyWhenReady, + mockShowAutomationPromptNotSentToast, mockTrack, store, storeListeners, @@ -12,6 +13,7 @@ const { mockInspectRuntimeTerminalProcess: vi.fn(), mockSendRuntimePtyInputVerified: vi.fn(), mockPasteDraftToAgentPtyWhenReady: vi.fn(), + mockShowAutomationPromptNotSentToast: vi.fn(), mockTrack: vi.fn(), storeListeners: new Set<(state: unknown, previousState: unknown) => void>(), startupLeafId: '11111111-1111-4111-8111-111111111111', @@ -88,6 +90,10 @@ vi.mock('@/lib/telemetry', () => ({ track: mockTrack })) +vi.mock('@/lib/agent-background-session-timeout-toast', () => ({ + showAutomationPromptNotSentToast: mockShowAutomationPromptNotSentToast +})) + import { ensureAgentStartupInTerminal, getSetupConfig, @@ -298,6 +304,65 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) + it('surfaces the not-sent toast when a follow-up prompt is dropped', async () => { + // Foreground never becomes a recognized agent and there is no live child, + // so the readiness wait times out and the prompt is not delivered. + mockInspectRuntimeTerminalProcess.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + expect(mockSendRuntimePtyInputVerified).not.toHaveBeenCalled() + expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith('aider') + }) + + it('does not toast when a follow-up prompt is delivered', async () => { + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'aider', + launchCommand: 'aider', + expectedProcess: 'aider', + followupPrompt: 'fix the spinner', + launchConfig: { agentArgs: '', agentEnv: {} } + } + }) + + expect(mockShowAutomationPromptNotSentToast).not.toHaveBeenCalled() + }) + + it('passes an onTimeout that surfaces the not-sent toast to the draft paste path', async () => { + await ensureAgentStartupInTerminal({ + worktreeId: 'wt-1', + startup: { + agent: 'claude', + launchCommand: 'claude', + expectedProcess: 'claude', + followupPrompt: null, + launchConfig: { agentArgs: '', agentEnv: {} }, + draftPrompt: 'review this before sending' + } + }) + + const call = mockPasteDraftToAgentPtyWhenReady.mock.calls.at(-1)?.[0] as + | { onTimeout?: () => void } + | undefined + expect(call?.onTimeout).toBeTypeOf('function') + call?.onTimeout?.() + expect(mockShowAutomationPromptNotSentToast).toHaveBeenCalledWith('claude') + }) + it('does not track when follow-up prompt delivery rejects', async () => { mockSendRuntimePtyInputVerified.mockRejectedValue(new Error('runtime timeout')) @@ -335,7 +400,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'pty-1', content: 'review this before sending', agent: 'claude', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) @@ -378,7 +444,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'agent-pty', content: 'Linear context draft', agent: 'codex', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) }) @@ -434,7 +501,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'pty-delayed', content: 'https://github.com/stablyai/orca/pull/2051', agent: 'codex', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) }) @@ -515,7 +583,8 @@ describe('ensureAgentStartupInTerminal prompt delivery', () => { ptyId: 'startup-pty', content: 'linked draft', agent: 'codex', - forcePaste: true + forcePaste: true, + onTimeout: expect.any(Function) }) }) diff --git a/src/renderer/src/lib/new-workspace.ts b/src/renderer/src/lib/new-workspace.ts index 149c0e7708f..0a7505762d0 100644 --- a/src/renderer/src/lib/new-workspace.ts +++ b/src/renderer/src/lib/new-workspace.ts @@ -4,6 +4,7 @@ import { pasteDraftToAgentPtyWhenReady } from '@/lib/agent-paste-draft' import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-followup-delivery' +import { showAutomationPromptNotSentToast } from '@/lib/agent-background-session-timeout-toast' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import type { LinkedWorkItemContext } from '@/lib/linked-work-item-context' import { @@ -302,12 +303,17 @@ async function deliverAgentStartupToTerminal( // (aider, goose, etc.) that need their initial prompt typed into the live // session and submitted. Wait until the agent owns the PTY before writing. if (startup.followupPrompt) { - await sendFollowupPromptWhenAgentReady({ + const delivered = await sendFollowupPromptWhenAgentReady({ ptyId, expectedProcess: startup.expectedProcess, prompt: startup.followupPrompt, settings: runtimeSettings }) + // Why: a dropped follow-up is otherwise silent — surface the same toast the + // draft path uses so the user knows to open the workspace and paste it. + if (!delivered) { + showAutomationPromptNotSentToast(startup.agent) + } } // Why: draftPrompt uses bracketed-paste so the URL lands atomically in the @@ -321,7 +327,9 @@ async function deliverAgentStartupToTerminal( agent: startup.agent, // Why: startup.draftPrompt is only attached after native draft launch // planning is unavailable, so this paste is the first delivery attempt. - forcePaste: true + forcePaste: true, + // Why: surface a dropped draft instead of silently losing it. + onTimeout: () => showAutomationPromptNotSentToast(startup.agent) }) } } 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 e96a8efec62..c4cb7808225 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -149,6 +149,25 @@ describe('buildDefaultTerminalOptions', () => { expect(merged.vtExtensions?.kittyKeyboard).toBe(false) }) + it('keeps kitty keyboard when a local Windows ConPTY pane is launching Grok', () => { + // Why: Grok needs KKP for Ctrl+Enter interject / Shift+Enter newline; the + // ConPTY withhold must not win when launchAgent (or tuiAgent) is grok. + const merged = { + ...buildDefaultTerminalOptions(), + ...buildTerminalKeyboardProtocolOptions({ + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + osRelease: '10.0.26100', + connectionId: null, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe', + executionHostId: 'local', + tuiAgent: 'grok' + }) + } + + expect(merged.vtExtensions?.kittyKeyboard).toBe(true) + }) + it('keeps the advertised kitty keyboard default for SSH and macOS/Linux panes', () => { for (const context of [ { diff --git a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts index 8c525a93aeb..87a596736ab 100644 --- a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { buildTerminalKeyboardProtocolOptions, + prefersKittyKeyboardDespiteWindowsConpty, shouldDisableKittyKeyboardForTerminal } from './terminal-keyboard-protocol' @@ -8,19 +9,50 @@ const WINDOWS_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' const MAC_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' const LINUX_UA = 'Mozilla/5.0 (X11; Linux x86_64)' +const localWindowsConpty = { + userAgent: WINDOWS_UA, + osRelease: '10.0.26100', + connectionId: null as string | null, + cwd: 'C:\\repo', + shellOverride: 'powershell.exe' as string | null, + executionHostId: 'local' as const +} + +describe('prefersKittyKeyboardDespiteWindowsConpty', () => { + it('is true only for Grok (needs KKP for modified Enter on ConPTY)', () => { + expect(prefersKittyKeyboardDespiteWindowsConpty('grok')).toBe(true) + expect(prefersKittyKeyboardDespiteWindowsConpty('claude')).toBe(false) + expect(prefersKittyKeyboardDespiteWindowsConpty('codex')).toBe(false) + expect(prefersKittyKeyboardDespiteWindowsConpty(null)).toBe(false) + expect(prefersKittyKeyboardDespiteWindowsConpty(undefined)).toBe(false) + }) +}) + describe('shouldDisableKittyKeyboardForTerminal', () => { it('disables Kitty keyboard for a local native Windows ConPTY pane', () => { // Regression for #2434: local Windows CLIs (e.g. Antigravity agy) read the // advertisement but do not decode CSI-u, so enhanced reporting swallows // Enter/Up/Down navigation. The advertisement must be withheld here. + expect(shouldDisableKittyKeyboardForTerminal(localWindowsConpty)).toBe(true) + }) + + it('keeps Kitty keyboard for Grok on a local Windows ConPTY pane', () => { + // Why: Grok needs KKP to distinguish Ctrl+Enter (interject) / Shift+Enter + // from plain Enter. Global ConPTY withhold must not apply when the pane's + // known agent is Grok (Orca launchAgent or equivalent). expect( shouldDisableKittyKeyboardForTerminal({ - userAgent: WINDOWS_UA, - osRelease: '10.0.26100', - connectionId: null, - cwd: 'C:\\repo', - shellOverride: 'powershell.exe', - executionHostId: 'local' + ...localWindowsConpty, + tuiAgent: 'grok' + }) + ).toBe(false) + }) + + it('still withholds Kitty keyboard for non-Grok agents on local Windows ConPTY', () => { + expect( + shouldDisableKittyKeyboardForTerminal({ + ...localWindowsConpty, + tuiAgent: 'claude' }) ).toBe(true) }) @@ -109,16 +141,18 @@ describe('shouldDisableKittyKeyboardForTerminal', () => { describe('buildTerminalKeyboardProtocolOptions', () => { it('withholds the Kitty keyboard advertisement for a local Windows ConPTY pane', () => { + expect(buildTerminalKeyboardProtocolOptions(localWindowsConpty)).toEqual({ + vtExtensions: { kittyKeyboard: false } + }) + }) + + it('returns no override for Grok on local Windows ConPTY so KKP stays advertised', () => { expect( buildTerminalKeyboardProtocolOptions({ - userAgent: WINDOWS_UA, - osRelease: '10.0.26100', - connectionId: null, - cwd: 'C:\\repo', - shellOverride: 'powershell.exe', - executionHostId: 'local' + ...localWindowsConpty, + tuiAgent: 'grok' }) - ).toEqual({ vtExtensions: { kittyKeyboard: false } }) + ).toEqual({}) }) it('returns no override for SSH panes so enhanced reporting stays advertised', () => { diff --git a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts index 0d591c473b6..8c804236f9b 100644 --- a/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts +++ b/src/renderer/src/lib/pane-manager/terminal-keyboard-protocol.ts @@ -1,5 +1,6 @@ import type { ITerminalOptions } from '@xterm/xterm' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { TuiAgent } from '../../../../shared/types' import { isLocalNativeWindowsConpty, type WindowsPtyCompatibilityContext @@ -7,6 +8,23 @@ import { export type TerminalKeyboardProtocolContext = WindowsPtyCompatibilityContext & { executionHostId: ExecutionHostId + /** + * Known TUI agent for this pane when available (launchAgent / foreground). + * Used only to opt specific agents out of the ConPTY KKP withhold. + */ + tuiAgent?: TuiAgent | null +} + +/** + * Agents that correctly decode Kitty CSI-u and need modified-Enter chords + * (Shift/Ctrl+Enter) on local Windows ConPTY. Global ConPTY withhold (#2434) + * targets CSI-u-blind CLIs (e.g. Antigravity); Grok is not in that set and + * relies on KKP for interject vs newline (official Grok Build keyboard docs). + */ +export function prefersKittyKeyboardDespiteWindowsConpty( + agent: TuiAgent | null | undefined +): boolean { + return agent === 'grok' } /** @@ -21,10 +39,16 @@ export type TerminalKeyboardProtocolContext = WindowsPtyCompatibilityContext & { * a genuine local Windows ConPTY pane restores standard navigation there while * preserving enhanced keyboard reporting for SSH and macOS/Linux panes (which * decode CSI-u correctly, including inside tmux). + * + * Exception: when `tuiAgent` is an agent known to need KKP on ConPTY (Grok), + * keep the advertisement so modified-Enter chords stay usable. */ export function shouldDisableKittyKeyboardForTerminal( context: TerminalKeyboardProtocolContext ): boolean { + if (prefersKittyKeyboardDespiteWindowsConpty(context.tuiAgent)) { + return false + } return isLocalNativeWindowsConpty(context) } diff --git a/src/renderer/src/lib/tui-agent-startup.test.ts b/src/renderer/src/lib/tui-agent-startup.test.ts index 1feb1e44c7a..14af5e911f9 100644 --- a/src/renderer/src/lib/tui-agent-startup.test.ts +++ b/src/renderer/src/lib/tui-agent-startup.test.ts @@ -166,7 +166,7 @@ describe('buildAgentStartupPlan', () => { }) }) - it('launches Grok first and injects the prompt after startup', () => { + it('launches Grok with the prompt as a positional argv', () => { expect( buildAgentStartupPlan({ agent: 'grok', @@ -176,9 +176,9 @@ describe('buildAgentStartupPlan', () => { }) ).toEqual({ agent: 'grok', - launchCommand: 'grok', + launchCommand: "grok -- 'Trace the failing test'", expectedProcess: 'grok', - followupPrompt: 'Trace the failing test', + followupPrompt: null, launchConfig: emptyLaunchConfig('grok') }) }) diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 1d94786a345..52004e147dd 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -63,6 +63,8 @@ import { } from './folder-workspace-path-status' import { toast } from 'sonner' import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode' +import { getConnectionId } from '@/lib/connection-context' +import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' /** Telemetry payload threaded from the launch site to `pty:spawn`. Main * fires `agent_started` only after the spawn succeeds — see @@ -582,7 +584,10 @@ export function ensureWorktreeHasInitialTerminal( launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null, { agent: launchAgent, - promptDelivery: sequencedStartup?.draftPrompt != null ? 'draft' : undefined + promptDelivery: sequencedStartup?.draftPrompt != null ? 'draft' : undefined, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionId(worktreeId) + ) }) } : {}), @@ -647,7 +652,10 @@ function applyDefaultTerminalTabs( launchAgent, ...initialAgentTabViewModeProps(store.settings ?? null, { agent: launchAgent, - promptDelivery: isStartupTab && startup?.draftPrompt != null ? 'draft' : undefined + promptDelivery: isStartupTab && startup?.draftPrompt != null ? 'draft' : undefined, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable( + getConnectionId(worktreeId) + ) }) } : {}), diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts new file mode 100644 index 00000000000..daebefb6e73 --- /dev/null +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -0,0 +1,236 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../shared/terminal-stream-protocol' +import { + getRemoteRuntimeTerminalMultiplexer, + resetRemoteRuntimeTerminalMultiplexersForTests, + type RemoteRuntimeMultiplexedTerminal +} from './remote-runtime-terminal-multiplexer' + +// Why: reproduces the silent frame-drop corruption. The server multiplex path +// drops Output frames when the websocket buffer is over its cap +// (encryptedBinaryReply returns false); the wire `seq` is an output high-water, so +// a drop leaves a detectable gap. This harness drives the real client +// multiplexer through the same subscribe transport the app uses and forces a +// drop, asserting the client resyncs instead of rendering a corrupt tail. + +type SubscribeCallbacks = { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void +} + +/** + * Minimal server that mimics src/main/runtime/rpc/methods/terminal.ts's multiplex + * output path: Output frames carry a monotonic UTF-16 high-water `seq`, and a + * SnapshotRequest is answered with an initial-style snapshot (no requestId). + */ +class FakeMultiplexServer { + private cursorUnits = 0 + private streamId = 0 + dropNextOutput = false + droppedFrames = 0 + holdNextManualSnapshot = false + snapshotRequests: (number | undefined)[] = [] + private heldManualRequestId: number | null = null + private snapshotData = 'INITIAL' + + constructor( + private readonly toClient: (bytes: Uint8Array) => void, + private readonly onServerSideDrop?: () => void + ) {} + + /** Client -> server frames arrive here (Subscribe / SnapshotRequest / Input). */ + receive(bytes: Uint8Array): void { + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + if (frame.opcode === TerminalStreamOpcode.Subscribe) { + const payload = decodeTerminalStreamJson<{ streamId: number }>(frame.payload) + this.streamId = payload?.streamId ?? 0 + this.sendSnapshot() + return + } + if (frame.opcode === TerminalStreamOpcode.SnapshotRequest) { + const payload = decodeTerminalStreamJson<{ requestId?: number }>(frame.payload) + this.snapshotRequests.push(payload?.requestId) + if (typeof payload?.requestId === 'number' && this.holdNextManualSnapshot) { + this.holdNextManualSnapshot = false + this.heldManualRequestId = payload.requestId + return + } + // Resync request: the server serializes the *current* buffer, so recovery + // includes everything the client missed. + this.snapshotData = 'RECOVERED' + this.sendSnapshot(payload?.requestId) + } + } + + private send(opcode: TerminalStreamOpcode, payload: Uint8Array, seq: number): void { + this.toClient(encodeTerminalStreamFrame({ opcode, streamId: this.streamId, seq, payload })) + } + + private sendSnapshot(requestId?: number): void { + this.send( + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ cols: 80, rows: 24, seq: this.cursorUnits, requestId }), + 0 + ) + this.send(TerminalStreamOpcode.SnapshotChunk, encodeTerminalStreamText(this.snapshotData), 0) + this.send(TerminalStreamOpcode.SnapshotEnd, new Uint8Array(), 0) + } + + /** Emit an Output chunk, honoring simulated websocket backpressure. */ + output(text: string): void { + const startSeq = this.cursorUnits + this.cursorUnits += text.length + if (this.dropNextOutput) { + // encryptedBinaryReply returned false: frame is NOT sent. The byte + // high-water still advances (server keeps producing), so the next frame's + // seq jumps past what the client last saw. + this.dropNextOutput = false + this.droppedFrames += 1 + this.onServerSideDrop?.() + return + } + void startSeq + this.send(TerminalStreamOpcode.Output, encodeTerminalStreamText(text), this.cursorUnits) + } + + flushHeldManualSnapshot(): void { + if (this.heldManualRequestId === null) { + throw new Error('No manual snapshot is held') + } + const requestId = this.heldManualRequestId + this.heldManualRequestId = null + this.snapshotData = 'MANUAL' + this.sendSnapshot(requestId) + } +} + +describe('remote terminal frame-drop resync', () => { + const unsubscribe = vi.fn() + let server: FakeMultiplexServer + + beforeEach(() => { + vi.clearAllMocks() + resetRemoteRuntimeTerminalMultiplexersForTests() + + const subscribe = vi.fn(async (_args: unknown, callbacks: SubscribeCallbacks) => { + server = new FakeMultiplexServer((bytes) => callbacks.onBinary?.(bytes)) + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { + unsubscribe, + sendBinary: (bytes: Uint8Array) => server.receive(bytes) + } + }) + + vi.stubGlobal('window', { + api: { runtimeEnvironments: { subscribe } } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + async function subscribeClient(): Promise<{ + data: string[] + snapshots: string[] + stream: RemoteRuntimeMultiplexedTerminal + }> { + const data: string[] = [] + const snapshots: string[] = [] + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-1') + const stream = await multiplexer.subscribeTerminal({ + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + callbacks: { + onData: (chunk) => data.push(chunk), + onSnapshot: (chunk) => snapshots.push(chunk) + } + }) + // Let the initial snapshot round-trip settle. + await Promise.resolve() + await Promise.resolve() + return { data, snapshots, stream } + } + + it('detects a dropped Output frame via the seq gap and resyncs', async () => { + const { data, snapshots } = await subscribeClient() + expect(snapshots).toEqual(['INITIAL']) + + server.output('aaa') + server.dropNextOutput = true + server.output('bbb') // dropped under backpressure — never reaches the client + server.output('ccc') // seq jumps past 'bbb', exposing the gap + + // Flush the client's resync SnapshotRequest -> server snapshot round-trip. + await Promise.resolve() + await Promise.resolve() + + // The corrupt tail ('ccc', which followed a gap) is NOT rendered as live data. + expect(data).toEqual(['aaa']) + expect(server.droppedFrames).toBe(1) + // Instead, a fresh authoritative snapshot recovers the terminal. + expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + }) + + it('passes contiguous output straight through without resyncing', async () => { + const { data, snapshots } = await subscribeClient() + + server.output('one') + server.output('two') + server.output('three') + await Promise.resolve() + await Promise.resolve() + + expect(data).toEqual(['one', 'two', 'three']) + expect(snapshots).toEqual(['INITIAL']) + }) + + it('uses UTF-16 sequence units when detecting gaps in multibyte output', async () => { + const { data, snapshots } = await subscribeClient() + + server.output('é') + server.dropNextOutput = true + server.output('🙂') + server.output('界') + await Promise.resolve() + await Promise.resolve() + + expect(data).toEqual(['é']) + expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + }) + + it('defers recovery until an in-flight manual snapshot finishes', async () => { + const { data, snapshots, stream } = await subscribeClient() + server.holdNextManualSnapshot = true + const manualSnapshot = stream.serializeBuffer({ scrollbackRows: 100 }) + await Promise.resolve() + + server.output('aaa') + server.dropNextOutput = true + server.output('🙂') + server.output('ccc') + + expect(data).toEqual(['aaa']) + expect(server.snapshotRequests).toHaveLength(1) + + server.flushHeldManualSnapshot() + await Promise.resolve() + await Promise.resolve() + + await expect(manualSnapshot).resolves.toMatchObject({ data: 'MANUAL' }) + expect(server.snapshotRequests).toHaveLength(2) + expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + }) +}) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 70fb729b9db..5f0b751a91f 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -77,6 +77,13 @@ type RemoteRuntimeMultiplexedTerminalState = { snapshotInfo: RemoteRuntimeSnapshotInfo | null initialSnapshotReceived: boolean pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null + // Why: Output frames carry a UTF-16 offset high-water `seq`; a jump past the + // expected next offset means the server dropped frames under backpressure. + // Track it so a gap triggers a self-healing snapshot resync instead of + // silently rendering corrupt/missing output (frame-drop resync). + expectedSeq: number | undefined + resyncInFlight: boolean + resyncPendingSend: boolean } type RemoteRuntimeSnapshotInfo = { @@ -111,7 +118,9 @@ type RemoteRuntimeSnapshotRequest = { const CONTROL_STREAM_ID = 0 const MAX_REMOTE_TERMINAL_SNAPSHOT_BYTES = 2 * 1024 * 1024 const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000 -const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = +// Why: exported so the transport can classify it as benign — the snapshot was +// skipped but live output continues, so it must not surface a fatal red banner. +export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' class RemoteRuntimeTerminalMultiplexer { @@ -149,7 +158,10 @@ class RemoteRuntimeTerminalMultiplexer { snapshotTarget: 'initial', snapshotInfo: null, initialSnapshotReceived: false, - pendingSnapshotRequest: null + pendingSnapshotRequest: null, + expectedSeq: undefined, + resyncInFlight: false, + resyncPendingSend: false } this.streams.set(streamId, state) @@ -334,10 +346,21 @@ class RemoteRuntimeTerminalMultiplexer { } if (frame.opcode === TerminalStreamOpcode.Output) { const data = decodeTerminalStreamText(frame.payload) - stream.callbacks.onData(data, { - seq: typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined, - rawLength: data.length - }) + const rawLength = data.length + // Why: a resync snapshot is authoritative; drop live output that arrives + // while it is in flight so the corrupt post-gap tail is never rendered. + if (stream.resyncInFlight) { + return + } + const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined + if (this.detectOutputGap(stream, seq, rawLength)) { + this.requestResyncSnapshot(stream) + return + } + if (typeof seq === 'number') { + stream.expectedSeq = seq + } + stream.callbacks.onData(data, { seq, rawLength }) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { @@ -400,9 +423,17 @@ class RemoteRuntimeTerminalMultiplexer { clearPendingSnapshotRequest(stream) } clearSnapshot(stream) + // Why: the snapshot is the new authoritative output high-water; align the + // gap detector to it and re-open the live path (used by both the initial + // snapshot and a frame-drop resync, which reuses the 'initial' target). if (target === 'initial') { + stream.expectedSeq = typeof info?.seq === 'number' ? info.seq : undefined + stream.resyncInFlight = false + stream.resyncPendingSend = false stream.initialSnapshotReceived = true stream.callbacks.onSubscribed?.() + } else { + this.sendDeferredResyncSnapshot(stream) } return } @@ -412,12 +443,71 @@ class RemoteRuntimeTerminalMultiplexer { if (pendingSnapshotRequest) { clearPendingSnapshotRequest(stream) pendingSnapshotRequest.reject(new Error(decodeTerminalStreamText(frame.payload))) + this.sendDeferredResyncSnapshot(stream) return } + // Why: a failed resync must re-open the live path or output stalls forever. + stream.resyncInFlight = false + stream.resyncPendingSend = false stream.callbacks.onError?.(decodeTerminalStreamText(frame.payload)) } } + // Why: Output `seq` is the UTF-16 high-water at the end of a chunk, so a chunk + // that begins after the last high-water (startSeq > expectedSeq) means the + // server dropped intervening frames under backpressure. Only flag a gap when + // both offsets are known, and never on the first seq (nothing to compare to). + private detectOutputGap( + stream: RemoteRuntimeMultiplexedTerminalState, + seq: number | undefined, + rawLength: number + ): boolean { + if (typeof seq !== 'number' || typeof stream.expectedSeq !== 'number') { + return false + } + const startSeq = seq - rawLength + return startSeq > stream.expectedSeq + } + + // Why: on a detected gap, discard the corrupt tail and pull a fresh + // authoritative snapshot. The request carries no requestId so the server + // reply renders through the initial-snapshot path (full reset), self-healing + // without surfacing an error to the user. + private requestResyncSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void { + if (stream.resyncInFlight) { + return + } + stream.resyncInFlight = true + stream.expectedSeq = undefined + if (stream.pendingSnapshotRequest) { + // Why: snapshot frame groups are not multiplexed; wait for the manual + // snapshot to finish so its response cannot be mistaken for recovery. + stream.resyncPendingSend = true + return + } + this.sendResyncSnapshot(stream) + } + + private sendDeferredResyncSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void { + if (!stream.resyncInFlight || !stream.resyncPendingSend || stream.pendingSnapshotRequest) { + return + } + this.sendResyncSnapshot(stream) + } + + private sendResyncSnapshot(stream: RemoteRuntimeMultiplexedTerminalState): void { + stream.resyncPendingSend = false + const sent = this.sendFrame( + stream.streamId, + TerminalStreamOpcode.SnapshotRequest, + encodeTerminalStreamJson({ scrollbackRows: undefined }) + ) + if (!sent) { + // Transport is down; the reconnect path re-subscribes from scratch. + stream.resyncInFlight = false + } + } + private requestSnapshot( stream: RemoteRuntimeMultiplexedTerminalState, opts?: { scrollbackRows?: number } @@ -431,6 +521,11 @@ class RemoteRuntimeTerminalMultiplexer { if (this.streams.get(stream.streamId) !== stream || !this.ready || !this.subscription) { return Promise.resolve(null) } + // Recovery uses an untagged snapshot frame group; callers can retry after + // it completes instead of racing another request onto the same frame lane. + if (stream.resyncInFlight) { + return Promise.resolve(null) + } if (stream.pendingSnapshotRequest) { return Promise.reject(new Error('Remote terminal snapshot already in flight.')) } @@ -438,8 +533,9 @@ class RemoteRuntimeTerminalMultiplexer { return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (stream.pendingSnapshotRequest?.timer === timer) { - stream.pendingSnapshotRequest = null + clearPendingSnapshotRequest(stream) reject(new Error('Remote terminal snapshot timed out.')) + this.sendDeferredResyncSnapshot(stream) } }, REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS) if (typeof timer.unref === 'function') { diff --git a/src/renderer/src/runtime/runtime-jira-client.ts b/src/renderer/src/runtime/runtime-jira-client.ts index c4d5ca666df..812e28b8233 100644 --- a/src/renderer/src/runtime/runtime-jira-client.ts +++ b/src/renderer/src/runtime/runtime-jira-client.ts @@ -12,6 +12,7 @@ import type { JiraMutationResult, JiraPriority, JiraProject, + JiraProjectStatusOrder, JiraSiteSelection, JiraTransition, JiraUser, @@ -280,3 +281,17 @@ export async function jiraListTransitions( ? callRuntimeRpc(target, 'jira.listTransitions', args, { timeoutMs: 30_000 }) : window.api.jira.listTransitions(args) } + +export async function jiraGetProjectStatusOrder( + settings: RuntimeJiraSettings, + projectKey: string, + siteId?: string | null +): Promise { + const target = getJiraRuntimeTarget(settings) + const args = { projectKey, siteId: siteId ?? undefined } + return target.kind === 'environment' + ? callRuntimeRpc(target, 'jira.getProjectStatusOrder', args, { + timeoutMs: 30_000 + }) + : window.api.jira.getProjectStatusOrder(args) +} diff --git a/src/renderer/src/store/slices/rate-limits.test.ts b/src/renderer/src/store/slices/rate-limits.test.ts new file mode 100644 index 00000000000..9772483f9e4 --- /dev/null +++ b/src/renderer/src/store/slices/rate-limits.test.ts @@ -0,0 +1,19 @@ +import { createStore, type StoreApi } from 'zustand/vanilla' +import { describe, expect, it } from 'vitest' +import { createRateLimitSlice } from './rate-limits' +import type { AppState } from '../types' + +function createRateLimitStore(): StoreApi { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return createStore()((...args: any[]) => + createRateLimitSlice(...(args as Parameters)) + ) as unknown as StoreApi +} + +describe('createRateLimitSlice', () => { + it('initializes Antigravity usage with a stable pending key', () => { + const store = createRateLimitStore() + + expect(store.getState().rateLimits.antigravity).toBeNull() + }) +}) diff --git a/src/renderer/src/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts index 37bd643833a..3df495cf32d 100644 --- a/src/renderer/src/store/slices/rate-limits.ts +++ b/src/renderer/src/store/slices/rate-limits.ts @@ -22,6 +22,7 @@ export const createRateLimitSlice: StateCreator { 'ports', 'kimi', 'minimax', + 'antigravity', 'grok' ]) expect(setUI).toHaveBeenCalledWith({ - statusBarItems: ['claude', 'resource-usage', 'ports', 'kimi', 'minimax', 'grok'], + statusBarItems: [ + 'claude', + 'resource-usage', + 'ports', + 'kimi', + 'minimax', + 'antigravity', + 'grok' + ], _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true }) }) @@ -1241,6 +1251,7 @@ describe('createUISlice hydratePersistedUI', () => { _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true }) ) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 321f9ce09a3..f5d2432a900 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -265,6 +265,7 @@ function migrateStatusBarItems(items: readonly string[] | undefined): StatusBarI const DEFAULT_ON_PORTS_STATUS_BAR_ITEM: StatusBarItem = 'ports' const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi' const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax' +const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity' const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok' function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds { @@ -2264,14 +2265,19 @@ export const createUISlice: StateCreator = (set, get) ui._minimaxStatusBarDefaultAdded || statusBarItems.includes('minimax') ? statusBarItems : [...statusBarItems, DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM] - const statusBarItemsWithGrok = - ui._grokStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('grok') + const statusBarItemsWithAntigravity = + ui._antigravityStatusBarDefaultAdded || statusBarItemsWithMiniMax.includes('antigravity') ? statusBarItemsWithMiniMax - : [...statusBarItemsWithMiniMax, DEFAULT_ON_GROK_STATUS_BAR_ITEM] + : [...statusBarItemsWithMiniMax, DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM] + const statusBarItemsWithGrok = + ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok') + ? statusBarItemsWithAntigravity + : [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM] if ( (!ui._portsStatusBarDefaultAdded || !ui._kimiStatusBarDefaultAdded || !ui._minimaxStatusBarDefaultAdded || + !ui._antigravityStatusBarDefaultAdded || !ui._grokStatusBarDefaultAdded) && typeof window !== 'undefined' ) { @@ -2281,6 +2287,7 @@ export const createUISlice: StateCreator = (set, get) _portsStatusBarDefaultAdded: true, _kimiStatusBarDefaultAdded: true, _minimaxStatusBarDefaultAdded: true, + _antigravityStatusBarDefaultAdded: true, _grokStatusBarDefaultAdded: true }) .catch(console.error) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index da8fe718293..4bd602c7634 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2587,6 +2587,7 @@ function createRateLimitsApi(): NonNullable['rateLimits']> { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null, minimaxCookieConfigured: false, @@ -2706,6 +2707,7 @@ function createPtyApi(): NonNullable['pty']> { kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, + rendererDispatcherReady: () => {}, setActiveRendererPty: () => {}, setRendererPtyVisible: () => {}, hasChildProcesses: () => Promise.resolve(false), @@ -2729,7 +2731,11 @@ function createPtyApi(): NonNullable['pty']> { peakMaxPendingCharsByPty: 0, peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, - ackGatedFlushSkipCount: 0 + ackGatedFlushSkipCount: 0, + rendererLifecycleResetCount: 0, + lastLifecycleResetClearedChars: 0, + rendererPtyDispatcherReady: false, + rendererDispatcherReadyForcedCount: 0 }), resetRendererDeliveryDebug: () => Promise.resolve(), onData: () => noopUnsubscribe, diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 3d4baeac3c4..943902b9bc0 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -13,11 +13,16 @@ import { isShellSafeEndpointValue, normalizeHookPayload, parseFormEncodedBody, + preparePendingGrokResultDiscovery, readRequestBody, resolveHookSource, writeEndpointFile, type HookListenerState } from './agent-hook-listener' +import { + clearGrokSessionPathLookupCacheForTests, + findGrokChatHistoryBySessionId +} from './grok-session-paths' import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' @@ -51,6 +56,7 @@ describe('shared agent-hook-listener', () => { }) afterEach(() => { + clearGrokSessionPathLookupCacheForTests() vi.unstubAllEnvs() }) @@ -1420,6 +1426,18 @@ describe('shared agent-hook-listener', () => { ).toBe(false) }) + it('lets Copilot Stop consume a generic message without scheduling transcript retry', () => { + expect( + hasPendingAgentResultText('copilot', { + payload: { + hookEventName: 'Stop', + message: 'Copilot final response', + transcript_path: '/tmp/copilot-transcript.jsonl' + } + }) + ).toBe(false) + }) + it('normalizes Grok hookEventName payloads and keeps prompt across tool events', () => { const prompt = normalizeHookPayload( state, @@ -1463,6 +1481,141 @@ describe('shared agent-hook-listener', () => { }) }) + it('previews Grok-native tool names (run_terminal_command / search_replace)', () => { + const shell = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'PreToolUse', + tool_name: 'run_terminal_command', + tool_input: { command: 'git status' } + } + }, + 'production' + ) + const edit = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'PreToolUse', + toolName: 'search_replace', + toolInput: { path: 'src/app.ts', old_string: 'a', new_string: 'b' } + } + }, + 'production' + ) + expect(shell?.payload).toMatchObject({ + agentType: 'grok', + state: 'working', + toolName: 'run_terminal_command', + toolInput: 'git status' + }) + expect(edit?.payload).toMatchObject({ + agentType: 'grok', + state: 'working', + toolName: 'search_replace', + toolInput: 'src/app.ts' + }) + }) + + it('maps Grok ask_user_question PreToolUse to waiting with interactivePrompt', () => { + const questions = [ + { + question: 'Ship to which region?', + options: [{ label: 'us-east', description: 'US East' }] + } + ] + const waiting = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'PreToolUse', + tool_name: 'ask_user_question', + tool_input: { questions } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'post_tool_use', + toolName: 'ask_user_question', + toolInput: { questions }, + toolResponse: { selected: ['us-east'] } + } + }, + 'production' + ) + expect(waiting?.payload).toMatchObject({ + agentType: 'grok', + state: 'waiting', + toolName: 'ask_user_question' + }) + expect(waiting?.payload.interactivePrompt).toContain('Ship to which region?') + expect(answered?.payload).toMatchObject({ + agentType: 'grok', + state: 'working', + toolName: 'ask_user_question' + }) + expect(answered?.payload.interactivePrompt).toBeUndefined() + }) + + it('does not recreate a Grok question card on post_tool_use_failure', () => { + const event = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { + hookEventName: 'post_tool_use_failure', + toolName: 'ask_user_question', + toolInput: { questions: [{ question: 'Retry?', options: ['yes', 'no'] }] }, + error: 'cancelled' + } + }, + 'production' + ) + + expect(event?.payload.state).toBe('working') + expect(event?.payload.interactivePrompt).toBeUndefined() + }) + + it('maps Grok StopFailure to done', () => { + normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { hookEventName: 'UserPromptSubmit', prompt: 'do work' } + }, + 'production' + ) + const done = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + payload: { hookEventName: 'StopFailure', error: 'api timeout' } + }, + 'production' + ) + expect(done?.payload).toMatchObject({ + agentType: 'grok', + state: 'done', + prompt: 'do work' + }) + }) + it('strips Grok internal user_query wrapper before caching the prompt', () => { const prompt = normalizeHookPayload( state, @@ -1600,7 +1753,7 @@ describe('shared agent-hook-listener', () => { expect(event).toBeNull() }) - it('reads Grok final assistant text from chat history on Stop', () => { + it('enriches Grok Stop from chat history despite a generic status message', () => { const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-session-')) const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf528' const cwd = join(tmpDir, 'workspace') @@ -1624,18 +1777,82 @@ describe('shared agent-hook-listener', () => { 'production' ) + const body = { + paneKey: PANE_KEY, + payload: { hookEventName: 'Stop', sessionId, cwd, message: 'Session completed' } + } + expect(hasPendingAgentResultText('grok', body)).toBe(true) + const done = normalizeHookPayload(state, 'grok', body, 'production') + + expect(done?.payload.state).toBe('done') + expect(done?.payload.lastAssistantMessage).toBe('Hi! How can I help you today?') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('uses the hook envelope Grok home instead of the listener service environment', () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-envelope-home-')) + const serviceGrokHome = join(tmpDir, 'service-grok') + const hookGrokHome = join(tmpDir, 'hook-grok') + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf529' + const cwd = join(tmpDir, 'workspace') + const sessionDir = join(hookGrokHome, 'sessions', encodeURIComponent(cwd), sessionId) + try { + vi.stubEnv('GROK_HOME', serviceGrokHome) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'from effective Grok home' })}\n` + ) + const done = normalizeHookPayload( state, 'grok', { paneKey: PANE_KEY, + grokHome: hookGrokHome, payload: { hookEventName: 'Stop', sessionId, cwd } }, 'production' ) - expect(done?.payload.state).toBe('done') - expect(done?.payload.lastAssistantMessage).toBe('Hi! How can I help you today?') + expect(done?.payload.lastAssistantMessage).toBe('from effective Grok home') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it.each([ + 'relative/grok-home', + join(tmpdir(), 'x'.repeat(4096)), + `${join(tmpdir(), 'grok-home')}\ninvalid` + ])('ignores invalid hook-envelope Grok home %s', (grokHome) => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-invalid-home-')) + const serviceGrokHome = join(tmpDir, 'service-grok') + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf530' + const cwd = join(tmpDir, 'workspace') + const sessionDir = join(serviceGrokHome, 'sessions', encodeURIComponent(cwd), sessionId) + try { + vi.stubEnv('GROK_HOME', serviceGrokHome) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'fallback result' })}\n` + ) + + const done = normalizeHookPayload( + state, + 'grok', + { + paneKey: PANE_KEY, + grokHome, + payload: { hookEventName: 'Stop', sessionId, cwd } + }, + 'production' + ) + + expect(done?.payload.lastAssistantMessage).toBe('fallback result') } finally { rmSync(tmpDir, { recursive: true, force: true }) } @@ -1683,6 +1900,50 @@ describe('shared agent-hook-listener', () => { ).toBe(true) }) + it('enriches a long-cwd Grok result after async discovery completes', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'orca-grok-pending-home-')) + const hookGrokHome = join(tmpDir, 'hook-grok') + const sessionId = '019e37f4-5135-7b63-a4ab-6d13aa6bf531' + const cwd = `/${'long-workspace/'.repeat(30)}` + const sessionDir = join(hookGrokHome, 'sessions', 'workspace-slug', sessionId) + try { + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'chat_history.jsonl'), + `${JSON.stringify({ type: 'assistant', content: 'from slug session' })}\n` + ) + + const body = { + paneKey: PANE_KEY, + grokHome: hookGrokHome, + payload: { hookEventName: 'SessionEnd', sessionId, cwd } + } + const discovery = preparePendingGrokResultDiscovery('grok', body) + expect(discovery).not.toBeNull() + await discovery + await expect( + findGrokChatHistoryBySessionId(join(hookGrokHome, 'sessions'), sessionId) + ).resolves.toBe(join(sessionDir, 'chat_history.jsonl')) + + const done = normalizeHookPayload(state, 'grok', body, 'production') + expect(done?.payload.lastAssistantMessage).toBe('from slug session') + } finally { + rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('does not schedule Grok discovery for an invalid session id', () => { + expect( + hasPendingAgentResultText('grok', { + payload: { + hookEventName: 'SessionEnd', + sessionId: '../escape', + cwd: '/tmp/workspace' + } + }) + ).toBe(false) + }) + it('normalizes Hermes pre_llm_call to a working turn with prompt text', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 1619e4bd7ec..1513f117fbb 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -25,7 +25,7 @@ import { unlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { isAbsolute, join } from 'node:path' import { parseAgentStatusPayload, type ParsedAgentStatusPayload } from './agent-status-types' import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' @@ -36,6 +36,15 @@ import { } from './agent-session-resume' import { parsePaneKey } from './stable-pane-id' import { isHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { + buildGrokChatHistoryPathCandidates, + findGrokChatHistoryBySessionId, + getCachedGrokChatHistoryBySessionId, + GROK_SESSION_ID_MAX_LENGTH, + isSafeGrokSessionId, + resolveGrokChatHistoryPathSync, + resolveGrokSessionsDir +} from './grok-session-paths' /** Maximum request body size accepted by the listener (1 MB). */ export const HOOK_REQUEST_MAX_BYTES = 1_000_000 @@ -492,11 +501,19 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { exec_command: ['cmd', 'command'], shell_command: ['cmd', 'command'], run_terminal_cmd: ['command'], + // Why: Grok maps Bash/Edit/Write to snake_case first-party tool names + // (run_terminal_command, search_replace, …). Without these keys the status + // row shows a blank toolInput for the bulk of Grok tool turns. + run_terminal_command: ['command'], + search_replace: ['file_path', 'path', 'filePath'], + write_to_file: ['TargetFile', 'path', 'file_path'], execute_code: ['code', 'command', 'cmd'], apply_patch: ['path', 'file_path'], view_image: ['path', 'file_path'], AskUser: ['question', 'prompt', 'message'], ask_user: ['question', 'prompt', 'message'], + AskUserQuestion: ['questions', 'question', 'prompt', 'message'], + ask_user_question: ['questions', 'question', 'prompt', 'message'], bash: ['command'], powershell: ['command'], create: ['path', 'file_path'], @@ -517,7 +534,6 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { skill_manage: ['action', 'name', 'file_path'], delegate_task: ['task', 'prompt', 'description'], view_file: ['AbsolutePath', 'path', 'file_path'], - write_to_file: ['TargetFile', 'path', 'file_path'], replace_file_content: ['TargetFile', 'path', 'file_path'], multi_replace_file_content: ['TargetFile', 'path', 'file_path'], list_dir: ['DirectoryPath', 'path'], @@ -528,7 +544,9 @@ const TOOL_INPUT_KEYS_BY_TOOL: Record = { manage_task: ['TaskId', 'Action'], schedule: ['Prompt', 'DurationSeconds', 'CronExpression'], ask_question: ['question', 'questions'], - ask_permission: ['Action', 'Target', 'Reason'] + ask_permission: ['Action', 'Target', 'Reason'], + spawn_subagent: ['prompt', 'description', 'subagent_type'], + open_page: ['url'] } const FALLBACK_TOOL_INPUT_KEYS = [ @@ -673,14 +691,14 @@ function deriveInteractivePrompt( toolInput: unknown, eventName?: unknown ): string | undefined { - // Why: an AskUserQuestion is pending only on the Pre/Permission event. On - // PostToolUse it has already been answered, so re-asserting the `{questions}` - // prompt would re-show an answered card instead of letting it clear. The live - // working indicator keys off agentStatus.state (not this prompt), so dropping - // it here doesn't suppress it. + // Why: providers vary event casing; any post-tool event means the question is + // no longer pending and must not recreate its answered live card. + const normalizedEventName = normalizeHookEventName(eventName) + const isPostToolEvent = + normalizedEventName === 'post_tool_use' || normalizedEventName === 'post_tool_use_failure' if ( isAskUserQuestionTool(toolName) && - eventName !== 'PostToolUse' && + !isPostToolEvent && toolInput !== undefined && toolInput !== null ) { @@ -761,8 +779,8 @@ const TRANSCRIPT_CHUNK_BYTES = 64 * 1024 const TRANSCRIPT_MAX_SCAN_BYTES = 4 * 1024 * 1024 const AMP_THREAD_ID_MAX_LENGTH = 256 const AMP_MAX_SCOPED_THREAD_CACHE_KEYS = 32 -const GROK_SESSION_ID_MAX_LENGTH = 128 const GROK_SESSION_CWD_MAX_LENGTH = 4096 +const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 function extractAssistantTextFromLine(line: string): string | undefined { let entry: unknown @@ -1031,38 +1049,92 @@ function readBoundedString( return value && value.length <= maxLength ? value : undefined } -function isSafeGrokSessionId(sessionId: string): boolean { - return /^[A-Za-z0-9_-]+$/.test(sessionId) && sessionId.length <= GROK_SESSION_ID_MAX_LENGTH +function readGrokHomeEnvelope(record: Record): string | undefined { + const value = readBoundedString(record, ['grokHome'], GROK_HOME_ENVELOPE_MAX_LENGTH) + if (!value || value !== value.trim() || !isAbsolute(value) || hasControlCharacter(value)) { + return undefined + } + return value +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.charCodeAt(0) + return code <= 0x1f || code === 0x7f + }) +} + +type GrokSessionMetadata = { + sessionId: string + cwd?: string + sessionsDir: string } -function getGrokChatHistoryPath(hookPayload: Record): string | undefined { +function readGrokSessionMetadata( + hookPayload: Record, + grokHome?: string +): GrokSessionMetadata | undefined { const sessionId = readBoundedString( hookPayload, ['sessionId', 'session_id'], GROK_SESSION_ID_MAX_LENGTH ) + if (!sessionId || !isSafeGrokSessionId(sessionId)) { + return undefined + } const cwd = readBoundedString( hookPayload, ['cwd', 'workspaceRoot', 'workspace_root'], GROK_SESSION_CWD_MAX_LENGTH ) - if (!sessionId || !cwd || !isSafeGrokSessionId(sessionId)) { + // Why: hook scripts report the effective per-PTY/remote home; old scripts + // fall back to the listener runtime's Grok home for compatibility. + const sessionsDir = grokHome + ? join(grokHome, 'sessions') + : resolveGrokSessionsDir(process.env, homedir()) + return { sessionId, cwd, sessionsDir } +} + +function getGrokChatHistoryPath( + hookPayload: Record, + grokHome?: string +): string | undefined { + const metadata = readGrokSessionMetadata(hookPayload, grokHome) + if (!metadata) { + return undefined + } + const resolved = resolveGrokChatHistoryPathSync({ + sessionId: metadata.sessionId, + cwd: metadata.cwd ?? null, + sessionsDir: metadata.sessionsDir + }) + if (resolved) { + return resolved + } + const cached = getCachedGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId) + if (cached) { + return cached + } + // Why: hasPendingAgentResultText only needs a plausible on-disk target when + // the file may not exist yet (SessionEnd can race the last write). Prefer a + // short-cwd candidate when available; async discovery caches slug groups. + if (!metadata.cwd) { return undefined } - return join( - homedir(), - '.grok', - 'sessions', - encodeURIComponent(cwd), - sessionId, - 'chat_history.jsonl' + return ( + buildGrokChatHistoryPathCandidates({ + sessionId: metadata.sessionId, + cwd: metadata.cwd, + sessionsDir: metadata.sessionsDir + })[0] ?? undefined ) } function readLastAssistantFromGrokChatHistory( - hookPayload: Record + hookPayload: Record, + grokHome?: string ): string | undefined { - const chatHistoryPath = getGrokChatHistoryPath(hookPayload) + const chatHistoryPath = getGrokChatHistoryPath(hookPayload, grokHome) if (!chatHistoryPath) { return undefined } @@ -1076,12 +1148,15 @@ export function hasPendingAgentResultText(source: AgentHookSource, body: unknown if (!record) { return false } - const directMessage = - record.last_assistant_message ?? record.lastAssistantMessage ?? record.message - if (typeof directMessage === 'string' && directMessage.trim().length > 0) { + if (hasExplicitLastAssistantResult(record)) { return false } if (source === 'copilot') { + // Why: Copilot Stop consumes generic `message` as its final assistant text; + // Grok and Antigravity use that field for status text instead. + if (hasNonEmptyString(record.message)) { + return false + } const transcriptPath = record.transcript_path ?? record.transcriptPath return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 } @@ -1097,15 +1172,61 @@ export function hasPendingAgentResultText(source: AgentHookSource, body: unknown const transcriptPath = record.transcriptPath ?? record.transcript_path return typeof transcriptPath === 'string' && transcriptPath.trim().length > 0 } - if ( - source === 'grok' && - isGrokEvent(record.hookEventName ?? record.hook_event_name, 'stop', 'session_end') - ) { - return getGrokChatHistoryPath(record) !== undefined + const pendingGrokDiscovery = preparePendingGrokResultDiscovery(source, body) + if (pendingGrokDiscovery) { + void pendingGrokDiscovery + return true } return false } +function hasNonEmptyString(value: unknown): boolean { + return typeof value === 'string' && value.trim().length > 0 +} + +function hasExplicitLastAssistantResult(record: Record): boolean { + return ( + hasNonEmptyString(record.last_assistant_message) || + hasNonEmptyString(record.lastAssistantMessage) + ) +} + +/** Start bounded discovery only for a Grok completion that still needs result text. */ +export function preparePendingGrokResultDiscovery( + source: AgentHookSource, + body: unknown +): Promise | null { + if (source !== 'grok') { + return null + } + const envelope = + typeof body === 'object' && body !== null ? (body as Record) : null + const record = parseHookBodyPayloadRecord(body) + if (!record || hasExplicitLastAssistantResult(record)) { + return null + } + const eventName = + envelope?.hook_event_name ?? + envelope?.hookEventName ?? + record.hook_event_name ?? + record.hookEventName + if (!isGrokEvent(eventName, 'stop', 'session_end')) { + return null + } + const metadata = readGrokSessionMetadata( + record, + envelope ? readGrokHomeEnvelope(envelope) : undefined + ) + if (!metadata) { + return null + } + // Why: the server can await this signal without moving filesystem discovery + // back into the synchronous hook normalization path. + return findGrokChatHistoryBySessionId(metadata.sessionsDir, metadata.sessionId).then( + () => undefined + ) +} + function readLastAssistantFromTranscriptOnce(transcriptPath: string): string | undefined { return readLastTextFromTranscriptOnce(transcriptPath, extractAssistantTextFromLine) } @@ -1842,20 +1963,24 @@ function isGrokEvent(eventName: unknown, ...expected: readonly string[]): boolea function extractGrokToolFields( eventName: unknown, - hookPayload: Record + hookPayload: Record, + grokHome?: string ): ToolSnapshot { if (isGrokEvent(eventName, 'pre_tool_use', 'post_tool_use', 'post_tool_use_failure')) { const toolName = readString(hookPayload, 'toolName') ?? readString(hookPayload, 'tool_name') ?? readString(hookPayload, 'name') + const rawInput = + hookPayload.toolInput ?? hookPayload.tool_input ?? hookPayload.input ?? hookPayload.arguments const toolInput = - deriveToolInputPreview(toolName, hookPayload.toolInput) ?? - deriveToolInputPreview(toolName, hookPayload.tool_input) ?? - deriveToolInputPreview(toolName, hookPayload.input) ?? - deriveToolInputPreview(toolName, hookPayload.arguments) + deriveToolInputPreview(toolName, rawInput) ?? deriveFallbackToolInputPreview(rawInput) + // Why: Grok's ask_user_question is auto-allowed and arrives as PreToolUse + // (not PermissionRequest). Capture the full question payload so the live + // card path can render options instead of only a waiting Notification. + const interactivePrompt = deriveInteractivePrompt(toolName, rawInput, eventName) const update: ToolSnapshot = toolUpdate( - { toolName, toolInput }, + { toolName, toolInput, interactivePrompt }, { hasToolInputField: hasAnyOwnField(hookPayload, [ 'toolInput', @@ -1879,7 +2004,7 @@ function extractGrokToolFields( } return update } - if (isGrokEvent(eventName, 'stop', 'session_end')) { + if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { const direct = readString(hookPayload, 'lastAssistantMessage') ?? readString(hookPayload, 'last_assistant_message') @@ -1892,7 +2017,7 @@ function extractGrokToolFields( if (fromTranscript) { return { lastAssistantMessage: fromTranscript } } - const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload) + const fromChatHistory = readLastAssistantFromGrokChatHistory(hookPayload, grokHome) if (fromChatHistory) { return { lastAssistantMessage: fromChatHistory } } @@ -2115,7 +2240,8 @@ function hasExplicitUserPrompt( function extractToolFields( source: AgentHookSource, eventName: unknown, - hookPayload: Record + hookPayload: Record, + options?: { grokHome?: string } ): ToolSnapshot { // Why: exhaustive switch so adding a source to AgentHookSource fails // typecheck here instead of silently routing through OpenCode's extractor. @@ -2145,7 +2271,7 @@ function extractToolFields( case 'command-code': return extractCommandCodeToolFields(eventName, hookPayload) case 'grok': - return extractGrokToolFields(eventName, hookPayload) + return extractGrokToolFields(eventName, hookPayload, options?.grokHome) case 'copilot': return extractCopilotToolFields(normalizeCopilotEventName(eventName), hookPayload) case 'hermes': @@ -3013,7 +3139,8 @@ function normalizeGrokEvent( eventName: unknown, promptText: string, paneKey: string, - hookPayload: Record + hookPayload: Record, + grokHome?: string ): ParsedAgentStatusPayload | null { if (isGrokEvent(eventName, 'session_start')) { // Why: Grok emits SessionStart when the TUI opens/resumes. It should reset @@ -3026,18 +3153,25 @@ function normalizeGrokEvent( const notificationMessage = readString(hookPayload, 'message') const notificationType = getGrokNotificationType(hookPayload) const notificationLevel = readString(hookPayload, 'level') + const preToolName = + readString(hookPayload, 'toolName') ?? + readString(hookPayload, 'tool_name') ?? + readString(hookPayload, 'name') + // Why: Grok's ask_user_question is auto-allowed, so it emits PreToolUse while + // blocked on a human answer (same shape as Kimi). Map that to waiting so the + // sidebar attention state matches Claude PermissionRequest UX. + const isUserInputPreTool = + isGrokEvent(eventName, 'pre_tool_use') && isAskUserQuestionTool(preToolName) + let stateName: 'working' | 'waiting' | 'done' | null = null if ( - isGrokEvent( - eventName, - 'user_prompt_submit', - 'pre_tool_use', - 'post_tool_use', - 'post_tool_use_failure' - ) + isGrokEvent(eventName, 'user_prompt_submit', 'post_tool_use', 'post_tool_use_failure') || + (isGrokEvent(eventName, 'pre_tool_use') && !isUserInputPreTool) ) { stateName = 'working' - } else if (isGrokEvent(eventName, 'stop', 'session_end')) { + } else if (isUserInputPreTool) { + stateName = 'waiting' + } else if (isGrokEvent(eventName, 'stop', 'session_end', 'stop_failure')) { stateName = 'done' } else if ( isGrokEvent(eventName, 'notification') && @@ -3066,7 +3200,7 @@ function normalizeGrokEvent( const snapshot = resolveToolState( state, paneKey, - extractToolFields('grok', eventName, hookPayload), + extractToolFields('grok', eventName, hookPayload, { grokHome }), { resetOnNewTurn: isNewTurnEvent('grok', eventName) } ) @@ -3300,7 +3434,14 @@ export function normalizeHookPayload( ) break case 'grok': - payload = normalizeGrokEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + payload = normalizeGrokEvent( + state, + eventName, + promptText, + paneKey, + hookPayloadRecord, + readGrokHomeEnvelope(record) + ) break case 'copilot': payload = normalizeCopilotEvent(state, eventName, promptText, paneKey, hookPayloadRecord) diff --git a/src/shared/ai-vault-session-display.ts b/src/shared/ai-vault-session-display.ts new file mode 100644 index 00000000000..b35528ffd71 --- /dev/null +++ b/src/shared/ai-vault-session-display.ts @@ -0,0 +1,112 @@ +// Why: pure preview/search-text core for Agent Session History, lifted to +// /shared so both the renderer and the mobile package (which cannot import +// renderer modules) can build session previews from the same logic. +// INVARIANT: /shared is a leaf — this module imports only types from /shared. +import type { AiVaultSession, AiVaultSessionPreviewMessage } from './ai-vault-types' + +const CONVERSATION_ROLES = new Set(['user', 'assistant']) + +export type AiVaultSessionDisplayTurn = { + role: AiVaultSessionPreviewMessage['role'] + text: string + timestamp: string | null +} + +export function latestSessionConversationTurn( + session: AiVaultSession +): AiVaultSessionDisplayTurn | null { + return recentSessionConversationTurns(session, 1)[0] ?? null +} + +export function recentSessionConversationTurns( + session: AiVaultSession, + limit: number +): AiVaultSessionDisplayTurn[] { + if (limit <= 0) { + return [] + } + + return displayableSessionPreviewMessages(session).slice(-limit).map(toDisplayTurn) +} + +export function sessionDetailConversationTurns( + session: AiVaultSession, + limit: number +): AiVaultSessionDisplayTurn[] { + if (limit <= 0) { + return [] + } + + const turns = displayableSessionPreviewMessages(session) + .map(toDisplayTurn) + .filter((turn) => !turnTextMatchesSessionTitle(session.title, turn.text)) + + return dedupeAdjacentConversationTurns(turns).slice(-limit) +} + +function turnTextMatchesSessionTitle(title: string, turnText: string): boolean { + const sessionText = normalizeSessionDisplayText(title) + const candidateText = normalizeSessionDisplayText(turnText) + if (!sessionText || !candidateText) { + return false + } + if (sessionText === candidateText) { + return true + } + const longEnough = sessionText.length >= 24 && candidateText.length >= 24 + return ( + longEnough && (sessionText.startsWith(candidateText) || candidateText.startsWith(sessionText)) + ) +} + +function dedupeAdjacentConversationTurns( + turns: AiVaultSessionDisplayTurn[] +): AiVaultSessionDisplayTurn[] { + const deduped: AiVaultSessionDisplayTurn[] = [] + for (const turn of turns) { + const previous = deduped.at(-1) + if ( + previous && + previous.role === turn.role && + normalizeSessionDisplayText(previous.text) === normalizeSessionDisplayText(turn.text) + ) { + continue + } + deduped.push(turn) + } + return deduped +} + +function normalizeSessionDisplayText(value: string): string { + return value.trim().replace(/\s+/g, ' ').toLowerCase() +} + +export function sessionModelLabel(session: AiVaultSession): string | null { + return session.model || null +} + +export function sessionPreviewSearchText(session: AiVaultSession): string { + return displayableSessionPreviewMessages(session) + .map((message) => message.text) + .join(' ') +} + +function displayableSessionPreviewMessages( + session: AiVaultSession +): AiVaultSessionPreviewMessage[] { + const conversationTurns = session.previewMessages.filter((message) => + CONVERSATION_ROLES.has(message.role) + ) + + // Why: search hits should be explainable by the preview UI; tool/system text is + // only searchable when it is the fallback preview shown for the session. + return conversationTurns.length > 0 ? conversationTurns : session.previewMessages +} + +function toDisplayTurn(message: AiVaultSessionPreviewMessage): AiVaultSessionDisplayTurn { + return { + role: message.role, + text: message.text, + timestamp: message.timestamp + } +} diff --git a/src/shared/ai-vault-session-filters.test.ts b/src/shared/ai-vault-session-filters.test.ts new file mode 100644 index 00000000000..47a1e10b64d --- /dev/null +++ b/src/shared/ai-vault-session-filters.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import type { AiVaultSession } from './ai-vault-types' +import { + agentLabel, + filterAiVaultSessions, + folderLabel, + groupAiVaultSessions, + parseVaultQuery +} from './ai-vault-session-filters' +import { sessionPreviewSearchText } from './ai-vault-session-display' + +const baseSession: AiVaultSession = { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement vault filters', + cwd: '/Users/ada/repo/app', + branch: 'feature/vault', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [ + { role: 'user', text: 'add the scope tabs', timestamp: null }, + { role: 'assistant', text: 'done — added Workspace/Project/All', timestamp: null } + ], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: "cd '/Users/ada/repo/app' && claude --resume 'session-1'", + subagent: null +} + +const otherSession: AiVaultSession = { + ...baseSession, + id: 'codex:2', + agent: 'codex', + sessionId: 'session-2', + title: 'Repair terminal tabs', + cwd: '/Users/ada/other/packages/ui', + branch: 'fix/terminal', + filePath: '/Users/ada/.codex/sessions/session-2.jsonl', + previewMessages: [] +} + +describe('/shared ai-vault-session-filters (lifted core)', () => { + it('filters by agent, workspace scope, and plain/repo/path query terms', () => { + expect( + filterAiVaultSessions([baseSession, otherSession], { + query: 'vault repo:repo path:app', + agents: ['claude'], + scope: 'workspace', + sort: 'updated', + activeWorktreePaths: ['/Users/ada/repo'], + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + }) + + it('hides empty sessions by default and keeps non-empty ones', () => { + // A session only counts as empty without conversation previews or + // recoverable signals — preview turns alone make it resumable content. + const empty: AiVaultSession = { + ...baseSession, + id: 'claude:empty', + messageCount: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0 + } + expect( + filterAiVaultSessions([baseSession, empty], { + query: '', + agents: ['claude'], + scope: 'all', + sort: 'updated', + activeWorktreePaths: [], + hideEmptySessions: true + }).map((session) => session.id) + ).toEqual(['claude:1']) + }) + + it('groups by folder', () => { + const groups = groupAiVaultSessions([baseSession, otherSession], 'folder') + expect(groups.map((group) => group.label).sort()).toEqual(['packages/ui', 'repo/app']) + }) + + it('parses repo: and path: operators from the query', () => { + expect(parseVaultQuery('hello repo:orca path:/tmp world')).toEqual({ + terms: ['hello', 'world'], + repoTerms: ['orca'], + pathTerms: ['/tmp'] + }) + }) + + it('parses quoted repo:/path: operator values containing spaces', () => { + expect(parseVaultQuery('repo:"my repo" path:"/Users/ada/My Project"')).toEqual({ + terms: [], + repoTerms: ['my repo'], + pathTerms: ['/users/ada/my project'] + }) + }) + + it('exposes a stable agent label and folder label', () => { + expect(agentLabel('claude')).toBe('Claude') + expect(folderLabel('/Users/ada/repo/app')).toBe('repo/app') + }) + + it('builds preview search text from conversation turns', () => { + expect(sessionPreviewSearchText(baseSession)).toContain('scope tabs') + }) +}) diff --git a/src/shared/ai-vault-session-filters.ts b/src/shared/ai-vault-session-filters.ts new file mode 100644 index 00000000000..c943dcf6837 --- /dev/null +++ b/src/shared/ai-vault-session-filters.ts @@ -0,0 +1,298 @@ +// Why: this is the pure filter/group/query core for Agent Session History. +// It lives in /shared (not renderer) so the mobile package can reuse it — +// Metro only watches mobile/ + repo-root src/shared, never src/renderer. +// INVARIANT: /shared is a leaf — this module must NOT import from src/renderer. +import { isPathInsideOrEqual, normalizeRuntimePathSeparators } from './cross-platform-path' +import { isClipboardTextByteLengthOverLimit } from './clipboard-text' +import { parseWslUncPath } from './wsl-paths' +import type { + AiVaultAgent, + AiVaultGroup, + AiVaultScope, + AiVaultSession, + AiVaultSort +} from './ai-vault-types' +import { + aiVaultAgentLabel, + isAiVaultSessionRecoverableEmpty, + isAiVaultSessionResumableContent +} from './ai-vault-types' +import type { ExecutionHostId } from './execution-host' +import { sessionPreviewSearchText } from './ai-vault-session-display' + +// Why: the plain project descriptor is relocated here (no runtime dep) so the +// filter-state type can reference it without dragging the renderer-located +// ai-vault-session-projects runtime logic into /shared. +export type AiVaultSessionProject = { + kind: 'repo' | 'folder' | 'unknown' + key: string + label: string + projectId?: string + repoId?: string + hostKey?: ExecutionHostId +} + +export type AiVaultSessionFilterState = { + query: string + agents: readonly AiVaultAgent[] + scope: AiVaultScope + sort: AiVaultSort + activeWorktreePaths: readonly string[] + activeProjectKey?: string | null + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + hideEmptySessions: boolean +} + +export type AiVaultSessionGroup = { + key: string + label: string + sessions: AiVaultSession[] +} + +type ParsedQuery = { + terms: string[] + repoTerms: string[] + pathTerms: string[] +} + +export const AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES = 2 * 1024 + +export function isAiVaultSessionFilterQueryTooLarge( + query: string, + maxBytes = AI_VAULT_SESSION_FILTER_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(query, maxBytes) +} + +export function filterAiVaultSessions( + sessions: readonly AiVaultSession[], + filters: AiVaultSessionFilterState +): AiVaultSession[] { + if (isAiVaultSessionFilterQueryTooLarge(filters.query)) { + return [] + } + + const agentSet = new Set(filters.agents) + const parsedQuery = parseVaultQuery(filters.query) + + return sessions + .filter((session) => { + if (!agentSet.has(session.agent)) { + return false + } + // Hide plain empty sessions, but keep sessions with resumable content + // (some parsers only learn turns from previews, e.g. Grok) and zero-turn + // sessions that still carry recoverable content (queued prompts / + // subagent transcripts) so a lost conversation is surfaced distinctly. + if ( + filters.hideEmptySessions && + !isAiVaultSessionResumableContent(session) && + !isAiVaultSessionRecoverableEmpty(session) + ) { + return false + } + if (filters.scope === 'workspace') { + const cwd = session.cwd + if ( + !cwd || + !filters.activeWorktreePaths.some((pathValue) => + isAiVaultSessionInWorkspacePath(pathValue, cwd) + ) + ) { + return false + } + } + if (filters.scope === 'project') { + if (!filters.activeProjectKey) { + return false + } + if (filters.sessionProjectById?.get(session.id)?.key !== filters.activeProjectKey) { + return false + } + } + return matchesQuery(session, parsedQuery, filters) + }) + .sort((left, right) => compareSessions(left, right, filters.sort)) +} + +export function groupAiVaultSessions( + sessions: readonly AiVaultSession[], + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } = {} +): AiVaultSessionGroup[] { + const groups = new Map() + + for (const session of sessions) { + const { key, label } = getGroupIdentity(session, group, options) + const existing = groups.get(key) + if (existing) { + existing.sessions.push(session) + } else { + groups.set(key, { key, label, sessions: [session] }) + } + } + + return [...groups.values()] +} + +export function folderLabel(pathValue: string | null): string { + if (!pathValue) { + return 'Unknown location' + } + const parts = normalizeRuntimePathSeparators(pathValue).split('/').filter(Boolean) + if (parts.length >= 2) { + return parts.slice(-2).join('/') + } + return parts[0] ?? pathValue +} + +export function agentLabel(agent: AiVaultAgent): string { + return aiVaultAgentLabel(agent) +} + +export function parseVaultQuery(query: string): ParsedQuery { + const terms: string[] = [] + const repoTerms: string[] = [] + const pathTerms: string[] = [] + + for (const rawToken of tokenizeQuery(query)) { + const token = rawToken.toLowerCase() + if (token.startsWith('repo:')) { + const value = token.slice('repo:'.length) + if (value) { + repoTerms.push(value) + } + continue + } + if (token.startsWith('path:')) { + const value = token.slice('path:'.length) + if (value) { + pathTerms.push(value) + } + continue + } + terms.push(token) + } + + return { terms, repoTerms, pathTerms } +} + +function matchesQuery( + session: AiVaultSession, + parsed: ParsedQuery, + filters: Pick +): boolean { + const searchable = [ + session.title, + session.sessionId, + session.agent, + session.branch, + session.model, + session.cwd, + session.filePath, + sessionPreviewSearchText(session) + ] + .filter(Boolean) + .join(' ') + .toLowerCase() + + if (parsed.terms.some((term) => !searchable.includes(term))) { + return false + } + + const sessionProject = filters.sessionProjectById?.get(session.id) + const repoLabel = ( + sessionProject?.kind === 'repo' + ? (filters.projectLabelByKey?.get(sessionProject.key) ?? sessionProject.label) + : folderLabel(session.cwd) + ).toLowerCase() + if (parsed.repoTerms.some((term) => !repoLabel.includes(term))) { + return false + } + + const pathSearch = `${session.cwd ?? ''} ${session.filePath}`.toLowerCase() + if (parsed.pathTerms.some((term) => !pathSearch.includes(term))) { + return false + } + + return true +} + +function compareSessions(left: AiVaultSession, right: AiVaultSession, sort: AiVaultSort): number { + const leftValue = sort === 'created' ? left.createdAt : left.updatedAt + const rightValue = sort === 'created' ? right.createdAt : right.updatedAt + const leftTime = Date.parse(leftValue ?? left.modifiedAt) + const rightTime = Date.parse(rightValue ?? right.modifiedAt) + return rightTime - leftTime +} + +function getGroupIdentity( + session: AiVaultSession, + group: AiVaultGroup, + options: { + sessionProjectById?: ReadonlyMap + projectLabelByKey?: ReadonlyMap + } +): Pick { + if (group === 'agent') { + return { key: session.agent, label: agentLabel(session.agent) } + } + if (group === 'project') { + const sessionProject = options.sessionProjectById?.get(session.id) + if (sessionProject) { + return { + key: sessionProject.key, + label: + options.projectLabelByKey?.get(sessionProject.key) || + sessionProject.label || + folderLabel(session.cwd) + } + } + } + return { key: getFolderGroupKey(session.cwd), label: folderLabel(session.cwd) } +} + +function getFolderGroupKey(pathValue: string | null): string { + return pathValue ? normalizeRuntimePathSeparators(pathValue).toLowerCase() : 'unknown' +} + +function isAiVaultSessionInWorkspacePath(workspacePath: string, sessionCwd: string): boolean { + if (isPathInsideOrEqual(workspacePath, sessionCwd)) { + return true + } + + const workspaceWslPath = parseWslUncPath(workspacePath) + if (!workspaceWslPath) { + return false + } + + // WSL agent transcripts record Linux cwd values even when Orca stores the + // active worktree as a Windows UNC path. + return isPathInsideOrEqual(workspaceWslPath.linuxPath, sessionCwd) +} + +function tokenizeQuery(query: string): string[] { + const tokens: string[] = [] + // Why: keep quoted operator values (repo:/path:) intact so labels and paths + // containing spaces still match — e.g. path:"/Users/ada/My Project". + const pattern = /(repo|path):"([^"]+)"|(repo|path):'([^']+)'|"([^"]+)"|'([^']+)'|(\S+)/gi + let match: RegExpExecArray | null + while ((match = pattern.exec(query)) !== null) { + const operator = match[1] ?? match[3] + const operatorValue = match[2] ?? match[4] + if (operator && operatorValue?.trim()) { + tokens.push(`${operator.toLowerCase()}:${operatorValue.trim()}`) + continue + } + + const token = match[5] ?? match[6] ?? match[7] + if (token?.trim()) { + tokens.push(token.trim()) + } + } + return tokens +} diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index b0622da0481..b5f5f824602 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -25,6 +25,11 @@ export const AI_VAULT_AGENTS = [ 'kimi' ] as const satisfies readonly TuiAgent[] +// Why: the aiVault.listSessions RPC schema CLAMPS scopePaths to this bound +// (safe: scope paths only widen discovery). Producer-side caps against the same +// value are optional belt-and-braces, not required for the request to succeed. +export const AI_VAULT_SCOPE_PATHS_MAX_COUNT = 64 + export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number] export type AiVaultScope = 'workspace' | 'project' | 'all' export type AiVaultSort = 'updated' | 'created' diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7e0aef0cda9..d051d0500bb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -261,6 +261,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings { windowBackgroundBlur: false, minimizeToTrayOnClose: false, terminalClipboardOnSelect: false, + // Why: OSC 52 is a classic data-exfiltration vector (any process piping + // untrusted output into the terminal can rewrite the clipboard). Keep the + // conservative default off; users who need Grok/tmux/nvim remote copy can + // enable the toggle. OSC 52 *query* remains disabled separately. terminalAllowOsc52Clipboard: false, claudeAgentTeamsMode: 'off', setupScriptLaunchMode: 'new-tab', diff --git a/src/shared/grok-session-path-lookup-queue.ts b/src/shared/grok-session-path-lookup-queue.ts new file mode 100644 index 00000000000..4809b314ca0 --- /dev/null +++ b/src/shared/grok-session-path-lookup-queue.ts @@ -0,0 +1,158 @@ +import { resolve } from 'node:path' + +export const GROK_SESSION_PATH_CACHE_MAX_ENTRIES = 64 +export const GROK_SESSION_SCAN_ACTIVE_ROOT_MAX = 4 +export const GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES = 64 + +export type GrokSessionPathScanner = ( + sessionsDir: string, + sessionId: string, + maxGroupEntries: number +) => Promise + +type PendingLookup = { + key: string + rootKey: string + sessionsDir: string + sessionId: string + maxGroupEntries: number + resolve: (path: string | null) => void +} + +export class GrokSessionPathLookupQueue { + private readonly successfulPaths = new Map() + private readonly inflight = new Map>() + private readonly activeRoots = new Set() + private readonly pending: PendingLookup[] = [] + private scanner: GrokSessionPathScanner + + constructor(private readonly defaultScanner: GrokSessionPathScanner) { + this.scanner = defaultScanner + } + + getCached(sessionsDir: string, sessionId: string): string | null { + const key = this.lookupKey(sessionsDir, sessionId) + const cached = this.successfulPaths.get(key) + if (!cached) { + return null + } + this.successfulPaths.delete(key) + this.successfulPaths.set(key, cached) + return cached + } + + find(sessionsDir: string, sessionId: string, maxGroupEntries: number): Promise { + const key = this.lookupKey(sessionsDir, sessionId) + const cached = this.getCached(sessionsDir, sessionId) + if (cached) { + return Promise.resolve(cached) + } + const existing = this.inflight.get(key) + if (existing) { + return existing + } + const rootKey = this.rootKey(sessionsDir) + let resolveLookup: (path: string | null) => void = () => undefined + const lookup = new Promise((resolvePromise) => { + resolveLookup = resolvePromise + }) + const pending = { + key, + rootKey, + sessionsDir, + sessionId, + maxGroupEntries, + resolve: resolveLookup + } + if (this.mustQueue(rootKey)) { + if (this.pending.length >= GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES) { + return Promise.resolve(null) + } + this.inflight.set(key, lookup) + this.pending.push(pending) + this.drain() + return lookup + } + this.inflight.set(key, lookup) + this.start(pending) + return lookup + } + + clearForTests(): void { + this.successfulPaths.clear() + this.inflight.clear() + this.activeRoots.clear() + for (const pending of this.pending.splice(0)) { + pending.resolve(null) + } + this.scanner = this.defaultScanner + } + + setScannerForTests(scanner: GrokSessionPathScanner): void { + this.scanner = scanner + } + + private rootKey(sessionsDir: string): string { + const root = resolve(sessionsDir) + return process.platform === 'win32' ? root.toLowerCase() : root + } + + private lookupKey(sessionsDir: string, sessionId: string): string { + return `${this.rootKey(sessionsDir)}\0${sessionId}` + } + + private mustQueue(rootKey: string): boolean { + return ( + this.pending.length > 0 || + this.activeRoots.has(rootKey) || + this.activeRoots.size >= GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + ) + } + + private cache(key: string, path: string): void { + this.successfulPaths.delete(key) + this.successfulPaths.set(key, path) + while (this.successfulPaths.size > GROK_SESSION_PATH_CACHE_MAX_ENTRIES) { + const oldest = this.successfulPaths.keys().next().value + if (typeof oldest !== 'string') { + return + } + this.successfulPaths.delete(oldest) + } + } + + private start(pending: PendingLookup): void { + this.activeRoots.add(pending.rootKey) + void (async () => { + try { + const path = await this.scanner( + pending.sessionsDir, + pending.sessionId, + pending.maxGroupEntries + ) + if (path) { + this.cache(pending.key, path) + } + pending.resolve(path) + } catch { + pending.resolve(null) + } finally { + this.activeRoots.delete(pending.rootKey) + this.inflight.delete(pending.key) + this.drain() + } + })() + } + + private drain(): void { + while (this.pending.length > 0 && this.activeRoots.size < GROK_SESSION_SCAN_ACTIVE_ROOT_MAX) { + const next = this.pending[0] + // Why: strict FIFO avoids starving repeated lookups for one sessions root. + if (this.activeRoots.has(next.rootKey)) { + return + } + this.pending.shift() + this.start(next) + } + } +} diff --git a/src/shared/grok-session-paths.test.ts b/src/shared/grok-session-paths.test.ts new file mode 100644 index 00000000000..90d4cf69e1e --- /dev/null +++ b/src/shared/grok-session-paths.test.ts @@ -0,0 +1,387 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + GROK_ENCODED_CWD_DIR_MAX_BYTES, + GROK_SESSION_SCAN_ACTIVE_ROOT_MAX, + GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES, + buildGrokChatHistoryPathCandidates, + clearGrokSessionPathLookupCacheForTests, + findGrokChatHistoryBySessionId, + getCachedGrokChatHistoryBySessionId, + grokEncodedCwdDirName, + isGrokChatHistoryPath, + isSafeGrokSessionId, + resolveGrokChatHistoryPathSync, + resolveGrokHomeDir, + resolveGrokSessionsDir, + setGrokSessionDirectoryOpenerForTests, + setGrokSessionPathScannerForTests +} from './grok-session-paths' + +const tempDirs: string[] = [] + +afterEach(() => { + clearGrokSessionPathLookupCacheForTests() + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function makeRoot(): string { + const dir = mkdtempSync(join(tmpdir(), 'orca-grok-session-paths-')) + tempDirs.push(dir) + return dir +} + +function deferred(): { + promise: Promise + resolve: (value: T) => void + reject: (error: Error) => void +} { + let resolvePromise: (value: T) => void = () => undefined + let rejectPromise: (error: Error) => void = () => undefined + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { promise, resolve: resolvePromise, reject: rejectPromise } +} + +describe('grok-session-paths', () => { + it('honors GROK_HOME for home and sessions roots', () => { + const root = makeRoot() + expect(resolveGrokHomeDir({ GROK_HOME: root }, '/unused')).toBe(root) + expect(resolveGrokSessionsDir({ GROK_HOME: root }, '/unused')).toBe(join(root, 'sessions')) + expect(resolveGrokHomeDir({}, '/home/ada')).toBe(join('/home/ada', '.grok')) + }) + + it('refuses to invent encodeURIComponent names longer than 255 bytes', () => { + const longCwd = `/${'a'.repeat(200)}/${'b'.repeat(200)}` + expect(Buffer.byteLength(encodeURIComponent(longCwd), 'utf8')).toBeGreaterThan( + GROK_ENCODED_CWD_DIR_MAX_BYTES + ) + expect(grokEncodedCwdDirName(longCwd)).toBeNull() + expect( + buildGrokChatHistoryPathCandidates({ + sessionId: 'sess-1', + cwd: longCwd, + sessionsDir: '/tmp/sessions' + }) + ).toEqual([]) + }) + + it('rejects unsafe session ids and path-special cwd components', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const invalidIds = ['../escape', 'nested/id', 'nested\\id', '.', '..', 'x'.repeat(129)] + + for (const sessionId of invalidIds) { + expect(isSafeGrokSessionId(sessionId)).toBe(false) + expect(buildGrokChatHistoryPathCandidates({ sessionId, cwd: '/repo', sessionsDir })).toEqual( + [] + ) + await expect(findGrokChatHistoryBySessionId(sessionsDir, sessionId)).resolves.toBeNull() + expect(isGrokChatHistoryPath('/repo/chat_history.jsonl', sessionId)).toBe(false) + } + + expect(isSafeGrokSessionId('019e37f4-5135-7b63-a4ab-6d13aa6bf528')).toBe(true) + expect(grokEncodedCwdDirName('.')).toBeNull() + expect(grokEncodedCwdDirName('..')).toBeNull() + expect( + buildGrokChatHistoryPathCandidates({ sessionId: 'safe-id', cwd: '..', sessionsDir }) + ).toEqual([]) + }) + + it('resolves via encodeURIComponent(cwd) when the short path exists', () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const cwd = '/tmp/work' + const sessionId = 'sess-short' + const history = join(sessionsDir, encodeURIComponent(cwd), sessionId, 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{"type":"user"}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId, + cwd, + sessionsDir + }) + ).toBe(history) + }) + + it('does not synchronously discover a long-cwd slug group', () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const sessionId = 'sess-long' + // Simulate Grok's slug+hash group directory (not encodeURIComponent of cwd). + const slugGroup = 'long-path-ab12cd34' + const history = join(sessionsDir, slugGroup, sessionId, 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(join(sessionsDir, slugGroup, '.cwd'), '/very/long/path\n') + writeFileSync(history, '{"type":"assistant","content":"hi"}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId, + cwd: `/${'x'.repeat(300)}`, + sessionsDir + }) + ).toBeNull() + }) + + it('finds the documented group/session layout asynchronously', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const history = join(sessionsDir, 'slug-hash', 'sess-async', 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-async')).resolves.toBe(history) + }) + + it('deduplicates concurrent async discovery', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const history = join(sessionsDir, 'slug-hash', 'sess-deduped', 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + const first = findGrokChatHistoryBySessionId(sessionsDir, 'sess-deduped') + const second = findGrokChatHistoryBySessionId(sessionsDir, 'sess-deduped') + + expect(second).toBe(first) + await expect(first).resolves.toBe(history) + }) + + it('reuses a successful discovery from cache without another scan', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const history = join(sessionsDir, 'slug-hash', 'sess-cached', 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).resolves.toBe(history) + rmSync(join(sessionsDir, 'slug-hash'), { recursive: true, force: true }) + + expect(getCachedGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).toBe(history) + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-cached')).resolves.toBe(history) + }) + + it('does not descend below the documented group/session layout', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const nestedDecoy = join( + sessionsDir, + 'group', + 'other-session', + 'cache', + 'sess-target', + 'chat_history.jsonl' + ) + mkdirSync(dirname(nestedDecoy), { recursive: true }) + writeFileSync(nestedDecoy, '{}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId: 'sess-target', + cwd: '/group', + sessionsDir + }) + ).toBeNull() + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-target')).resolves.toBeNull() + }) + + it('applies the hard group-entry bound to the filesystem iteration subset', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + for (const group of ['a-group', 'b-group']) { + mkdirSync(join(sessionsDir, group), { recursive: true }) + } + const beyondBound = join(sessionsDir, 'z-target-group', 'sess-bounded', 'chat_history.jsonl') + mkdirSync(dirname(beyondBound), { recursive: true }) + writeFileSync(beyondBound, '{}\n') + setGrokSessionDirectoryOpenerForTests(async () => ({ + async *[Symbol.asyncIterator]() { + for (const name of ['a-group', 'b-group', 'z-target-group']) { + yield { + name, + isDirectory: () => true, + isSymbolicLink: () => false + } + } + }, + async close() {} + })) + + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-bounded', 2)).resolves.toBeNull() + await expect(findGrokChatHistoryBySessionId(sessionsDir, 'sess-bounded', 3)).resolves.toBe( + beyondBound + ) + }) + + it('stops the directory iterator after the exact eligible-entry cap and closes it', async () => { + let yielded = 0 + let closed = 0 + const entries = [ + { name: 'file', directory: false, symlink: false }, + { name: 'group-a', directory: true, symlink: false }, + { name: 'group-b', directory: true, symlink: false }, + { name: 'group-c', directory: true, symlink: false } + ] + setGrokSessionDirectoryOpenerForTests(async () => ({ + async *[Symbol.asyncIterator]() { + for (const entry of entries) { + yielded += 1 + yield { + name: entry.name, + isDirectory: () => entry.directory, + isSymbolicLink: () => entry.symlink + } + } + }, + async close() { + closed += 1 + } + })) + + await expect( + findGrokChatHistoryBySessionId('/missing/sessions', 'sess-iterator', 2) + ).resolves.toBeNull() + + expect(yielded).toBe(3) + expect(closed).toBe(1) + }) + + it('deduplicates the exact promise while a scanner is deferred', async () => { + const scan = deferred() + let calls = 0 + setGrokSessionPathScannerForTests(async () => { + calls += 1 + return scan.promise + }) + + const first = findGrokChatHistoryBySessionId('/sessions/a', 'sess-same') + const second = findGrokChatHistoryBySessionId('/sessions/a', 'sess-same') + expect(second).toBe(first) + expect(calls).toBe(1) + + scan.resolve(null) + await expect(first).resolves.toBeNull() + }) + + it('runs at most one scan per sessions root', async () => { + const scans = new Map>>() + const started: string[] = [] + setGrokSessionPathScannerForTests(async (root, sessionId) => { + const key = `${root}:${sessionId}` + started.push(key) + const scan = deferred() + scans.set(key, scan) + return scan.promise + }) + + const first = findGrokChatHistoryBySessionId('/sessions/root', 'sess-1') + const second = findGrokChatHistoryBySessionId('/sessions/root', 'sess-2') + expect(started).toEqual(['/sessions/root:sess-1']) + + scans.get('/sessions/root:sess-1')?.resolve(null) + await first + expect(started).toEqual(['/sessions/root:sess-1', '/sessions/root:sess-2']) + scans.get('/sessions/root:sess-2')?.resolve(null) + await second + }) + + it('caps active roots globally and drains queued roots in FIFO order', async () => { + const scans = new Map>>() + const started: string[] = [] + setGrokSessionPathScannerForTests(async (root) => { + started.push(root) + const scan = deferred() + scans.set(root, scan) + return scan.promise + }) + const roots = Array.from( + { length: GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + 2 }, + (_, index) => `/sessions/root-${index}` + ) + const lookups = roots.map((root, index) => + findGrokChatHistoryBySessionId(root, `sess-${index}`) + ) + + expect(started).toEqual(roots.slice(0, GROK_SESSION_SCAN_ACTIVE_ROOT_MAX)) + scans.get(roots[0])?.resolve(null) + await lookups[0] + expect(started.at(-1)).toBe(roots[GROK_SESSION_SCAN_ACTIVE_ROOT_MAX]) + scans.get(roots[1])?.resolve(null) + await lookups[1] + expect(started.at(-1)).toBe(roots[GROK_SESSION_SCAN_ACTIVE_ROOT_MAX + 1]) + + for (const root of roots.slice(2)) { + scans.get(root)?.resolve(null) + } + await Promise.all(lookups) + }) + + it('resolves queue overflow to null and drains the bounded queue', async () => { + const firstScan = deferred() + let calls = 0 + setGrokSessionPathScannerForTests(async () => { + calls += 1 + return calls === 1 ? firstScan.promise : null + }) + const active = findGrokChatHistoryBySessionId('/sessions/overflow', 'sess-active') + const queued = Array.from({ length: GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES }, (_, index) => + findGrokChatHistoryBySessionId('/sessions/overflow', `sess-queued-${index}`) + ) + const overflow = findGrokChatHistoryBySessionId('/sessions/overflow', 'sess-overflow') + + await expect(overflow).resolves.toBeNull() + expect(calls).toBe(1) + firstScan.resolve(null) + await expect(active).resolves.toBeNull() + await expect(Promise.all(queued)).resolves.toEqual( + Array.from({ length: GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES }, () => null) + ) + expect(calls).toBe(1 + GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES) + }) + + it('cleans up a rejected scan and starts the next queued lookup', async () => { + const firstScan = deferred() + const started: string[] = [] + setGrokSessionPathScannerForTests(async (_root, sessionId) => { + started.push(sessionId) + if (sessionId === 'sess-reject') { + return firstScan.promise + } + return null + }) + const rejected = findGrokChatHistoryBySessionId('/sessions/reject', 'sess-reject') + const next = findGrokChatHistoryBySessionId('/sessions/reject', 'sess-next') + + firstScan.reject(new Error('scanner failed')) + await expect(rejected).resolves.toBeNull() + await expect(next).resolves.toBeNull() + expect(started).toEqual(['sess-reject', 'sess-next']) + }) + + it('does not synchronously scan sessions when cwd is absent', async () => { + const root = makeRoot() + const sessionsDir = join(root, 'sessions') + const sessionId = 'sess-env' + const history = join(sessionsDir, encodeURIComponent('/repo'), sessionId, 'chat_history.jsonl') + mkdirSync(dirname(history), { recursive: true }) + writeFileSync(history, '{}\n') + + expect( + resolveGrokChatHistoryPathSync({ + sessionId, + sessionsDir, + env: { GROK_HOME: root } + }) + ).toBeNull() + await expect(findGrokChatHistoryBySessionId(sessionsDir, sessionId)).resolves.toBe(history) + }) +}) diff --git a/src/shared/grok-session-paths.ts b/src/shared/grok-session-paths.ts new file mode 100644 index 00000000000..e4d59c61f09 --- /dev/null +++ b/src/shared/grok-session-paths.ts @@ -0,0 +1,294 @@ +import { lstatSync } from 'node:fs' +import { lstat, opendir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + GrokSessionPathLookupQueue, + type GrokSessionPathScanner +} from './grok-session-path-lookup-queue' + +export { + GROK_SESSION_PATH_CACHE_MAX_ENTRIES, + GROK_SESSION_SCAN_ACTIVE_ROOT_MAX, + GROK_SESSION_SCAN_QUEUE_MAX_ENTRIES, + type GrokSessionPathScanner +} from './grok-session-path-lookup-queue' + +export const GROK_CHAT_HISTORY_FILE = 'chat_history.jsonl' +// Why: Grok URL-encodes the cwd for the sessions group directory. When that +// encoded name exceeds 255 bytes it switches to a slug+hash layout. +export const GROK_ENCODED_CWD_DIR_MAX_BYTES = 255 +export const GROK_SESSION_ID_MAX_LENGTH = 128 +// Why: session discovery runs in hook/main hot paths; one corrupt or enormous +// sessions root must not cause unbounded candidate probes. +export const GROK_SESSION_GROUP_SCAN_MAX_ENTRIES = 2_048 + +export type GrokSessionPathEnv = + | NodeJS.ProcessEnv + | Partial> + +/** Official ids are UUIDs; keep legacy/test token ids while rejecting paths. */ +export function isSafeGrokSessionId(sessionId: string): boolean { + return ( + sessionId.length > 0 && + sessionId.length <= GROK_SESSION_ID_MAX_LENGTH && + /^[A-Za-z0-9_-]+$/.test(sessionId) + ) +} + +/** Resolve Grok's configured home, falling back to the runtime user's home. */ +export function resolveGrokHomeDir( + env: GrokSessionPathEnv = process.env, + homeDir: string = homedir() +): string { + const fromEnv = env.GROK_HOME?.trim() + return fromEnv || join(homeDir, '.grok') +} + +export function resolveGrokSessionsDir( + env: GrokSessionPathEnv = process.env, + homeDir: string = homedir() +): string { + return join(resolveGrokHomeDir(env, homeDir), 'sessions') +} + +/** Return Grok's safe cwd-group component, or null for slug/invalid layouts. */ +export function grokEncodedCwdDirName(cwd: string): string | null { + const trimmed = cwd.trim() + if (!trimmed) { + return null + } + let encoded: string + try { + encoded = encodeURIComponent(trimmed) + } catch { + return null + } + // encodeURIComponent deliberately leaves dots untouched; reject path syntax. + if (encoded === '.' || encoded === '..' || encoded.includes('/') || encoded.includes('\\')) { + return null + } + return Buffer.byteLength(encoded, 'utf8') <= GROK_ENCODED_CWD_DIR_MAX_BYTES ? encoded : null +} + +/** Fast-path candidates when both session id and cwd are known. */ +export function buildGrokChatHistoryPathCandidates(args: { + sessionId: string + cwd?: string | null + sessionsDir: string +}): string[] { + const sessionId = args.sessionId.trim() + if (!isSafeGrokSessionId(sessionId)) { + return [] + } + const cwd = args.cwd?.trim() + if (!cwd) { + return [] + } + const encoded = grokEncodedCwdDirName(cwd) + if (!encoded) { + return [] + } + const candidate = join(args.sessionsDir, encoded, sessionId, GROK_CHAT_HISTORY_FILE) + return isPathWithin(args.sessionsDir, candidate) ? [candidate] : [] +} + +/** Resolve only the exact cwd candidate; safe for synchronous hook hot paths. */ +export function resolveGrokChatHistoryPathSync(args: { + sessionId: string + cwd?: string | null + sessionsDir?: string + env?: GrokSessionPathEnv + homeDir?: string +}): string | null { + const sessionId = args.sessionId.trim() + if (!isSafeGrokSessionId(sessionId)) { + return null + } + const sessionsDir = + args.sessionsDir ?? resolveGrokSessionsDir(args.env ?? process.env, args.homeDir ?? homedir()) + + for (const candidate of buildGrokChatHistoryPathCandidates({ + sessionId, + cwd: args.cwd, + sessionsDir + })) { + if (isSafeChatHistoryFileSync(sessionsDir, candidate)) { + return candidate + } + } + return null +} + +type GrokSessionDirectoryEntry = { + name: string + isDirectory(): boolean + isSymbolicLink(): boolean +} + +type GrokSessionDirectory = AsyncIterable & { + close(): Promise +} + +type GrokSessionDirectoryOpener = (sessionsDir: string) => Promise + +const defaultSessionDirectoryOpener: GrokSessionDirectoryOpener = (sessionsDir) => + opendir(sessionsDir) +let sessionDirectoryOpener = defaultSessionDirectoryOpener +const sessionPathLookupQueue = new GrokSessionPathLookupQueue(scanGrokChatHistoryBySessionId) + +/** Read a previously resolved path without filesystem work (hook retry path). */ +export function getCachedGrokChatHistoryBySessionId( + sessionsDir: string, + sessionId: string +): string | null { + const trimmedId = sessionId.trim() + if (!isSafeGrokSessionId(trimmedId)) { + return null + } + return sessionPathLookupQueue.getCached(sessionsDir, trimmedId) +} + +/** Async bounded lookup with inflight dedupe and a small successful-result cache. */ +export function findGrokChatHistoryBySessionId( + sessionsDir: string, + sessionId: string, + maxGroupEntries = GROK_SESSION_GROUP_SCAN_MAX_ENTRIES +): Promise { + const trimmedId = sessionId.trim() + if (!isSafeGrokSessionId(trimmedId)) { + return Promise.resolve(null) + } + return sessionPathLookupQueue.find(sessionsDir, trimmedId, maxGroupEntries) +} + +async function scanGrokChatHistoryBySessionId( + sessionsDir: string, + sessionId: string, + maxGroupEntries: number +): Promise { + const max = normalizeGroupEntryLimit(maxGroupEntries) + if (max === 0) { + return null + } + let directory: GrokSessionDirectory | undefined + try { + directory = await sessionDirectoryOpener(sessionsDir) + let eligibleEntries = 0 + // Why: the filesystem's iteration order defines the bounded subset; sorting + // would first materialize an unbounded sessions root on the main process. + for await (const entry of directory) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue + } + eligibleEntries += 1 + const history = join(sessionsDir, entry.name, sessionId, GROK_CHAT_HISTORY_FILE) + if (await isSafeChatHistoryFile(sessionsDir, history)) { + return history + } + if (eligibleEntries >= max) { + return null + } + } + return null + } catch { + return null + } finally { + if (directory) { + try { + await directory.close() + } catch { + // Async iteration closes Node Dir handles automatically; custom/test + // iterators still get the explicit close attempt above. + } + } + } +} + +/** Test isolation for the module-level inflight/success caches. */ +export function clearGrokSessionPathLookupCacheForTests(): void { + sessionPathLookupQueue.clearForTests() + sessionDirectoryOpener = defaultSessionDirectoryOpener +} + +function normalizeGroupEntryLimit(requestedMax: number): number { + return Math.min( + GROK_SESSION_GROUP_SCAN_MAX_ENTRIES, + Math.max(0, Math.floor(Number.isFinite(requestedMax) ? requestedMax : 0)) + ) +} + +/** Replace directory IO for bounded-iterator tests. */ +export function setGrokSessionDirectoryOpenerForTests(opener: GrokSessionDirectoryOpener): void { + sessionDirectoryOpener = opener +} + +/** Replace scan IO for queue/concurrency tests. */ +export function setGrokSessionPathScannerForTests(scanner: GrokSessionPathScanner): void { + sessionPathLookupQueue.setScannerForTests(scanner) +} + +function isPathWithin(root: string, candidate: string): boolean { + const rel = relative(resolve(root), resolve(candidate)) + return rel.length > 0 && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel) +} + +function candidateSessionDir(candidate: string): string { + return dirname(candidate) +} + +function isSafeChatHistoryFileSync(sessionsDir: string, candidate: string): boolean { + if (!isPathWithin(sessionsDir, candidate)) { + return false + } + try { + const sessionDir = candidateSessionDir(candidate) + const groupStat = lstatSync(dirname(sessionDir)) + const sessionStat = lstatSync(sessionDir) + const fileStat = lstatSync(candidate) + return ( + groupStat.isDirectory() && + !groupStat.isSymbolicLink() && + sessionStat.isDirectory() && + !sessionStat.isSymbolicLink() && + fileStat.isFile() && + !fileStat.isSymbolicLink() + ) + } catch { + return false + } +} + +async function isSafeChatHistoryFile(sessionsDir: string, candidate: string): Promise { + if (!isPathWithin(sessionsDir, candidate)) { + return false + } + try { + const sessionDir = candidateSessionDir(candidate) + const [groupStat, sessionStat, fileStat] = await Promise.all([ + lstat(dirname(sessionDir)), + lstat(sessionDir), + lstat(candidate) + ]) + return ( + groupStat.isDirectory() && + !groupStat.isSymbolicLink() && + sessionStat.isDirectory() && + !sessionStat.isSymbolicLink() && + fileStat.isFile() && + !fileStat.isSymbolicLink() + ) + } catch { + return false + } +} + +/** True when path looks like a Grok chat history under a safe session id. */ +export function isGrokChatHistoryPath(path: string, sessionId: string): boolean { + const trimmedId = sessionId.trim() + return ( + isSafeGrokSessionId(trimmedId) && + basename(path) === GROK_CHAT_HISTORY_FILE && + basename(dirname(path)) === trimmedId + ) +} diff --git a/src/shared/jira-types.ts b/src/shared/jira-types.ts index 8ce07fde4d0..7831bd18416 100644 --- a/src/shared/jira-types.ts +++ b/src/shared/jira-types.ts @@ -81,6 +81,10 @@ export type JiraStatus = { colorName?: string } +export type JiraProjectStatusOrder = { + statusIdsByColumn: string[][] +} + export type JiraTransition = { id: string name: string diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 57db2bf2e05..287b3ced8fc 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -27,6 +27,11 @@ export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY = 'folder-workspace.path-status.v1' as const +// Why: signals the host exposes the Agent Session History scanner over RPC +// (aiVault.listSessions). Registered unconditionally for every build, so it is a +// STATIC capability advertised by getStatus() automatically — NOT a runtime +// conditional like browser.headless.v1. +export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const // Why: signals a host owns browser pages with no renderer (headless serve via the // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. @@ -44,7 +49,8 @@ export const RUNTIME_CAPABILITIES = [ PROJECT_HOST_SETUP_RUNTIME_CAPABILITY, TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, - FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY + FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, + AI_VAULT_RUNTIME_CAPABILITY ] as const export type RuntimeCapability = (typeof RUNTIME_CAPABILITIES)[number] | (string & {}) diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts index 255965ec034..6d35c2d22fc 100644 --- a/src/shared/rate-limit-types.test.ts +++ b/src/shared/rate-limit-types.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { RateLimitState } from './rate-limit-types' describe('RateLimitState', () => { - it('documents the MiniMax surface used by the AccountsPane settings UI', () => { + it('documents provider surfaces used by settings and status-bar UI', () => { // Why: the AccountsPane and the status bar both read these fields // from RateLimitState. The shape must stay stable so that the // visibility check (status-bar-provider-visibility) keeps working @@ -13,6 +13,7 @@ describe('RateLimitState', () => { gemini: null, opencodeGo: null, kimi: null, + antigravity: null, minimax: null, grok: null, minimaxCookieConfigured: false, @@ -23,6 +24,7 @@ describe('RateLimitState', () => { inactiveCodexAccounts: [] } + expect(state.antigravity).toBeNull() expect(state.minimax).toBeNull() expect(state.minimaxCookieConfigured).toBe(false) }) diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index d19699dee01..5470e851506 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -44,7 +44,7 @@ export type UsageRateLimitMetadata = { } export type ProviderRateLimits = { - provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' + provider: 'claude' | 'codex' | 'gemini' | 'opencode-go' | 'kimi' | 'minimax' | 'grok' | 'antigravity' /** 5-hour session window, null if not available. */ session: RateLimitWindow | null /** 7-day weekly window, null if not available. */ @@ -109,6 +109,7 @@ export type RateLimitState = { gemini: ProviderRateLimits | null opencodeGo: ProviderRateLimits | null kimi: ProviderRateLimits | null + antigravity: ProviderRateLimits | null minimax: ProviderRateLimits | null grok: ProviderRateLimits | null /** diff --git a/src/shared/remote-runtime-client.ts b/src/shared/remote-runtime-client.ts index ecd9f1df790..15ec38e4005 100644 --- a/src/shared/remote-runtime-client.ts +++ b/src/shared/remote-runtime-client.ts @@ -29,6 +29,7 @@ import { type RemoteRuntimeSocketLivenessMonitor, type RemoteRuntimeSocketLivenessOptions } from './remote-runtime-socket-liveness' +import { createWsOutboundBackpressureQueue } from './ws-outbound-backpressure-queue' export { RemoteRuntimeClientError } from './remote-runtime-client-error' @@ -375,6 +376,8 @@ export async function subscribeRemoteRuntimeRequest( const cleanupSocketListeners = (): WebSocket | null => { liveness?.stop() liveness = null + sendQueue?.dispose() + sendQueue = null const socket = ws if (!socket) { return null @@ -420,11 +423,37 @@ export async function subscribeRemoteRuntimeRequest( } } + // Why: client input (keystrokes) must never be dropped under backpressure. + // Hold encrypted frames in order while bufferedAmount is over the cap and + // drain as it clears; a wedged link (hard cap) fails the socket so the + // renderer resubscribes and replays a fresh snapshot. + let sendQueue: ReturnType> | null = null + const ensureSendQueue = ( + socket: WebSocket + ): ReturnType> => { + if (!sendQueue) { + sendQueue = createWsOutboundBackpressureQueue({ + send: (frame) => socket.send(frame, { binary: true }), + byteLengthOf: (frame) => frame.byteLength, + getBufferedAmount: () => socket.bufferedAmount, + isWritable: () => socket.readyState === WebSocket.OPEN, + onOverflow: () => + fail( + new RemoteRuntimeClientError( + 'remote_runtime_unavailable', + 'Remote Orca runtime send buffer overflow; reconnecting.' + ) + ) + }) + } + return sendQueue + } + const sendBinary = (bytes: Uint8Array): boolean => { if (state !== 'ready' || !ws || ws.readyState !== WebSocket.OPEN) { return false } - ws.send(Buffer.from(encryptBytes(bytes, sharedKey)), { binary: true }) + ensureSendQueue(ws).enqueue(Buffer.from(encryptBytes(bytes, sharedKey))) return true } diff --git a/src/shared/remote-runtime-shared-control-connection.test.ts b/src/shared/remote-runtime-shared-control-connection.test.ts index 250ec27bf40..b355bda7dfa 100644 --- a/src/shared/remote-runtime-shared-control-connection.test.ts +++ b/src/shared/remote-runtime-shared-control-connection.test.ts @@ -346,18 +346,18 @@ describe('RemoteRuntimeSharedControlConnection', () => { connection.close() }) - it('refreshes pending request timeouts when keepalive frames show server progress', async () => { + it('times out a stuck short RPC on its absolute deadline despite keepalive frames', async () => { + // Why: a keepalive on the shared socket is armed by an unrelated long-poll, + // not by this request. It must NOT extend a stuck short RPC's deadline — + // otherwise a hung server call hangs the caller forever (#7948). const server = await createServer({ + silentMethods: ['worktree.hang'], sendKeepaliveBeforeResponse: true, - keepaliveDelayMs: 25, - responseDelayMs: 60 + keepaliveDelayMs: 20 }) const connection = new RemoteRuntimeSharedControlConnection(server.pairing) - await expect(connection.request('worktree.ps', undefined, 50)).resolves.toMatchObject({ - ok: true, - result: { method: 'worktree.ps' } - }) + await expect(connection.request('worktree.hang', undefined, 60)).rejects.toThrow('Timed out') connection.close() }) @@ -618,6 +618,15 @@ function handleRequest( delayedResponses: (() => void)[] ): void { requests.push(request) + // Why: keepalives are armed by an unrelated long-poll and keep flowing even + // while a method is deliberately silent — emit them before the silent return. + if (options.sendKeepaliveBeforeResponse && options.keepaliveDelayMs !== undefined) { + const timer = setInterval( + () => sendEncrypted(ws, sharedKey, { _keepalive: true }), + options.keepaliveDelayMs + ) + ws.once('close', () => clearInterval(timer)) + } if (options.silentMethods?.includes(request.method)) { return } @@ -647,13 +656,10 @@ function handleRequest( }) } const closeAfterResponse = streaming && options.closeAfterStreamingResponse?.() === true - if (options.sendKeepaliveBeforeResponse) { - const sendKeepalive = (): void => sendEncrypted(ws, sharedKey, { _keepalive: true }) - if (options.keepaliveDelayMs !== undefined) { - setTimeout(sendKeepalive, options.keepaliveDelayMs) - } else { - sendKeepalive() - } + // Delayed/periodic keepalives are handled by the interval above; here we only + // cover the immediate single-keepalive-before-response case. + if (options.sendKeepaliveBeforeResponse && options.keepaliveDelayMs === undefined) { + sendEncrypted(ws, sharedKey, { _keepalive: true }) } if (options.delaySubscriptionReady && streaming) { delayedResponses.push(sendResponse) diff --git a/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts new file mode 100644 index 00000000000..6c1ec5c7db5 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-keepalive-refresh.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { requestSharedControl } from './remote-runtime-shared-control-requests' +import { + refreshSharedControlPendingRequestTimeouts, + resolveSharedControlPendingResponse +} from './remote-runtime-shared-control-state' +import type { SharedControlPendingRequest } from './remote-runtime-shared-control-types' + +// Why: a keepalive frame on the shared-control socket is armed by an unrelated +// long-poll, not by any given pending short RPC. These fake-timer tests pin the +// deadline semantics: keepalives must NOT keep a stuck short RPC alive forever, +// but MAY extend a long-poll that opted into the short-RPC path. +describe('shared control keepalive timeout refresh semantics', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function startRequest(options: { refreshTimeoutOnKeepalive?: boolean } = {}): { + pendingRequests: Map> + promise: Promise + onTimeout: ReturnType + } { + const pendingRequests = new Map>() + const onTimeout = vi.fn() + const promise = requestSharedControl({ + pendingRequests, + method: 'git.status', + params: undefined, + timeoutMs: 1000, + // ensureReady resolves immediately; the request is "in flight" but the + // server never answers, modelling a genuinely stuck server-side call. + ensureReady: () => Promise.resolve(), + send: () => undefined, + onTimeout, + refreshTimeoutOnKeepalive: options.refreshTimeoutOnKeepalive + }) + // Swallow the eventual rejection so unhandled-rejection noise doesn't leak. + promise.catch(() => undefined) + return { pendingRequests, promise, onTimeout } + } + + it('times out a stuck short RPC even while keepalive frames keep arriving', async () => { + const { pendingRequests, promise, onTimeout } = startRequest() + + // Periodic keepalives arrive faster than the 1000ms deadline — as they + // would while a long-poll subscription streams over the same socket. + for (let elapsed = 0; elapsed < 1000; elapsed += 200) { + await vi.advanceTimersByTimeAsync(200) + refreshSharedControlPendingRequestTimeouts(pendingRequests) + } + await vi.advanceTimersByTimeAsync(1) + + await expect(promise).rejects.toThrow() + // The stuck-request path tears the connection down so reconnect+replay runs. + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(pendingRequests.size).toBe(0) + }) + + it('keeps refreshing a long-poll request that opted into keepalive refresh', async () => { + const { pendingRequests, promise, onTimeout } = startRequest({ + refreshTimeoutOnKeepalive: true + }) + + // Same keepalive cadence, but this request opted in, so each keepalive + // pushes the deadline out and it never fires. + for (let elapsed = 0; elapsed < 3000; elapsed += 200) { + await vi.advanceTimersByTimeAsync(200) + refreshSharedControlPendingRequestTimeouts(pendingRequests) + } + + expect(onTimeout).not.toHaveBeenCalled() + expect(pendingRequests.size).toBe(1) + + // It still resolves normally once the server finally answers. + const [requestId] = pendingRequests.keys() + resolveSharedControlPendingResponse(pendingRequests, requestId!, { + id: requestId!, + ok: true, + result: { done: true }, + _meta: { runtimeId: 'runtime-test' } + }) + await expect(promise).resolves.toMatchObject({ ok: true }) + }) + + it('fires the deadline for a short RPC when no keepalives arrive', async () => { + const { pendingRequests, promise, onTimeout } = startRequest() + + await vi.advanceTimersByTimeAsync(1001) + + await expect(promise).rejects.toThrow() + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(pendingRequests.size).toBe(0) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-requests.ts b/src/shared/remote-runtime-shared-control-requests.ts index 5f8d0ca2b7c..91f555af787 100644 --- a/src/shared/remote-runtime-shared-control-requests.ts +++ b/src/shared/remote-runtime-shared-control-requests.ts @@ -17,6 +17,9 @@ export function requestSharedControl(args: { ensureReady: () => Promise send: (requestId: string, method: string, params: unknown) => void onTimeout?: (error: RemoteRuntimeClientError) => void + // Why: default off — ordinary short RPCs keep an absolute deadline. Only + // long-polls routed through this path opt in so keepalives extend them. + refreshTimeoutOnKeepalive?: boolean }): Promise> { const requestId = randomUUID() return new Promise>((resolve, reject) => { @@ -41,7 +44,8 @@ export function requestSharedControl(args: { method: args.method, resolve: resolve as (response: RuntimeRpcResponse) => void, reject, - timeout + timeout, + refreshTimeoutOnKeepalive: args.refreshTimeoutOnKeepalive ?? false }) void args.ensureReady().then( () => args.send(requestId, args.method, args.params), diff --git a/src/shared/remote-runtime-shared-control-state.ts b/src/shared/remote-runtime-shared-control-state.ts index 72f139a4945..0190398e3c8 100644 --- a/src/shared/remote-runtime-shared-control-state.ts +++ b/src/shared/remote-runtime-shared-control-state.ts @@ -64,6 +64,11 @@ export function refreshSharedControlPendingRequestTimeouts( pendingRequests: Map> ): void { for (const pending of pendingRequests.values()) { + // Why: only long-poll requests opted into keepalive refresh; refreshing an + // ordinary short RPC would keep a genuinely-stuck server call alive forever. + if (!pending.refreshTimeoutOnKeepalive) { + continue + } const timeout = pending.timeout as ReturnType & { refresh?: () => void } timeout.refresh?.() } @@ -144,6 +149,13 @@ export function handleSharedControlSubscriptionResponse( subscription: SharedControlLogicalSubscription, response: RuntimeRpcResponse ): void { + // The replay window ends on either success or error. An error created no + // remote subscription, so a later close must finish locally instead of + // waiting forever for an id that will never arrive. + subscription.awaitingResubscribe = false + if (!response.ok) { + subscription.sent = false + } if (response.ok) { const subscriptionId = getSubscriptionId(response.result) if (subscriptionId) { diff --git a/src/shared/remote-runtime-shared-control-subscriptions.test.ts b/src/shared/remote-runtime-shared-control-subscriptions.test.ts new file mode 100644 index 00000000000..a1b843f7488 --- /dev/null +++ b/src/shared/remote-runtime-shared-control-subscriptions.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from './runtime-rpc-envelope' +import { + closeSharedControlLogicalSubscription, + createSharedControlSubscription, + handleSharedControlLogicalResponse, + replaySharedControlSubscriptions +} from './remote-runtime-shared-control-subscriptions' +import type { SharedControlLogicalSubscription } from './remote-runtime-shared-control-types' + +function makeSubscriptions(): { + subscriptions: Map> + subscription: SharedControlLogicalSubscription +} { + const subscriptions = new Map>() + const subscription = createSharedControlSubscription({ + requestId: 'req-1', + method: 'runtime.clientEvents.subscribe', + params: null, + callbacks: { onResponse: vi.fn(), onError: vi.fn() } + }) + subscriptions.set(subscription.requestId, subscription) + return { subscriptions, subscription } +} + +function okResponse(subscriptionId: string): RuntimeRpcResponse { + return { + ok: true, + id: 'req-1', + result: { subscriptionId } + } as unknown as RuntimeRpcResponse +} + +describe('closeSharedControlLogicalSubscription — replay-window leak', () => { + it('sends the unsubscribe when closed after an established subscribe replay completes', () => { + const { subscriptions, subscription } = makeSubscriptions() + // First establishment: server assigned a concrete subscription id. + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + + // Established subscription cleans up immediately by its known id. + expect(request).toHaveBeenCalledWith('runtime.clientEvents.unsubscribe', { + subscriptionId: 'server-sub-1' + }) + expect(subscriptions.size).toBe(0) + }) + + it('does NOT leak a server subscription when close races the replay resubscribe window', () => { + const { subscriptions, subscription } = makeSubscriptions() + // Subscription was previously established on the server. + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + + // Reconnect replay: sent flips false and the old id is cleared right before + // the resubscribe frame goes out. The server WILL assign a fresh id. + const sent: SharedControlLogicalSubscription[] = [] + replaySharedControlSubscriptions({ + subscriptions, + send: (s) => { + sent.push(s) + }, + tagReplayedResponses: true + }) + expect(subscription.sent).toBe(false) + expect(subscription.remoteSubscriptionId).toBeNull() + + // close() arrives during the window: no remoteSubscriptionId yet, so a + // naive close finishes locally and never unsubscribes — leaking the server + // subscription that the in-flight resubscribe is about to create. + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + + // The close must be deferred, not finished with no cleanup. + expect(subscriptions.size).toBe(1) + expect(request).not.toHaveBeenCalled() + + // When the resubscribe's ready response arrives with the new server id, the + // deferred close fires the unsubscribe and only then finishes. + handleSharedControlLogicalResponse({ + subscriptions, + subscription, + response: okResponse('server-sub-2'), + request + }) + + expect(request).toHaveBeenCalledWith('runtime.clientEvents.unsubscribe', { + subscriptionId: 'server-sub-2' + }) + expect(subscriptions.size).toBe(0) + }) + + it('finishes locally without an unsubscribe for a never-sent subscription', () => { + const { subscriptions, subscription } = makeSubscriptions() + // Brand new: never sent, no server subscription exists. + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + + expect(request).not.toHaveBeenCalled() + expect(subscriptions.size).toBe(0) + }) + + it('finishes a deferred close locally when replay returns an error', () => { + const { subscriptions, subscription } = makeSubscriptions() + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + replaySharedControlSubscriptions({ subscriptions, send: vi.fn(), tagReplayedResponses: true }) + + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + handleSharedControlLogicalResponse({ + subscriptions, + subscription, + response: { + ok: false, + id: 'req-1', + error: { code: 'replay_failed', message: 'replay failed' } + } as RuntimeRpcResponse, + request + }) + + expect(request).not.toHaveBeenCalled() + expect(subscriptions.size).toBe(0) + }) + + it('does not leave a later close waiting after replay already failed', () => { + const { subscriptions, subscription } = makeSubscriptions() + subscription.sent = true + subscription.remoteSubscriptionId = 'server-sub-1' + replaySharedControlSubscriptions({ subscriptions, send: vi.fn(), tagReplayedResponses: true }) + + handleSharedControlLogicalResponse({ + subscriptions, + subscription, + response: { + ok: false, + id: 'req-1', + error: { code: 'replay_failed', message: 'replay failed' } + } as RuntimeRpcResponse, + request: vi.fn() + }) + + const request = vi.fn() + closeSharedControlLogicalSubscription({ subscriptions, subscription, request }) + expect(request).not.toHaveBeenCalled() + expect(subscriptions.size).toBe(0) + }) +}) diff --git a/src/shared/remote-runtime-shared-control-subscriptions.ts b/src/shared/remote-runtime-shared-control-subscriptions.ts index d4122eac9e4..3385cfc390a 100644 --- a/src/shared/remote-runtime-shared-control-subscriptions.ts +++ b/src/shared/remote-runtime-shared-control-subscriptions.ts @@ -62,9 +62,14 @@ export function closeSharedControlLogicalSubscription(args: { args.request(cleanup.method, cleanup.params) return } - if (args.subscription.sent && cleanupNeedsRemoteSubscriptionId(args.subscription.method)) { + if ( + (args.subscription.sent || args.subscription.awaitingResubscribe) && + cleanupNeedsRemoteSubscriptionId(args.subscription.method) + ) { // Why: id-scoped server subscriptions can only be cleaned up after the - // server returns its concrete subscription id in the ready response. + // server returns its concrete subscription id in the ready response. This + // also covers the reconnect replay window (sent===false, id cleared) where + // a resubscribe is in flight — finishing locally there would leak it. args.subscription.closeAfterReady = true return } @@ -100,6 +105,10 @@ export function replaySharedControlSubscriptions(args: { } subscription.sent = false subscription.remoteSubscriptionId = null + // Why: mark the id-less window so a close() racing this resubscribe defers + // to closeAfterReady instead of finishing locally and leaking the server + // subscription the resubscribe is about to create. + subscription.awaitingResubscribe = true if (args.tagReplayedResponses) { subscription.pendingReplayTag = true } diff --git a/src/shared/remote-runtime-shared-control-types.ts b/src/shared/remote-runtime-shared-control-types.ts index 4eb671ea3b3..98babc9d5a1 100644 --- a/src/shared/remote-runtime-shared-control-types.ts +++ b/src/shared/remote-runtime-shared-control-types.ts @@ -12,6 +12,12 @@ export type SharedControlPendingRequest = { resolve: (response: RuntimeRpcResponse) => void reject: (error: Error) => void timeout: ReturnType + // Why: keepalives on the shared socket are armed for an unrelated long-poll, + // not this request. Only requests that opt in (long-polls issued via the + // short-RPC path) may have their deadline refreshed by a keepalive; ordinary + // short RPCs keep an absolute deadline so a stuck server call still times + // out, tears the socket down, and reconnects/replays as designed. + refreshTimeoutOnKeepalive: boolean } export type SharedControlSubscriptionCallbacks = { @@ -34,6 +40,11 @@ export type SharedControlLogicalSubscription = { // response is the authoritative re-emitted snapshot and gets tagged so // monotonic freshness gates don't drop it (#7718). pendingReplayTag?: boolean + // Why: true from the moment a reconnect replay clears remoteSubscriptionId + // until the resubscribe response arrives. A close() during this window has no + // id to unsubscribe by yet, so it must defer instead of finishing locally — + // otherwise the resubscribe the server is about to accept leaks. + awaitingResubscribe?: boolean } export type SharedControlReadyWaiter = { diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 136c58e58ac..f3328ab66dc 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -63,6 +63,7 @@ export type RuntimeStatus = { capabilities?: RuntimeCapability[] remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null hostPlatform?: NodeJS.Platform + terminalWindowsShell?: string | null // Why: legacy or saved WebSocket pairings may not carry scope metadata, so // the server stamps the authenticated token scope here for status.get only. deviceScope?: DeviceScope @@ -585,18 +586,25 @@ export type RuntimeWorktreePsSummary = { workspaceKind?: 'git' | 'folder-workspace' worktreeId: string repoId: string + hostId?: Worktree['hostId'] + terminalPlatform?: NodeJS.Platform repo: string path: string branch: string isArchived: boolean isMainWorktree: boolean hasHostSidebarActivity: boolean + worktreeInstanceId?: string + lineageWorktreeInstanceId?: string + parentWorktreeInstanceId?: string parentWorktreeId: string | null childWorktreeIds: string[] displayName: string workspaceStatus: string sortOrder: number manualOrder?: number + lastActivityAt?: number + createdAt?: number linkedIssue: number | null linkedPR: { number: number; state: string } | null linkedLinearIssue: string | null diff --git a/src/shared/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts index 0d48a6364f7..b1884262d7d 100644 --- a/src/shared/status-bar-defaults.ts +++ b/src/shared/status-bar-defaults.ts @@ -4,6 +4,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [ 'claude', 'codex', 'gemini', + 'antigravity', 'opencode-go', 'kimi', 'minimax', diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts new file mode 100644 index 00000000000..6d88f56a070 --- /dev/null +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker' + +describe('TerminalKittyKeyboardModeTracker', () => { + it('starts inactive and ignores non-kitty sequences', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + expect(tracker.flags).toBe(0) + tracker.scan('plain output \x1b[?2004h\x1b[38;5;10mcolored\x1b[0m') + expect(tracker.flags).toBe(0) + }) + + it('does not treat CSI u (restore cursor) or the CSI ? u query as kitty state', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[u\x1b[?u') + expect(tracker.flags).toBe(0) + }) + + it('tracks push and pop like xterm, including the pop-to-empty zeroing', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x1b[>7u') + expect(tracker.flags).toBe(7) + tracker.scan('\x1b[5u\x1b[ { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[=1;1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x1b[=2;2u') + expect(tracker.flags).toBe(3) + tracker.scan('\x1b[=1;3u') + expect(tracker.flags).toBe(2) + // Mode defaults to 1 (set) when omitted. + tracker.scan('\x1b[=4u') + expect(tracker.flags).toBe(4) + }) + + it("clears state for Orca's defensive reset sequence and RIS", () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u') + tracker.scan('\x1b[<99u\x1b[=0u') + expect(tracker.flags).toBe(0) + + tracker.scan('\x1b[>1u') + tracker.scan('\x1bc') + expect(tracker.flags).toBe(0) + }) + + it('keeps per-screen flags across alternate-screen switches', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x1b[?1049h') + expect(tracker.flags).toBe(0) + tracker.scan('\x1b[>2u') + expect(tracker.flags).toBe(2) + tracker.scan('\x1b[?1049l') + expect(tracker.flags).toBe(1) + }) + + it('handles sequences split across chunks and C1 CSI', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>') + expect(tracker.flags).toBe(0) + tracker.scan('1u') + expect(tracker.flags).toBe(1) + tracker.scan('\x9b<99u') + expect(tracker.flags).toBe(0) + tracker.scan('\x9b>7u') + expect(tracker.flags).toBe(7) + }) + + it('caps the mirrored stack without losing the current flags', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + for (let i = 0; i < 40; i++) { + tracker.scan(`\x1b[>${(i % 3) + 1}u`) + } + expect(tracker.flags).toBe((39 % 3) + 1) + }) + + it('reset() returns to the inactive state', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u\x1b[?1049h\x1b[>2u') + tracker.reset() + expect(tracker.flags).toBe(0) + tracker.scan('\x1b[?1049l') + expect(tracker.flags).toBe(0) + }) + + it('clears kitty state on DECSTR (CSI ! p) like xterm, without switching screens', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u\x1b[!p') + expect(tracker.flags).toBe(0) + + // xterm's soft reset wipes both screens' slots but stays on the current + // buffer; a later alt-screen exit must not resurrect pre-reset flags. + const onAlt = new TerminalKittyKeyboardModeTracker() + onAlt.scan('\x1b[>1u\x1b[?1049h\x1b[>2u') + expect(onAlt.flags).toBe(2) + onAlt.scan('\x1b[!p') + expect(onAlt.flags).toBe(0) + onAlt.scan('\x1b[?1049l') + expect(onAlt.flags).toBe(0) + }) + + it('handles DECSTR split across chunks', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scan('\x1b[>1u\x1b[!') + expect(tracker.flags).toBe(1) + tracker.scan('p') + expect(tracker.flags).toBe(0) + }) + + it('applies replayed pushes as sets so redelivered windows cannot grow the stack', () => { + const tracker = new TerminalKittyKeyboardModeTracker() + // Live negotiation, then two relay reconnects redelivering the same + // retained window containing the app's one-time push. + tracker.scan('\x1b[>1u') + tracker.scanReplay('\x1b[>1u') + tracker.scanReplay('\x1b[>1u') + expect(tracker.flags).toBe(1) + // The TUI's single exit pop must drain to zero despite the redeliveries. + tracker.scan('\x1b[ { + const fresh = new TerminalKittyKeyboardModeTracker() + fresh.scanReplay('\x1b[>1u') + expect(fresh.flags).toBe(1) + fresh.scan('\x1b[1uoutput\x1b[ u push, CSI < u pop, + * CSI = u set) by scanning the raw PTY output stream, replicating xterm's + * exact stack/screen algorithm including the per-screen flag slots swapped by + * DECSET/DECRST 47/1047/1049, the full reset on RIS, and the soft reset on + * DECSTR (CSI ! p). + * + * Why a mirror instead of reading xterm's internal state: Orca defensively + * wipes the renderer terminal's kitty flags at moments when the TUI may have + * died (Ctrl+C interrupts, reattach resets) while the TUI is usually still + * alive and expecting protocol-encoded input. This tracker is fed only by + * application output, so it reflects what the *application* negotiated, + * independent of renderer-side defensive writes. The daemon reuses it to + * carry flags into snapshots (xterm's SerializeAddon does not serialize kitty + * state). + */ +export class TerminalKittyKeyboardModeTracker { + private scanTail = '' + private currentFlags = 0 + private mainFlags = 0 + private altFlags = 0 + private mainStack: number[] = [] + private altStack: number[] = [] + private alternateScreenActive = false + + /** Current effective kitty keyboard flags (0 = protocol inactive). */ + get flags(): number { + return this.currentFlags + } + + reset(): void { + this.scanTail = '' + this.currentFlags = 0 + this.mainFlags = 0 + this.altFlags = 0 + this.mainStack = [] + this.altStack = [] + this.alternateScreenActive = false + } + + scan(data: string): void { + this.scanInternal(data, false) + } + + /** + * Scan bytes replayed from a retained history window (reattach payloads, + * relay replays, daemon snapshots). Replays can redeliver the application's + * one-time CSI > u push — applying it with stack semantics on every delivery + * grows the mirrored stack, so the TUI's eventual single pop lands on a + * stale frame and Option chords stay kitty-encoded in a plain shell. Pushes + * seen during replay therefore apply as idempotent sets. Known limit: a + * NESTED push/push/pop inside the window collapses to flags 0 on the pop — + * unavoidable stackless-replay tradeoff (a redelivered push is byte-wise + * indistinguishable from a new one); real TUIs push once at startup. + */ + scanReplay(data: string): void { + this.scanInternal(data, true) + } + + private scanInternal(data: string, replay: boolean): void { + const input = this.scanTail + data + this.scanTail = this.extractScanTail(input) + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const kittyModeRe = /\x1bc|(?:\x1b\[|\x9b)(?:!p|\?([0-9;]+)([hl])|([<>=])([0-9;]*)u)/g + let match: RegExpExecArray | null + while ((match = kittyModeRe.exec(input)) !== null) { + if (match[0] === '\x1bc') { + // RIS resets kitty state and returns to the main screen. + const tail = this.scanTail + this.reset() + this.scanTail = tail + continue + } + if (match[0].endsWith('!p')) { + this.applySoftReset() + continue + } + if (match[1] !== undefined) { + this.applyScreenSwitch(match[1], match[2] === 'h') + continue + } + this.applyKittySequence(match[3], match[4] ?? '', replay) + } + } + + private applySoftReset(): void { + // Why: xterm's DECSTR (CSI ! p) wipes kitty flags and stacks for both + // screens via coreService.reset but does not switch buffers — mirror that + // so a soft-resetting TUI stops receiving kitty-encoded Option chords. + this.currentFlags = 0 + this.mainFlags = 0 + this.altFlags = 0 + this.mainStack = [] + this.altStack = [] + } + + private applyScreenSwitch(params: string, enabled: boolean): void { + for (const rawParam of params.split(';')) { + const param = Number(rawParam) + if (param !== 47 && param !== 1047 && param !== 1049) { + continue + } + // Why: xterm swaps the current flags with the inactive screen's slot on + // every 47/1047/1049 transition, without an already-active guard — + // mirror it exactly so this state matches what the renderer encodes. + if (enabled) { + this.mainFlags = this.currentFlags + this.currentFlags = this.altFlags + this.alternateScreenActive = true + } else { + this.altFlags = this.currentFlags + this.currentFlags = this.mainFlags + this.alternateScreenActive = false + } + } + } + + private applyKittySequence(prefix: string, params: string, replay: boolean): void { + const parsed = params.split(';').map((entry) => Number(entry)) + const stack = this.alternateScreenActive ? this.altStack : this.mainStack + if (prefix === '>') { + if (!replay) { + if (stack.length >= KITTY_STACK_LIMIT) { + stack.shift() + } + stack.push(this.currentFlags) + } + this.currentFlags = parsed[0] || 0 + return + } + if (prefix === '<') { + const count = Math.max(1, parsed[0] || 1) + for (let i = 0; i < count && stack.length > 0; i++) { + this.currentFlags = stack.pop() as number + } + if (stack.length === 0) { + this.currentFlags = 0 + } + return + } + const flags = parsed[0] || 0 + const mode = parsed.length > 1 && parsed[1] ? parsed[1] : 1 + if (mode === 1) { + this.currentFlags = flags + } else if (mode === 2) { + this.currentFlags |= flags + } else if (mode === 3) { + this.currentFlags &= ~flags + } + } + + private extractScanTail(input: string): string { + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = input.slice(start) + if (tail.length > KITTY_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { + return tail + } + const body = tail.startsWith('\x1b[') + ? tail.slice(2) + : tail.startsWith('\x9b') + ? tail.slice(1) + : null + if (body === null) { + return '' + } + return this.isIncompleteSequenceBody(body) ? tail : '' + } + + private isIncompleteSequenceBody(body: string): boolean { + return body === '!' || /^[<>=?]?[0-9;]*$/.test(body) + } +} diff --git a/src/shared/terminal-query-reply.test.ts b/src/shared/terminal-query-reply.test.ts index ee26abf1604..12c6c57d61b 100644 --- a/src/shared/terminal-query-reply.test.ts +++ b/src/shared/terminal-query-reply.test.ts @@ -1,3 +1,4 @@ +import { Terminal } from '@xterm/headless' import { describe, expect, it } from 'vitest' import { isTerminalQueryReply } from './terminal-query-reply' @@ -36,6 +37,23 @@ describe('isTerminalQueryReply', () => { expect(isTerminalQueryReply('\x1bP>|xterm.js(5.6.0)\x1b\\')).toBe(true) }) + it('classifies the fully framed XTVERSION reply emitted by real xterm', async () => { + const terminal = new Terminal() + const replies: string[] = [] + const disposable = terminal.onData((data) => replies.push(data)) + try { + await new Promise((resolve) => terminal.write('\x1b[>q', resolve)) + expect(replies).toHaveLength(1) + const reply = replies[0] + expect(reply.startsWith('\x1bP>|xterm.js(')).toBe(true) + expect(reply.endsWith(')\x1b\\')).toBe(true) + expect(isTerminalQueryReply(reply)).toBe(true) + } finally { + disposable.dispose() + terminal.dispose() + } + }) + it('documents the accepted modified-F3/CPR collision', () => { // xterm.js encodes Shift+F3 as CSI 1;2R — byte-identical to a CPR report. // Classified as a reply on purpose: order is still preserved (the immediate diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index fc06ac4912b..ff4427bb5da 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -28,6 +28,8 @@ export type TuiAgentConfig = { launchCmdByPlatform?: Partial> expectedProcess: string promptInjectionMode: AgentPromptInjectionMode + /** Option terminator required before positional prompts that may look like CLI syntax. */ + argvPromptSeparator?: '--' /** Why: flag that launches the TUI with the given text already in the * input box but NOT submitted, so the user still gets a reviewable draft. * Only set when the CLI documents native support — e.g. Claude's @@ -345,7 +347,14 @@ export const TUI_AGENT_CONFIG: Record = { detectCmd: 'grok', launchCmd: 'grok', expectedProcess: 'grok', - promptInjectionMode: 'stdin-after-start' + // Why: Grok CLI accepts an initial prompt as a positional argv + // (`grok "fix the bug"`). Prefer argv over stdin-after-start so multi-line + // / special-character prompts are not typed as raw PTY keystrokes, and so + // clipboard-derived launch text is not mangled by line-edit shortcuts. + promptInjectionMode: 'argv', + // Why: prompts such as `help` or `--version` otherwise select Grok CLI + // syntax instead of starting an interactive turn with that literal text. + argvPromptSeparator: '--' }, devin: { detectCmd: 'devin', diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 5aef452c833..71f455fbc57 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -49,6 +49,63 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe('claude "fix ^"quoted^" ^& ^%PATH^%"') }) + it('terminates Grok options before a flag-shaped POSIX prompt', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: '--version', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("grok -- '--version'") + }) + + it('terminates Grok options before a flag-shaped PowerShell prompt', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: '-h', + cmdOverrides: {}, + platform: 'win32' + }) + + expect(plan?.launchCommand).toBe("grok -- '-h'") + }) + + it('terminates Grok options before a subcommand-shaped cmd prompt', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: 'help', + cmdOverrides: {}, + platform: 'win32', + shell: 'cmd' + }) + + expect(plan?.launchCommand).toBe('grok -- "help"') + }) + + it('places the Grok prompt separator after configured agent arguments', () => { + const plan = buildAgentStartupPlan({ + agent: 'grok', + prompt: '--version', + cmdOverrides: {}, + agentArgs: '--always-approve', + platform: 'win32' + }) + + expect(plan?.launchCommand).toBe("grok '--always-approve' -- '--version'") + }) + + it('does not add the Grok prompt separator to other argv agents', () => { + const plan = buildAgentStartupPlan({ + agent: 'codex', + prompt: '--version', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("codex '--version'") + }) + it('does not launch Codex with the Orca profile when agent status hooks are enabled', () => { const plan = buildAgentStartupPlan({ agent: 'codex', diff --git a/src/shared/tui-agent-startup.ts b/src/shared/tui-agent-startup.ts index 30ca71132e5..924108f7a19 100644 --- a/src/shared/tui-agent-startup.ts +++ b/src/shared/tui-agent-startup.ts @@ -118,9 +118,10 @@ export function buildAgentStartupPlan(args: { const quotedPrompt = quoteStartupArg(trimmedPrompt, shell) if (config.promptInjectionMode === 'argv') { + const promptSeparator = config.argvPromptSeparator ? ` ${config.argvPromptSeparator}` : '' return { agent, - launchCommand: `${baseCommand.command} ${quotedPrompt}`, + launchCommand: `${baseCommand.command}${promptSeparator} ${quotedPrompt}`, expectedProcess: config.expectedProcess, followupPrompt: null, launchConfig, diff --git a/src/shared/types.ts b/src/shared/types.ts index 3af1e7fdd8f..75f23a7c306 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1844,6 +1844,7 @@ export type { JiraMutationResult, JiraPriority, JiraProject, + JiraProjectStatusOrder, JiraSite, JiraSiteSelection, JiraStatus, @@ -2596,8 +2597,8 @@ export type GlobalSettings = { * paste with Cmd/Ctrl+V without an intervening Cmd/Ctrl+Shift+C. Defaults * to false so existing users keep the explicit-copy behavior. */ terminalClipboardOnSelect: boolean - /** Why: lets TUIs like tmux, nvim, and fzf copy to the system clipboard via - * the OSC 52 escape sequence — essential for SSH-hosted workflows where + /** Why: lets TUIs like Grok, tmux, nvim, and fzf copy to the system clipboard + * via the OSC 52 escape sequence — essential for SSH-hosted workflows where * the terminal is the only bridge to the local clipboard. Defaults to * false because OSC 52 is a classic data-exfiltration vector (any * process piping untrusted output into the terminal — `cat attacker.log` @@ -3151,6 +3152,7 @@ export type StatusBarItem = | 'claude' | 'codex' | 'gemini' + | 'antigravity' | 'opencode-go' | 'kimi' | 'minimax' @@ -3270,6 +3272,8 @@ export type PersistedUIState = { _kimiStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on MiniMax status item. */ _minimaxStatusBarDefaultAdded?: boolean + /** One-shot migration flag for adding the default-on Antigravity status item. */ + _antigravityStatusBarDefaultAdded?: boolean /** One-shot migration flag for adding the default-on Grok status item. */ _grokStatusBarDefaultAdded?: boolean statusBarItems: StatusBarItem[] diff --git a/src/shared/ws-outbound-backpressure-queue.test.ts b/src/shared/ws-outbound-backpressure-queue.test.ts new file mode 100644 index 00000000000..6e8dd66479d --- /dev/null +++ b/src/shared/ws-outbound-backpressure-queue.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { createWsOutboundBackpressureQueue } from './ws-outbound-backpressure-queue' + +// Deterministic harness: bufferedAmount and the drain timer are both injected, +// so no wall-clock races. `runTimers` fires the single parked drain callback. + +function createHarness(overrides?: { + softCapBytes?: number + maxQueuedBytes?: number + writable?: boolean +}) { + const sent: string[] = [] + let bufferedAmount = 0 + let writable = overrides?.writable ?? true + const overflow = vi.fn() + let pendingTimer: (() => void) | null = null + + const queue = createWsOutboundBackpressureQueue({ + send: (frame) => sent.push(frame), + byteLengthOf: (frame) => frame.length, + getBufferedAmount: () => bufferedAmount, + isWritable: () => writable, + onOverflow: overflow, + softCapBytes: overrides?.softCapBytes ?? 100, + maxQueuedBytes: overrides?.maxQueuedBytes ?? 1000, + drainPollMs: 10, + setTimer: (cb) => { + pendingTimer = cb + return 1 as unknown as ReturnType + }, + clearTimer: () => { + pendingTimer = null + } + }) + + return { + queue, + sent, + overflow, + setBuffered: (value: number) => { + bufferedAmount = value + }, + setWritable: (value: boolean) => { + writable = value + }, + runTimer: () => { + const cb = pendingTimer + pendingTimer = null + cb?.() + }, + hasTimer: () => pendingTimer !== null + } +} + +describe('ws outbound backpressure queue', () => { + it('sends straight through while under the soft cap', () => { + const h = createHarness() + h.queue.enqueue('a') + h.queue.enqueue('b') + expect(h.sent).toEqual(['a', 'b']) + expect(h.hasTimer()).toBe(false) + }) + + it('parks frames in order while over the cap and drains on recovery without loss', () => { + const h = createHarness({ softCapBytes: 100 }) + h.setBuffered(200) // over cap + h.queue.enqueue('one') + h.queue.enqueue('two') + h.queue.enqueue('three') + // Nothing sent yet; all held in order. + expect(h.sent).toEqual([]) + expect(h.queue.queuedBytes()).toBe('one'.length + 'two'.length + 'three'.length) + + // Link recovers; the drain timer flushes everything in FIFO order. + h.setBuffered(0) + h.runTimer() + expect(h.sent).toEqual(['one', 'two', 'three']) + expect(h.queue.queuedBytes()).toBe(0) + }) + + it('keeps ordering when a frame arrives while a backlog is parked', () => { + const h = createHarness({ softCapBytes: 100 }) + h.setBuffered(200) + h.queue.enqueue('first') + h.setBuffered(0) + // Even though the wire is now clear, an existing backlog means the new + // frame must queue behind it, not jump the line. + h.queue.enqueue('second') + expect(h.sent).toEqual([]) + h.runTimer() + expect(h.sent).toEqual(['first', 'second']) + }) + + it('signals overflow (and drops backlog) when the hard cap is exceeded', () => { + const h = createHarness({ softCapBytes: 10, maxQueuedBytes: 8 }) + h.setBuffered(100) // over soft cap: everything queues + h.queue.enqueue('12345') // 5 bytes queued + expect(h.overflow).not.toHaveBeenCalled() + h.queue.enqueue('67890') // 10 bytes total > 8 -> overflow + expect(h.overflow).toHaveBeenCalledTimes(1) + // After overflow the queue is inert: no sends, no further overflow calls. + h.setBuffered(0) + h.runTimer() + h.queue.enqueue('later') + expect(h.sent).toEqual([]) + expect(h.overflow).toHaveBeenCalledTimes(1) + }) + + it('drops the backlog if the socket becomes unwritable mid-park', () => { + const h = createHarness({ softCapBytes: 10 }) + h.setBuffered(100) + h.queue.enqueue('data') + h.setWritable(false) + h.runTimer() + expect(h.sent).toEqual([]) + expect(h.queue.queuedBytes()).toBe(0) + }) + + it('does not fast-path a frame while the socket is unwritable', () => { + const h = createHarness({ writable: false }) + + h.queue.enqueue('data') + + expect(h.sent).toEqual([]) + expect(h.queue.queuedBytes()).toBe('data'.length) + expect(h.hasTimer()).toBe(true) + }) +}) diff --git a/src/shared/ws-outbound-backpressure-queue.ts b/src/shared/ws-outbound-backpressure-queue.ts new file mode 100644 index 00000000000..666fcb0decf --- /dev/null +++ b/src/shared/ws-outbound-backpressure-queue.ts @@ -0,0 +1,147 @@ +// Why: both the server reply path (e2ee-channel) and the client send path +// (remote-runtime-client) write to a ws with no backpressure handling. A fast +// producer over a slow link balloons ws.bufferedAmount / RSS without bound, or +// (binary path) silently drops frames. This queue holds outbound frames in +// order while bufferedAmount is over a soft cap and flushes as it drains, so no +// frame is dropped or reordered. It only signals overflow when a hard byte +// bound is exceeded (the link is effectively dead), letting the caller force a +// clean reconnect/resync instead of growing memory without limit. +// +// Generic over the frame type so it serves both the text reply path (encrypted +// base64 strings) and the binary send path (Uint8Array frames). + +export type WsOutboundBackpressureQueueOptions = { + /** Send a frame on the wire. Called only when under the soft cap. */ + send: (frame: TFrame) => void + /** Serialized byte length of a frame, for cap accounting. */ + byteLengthOf: (frame: TFrame) => number + /** Current ws.bufferedAmount in bytes. */ + getBufferedAmount: () => number + /** True when the socket can still accept sends (OPEN and keyed). */ + isWritable: () => boolean + /** + * Called once when queued bytes exceed maxQueuedBytes — the link is wedged. + * The caller should tear the connection down so a fresh subscription can + * replay an authoritative snapshot. The queue drops its backlog afterward. + */ + onOverflow: () => void + /** Soft cap: stop draining onto the wire while bufferedAmount is above this. */ + softCapBytes?: number + /** Hard cap on bytes held in this queue before onOverflow fires. */ + maxQueuedBytes?: number + /** Poll interval used to re-check bufferedAmount while parked. */ + drainPollMs?: number + /** Injectable scheduler for deterministic tests. */ + setTimer?: (cb: () => void, ms: number) => ReturnType + clearTimer?: (timer: ReturnType) => void +} + +export type WsOutboundBackpressureQueue = { + /** Queue-or-send a frame. Preserves order across all prior frames. */ + enqueue: (frame: TFrame) => void + /** Bytes currently held (not yet handed to the wire). */ + queuedBytes: () => number + /** Drop the backlog and stop the drain timer (call on close). */ + dispose: () => void +} + +const DEFAULT_SOFT_CAP_BYTES = 8 * 1024 * 1024 +// Why: tolerate a large transient burst (e.g. a build log spike) before +// declaring the link dead; 64 MiB is ~8x the soft cap yet still bounds RSS. +const DEFAULT_MAX_QUEUED_BYTES = 64 * 1024 * 1024 +const DEFAULT_DRAIN_POLL_MS = 25 + +export function createWsOutboundBackpressureQueue( + options: WsOutboundBackpressureQueueOptions +): WsOutboundBackpressureQueue { + const softCapBytes = options.softCapBytes ?? DEFAULT_SOFT_CAP_BYTES + const maxQueuedBytes = options.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES + const drainPollMs = options.drainPollMs ?? DEFAULT_DRAIN_POLL_MS + const setTimer = options.setTimer ?? ((cb, ms) => setTimeout(cb, ms)) + const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer)) + + // Why: a ws without a numeric bufferedAmount (some mocks/transports) must not + // strand frames in the queue forever; treat unknown backpressure as "clear". + const bufferedAmount = (): number => { + const value = options.getBufferedAmount() + return Number.isFinite(value) ? value : 0 + } + + const queue: { frame: TFrame; bytes: number }[] = [] + let queueHead = 0 + let queued = 0 + let timer: ReturnType | null = null + let overflowed = false + let disposed = false + + const stopTimer = (): void => { + if (timer !== null) { + clearTimer(timer) + timer = null + } + } + + const dropBacklog = (): void => { + queue.length = 0 + queueHead = 0 + queued = 0 + stopTimer() + } + + // Drain as many queued frames as the wire will take without crossing the + // soft cap; re-arm the poll timer if frames remain. + const drain = (): void => { + if (disposed || overflowed) { + return + } + if (!options.isWritable()) { + // Socket went away mid-park; let the transport's own close path clean up. + dropBacklog() + return + } + while (queueHead < queue.length && bufferedAmount() <= softCapBytes) { + const entry = queue[queueHead++] + queued -= entry.bytes + options.send(entry.frame) + } + if (queueHead < queue.length) { + timer = setTimer(drain, drainPollMs) + } else { + // Why: resetting the drained array keeps enqueue/drain O(1) per frame; + // repeated Array.shift() would make recovery from a large backlog O(n²). + queue.length = 0 + queueHead = 0 + stopTimer() + } + } + + return { + enqueue(frame: TFrame): void { + if (disposed || overflowed) { + return + } + // Fast path: nothing parked and the wire is under the cap — send directly. + if (queueHead === queue.length && options.isWritable() && bufferedAmount() <= softCapBytes) { + options.send(frame) + return + } + const bytes = options.byteLengthOf(frame) + queue.push({ frame, bytes }) + queued += bytes + if (queued > maxQueuedBytes) { + overflowed = true + dropBacklog() + options.onOverflow() + return + } + if (timer === null) { + timer = setTimer(drain, drainPollMs) + } + }, + queuedBytes: () => queued, + dispose(): void { + disposed = true + dropBacklog() + } + } +} diff --git a/tests/e2e/chinese-ime-chat-input-repro.spec.ts b/tests/e2e/chinese-ime-chat-input-repro.spec.ts index 02dbb03c112..8994e235cdd 100644 --- a/tests/e2e/chinese-ime-chat-input-repro.spec.ts +++ b/tests/e2e/chinese-ime-chat-input-repro.spec.ts @@ -629,7 +629,9 @@ test.describe('Chinese IME terminal chat input repro', () => { const postCompositionLog = await readImeEventLog(orcaPage) const postCompositionEndIndex = postCompositionLog.findIndex( (entry, index) => - index >= postCompositionLogStart && entry.type === 'compositionend' && entry.data === '再见' + index >= postCompositionLogStart && + entry.type === 'compositionend' && + entry.data === '再见' ) const postCompositionSelectorIndex = postCompositionLog.findIndex( (entry, index) => diff --git a/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts new file mode 100644 index 00000000000..f84691db5db --- /dev/null +++ b/tests/e2e/rich-markdown-link-bubble-stacking.spec.ts @@ -0,0 +1,157 @@ +/** + * Reliability contract: + * - Invariant: link actions remain visible and interactive when they cross the Explorer seam. + * - Failure source: the in-editor bubble was clipped by overflow-hidden workbench ancestors. + * - Oracle: a real Chromium hit test inside the bubble/Explorer overlap resolves to the bubble. + * - Layer: Electron is required because DOM shims cannot model clipping or stacking contexts. + * - Wait: visible editor, Explorer, link, and bubble locators; no timing sleeps. + * - Artifacts: Playwright retains a trace and screenshot on failure. + * - Maturity: experimental pending CI soak history. + */ + +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +const LINK_HREF = 'https://example.com/a/very/long/path/that/makes/the-link-bubble-wide' +const MARKDOWN = `# Rich markdown link overlay repro + +This paragraph deliberately places the link near the right edge of the editor so its URL bubble reaches the Explorer boundary: alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau [hover this URL](${LINK_HREF}). +` + +type OverlapHitTest = { + bubbleRight: number + explorerLeft: number + overlapWidth: number + topElementIsBubble: boolean +} + +test.describe('Rich markdown link bubble stacking', () => { + test('link actions stay above the right Explorer', async ({ orcaPage }, testInfo) => { + await orcaPage.setViewportSize({ width: 1920, height: 1080 }) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'link-bubble-stacking', + testInfo.workerIndex, + MARKDOWN + ) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available — is the app in dev mode?') + } + store.setState({ + rightSidebarOpen: true, + rightSidebarTab: 'explorer', + rightSidebarWidth: 780 + }) + }) + + const explorer = orcaPage.locator('[data-orca-explorer-shell]') + const link = orcaPage.locator(`.rich-markdown-editor a[href="${LINK_HREF}"]`) + await expect(explorer).toBeVisible() + await expect(link).toBeVisible() + + await link.click() + + const bubble = orcaPage.locator('.rich-markdown-link-bubble') + await expect(bubble).toBeVisible() + await expect(bubble.locator('.rich-markdown-link-url')).toContainText('https://example.com') + + const overlap = await orcaPage.evaluate((): OverlapHitTest => { + const bubble = document.querySelector('.rich-markdown-link-bubble') + const explorer = document.querySelector('[data-orca-explorer-shell]') + if (!bubble || !explorer) { + throw new Error('Link bubble or Explorer was not rendered') + } + + const bubbleRect = bubble.getBoundingClientRect() + const explorerRect = explorer.getBoundingClientRect() + const overlapLeft = Math.max(bubbleRect.left, explorerRect.left) + const overlapRight = Math.min(bubbleRect.right, explorerRect.right) + const overlapWidth = Math.max(0, overlapRight - overlapLeft) + const probeX = overlapLeft + Math.min(4, overlapWidth / 2) + const probeY = bubbleRect.top + bubbleRect.height / 2 + const topElement = document.elementFromPoint(probeX, probeY) + + return { + bubbleRight: bubbleRect.right, + explorerLeft: explorerRect.left, + overlapWidth, + topElementIsBubble: topElement !== null && bubble.contains(topElement) + } + }) + + expect(overlap.bubbleRight).toBeGreaterThan(overlap.explorerLeft) + expect(overlap.overlapWidth).toBeGreaterThan(8) + expect(overlap.topElementIsBubble).toBe(true) + + const editButton = bubble.getByTitle('Edit link') + const editButtonBounds = await editButton.boundingBox() + const explorerBounds = await explorer.boundingBox() + expect(editButtonBounds).not.toBeNull() + expect(explorerBounds).not.toBeNull() + expect(editButtonBounds!.x + editButtonBounds!.width).toBeGreaterThan(explorerBounds!.x) + await editButton.click() + const input = bubble.locator('input') + await expect(input).toBeFocused() + await input.fill(`https://example.com/${'long-url-segment/'.repeat(30)}`) + await input.press('End') + expect(await input.evaluate((element) => element.scrollLeft)).toBeGreaterThan(0) + await expect(input).toBeFocused() + await expect(bubble).toBeVisible() + await orcaPage.keyboard.press('Escape') + await expect(bubble.locator('.rich-markdown-link-url')).toBeVisible() + + await explorer.getByPlaceholder('Find files').click() + await expect(bubble).toHaveCount(0) + + await orcaPage.getByRole('heading', { name: 'Rich markdown link overlay repro' }).click() + await link.click() + await expect(bubble).toBeVisible() + const originalEditorZoom = await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available — is the app in dev mode?') + } + const zoom = store.getState().editorFontZoomLevel + store.getState().setEditorFontZoomLevel(zoom + 1) + return zoom + }) + await expect(bubble).toHaveCount(0) + await orcaPage.evaluate((zoom) => { + window.__store?.getState().setEditorFontZoomLevel(zoom) + }, originalEditorZoom) + + await orcaPage.getByRole('heading', { name: 'Rich markdown link overlay repro' }).click() + await link.click() + await expect(bubble).toBeVisible() + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available — is the app in dev mode?') + } + store.getState().setActiveView('settings') + }) + await expect(bubble).toHaveCount(0) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +})