mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/131-crashes
# Conflicts: # src/relay/git-handler.ts
This commit is contained in:
@@ -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'
|
||||
|
||||
File diff suppressed because one or more lines are too long
+163
-17
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/**'
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.2m">
|
||||
<title>downloads: 4.2m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.4m">
|
||||
<title>downloads: 4.4m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.2m</text>
|
||||
<text x="93.5" y="14">4.2m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.4m</text>
|
||||
<text x="93.5" y="14">4.4m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -0,0 +1,92 @@
|
||||
# Layout-aware find in editable Monaco editors
|
||||
|
||||
## Problem
|
||||
|
||||
Issue [#7953](https://github.com/stablyai/orca/issues/7953) reports that `Cmd+F` can type `f` into a TypeScript file instead of opening find on macOS.
|
||||
|
||||
Orca's editable Monaco surfaces currently install its layout-aware save shortcut through `editor-shortcuts.ts` (`src/renderer/src/components/editor/editor-shortcuts.ts:18`), but leave find entirely to Monaco's internal keycode dispatch. Orca already declares `editor.find` as `Mod+F` (`src/shared/keybindings.ts:784`) and its matcher resolves logical keys before physical-code fallback (`src/shared/keybindings.ts:2041`).
|
||||
|
||||
A real Electron repro sends a macOS event with logical key `f`, physical code `KeyU`, and virtual key code `U`, as produced by a non-QWERTY layout. Monaco leaves its find widget closed. The equivalent QWERTY `KeyF` event opens it.
|
||||
|
||||
## Root cause
|
||||
|
||||
Monaco's built-in find keybinding follows the physical/virtual keycode delivered by Chromium. Orca's shortcut system is layout-aware, but its editable Monaco integrations do not use it for find. On layouts where the key that produces `f` is not physical `KeyF`, Monaco misses the chord and Native Edit Context remains in editing mode.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Reimplementing Monaco's find widget, match navigation, or search state.
|
||||
- Changing find behavior in markdown preview, rich markdown, PDF, browser, terminal, or file search.
|
||||
- Changing find behavior in read-only diff surfaces.
|
||||
- Reworking all Monaco keybindings or making Monaco defaults fully obey shortcut unbinding in this patch.
|
||||
- Adding telemetry for a local keyboard action.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add a focused editor find installer beside the existing save installer in `editor-shortcuts.ts`. It matches `editor.find` through `editorShortcutMatches`, consumes every matched event before it reaches Native Edit Context or Monaco, and invokes a supplied callback only for the initial, non-repeat keydown. Repeat events must still be prevented and propagation-stopped so Monaco's QWERTY binding cannot reopen/reset the widget.
|
||||
2. Install that handler on every editable Monaco container in the source editor, editable diff views, and notebook code cells. Run that editor's existing `actions.find` action and dispose the bridge from its existing teardown callback.
|
||||
3. Add unit coverage at the DOM-listener seam for logical `f` with a non-`KeyF` physical code, QWERTY/default behavior, repeat suppression, unrelated typing, and cleanup.
|
||||
4. Preserve an Electron regression loop that drives both QWERTY and layout-aware raw key events against a real `.ts` editor and verifies the existing Monaco find widget becomes visible without dirtying the file.
|
||||
|
||||
## Data flow
|
||||
|
||||
- macOS/Linux/Windows keydown reaches the focused editable Monaco container.
|
||||
- `editorShortcutMatches('editor.find', event)` resolves the active platform, user bindings, modifiers, and logical key.
|
||||
- On match, Orca consumes the DOM event and calls Monaco's existing `actions.find` action.
|
||||
- Monaco owns the visible find widget and focus exactly as before.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Auto-repeat must be consumed without invoking find again; returning early before prevention would let Monaco handle a repeated QWERTY `KeyF` event.
|
||||
- Ordinary unmodified `f` typing and unrelated shortcuts must continue to Monaco unchanged.
|
||||
- A removed/disposed source editor, diff pane, or notebook cell must not retain the listener.
|
||||
- QWERTY `Cmd/Ctrl+F` must still open the same Monaco widget once, not twice.
|
||||
- User-configured bindings accepted by Orca's `editor.find` matcher should open find; Monaco's own default bindings remain outside the scope of this patch.
|
||||
- The behavior is renderer-local and does not read files or execute commands, so local, SSH, and Remote Orca files share the same path.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit: `editor-shortcuts.test.ts` dispatches keyboard events through a real element and parameterizes the shared bridge across macOS (`metaKey`) and Linux/Windows (`ctrlKey`) using logical `f` with physical `KeyU`. It also asserts QWERTY/default handling, matched-repeat prevention without a second callback, unrelated typing, and disposal behavior used by every editable Monaco integration.
|
||||
- Electron: open a disposable `.ts` file and drive `Cmd+F`/`Ctrl+F` using the platform modifier; assert `.find-widget.visible`, focused find input, unchanged source text, and clean editor state.
|
||||
- Electron layout regression: on macOS, dispatch logical `f` with non-QWERTY physical/virtual key identity; assert the same find state and unchanged source text.
|
||||
- Adjacent smoke: dismiss find, type a normal character, and verify it edits the file rather than reopening find.
|
||||
- Static: run focused Vitest, `pnpm typecheck`, and `pnpm lint`.
|
||||
|
||||
## UI quality bar
|
||||
|
||||
No new UI. The existing Monaco find widget must appear in its current position and styling, focus its input, and leave the editor content unchanged. No overlap, clipping, duplicate widget, or focus flicker is acceptable.
|
||||
|
||||
## Review screenshots
|
||||
|
||||
1. QWERTY/default shortcut with the existing Monaco find widget visible in a `.ts` editor.
|
||||
2. Non-QWERTY logical-`f` regression path with the same find widget visible and source text unchanged.
|
||||
3. Adjacent ordinary typing state after find is dismissed, showing the source editor still accepts text normally.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add failing unit coverage for the layout-aware find installer contract.
|
||||
2. Implement the installer in `editor-shortcuts.ts`.
|
||||
3. Wire it to each editable Monaco surface's existing find action and lifecycle cleanup.
|
||||
4. Run focused tests, typecheck, lint, and the Electron QWERTY/layout/typing scenarios.
|
||||
|
||||
## Lightweight Eng Review
|
||||
|
||||
- Scope: Kept to one shared shortcut installer and the existing mount/teardown seams for source editors, editable diff panes, and notebook code cells; no new find implementation or global shortcut interception.
|
||||
- Architecture/data flow: The renderer-local Monaco container owns the keydown. Orca's canonical matcher resolves the logical key, while Monaco continues to own widget state and rendering. No main/preload/IPC, persistence, network, SSH, or provider boundary changes.
|
||||
- Failure modes covered:
|
||||
- Non-QWERTY logical key differs from physical/virtual keycode.
|
||||
- QWERTY double handling.
|
||||
- Auto-repeat escaping to Monaco's native QWERTY handler.
|
||||
- Listener surviving editor disposal.
|
||||
- Ordinary typing being consumed.
|
||||
- Custom `editor.find` chord accepted by Orca but not Monaco.
|
||||
- Test coverage required:
|
||||
- DOM-listener unit tests in `src/renderer/src/components/editor/editor-shortcuts.test.ts`, parameterized for Darwin/Meta, Linux/Ctrl, and Windows/Ctrl.
|
||||
- Electron-visible QWERTY and layout-aware `.ts` scenarios.
|
||||
- Adjacent ordinary typing smoke test.
|
||||
- Performance/blast radius: One capture listener per mounted editable Monaco editor, doing a constant-time keybinding comparison only for events within that editor. Multiple mounted diff sections do not fan out because each listener is scoped to its own container. Listeners are removed with Monaco disposal; no polling, scans, IPC, storage, or render-loop work.
|
||||
- UI quality bar: Existing Monaco find widget only; verify focus, unchanged source text, no duplicate opening, and unchanged styling against `docs/STYLEGUIDE.md`.
|
||||
- Required review screenshots:
|
||||
1. Default QWERTY find-open state.
|
||||
2. Non-QWERTY logical-key find-open state.
|
||||
3. Find-dismissed ordinary typing state.
|
||||
- Residual risks: Monaco's internal default bindings remain active when a user explicitly rebinds `editor.find`; fully suppressing/remapping Monaco's native keybinding table is a separate, larger change.
|
||||
@@ -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.
|
||||
@@ -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 (
|
||||
<MobileAgentSessionHistoryPanel
|
||||
hostId={firstParam(params.hostId)}
|
||||
worktreeId={firstParam(params.worktreeId)}
|
||||
name={firstParam(params.name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<Worktree | null>(null)
|
||||
const [hostCapabilities, setHostCapabilities] = useState<string[]>([])
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(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,
|
||||
|
||||
@@ -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<MobileNewTabAgentLoadState>('idle')
|
||||
const [createTabAgentOptions, setCreateTabAgentOptions] = useState<MobileNewTabAgentOption[]>([])
|
||||
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
|
||||
const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
|
||||
const [markdownActionTarget, setMarkdownActionTarget] = useState<Extract<
|
||||
MobileSessionTab,
|
||||
@@ -1101,6 +1106,12 @@ export default function SessionScreen() {
|
||||
activeSessionTab?.type !== 'browser'
|
||||
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
|
||||
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(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<boolean | null>(
|
||||
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 (
|
||||
<View ref={setMobileSessionRootRef} style={styles.container}>
|
||||
<View style={styles.kavInner}>
|
||||
@@ -4486,45 +4515,27 @@ export default function SessionScreen() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
activePanel === 'files' && styles.filesButtonActive
|
||||
]}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
hitSlop={8}
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'files'}
|
||||
accessibilityLabel="Open file explorer"
|
||||
>
|
||||
<Folder size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
icon={Folder}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
/>
|
||||
{!isFolderWorkspaceRoute && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
activePanel === 'sourceControl' && styles.filesButtonActive
|
||||
]}
|
||||
onPress={() => handlePanelTap('sourceControl')}
|
||||
hitSlop={8}
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'sourceControl'}
|
||||
accessibilityLabel="Open source control"
|
||||
>
|
||||
<GitBranch size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
icon={GitBranch}
|
||||
onPress={() => handlePanelTap('sourceControl')}
|
||||
/>
|
||||
)}
|
||||
{prRepoContextLoaded && prIsGithubRepo ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
activePanel === 'pr' && styles.filesButtonActive
|
||||
]}
|
||||
onPress={() => handlePanelTap('pr')}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Open pull request"
|
||||
>
|
||||
<ListChecks size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
{showHeaderMoreButton ? (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'pr'}
|
||||
accessibilityLabel="More session actions"
|
||||
icon={MoreHorizontal}
|
||||
onPress={() => setShowHeaderMoreActions(true)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -5141,6 +5152,15 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<MobileSessionHeaderMoreActionsSheet
|
||||
visible={showHeaderMoreActions}
|
||||
showAgentSessionHistory={showAgentSessionHistoryAction}
|
||||
showChecks={showChecksAction}
|
||||
onOpenAgentSessionHistory={openAgentSessionHistory}
|
||||
onOpenChecks={() => handlePanelTap('pr')}
|
||||
onClose={() => setShowHeaderMoreActions(false)}
|
||||
/>
|
||||
|
||||
<ActionSheetModal
|
||||
visible={showCreateTabDrawer}
|
||||
title="New Tab"
|
||||
|
||||
@@ -46,6 +46,10 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
|
||||
name="[hostId]/source-control/[worktreeId]"
|
||||
options={{ title: 'Source Control' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[hostId]/agent-history/[worktreeId]"
|
||||
options={{ title: 'Agent Session History' }}
|
||||
/>
|
||||
<Stack.Screen name="[hostId]/review/[worktreeId]" options={{ title: 'Changes' }} />
|
||||
<Stack.Screen name="[hostId]/pr/[worktreeId]" options={{ title: 'Pull Request' }} />
|
||||
</Stack>
|
||||
|
||||
@@ -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 (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
{/* Why: desktop uses Tabler's category glyph for Agent Session History. */}
|
||||
<Path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<Path
|
||||
d="M14 4h6v6h-6z"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M4 14h6v6h-6z"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M7 7m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -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<string, AiVaultSession>
|
||||
refreshing: boolean
|
||||
showCurrentWorktreeBadges: boolean
|
||||
resumeActionStateBySessionId?: ReadonlyMap<string, { disabled: boolean; loading: boolean }>
|
||||
onResume?: (session: AiVaultSession) => void | Promise<void>
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function MobileAgentSessionHistoryList({
|
||||
sections,
|
||||
sessionsById,
|
||||
refreshing,
|
||||
showCurrentWorktreeBadges,
|
||||
resumeActionStateBySessionId,
|
||||
onResume,
|
||||
onRefresh
|
||||
}: Props) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const toggleExpanded = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: MobileAgentHistoryCard }) => (
|
||||
<AgentHistoryCardRow
|
||||
card={item}
|
||||
expanded={expandedId === item.id}
|
||||
session={sessionsById.get(item.id) ?? null}
|
||||
showCurrentWorktreeBadge={showCurrentWorktreeBadges}
|
||||
resumeActionState={resumeActionStateBySessionId?.get(item.id)}
|
||||
onResume={onResume}
|
||||
onPress={() => toggleExpanded(item.id)}
|
||||
/>
|
||||
),
|
||||
[
|
||||
expandedId,
|
||||
onResume,
|
||||
resumeActionStateBySessionId,
|
||||
sessionsById,
|
||||
showCurrentWorktreeBadges,
|
||||
toggleExpanded
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(card) => card.id}
|
||||
stickySectionHeadersEnabled={false}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.textSecondary}
|
||||
/>
|
||||
}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupHeaderText} numberOfLines={1}>
|
||||
{section.label}
|
||||
</Text>
|
||||
<Text style={styles.groupHeaderCount}>{section.data.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
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<void>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const previewTurns = useMemo(
|
||||
() => (expanded && session ? recentSessionConversationTurns(session, PREVIEW_TURN_LIMIT) : []),
|
||||
[expanded, session]
|
||||
)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.cardTopRow}>
|
||||
<MobileAgentIcon agentId={card.agent} size={16} />
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
{card.title}
|
||||
</Text>
|
||||
{card.timeAgo ? <Text style={styles.cardTimeAgo}>{card.timeAgo}</Text> : null}
|
||||
</View>
|
||||
{card.lastMessage ? (
|
||||
<Text style={styles.cardLastMessage} numberOfLines={expanded ? undefined : 2}>
|
||||
{card.lastMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.cardMetaRow}>
|
||||
<Text style={styles.cardMetaText}>{card.agentLabel}</Text>
|
||||
<Text style={styles.cardMetaText}>
|
||||
{card.messageCount} {card.messageCount === 1 ? 'message' : 'messages'}
|
||||
</Text>
|
||||
{showCurrentWorktreeBadge && card.isCurrentWorktree ? (
|
||||
<View style={styles.currentBadge}>
|
||||
<Text style={styles.currentBadgeText}>current worktree</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{session && onResume ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
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 ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Play size={17} color={colors.textPrimary} strokeWidth={2.4} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{expanded && previewTurns.length > 0 ? (
|
||||
<View style={styles.preview}>
|
||||
{previewTurns.map((turn, index) => (
|
||||
<View key={`${card.id}-turn-${index}`} style={styles.previewTurn}>
|
||||
<Text style={styles.previewRole}>{turn.role}</Text>
|
||||
<Text style={styles.previewText}>{turn.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -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<Worktree[]>([])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [resumingSessionId, setResumingSessionId] = useState<string | null>(null)
|
||||
const [resumeMessage, setResumeMessage] = useState<string | null>(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<void> => {
|
||||
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 (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.header} edges={['top']}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Agent Session History
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{worktreeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.refreshButton, pressed && styles.refreshButtonPressed]}
|
||||
onPress={() => void onRefresh()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Refresh agent sessions"
|
||||
>
|
||||
<RefreshCw size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{screenState.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : screenState.kind === 'unsupported' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>Agent Session History Unavailable</Text>
|
||||
<Text style={styles.stateText}>
|
||||
Update Orca on this host to browse agent session history.
|
||||
</Text>
|
||||
</View>
|
||||
) : screenState.kind === 'error' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>Unable to Load</Text>
|
||||
<Text style={styles.stateText}>{screenState.message}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={retry}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.scopeTabs}>
|
||||
{SCOPE_TABS.map((tab) => {
|
||||
const active = scope === tab.scope
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.scope}
|
||||
style={[styles.scopeTab, active && styles.scopeTabActive]}
|
||||
onPress={() => onSelectScope(tab.scope)}
|
||||
>
|
||||
<Text style={[styles.scopeTabText, active && styles.scopeTabTextActive]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search sessions, repo:, path:"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
{issues.length > 0 ? (
|
||||
<View style={styles.noticeBanner}>
|
||||
<Text style={styles.noticeText}>
|
||||
{issues.length} {issues.length === 1 ? 'transcript' : 'transcripts'} skipped
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{resumeMessage ? (
|
||||
<View style={styles.resumeBanner}>
|
||||
<Text style={styles.resumeBannerText}>{resumeMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{sections.length === 0 ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>No agent sessions</Text>
|
||||
<Text style={styles.stateText}>
|
||||
{query ? 'No sessions match your search.' : 'No past agent sessions in this scope.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<MobileAgentSessionHistoryList
|
||||
sections={sections}
|
||||
sessionsById={sessionsById}
|
||||
refreshing={refreshing}
|
||||
showCurrentWorktreeBadges={shouldShowMobileCurrentWorktreeBadge(scope)}
|
||||
resumeActionStateBySessionId={resumeActionStateBySessionId}
|
||||
onResume={onResumeSession}
|
||||
onRefresh={() => void onRefresh()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_SESSIONS: AiVaultSession[] = []
|
||||
const EMPTY_ISSUES: { agent: AiVaultSession['agent']; path: string; message: string }[] = []
|
||||
|
||||
async function loadMobileResumeMetadata(client: Pick<RpcClient, 'sendRequest'>): 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}`
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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> = {}): 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<Worktree> & { 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.'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<Worktree, 'repoId' | 'worktreeId'> & {
|
||||
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<string> {
|
||||
const ids = new Set<string>([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'
|
||||
}
|
||||
@@ -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<Worktree>): Pick<Worktree, 'worktreeId' | 'path' | 'repoId'> {
|
||||
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'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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<Worktree, 'worktreeId' | 'path' | 'repoId'> | null,
|
||||
liveWorktrees: readonly Pick<Worktree, 'worktreeId' | 'path' | 'repoId'>[]
|
||||
): 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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}))
|
||||
}
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<AiVaultSession, 'cwd'>,
|
||||
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<AiVaultSession, 'id'>[],
|
||||
resumingSessionId: string | null
|
||||
): ReadonlyMap<string, MobileAgentHistoryResumeActionState> {
|
||||
return new Map(
|
||||
sessions.map((session) => [
|
||||
session.id,
|
||||
{
|
||||
disabled: resumingSessionId !== null,
|
||||
loading: resumingSessionId === session.id
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -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<AiVaultSession, 'cwd'> {
|
||||
return { cwd }
|
||||
}
|
||||
|
||||
function worktree(overrides: Partial<Worktree> & { 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' })
|
||||
})
|
||||
})
|
||||
@@ -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<AiVaultSession, 'cwd'>
|
||||
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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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<AiVaultScope>('workspace')
|
||||
const [screenState, setScreenState] = useState<AgentHistoryScreenState>({ kind: 'loading' })
|
||||
const [hostStatusResult, setHostStatusResult] = useState<unknown>(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<void> => {
|
||||
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<typeof useMobileAgentHistoryState>
|
||||
@@ -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
|
||||
}
|
||||
@@ -21,9 +21,9 @@ export {
|
||||
hasRenderableUsage
|
||||
} from './account-usage-state'
|
||||
|
||||
// Why: matches desktop StatusBar convention — bars show percent remaining
|
||||
// (so a fresh account renders full, a depleted one renders empty), not
|
||||
// percent used. Color thresholds invert accordingly.
|
||||
// Why: matches desktop StatusBar — bars show percent used (consumption), same
|
||||
// as Claude/Codex harness meters. Fresh account is empty/green; depleted is
|
||||
// full/red.
|
||||
export function UsageBar({
|
||||
label,
|
||||
usedPercent,
|
||||
@@ -35,13 +35,15 @@ export function UsageBar({
|
||||
unavailable: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent))
|
||||
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
|
||||
const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent)))
|
||||
// Why: same consumption bands as desktop barColor (green <60, amber <80, red ≥80).
|
||||
const barColor =
|
||||
remaining == null
|
||||
used == null
|
||||
? colors.textMuted
|
||||
: remaining <= 10
|
||||
: used >= 80
|
||||
? colors.statusRed
|
||||
: remaining <= 30
|
||||
: used >= 60
|
||||
? colors.statusAmber
|
||||
: colors.statusGreen
|
||||
return (
|
||||
@@ -52,7 +54,7 @@ export function UsageBar({
|
||||
style={[
|
||||
styles.usageFill,
|
||||
{
|
||||
width: `${remaining ?? 0}%`,
|
||||
width: `${used ?? 0}%`,
|
||||
backgroundColor: unavailable ? colors.textMuted : barColor
|
||||
}
|
||||
]}
|
||||
@@ -61,9 +63,7 @@ export function UsageBar({
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
|
||||
) : (
|
||||
<Text style={styles.usageValue}>
|
||||
{unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`}
|
||||
</Text>
|
||||
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -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<HeaderIconProps>
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function MobileSessionHeaderIconButton({
|
||||
active = false,
|
||||
accessibilityLabel,
|
||||
icon: Icon,
|
||||
onPress
|
||||
}: MobileSessionHeaderIconButtonProps) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
active && styles.filesButtonActive
|
||||
]}
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
<Icon size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<ActionSheetModal
|
||||
visible={visible}
|
||||
actions={[
|
||||
...(showAgentSessionHistory
|
||||
? [
|
||||
{
|
||||
label: 'Agent History',
|
||||
hint: 'Browse and resume agent sessions',
|
||||
renderIcon: () => (
|
||||
<MobileAgentSessionHistoryIcon
|
||||
size={16}
|
||||
color={colors.textSecondary}
|
||||
strokeWidth={2.1}
|
||||
/>
|
||||
),
|
||||
onPress: onOpenAgentSessionHistory
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(showChecks
|
||||
? [
|
||||
{
|
||||
label: 'Checks',
|
||||
hint: 'Open pull request checks',
|
||||
icon: ListChecks,
|
||||
onPress: onOpenChecks
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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> = {}): 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()
|
||||
})
|
||||
})
|
||||
@@ -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<NodeJS.Platform>([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
])
|
||||
|
||||
export function buildMobileAiVaultResumeCommand(args: {
|
||||
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
|
||||
Partial<Pick<AiVaultSession, 'filePath'>>
|
||||
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<Record<TuiAgent, string | null>>
|
||||
agentDefaultArgs?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>>
|
||||
}
|
||||
|
||||
export type MobileAiVaultResumeLaunch = {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchAgent?: TuiAgent
|
||||
}
|
||||
|
||||
export function buildMobileAiVaultResumeLaunch(args: {
|
||||
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
|
||||
Partial<Pick<AiVaultSession, 'filePath'>>
|
||||
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<Record<TuiAgent, string | null>> | null | undefined
|
||||
): Partial<Record<TuiAgent, string>> {
|
||||
const normalized: Partial<Record<TuiAgent, string>> = {}
|
||||
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<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
launch: MobileAiVaultResumeLaunch & { clientMutationId?: string }
|
||||
): Promise<MobileReviewTerminalTab> {
|
||||
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<string, string>()
|
||||
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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,11 +25,18 @@ function worktree(overrides: Partial<Worktree> = {}): 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' })
|
||||
|
||||
@@ -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<string> = 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<string, Worktree[]>()
|
||||
const childIds = new Set<string>()
|
||||
|
||||
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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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> = {}): 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'
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, Worktree[]>()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })] })]
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"extends": "expo/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"strict": true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.133",
|
||||
"version": "1.4.135-rc.2",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
Generated
+3
-3
@@ -9,7 +9,7 @@ patchedDependencies:
|
||||
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch
|
||||
'@xterm/addon-webgl@0.20.0-beta.286':
|
||||
hash: 296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145
|
||||
hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 407ae07e1e0e2ff2e8b58696449c54c31e51d87535bc6aa4a7a7b0b561407282
|
||||
@@ -211,7 +211,7 @@ importers:
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145)(@xterm/xterm@6.1.0-beta.287)
|
||||
version: 0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287)
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -9300,7 +9300,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=296e716bb67c0aa3d4ff47d493b9fd9ba9518b7e9f6597005fd680ac04274145)(@xterm/xterm@6.1.0-beta.287)':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19)(@xterm/xterm@6.1.0-beta.287)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
env: 'production',
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
async function postGrokHook(
|
||||
endpoint: { port: string; token: string },
|
||||
payload: Record<string, unknown>
|
||||
): Promise<Response> {
|
||||
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<void>((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<void>((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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<AiVaultListResult> | 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<AiVaultListResult> {
|
||||
// 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<string[]> {
|
||||
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 = {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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', {
|
||||
browserPageId: 'browser-1',
|
||||
url: 'https://example.com/login'
|
||||
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',
|
||||
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<number, unknown>
|
||||
}
|
||||
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
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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<BrowserPermissionDeniedEvent, 'browserPageId'>
|
||||
type PendingPopupEvent = Omit<BrowserPopupEvent, 'browserPageId'>
|
||||
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<number, string>()
|
||||
private readonly popupOwnerContextByGuestId = new Map<number, PopupOwnerContext>()
|
||||
// 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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -79,7 +79,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalRightClickToPaste: false,
|
||||
terminalFocusFollowsMouse: false,
|
||||
terminalClipboardOnSelect: false,
|
||||
terminalAllowOsc52Clipboard: false,
|
||||
terminalAllowOsc52Clipboard: true,
|
||||
setupScriptLaunchMode: 'split-vertical',
|
||||
terminalScrollbackRows: 5_000,
|
||||
localAccountRuntime: 'host',
|
||||
|
||||
@@ -84,7 +84,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalRightClickToPaste: false,
|
||||
terminalFocusFollowsMouse: false,
|
||||
terminalClipboardOnSelect: false,
|
||||
terminalAllowOsc52Clipboard: false,
|
||||
terminalAllowOsc52Clipboard: true,
|
||||
setupScriptLaunchMode: 'split-vertical',
|
||||
terminalScrollbackRows: 5_000,
|
||||
localAccountRuntime: 'host',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>()
|
||||
): 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<string, number>()
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, string>
|
||||
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<string>): Promise<Set<string>>
|
||||
}
|
||||
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<string>): Promise<Set<string>>
|
||||
}
|
||||
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))
|
||||
|
||||
@@ -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<CreateOrAttachResult>('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<CreateOrAttachResult>('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
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[<u')
|
||||
|
||||
const snapshot = emulator.getSnapshot()
|
||||
expect(snapshot.modes.kittyKeyboardFlags).toBe(0)
|
||||
expect(snapshot.rehydrateSequences).not.toContain('u')
|
||||
})
|
||||
|
||||
it('re-arms kitty flags after the alt-screen switch so they land on the negotiated screen', async () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof mockPtyProcess> & {
|
||||
destroy: ReturnType<typeof vi.fn>
|
||||
}
|
||||
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<typeof mockPtyProcess> & {
|
||||
destroy: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
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')
|
||||
/** 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_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<AgentHookInstallStatus> {
|
||||
async installRemote(
|
||||
sftp: SFTPWrapper,
|
||||
remoteHome: string,
|
||||
remoteGrokHome?: string
|
||||
): Promise<AgentHookInstallStatus> {
|
||||
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)
|
||||
|
||||
@@ -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() : {}
|
||||
})
|
||||
|
||||
+30
-27
@@ -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<AiVaultListR
|
||||
const executionHostScope = normalizeExecutionHostScope(
|
||||
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
// Why: local-scope scans go straight to the shared cache module (also used by
|
||||
// the runtime RPC method), so the desktop panel and a paired mobile client
|
||||
// never double-scan the same transcripts; the cache below only has to dedupe
|
||||
// the multi-host (ssh/runtime/all) merges that exist on the desktop side.
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args)
|
||||
}
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
const key = JSON.stringify({
|
||||
limit: args?.limit ?? 'default',
|
||||
@@ -105,10 +116,6 @@ async function scanAiVaultSessionsByHostScope(
|
||||
args: AiVaultListArgs | undefined,
|
||||
executionHostScope: ExecutionHostScope
|
||||
): Promise<AiVaultListResult> {
|
||||
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<AiVaultListResult> {
|
||||
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<string[]> {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}/**`])
|
||||
}
|
||||
return [
|
||||
...ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT),
|
||||
...ignoreDirs
|
||||
.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)
|
||||
.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)] } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Watched
|
||||
let errorCleanedUp = false
|
||||
|
||||
const watcherOptions = {
|
||||
ignore: buildParcelWatcherIgnoreOption(WATCHER_IGNORE_DIRS),
|
||||
...buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS),
|
||||
// Why: Parcel checks Watchman before the native Windows backend by
|
||||
// default, and Windows prints a shell-level "watchman not recognized"
|
||||
// error for that probe. Pinning the backend keeps local watches quiet.
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createIssue,
|
||||
getIssue,
|
||||
getIssueComments,
|
||||
getProjectStatusOrder,
|
||||
listAssignableUsers,
|
||||
listCreateFields,
|
||||
listIssueTypes,
|
||||
@@ -259,4 +260,14 @@ export function registerJiraHandlers(): void {
|
||||
}
|
||||
return listTransitions(args.key.trim(), normalizeSiteId(args.siteId))
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'jira:getProjectStatusOrder',
|
||||
async (_event, args: { projectKey: string; siteId?: string }) => {
|
||||
if (typeof args?.projectKey !== 'string' || !args.projectKey.trim()) {
|
||||
return { statusIdsByColumn: [] }
|
||||
}
|
||||
return getProjectStatusOrder(args.projectKey.trim(), normalizeSiteId(args.siteId))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveWatcherProcessEntryPath } from './parcel-watcher-entry-path'
|
||||
|
||||
describe('resolveWatcherProcessEntryPath', () => {
|
||||
it('uses an adjacent entry when electron-vite appPath is already out/main', () => {
|
||||
const builtMainPath = path.join(process.cwd(), 'out', 'main')
|
||||
const adjacentEntry = path.join(builtMainPath, 'parcel-watcher-process-entry.js')
|
||||
|
||||
expect(
|
||||
resolveWatcherProcessEntryPath(
|
||||
builtMainPath,
|
||||
false,
|
||||
(candidate) => candidate === adjacentEntry
|
||||
)
|
||||
).toBe(adjacentEntry)
|
||||
})
|
||||
|
||||
it('uses the nested build entry when appPath is the project root', () => {
|
||||
expect(resolveWatcherProcessEntryPath(process.cwd(), false, () => false)).toBe(
|
||||
path.join(process.cwd(), 'out', 'main', 'parcel-watcher-process-entry.js')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the unpacked nested entry for packaged apps', () => {
|
||||
const appPath = path.join('C:', 'Orca', 'resources', 'app.asar')
|
||||
|
||||
expect(resolveWatcherProcessEntryPath(appPath, true, () => true)).toBe(
|
||||
path.join(
|
||||
'C:',
|
||||
'Orca',
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'parcel-watcher-process-entry.js'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ElectronAppPath = { getAppPath(): string; isPackaged: boolean }
|
||||
|
||||
function loadElectronApp(): ElectronAppPath | null {
|
||||
try {
|
||||
return require('electron').app ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWatcherProcessEntryPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
// Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the
|
||||
// packaged entry must be forked from app.asar.unpacked.
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
const adjacentBuildEntry = join(basePath, 'parcel-watcher-process-entry.js')
|
||||
// Why: electron-vite's unpackaged appPath is already out/main. Appending
|
||||
// out/main again silently disables crash isolation in dev and E2E builds.
|
||||
if (!isPackaged && pathExists(adjacentBuildEntry)) {
|
||||
return adjacentBuildEntry
|
||||
}
|
||||
return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js')
|
||||
}
|
||||
|
||||
export function getWatcherProcessEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
return resolveWatcherProcessEntryPath(
|
||||
app?.getAppPath() ?? process.cwd(),
|
||||
app?.isPackaged === true
|
||||
)
|
||||
}
|
||||
@@ -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<typeof process.listeners>
|
||||
let originalExitListeners: ReturnType<typeof process.listeners>
|
||||
let originalDisconnectListeners: ReturnType<typeof process.listeners>
|
||||
|
||||
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> }) => void)
|
||||
| undefined
|
||||
const deadlockedUnsubscribe = vi.fn(() => new Promise<void>(() => 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<void>(() => 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> }) => void) | undefined
|
||||
const deadlockedUnsubscribe = vi.fn(() => new Promise<void>(() => 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)
|
||||
})
|
||||
})
|
||||
@@ -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<void> {
|
||||
async function startCanary(getStableActivityRevision: () => number | null): Promise<void> {
|
||||
let canaryDir: string
|
||||
let lastEventAt = 0
|
||||
try {
|
||||
@@ -79,9 +80,11 @@ async function startCanary(hasLiveSubscriptions: () => boolean): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
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<number, Promise<ParcelWatcher.AsyncSubscription | null>>()
|
||||
const liveSubscriptionIds = new Set<number>()
|
||||
let pendingSubscriptionCrawls = 0
|
||||
let subscriptionActivityRevision = 0
|
||||
let nativeLifecycleTail = Promise.resolve()
|
||||
|
||||
const beginSubscriptionCrawl = (): void => {
|
||||
pendingSubscriptionCrawls++
|
||||
subscriptionActivityRevision++
|
||||
}
|
||||
|
||||
const finishSubscriptionCrawl = (): void => {
|
||||
pendingSubscriptionCrawls--
|
||||
subscriptionActivityRevision++
|
||||
}
|
||||
|
||||
const runNativeWatcherLifecycleExclusive = <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
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<ParcelWatcher.AsyncSubscription | null> => {
|
||||
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<void> => {
|
||||
// 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
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// they can refresh state that changed during the gap.
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type * as ParcelWatcher from '@parcel/watcher'
|
||||
import { getWatcherProcessEntryPath } from './parcel-watcher-entry-path'
|
||||
import type {
|
||||
HostToWatcherMessage,
|
||||
WatcherProcessEvent,
|
||||
@@ -42,23 +42,6 @@ let loggedInProcessFallback = false
|
||||
const records = new Map<number, SubscriptionRecord>()
|
||||
const pendingUnsubscribes = new Map<number, () => void>()
|
||||
|
||||
function loadElectronApp(): { getAppPath(): string; isPackaged: boolean } | null {
|
||||
try {
|
||||
return require('electron').app ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getWatcherProcessEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
const appPath = app?.getAppPath() ?? process.cwd()
|
||||
// Why: ELECTRON_RUN_AS_NODE bypasses Electron's asar integration, so the
|
||||
// packaged entry must be forked from app.asar.unpacked.
|
||||
const basePath = app?.isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
return join(basePath, 'out', 'main', 'parcel-watcher-process-entry.js')
|
||||
}
|
||||
|
||||
function shouldRunInProcess(entryPath: string): boolean {
|
||||
// Why: vitest suites mock '@parcel/watcher' at the module level; a forked
|
||||
// child would bypass those mocks (and could execute a stale build output),
|
||||
|
||||
+454
-1
@@ -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()
|
||||
|
||||
+164
-6
@@ -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<void> | 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<typeof setTimeout> | null = null
|
||||
|
||||
function getMaxMapValue(values: Iterable<number>): 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) {
|
||||
|
||||
@@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<JiraProjectStatusOrder> {
|
||||
// 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<JiraPagedResponse<JiraRecord>>(
|
||||
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<unknown>(
|
||||
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<string>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<string | null> {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>): 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user