diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml index 235868f6155..76595f338a6 100644 --- a/.github/workflows/computer-e2e.yml +++ b/.github/workflows/computer-e2e.yml @@ -6,6 +6,8 @@ on: - '.github/workflows/computer-e2e.yml' - 'config/electron-builder.config.cjs' - 'config/scripts/build-computer-macos.mjs' + - 'config/scripts/build-windows-cli-launcher.mjs' + - 'config/scripts/build-windows-cli-launcher.test.mjs' - 'config/scripts/computer-e2e-workflow.test.mjs' - 'config/scripts/computer-use-skill-guidance.test.mjs' - 'config/scripts/computer-use-smoke.mjs' @@ -22,12 +24,15 @@ on: - 'native/computer-use-macos/**' - 'native/computer-use-linux/**' - 'native/computer-use-windows/**' + - 'native/windows-cli-launcher/**' - 'skills/computer-use/SKILL.md' - 'src/cli/**' - 'src/main/computer/**' - 'src/main/runtime/rpc/dispatcher.ts' - 'src/main/runtime/rpc/errors.ts' - 'src/main/runtime/rpc/methods/computer*.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.ts' + - 'src/main/ssh/ssh-remote-cli-launcher.test.ts' - 'src/shared/computer-use-*.ts' - 'tests/e2e/computer-linux.e2e.ts' - 'tests/e2e/computer-mac.e2e.ts' @@ -73,6 +78,7 @@ jobs: - run: >- pnpm vitest run config/scripts/build-windows-cli-launcher.test.mjs + src/main/ssh/ssh-remote-cli-launcher.test.ts config/scripts/computer-e2e-workflow.test.mjs config/scripts/computer-use-skill-guidance.test.mjs config/scripts/computer-use-smoke.test.mjs diff --git a/.gitignore b/.gitignore index ed81a687a69..1874a068079 100644 --- a/.gitignore +++ b/.gitignore @@ -100,9 +100,9 @@ docs/reference/react-performance-audit.md validation-screenshots/ .stably-browser -# PR verification evidence screenshots are referenced from notes but should not -# be committed. -notes/artifacts/ +# Local scratch notes and PR evidence screenshots (not part of the product). +/notes/ +/pr-evidence/ # Playwright test-results/ @@ -123,5 +123,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json src/renderer/src/i18n/locales/.ja-catalog-cache.json src/renderer/src/i18n/locales/.es-catalog-cache.json -# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md +# Bench result JSONs are working artifacts tools/benchmarks/results/terminal-pipeline-*.json diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 8249b5e7afb..8d98ecad92b 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-12", + "updatedAt": "2026-07-13", "policy": { "maturityLevels": [ "experimental", @@ -160,6 +160,165 @@ ], "demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if index-only churn can emit worktrees:changed, or if structural add/remove/HEAD/lock changes fail to converge." }, + { + "id": "runtime.headless-desktop-promotion-continuity", + "title": "Headless serve opens its desktop without replacing live terminal sessions", + "maturity": "experimental", + "protection": "partial", + "owner": "runtime-platform", + "layer": "electron-runtime-contract", + "surfaces": [ + "headless orca serve", + "single-instance desktop activation", + "CLI open", + "persistent terminal reattach" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "daemon", + "ssh" + ], + "coverageNotes": "Deterministic unit coverage exercises activation gating, single-instance ownership, quit policy, local/remote CLI status, headless binding persistence, local daemon identity, and SSH identity transfer. A macOS Electron journey starts one headless owner in an isolated profile, creates and writes to a daemon PTY, activates the GUI through a second process, and verifies the original owner/runtime/daemon/PTY identities plus pre- and post-promotion I/O. Live packaged, Linux, and Windows journeys remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/8457" + ], + "invariant": "A safely promotable headless serve process is the single app owner. Desktop activation opens a window in that same process only after the persistent PTY provider and runtime RPC are ready; every live persisted local or SSH terminal remains bound to the same PTY/session, and a committed Cmd+Q still exits the promoted app.", + "oracle": "Unit tests coalesce early activation, fail closed on a fallback local provider, preserve the production single-instance path for serve, expose explicit desktop window state to local and remote clients, persist headless tab/leaf bindings, transfer local and SSH reattach metadata, and retain quit intent. The Electron journey asserts one main-process PID, runtime id, daemon PID, and PTY id across activation; confirms output written before promotion is visible afterward; confirms new terminal input still works; and requires the activating second process to exit.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "pnpm exec electron-vite build --mode e2e", + "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1" + ], + "testFiles": [ + "src/main/startup/serve-desktop-activation.test.ts", + "src/main/startup/serve-desktop-activation-wiring.test.ts", + "src/main/startup/single-instance-lock.test.ts", + "src/main/startup/window-all-closed-quit-policy.test.ts", + "src/cli/runtime-client.test.ts", + "src/cli/runtime/websocket-transport.test.ts", + "src/main/runtime/orca-runtime.test.ts", + "tests/e2e/headless-serve-desktop-activation.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/main/startup/serve-desktop-activation.test.ts", + "assertions": [ + "early activation requests coalesce until the persistent provider is ready", + "a blocked provider drops pending activation and never opens a window" + ] + }, + { + "file": "src/main/startup/serve-desktop-activation-wiring.test.ts", + "assertions": [ + "second-instance and macOS app activation use the same safety gate", + "headless PTY registration waits for provider settlement and promotion waits for RPC startup" + ] + }, + { + "file": "src/main/startup/single-instance-lock.test.ts", + "assertions": [ + "serve never skips the single-instance lock even in development", + "the isolated E2E profile can opt into the production ownership path" + ] + }, + { + "file": "src/main/startup/window-all-closed-quit-policy.test.ts", + "assertions": [ + "a promoted serve owner remains alive after an ordinary window close but exits after a committed quit" + ] + }, + { + "file": "src/cli/runtime-client.test.ts", + "assertions": [ + "local open activates a reachable headless owner and waits for a desktop window", + "unsafe promotion returns an explicit blocked error instead of launching a second owner" + ] + }, + { + "file": "src/cli/runtime/websocket-transport.test.ts", + "assertions": [ + "remote-paired open reports remote desktop state without launching a local app" + ] + }, + { + "file": "src/main/runtime/orca-runtime.test.ts", + "assertions": [ + "the headless sentinel transfers authority to the first real window", + "headless local and SSH PTY bindings are persisted on first promotion and later windowless reattach without changing ordinary desktop spawn persistence", + "status distinguishes available, openable, initializing, and blocked desktop states", + "desktop-only bell, command, and link scanners remain disabled until a real renderer graph is ready" + ] + }, + { + "file": "tests/e2e/headless-serve-desktop-activation.spec.ts", + "assertions": [ + "desktop activation keeps the same main owner PID, runtime id, daemon PID, and PTY id", + "terminal output written before promotion remains visible and post-promotion input/output still works", + "the activating second process exits instead of becoming another owner" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/startup/serve-desktop-activation.test.ts src/main/startup/serve-desktop-activation-wiring.test.ts src/main/startup/single-instance-lock.test.ts src/main/startup/window-all-closed-quit-policy.test.ts src/cli/runtime-client.test.ts src/cli/runtime/websocket-transport.test.ts src/main/runtime/orca-runtime.test.ts", + "result": "passed", + "durationSeconds": 13, + "summary": "Seven activation, ownership, quit, local/remote CLI, and runtime contract files passed with 704 tests, including first and repeated windowless reattach, local/SSH identity transfer, ordinary desktop persistence isolation, and dynamic side-effect scanner gating." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/headless-serve-desktop-activation.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 52, + "summary": "The isolated Electron journey passed repeatedly on the final source; the latest 51.6-second run retained the same main owner, runtime, daemon, and PTY, restored pre-promotion output, accepted post-promotion input, and observed the activating process exit." + } + ], + "runtimeBudget": { + "p95Seconds": 120, + "scope": "focused unit contracts plus one isolated Electron headless-to-desktop journey" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic contracts and two consecutive local macOS Electron passes; CI and cross-platform soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The first Electron red run proved that bypassing the dev single-instance lock created a second owner. After enforcing one owner, the next red run retained owner/runtime/daemon identity but exposed a new PTY because the headless tab/leaf binding was not persisted. The final implementation persists that binding before renderer hydration and both subsequent Electron runs kept the original PTY and transcript. Focused unit tests were also observed red before the activation gate, promotion metadata transfer, and headless spawn-persistence changes were added." + }, + "performanceBudget": { + "required": false, + "evidence": "Activation adds no polling in the app owner and performs one bounded pass over live PTY records plus persisted terminal bindings only when a headless owner opens its first window. Desktop-only bell, command, PR-link, and mode scanners are rebuilt only while a real renderer graph is ready, preserving the prior pure-headless output path. CLI open keeps its existing 250ms bounded startup poll." + }, + "promotionCriteria": [ + "Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.", + "Add live packaged activation coverage on macOS plus representative Linux and Windows single-instance journeys.", + "Add an Electron SSH promotion journey in addition to the deterministic identity-transfer unit contract." + ], + "knownGaps": [ + "The Electron journey uses an isolated development bundle rather than the installed application so it cannot disturb a real user session.", + "Linux and Windows single-instance activation have unit coverage but no live Electron evidence yet.", + "SSH identity transfer is deterministic unit coverage only; the live Electron journey currently exercises the local daemon provider." + ], + "demotionRule": "Quarantine the Electron journey only with a linked product or harness defect; demote if activation changes the owner/runtime/daemon/PTY identity, loses prior output, opens before provider readiness, or fails to honor a committed quit." + }, { "id": "editor.live-log-append-stability", "title": "Long live session logs retain their Monaco viewport while appending", @@ -172,20 +331,24 @@ "providers": ["local"], "coveredPlatforms": ["macos"], "coveredProviders": ["local"], - "coverageNotes": "Focused tests and real-Monaco 9/50 MiB benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", + "coverageNotes": "Focused tests and real-Monaco 9/50 MiB performance and undo-retention benchmarks are platform-independent. Local macOS Electron evidence opens a synthetic 9 MiB transcript through Agent Session History at fixed 900x720 viewport, 13px font, 1x zoom, and asserts the full 9 MiB model length loaded as a font-metric-independent containment check (word-wrap pixel geometry varies ~10% across runners, so a generous content-height floor is only a collapsed/truncated-render smoke check), then verifies three five-second-cadence watcher appends with Find open and closed. Live Windows/Linux evidence remains uncollected.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/8432"], - "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; arbitrary rewrites continue to replace the model content.", - "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, and full replacement for non-appends. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, forcing GC and event-loop settlement between every arm. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, renderer survival, and forced-GC heap/native-memory budgets.", + "invariant": "Append-only external file growth changes only Monaco's model suffix, retaining the viewport, selection, Find state, and renderer liveness above the append point; read-only live tails do not create undo history, while editable external updates remain undoable and arbitrary rewrites continue to replace the model content.", + "oracle": "Focused tests assert one post-mount content owner, actual outer lifecycle remount ordering across retained path models, exact end-of-model suffix edits with one model read, no-op equality, full replacement for non-appends, and real-Monaco undo behavior for read-only live tails versus editable files. With Node forced GC, real-Monaco benchmarks alternate 30 suffix and 30 replacement samples after five warmups on fresh equivalent models at 9 and 50 MiB, then compare exact Monaco undo-service and ArrayBuffer retention after five 10 MiB appends. The Electron scenario alternates an e2e-only legacy setValue red control and the fixed watcher append from restored equivalent model/geometry at the measured legacy-failure cadence, asserting that the control disrupts anchor state while the fixed path preserves visible ranges, selection, complete Find state, scroll offset, non-undoability, renderer survival, and forced-GC heap/native-memory budgets.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.test.ts src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.bench.ts --pool=threads", + "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1" ], "testFiles": [ "src/renderer/src/components/editor/monaco-content-sync.test.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", "src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx", "src/renderer/src/components/editor/monaco-content-sync.bench.ts", + "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", "tests/e2e/agent-session-log-tail-stability.spec.ts" ], "assertionRefs": [ @@ -194,9 +357,14 @@ "assertions": [ "append-only drift reads the current model once and inserts only at the previous model end", "identical content emits no edit and non-append drift retains full replacement plus undo stops", + "read-only live-tail appends, replacements, truncations, and stale retained-model remounts use non-undoing edits", "a stale retained target model reconciles on mount without explicit undo stops while prior-path content and undo history remain isolated" ] }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "assertions": ["a real Monaco read-only live-tail append leaves canUndo false while an ordinary external update remains undoable"] + }, { "file": "src/renderer/src/components/editor/MonacoEditor.content-owner.test.tsx", "assertions": ["the Monaco wrapper receives defaultValue and no controlled value prop"] @@ -209,12 +377,17 @@ "file": "src/renderer/src/components/editor/monaco-content-sync.bench.ts", "assertions": ["with forced GC and deterministic settlement between every arm, fresh real-Monaco 9 MiB and 50 MiB models alternate 30 append and 30 replacement samples after five warmups; append p95 stays below 50/100ms and at least 2x faster"] }, + { + "file": "src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts", + "assertions": ["five 10 MiB read-only live-tail appends retain zero Monaco undo-service and ArrayBuffer bytes while the undoable control retains at least 50 MiB"] + }, { "file": "tests/e2e/agent-session-log-tail-stability.spec.ts", "assertions": [ "production Agent Session History opens a synthetic 9 MiB View Log and confirms the full model length loaded as font-metric-independent containment, with a generous content-height floor as a collapsed/truncated-render smoke check", "an executable e2e-only legacy setValue control disrupts selection/Find/anchor state at each fixed-geometry five-second sample, then restores the equivalent model state before the fixed arm", "three alternating watcher suffix appends preserve visible ranges, selection, scroll offset, Find open/query/active-match state, and exact suffix content", + "the production read-only live-tail model remains non-undoable before and after every watcher append", "the renderer remains responsive with no render-process-gone event and forced-GC JS-heap/working-set/private-memory peak and retained budgets hold against paired legacy controls" ] } @@ -246,12 +419,39 @@ "result": "passed", "durationSeconds": 78, "summary": "The production View Log journey alternated retained e2e-only legacy-red controls with fixed appends from restored equivalent state; every control detected instability while the fixed path retained viewport, selection, complete Find state, renderer liveness, and normalized forced-GC/native memory budgets." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts", + "result": "passed", + "durationSeconds": 4, + "summary": "The real-Monaco undo-history test confirmed a read-only live-tail append leaves canUndo false while an ordinary external update remains undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "node --expose-gc ./node_modules/vitest/vitest.mjs bench src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts --pool=threads", + "result": "passed", + "durationSeconds": 5, + "summary": "The undoable 50 MiB control retained 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes; the read-only live-tail arm retained zero of both and remained non-undoable." + }, + { + "date": "2026-07-13", + "runner": "local", + "platform": "macos", + "command": "pnpm run test:e2e -- tests/e2e/agent-session-log-tail-stability.spec.ts --workers=1", + "result": "passed", + "durationSeconds": 78, + "summary": "The production View Log journey preserved viewport, selection, Find state, renderer liveness, and forced-GC/native budgets across three watcher appends while canUndo remained false." } ], "runtimeBudget": { "p95Seconds": 600, "scope": "local focused renderer tests plus one Electron production-journey scenario" }, "flakeHistory": { "status": "unknown", "evidence": "New deterministic gate with local macOS passes; CI soak history is not yet available." }, - "redGreenEvidence": { "status": "complete", "evidence": "The retained Electron gate itself executes the former read-only wrapper setValue behavior behind MODE=e2e at fixed 900x720, 13px, 1x zoom, the full 9 MiB model length loaded (with a generous content-height floor, since word-wrap pixel geometry is runner-dependent), and five-second cadence. The control retains a flat incoming value like the former controlled IPC prop. Each legacy arm must disrupt selection, Find active-match state, visible range, or scroll anchor, then restore identical model length/tail/geometry and anchor state before the fixed watcher arm; every fixed arm must preserve them. Production builds never install the control. The original exact-file dev repro also recorded renderer exit code 5." }, - "performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node command requires --expose-gc and --pool=threads so worker GC is available; alternating fresh real-Monaco arms force GC and deterministic event-loop settlement after every operation. Observed p95 ranges: 9 MiB append 4.02-5.51ms versus replacement 81.42-83.76ms; 50 MiB append 22.72-26.60ms versus replacement 445.11-449.21ms. Electron alternates each legacy replacement control and fixed watcher suffix from equivalent restored model state/geometry, samples forced-GC JS heap plus app.getAppMetrics renderer working set and OS private memory before/after/settled, asserts retained memory within max(20MiB,10%), and requires suffix peak deltas for jsHeapMb, workingSetMb, and privateMb not exceed the paired legacy controls." }, + "redGreenEvidence": { "status": "complete", "evidence": "A fail-first real-Monaco test observed canUndo=true after one read-only live-tail append, and the forced-GC 50 MiB control retained 104,858,630 bytes in Monaco's undo service. After the fix the read-only arm retained zero undo-service bytes while the editable control stayed undoable. The retained Electron gate also proves each fixed watcher arm preserves viewport, selection, Find state, and non-undoability. Production builds never install its legacy setValue control." }, + "performanceBudget": { "required": true, "evidence": "Every update retrieves the model value once and performs at most one equality-or-prefix comparison; a matching append submits only the suffix. The registered Node commands require --expose-gc and --pool=threads so worker GC is available. Current p95: 9 MiB append 5.93-7.12ms versus replacement 114.09-145.89ms; 50 MiB append 26.84-34.91ms versus replacement 602.36-699.21ms. The new 50 MiB retention arm measured 104,858,630 undo-service bytes and 104,857,790 ArrayBuffer bytes for the undoable control versus zero for read-only live-tail sync. Electron forced-GC JS-heap, renderer working-set, and OS-private-memory budgets also pass." }, "promotionCriteria": [ "Collect stable soak history on macOS, Linux, and Windows.", "Accumulate 100 consecutive deterministic gate passes or 14 days without unexplained flakes." diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index c8478f3aee0..6f9b86bdb4b 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -5,7 +5,11 @@ import { existsSync, mkdirSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' if (process.platform !== 'win32') { - process.exit(0) + // Why: electron-builder treats a skipped native build like success and can + // continue toward a Windows package whose declared orca.exe does not exist. + throw new Error( + 'Windows CLI launcher compilation requires a Windows host; refusing to package without it.' + ) } const repoRoot = resolve(import.meta.dirname, '../..') diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e5bcbaa557b..4af5a68dd5a 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -5,9 +5,27 @@ import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' const itWindows = process.platform === 'win32' ? it : it.skip +const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') describe('Windows CLI launcher', () => { + itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => { + const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher ')) + try { + const result = spawnSync( + process.execPath, + ['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')], + { cwd: projectRoot, encoding: 'utf8' } + ) + + expect(result.status).not.toBe(0) + expect(result.stderr).toContain('Windows CLI launcher') + expect(result.stderr).toContain('Windows host') + } finally { + rmSync(outputRoot, { recursive: true, force: true }) + } + }) + itWindows('preserves a multiline argument from PowerShell through the native launcher', () => { const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher ')) try { diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index b60571232c8..f59489f67c0 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 4.9m + + downloads: 5.0m @@ -15,7 +15,7 @@ downloads downloads - 4.9m - 4.9m + 5.0m + 5.0m diff --git a/mobile/app/h/[hostId]/accounts.tsx b/mobile/app/h/[hostId]/accounts.tsx index 2a5f78abfa8..652dfffee75 100644 --- a/mobile/app/h/[hostId]/accounts.tsx +++ b/mobile/app/h/[hostId]/accounts.tsx @@ -23,6 +23,7 @@ import { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, UsageBar } from '../../../src/components/AccountUsage' @@ -40,6 +41,14 @@ export default function AccountsScreen() { const [refreshing, setRefreshing] = useState(false) const [busyAccountId, setBusyAccountId] = useState(null) + // Why: the reset countdown must stay fresh while the screen sits open — + // snapshot pushes only arrive when the desktop's rate-limit poll completes. + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(id) + }, []) + useEffect(() => { if (!hostId) { return @@ -163,12 +172,14 @@ export default function AccountsScreen() { usedPercent={activeSessionBar.usedPercent} unavailable={activeSessionBar.unavailable} loading={activeSessionBar.loading} + resetText={getWindowResetLabel(activeUsage, 'session', now)} /> ) : null} @@ -211,12 +222,14 @@ export default function AccountsScreen() { usedPercent={sessionBar.usedPercent} unavailable={sessionBar.unavailable} loading={sessionBar.loading} + resetText={getWindowResetLabel(usage, 'session', now)} /> {usage?.error ? ( diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 01a3cc95acd..b8db4e3a292 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1428,6 +1428,7 @@ export function HostScreen({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={worktrees} onVisibleChange={(visible) => { newWorktreeModalVisibleRef.current = visible }} diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index fea23f01764..c61d01559cd 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -61,6 +61,7 @@ import { } from '../../../src/session/mobile-file-syntax' import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' import { filterWorkspaceAgents, isWorkspaceAgentEnabled, @@ -858,7 +859,6 @@ const GITHUB_REPO_CONCURRENCY = 3 const MAX_RENDERED_PR_DIFF_LINES = 400 const GITLAB_PER_PAGE = 50 const LINEAR_LIMIT = 50 -const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' // Why: task detail drawers can launch child sheets; children must layer above // the still-mounted parent while its dismissal animation/state remains alive. const TASK_SECONDARY_DRAWER_Z_INDEX = 1100 diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 224511d38c5..d28e9ac1d40 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -1935,48 +1935,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.52.0': resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.52.0': resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.52.0': resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.52.0': resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.52.0': resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.52.0': resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.52.0': resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxfmt/binding-openharmony-arm64@0.52.0': resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==} @@ -2049,48 +2057,56 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.71.0': resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.71.0': resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.71.0': resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.71.0': resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] + libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.71.0': resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.71.0': resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@oxlint/binding-linux-x64-musl@1.71.0': resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@oxlint/binding-openharmony-arm64@1.71.0': resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==} @@ -2552,36 +2568,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.1.3': resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.1.3': resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.1.3': resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.1.3': resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.1.3': resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.1.3': resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} @@ -4881,24 +4903,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -7025,22 +7051,22 @@ snapshots: '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7050,7 +7076,7 @@ snapshots: '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7085,12 +7111,12 @@ snapshots: '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': dependencies: @@ -7105,42 +7131,42 @@ snapshots: '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': dependencies: @@ -8604,7 +8630,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -8771,7 +8797,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -9529,24 +9555,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.3 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -9561,7 +9587,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 '@types/hammerjs@2.0.46': {} @@ -9639,7 +9665,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -9659,7 +9685,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -9676,6 +9702,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9685,6 +9720,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -9714,6 +9753,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -9972,7 +10026,7 @@ snapshots: babel-plugin-istanbul@6.1.1: dependencies: - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@istanbuljs/load-nyc-config': 1.1.0 '@istanbuljs/schema': 0.1.6 istanbul-lib-instrument: 5.2.1 @@ -10273,7 +10327,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10282,7 +10336,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -10678,7 +10732,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.3 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: @@ -10776,11 +10830,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -10804,7 +10858,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -10827,7 +10881,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -10838,7 +10892,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -11515,7 +11569,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.3 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-nonce@1.0.1: {} @@ -12087,7 +12141,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.6.0 + '@types/node': 26.1.1 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -12242,7 +12296,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.6.0 + '@types/node': 26.1.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -12287,7 +12341,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 25.6.0 + '@types/node': 26.1.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -14008,6 +14062,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 diff --git a/mobile/pnpm-workspace.yaml b/mobile/pnpm-workspace.yaml new file mode 100644 index 00000000000..35f983a345a --- /dev/null +++ b/mobile/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +allowBuilds: + esbuild: true + +overrides: + xcode>uuid: 11.1.1 diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs index eb50c9a45c0..d6489b530ee 100755 --- a/mobile/scripts/start-emulator.mjs +++ b/mobile/scripts/start-emulator.mjs @@ -506,6 +506,11 @@ async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktr await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the first deep link can arrive while the freshly opened Expo app is + // still mounting, so resend it once the JS router is ready to receive URLs. + await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl]) + await new Promise((resolve) => setTimeout(resolve, 2000)) + // Why: the mobile app intentionally asks for a trust confirmation before // saving a host. This lands on the Pair button on current iPhone simulators. await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], { diff --git a/mobile/src/components/AccountUsage.tsx b/mobile/src/components/AccountUsage.tsx index 00b3e607487..713a2dd8a46 100644 --- a/mobile/src/components/AccountUsage.tsx +++ b/mobile/src/components/AccountUsage.tsx @@ -17,6 +17,7 @@ export { getActiveProviderRateLimits, getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage } from './account-usage-state' @@ -28,12 +29,14 @@ export function UsageBar({ label, usedPercent, unavailable, - loading + loading, + resetText }: { label: string usedPercent: number | null unavailable: boolean loading?: boolean + resetText?: string | null }) { // 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))) @@ -47,34 +50,48 @@ export function UsageBar({ ? colors.statusAmber : colors.statusGreen return ( - - {label} - - + + + {label} + + + + {loading ? ( + + ) : ( + {unavailable || used == null ? '—' : `${used}%`} + )} - {loading ? ( - - ) : ( - {unavailable || used == null ? '—' : `${used}%`} - )} + {resetText ? ( + + {resetText} + + ) : null} ) } const styles = StyleSheet.create({ + usageBarColumn: { + flex: 1, + gap: 2 + }, usageBar: { flexDirection: 'row', alignItems: 'center', - gap: spacing.xs, - flex: 1 + gap: spacing.xs }, usageLabel: { fontSize: typography.metaSize, @@ -100,5 +117,12 @@ const styles = StyleSheet.create({ }, usageSpinner: { width: 36 + }, + // Why: indented past the window label so the countdown aligns with the + // start of the track above it. + usageResetText: { + fontSize: typography.metaSize, + color: colors.textMuted, + marginLeft: 22 + spacing.xs } }) diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx index 4e24e8c2e06..eb23e53a554 100644 --- a/mobile/src/components/BottomDrawer.tsx +++ b/mobile/src/components/BottomDrawer.tsx @@ -24,6 +24,7 @@ import Animated, { } from 'react-native-reanimated' import { colors, spacing } from '../theme/mobile-theme' import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state' +import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host' import { useResponsiveLayout } from '../layout/responsive-layout' const DISMISS_THRESHOLD = 80 @@ -107,6 +108,7 @@ function MountedBottomDrawer({ // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard // transforms below) is unchanged, so phone behavior stays identical. const { isWideLayout, modalMaxWidth } = useResponsiveLayout() + const insideModalHost = useInsideBottomDrawerModalHost() useEffect(() => { if (visible) { @@ -265,104 +267,114 @@ function MountedBottomDrawer({ return { opacity: progress.value * dragFade } }) - // Why: rendering through a full-screen Modal lifts the sheet into its own - // native window so it always covers the viewport — even when the drawer is - // mounted deep inside a ScrollView, where a plain absolute overlay anchors to - // the scrolled content and clips the sheet. The Modal stays mounted (visible) - // for the whole life of MountedBottomDrawer so the reanimated exit animation - // runs before the parent unmounts us; show/hide is driven by `progress`, so - // animationType stays "none". onRequestClose handles the Android back button. + // Why: the sheet renders through a full-screen native window (its own Modal + // below, or the shared BottomDrawerModalHost) so it always covers the viewport + // — even when mounted deep inside a ScrollView, where a plain absolute overlay + // anchors to the scrolled content and clips the sheet. Show/hide is driven by + // `progress` (animationType "none") so the reanimated exit animation runs before + // the parent unmounts us. + const overlay = ( + + + + + + + + + {!contentScrollable ? ( + <> + + + + + + {children} + + ) : dragContentToDismiss ? ( + <> + + + + + + + + + + {children} + + + + + + ) : ( + <> + + + + + + + {children} + + + )} + + + + + + ) + + // Why: inside a BottomDrawerModalHost the host owns the single native Modal; + // rendering our own would stack modals and reintroduce the iOS present/dismiss + // race the host exists to avoid. The host handles the Android back button. + if (insideModalHost) { + return overlay + } + return ( - - - - - - - - - {!contentScrollable ? ( - <> - - - - - - {children} - - ) : dragContentToDismiss ? ( - <> - - - - - - - - - - {children} - - - - - - ) : ( - <> - - - - - - - {children} - - - )} - - - - - + {overlay} ) } diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 961a62a7eaa..09a0914d4aa 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -10,21 +10,20 @@ import { ActivityIndicator, Keyboard } from 'react-native' -import { ChevronDown, ChevronUp, Check } from 'lucide-react-native' +import { ChevronDown, ChevronUp } from 'lucide-react-native' import type { RpcClient } from '../transport/rpc-client' -import type { RpcSuccess } from '../transport/types' +import type { RpcResponse, RpcSuccess } from '../transport/types' import { colors, spacing, radii, typography } from '../theme/mobile-theme' -import { BottomDrawer } from './BottomDrawer' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' +import { BottomDrawerModalHost } from './bottom-drawer-modal-host' import { PickerListDrawer } from './PickerListDrawer' import { MobileAgentIcon } from './MobileAgentIcon' -import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput' import { getSuggestedCreatureName } from './worktree-name-suggestion' import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate' -import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout' import { isSetupHookTrusted, normalizeSetupHookTrust, - trustedOrcaHooksWithSetupApproval, + persistSetupHookTrustApproval, wasSetupHookPreviouslyApproved, type SetupHookTrust } from '../tasks/setup-hook-trust' @@ -49,6 +48,24 @@ import { refreshMobileNewWorkspaceDialogSelectedRepo, resolveMobileNewWorkspaceDialogRepoId } from '../worktree/new-workspace-dialog-repo-selection' +import { createBlankWorkspace } from '../tasks/blank-workspace-create' +import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create' +import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability' +import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + type TaskProvider +} from '../tasks/mobile-task-providers' +import { useMobileComposerSource } from '../tasks/use-mobile-composer-source' +import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' +import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' +import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' +import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' +import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField' +import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer' +import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields' +import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer' type Repo = { id: string @@ -56,6 +73,9 @@ type Repo = { path: string badgeColor?: string connectionId?: string | null + kind?: 'git' | 'folder' + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null } type SetupDecision = 'inherit' | 'run' | 'skip' @@ -91,13 +111,11 @@ type CreateOptions = { approvedSetupContentHash?: string } -type SetupTrustPrompt = { - repoId: string - repoName: string - scriptContent: string - contentHash: string - previouslyApproved: boolean -} +type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust' + +// Why: iOS cannot reliably present a second native modal until the first drawer's +// exit commits; one extra frame keeps transitions sequential on slower devices. +const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16 function repoColor(name: string): string { const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] @@ -124,6 +142,7 @@ type Props = { // on the on-disk directory basename, so paths (not displayNames) are // what the suggestion logic must dedupe against. existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onCreated: (worktreeId: string, name: string) => void onClose: () => void } @@ -133,6 +152,7 @@ export function NewWorktreeModal({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { @@ -157,6 +177,7 @@ export function NewWorktreeModal({ client={client} hostId={hostId} existingWorktreePaths={existingWorktreePaths} + existingWorktrees={existingWorktrees} onCreated={onCreated} onClose={onClose} /> @@ -168,25 +189,28 @@ function NewWorktreeModalContent({ client, hostId, existingWorktreePaths, + existingWorktrees, onCreated, onClose }: Props) { const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null)) const [repos, setRepos] = useState(initialRepos ?? []) const [selectedRepo, setSelectedRepo] = useState(null) - const [showRepoPicker, setShowRepoPicker] = useState(false) - const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true) + const [drawerView, setDrawerView] = useState('form') + const drawerTransitionTimerRef = useRef | null>(null) + const createInFlightRef = useRef(false) + const setupTrustActionInFlightRef = useRef(false) const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!) const [runtimeSettings, setRuntimeSettings] = useState(null) const [detectedAgentIdsState, setDetectedAgentIdsState] = useState( null ) const [agentOverriddenState, setAgentOverridden] = useState(false) - const [showAgentPicker, setShowAgentPicker] = useState(false) const [sshState, setSshState] = useState(null) const [sshConnectingTargetId, setSshConnectingTargetId] = useState(null) - const [name, setName] = useState('') const [note, setNote] = useState('') + const [availableProviders, setAvailableProviders] = useState([]) + const [tasksSupported, setTasksSupported] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) const [setupHookDetails, setSetupHookDetails] = useState(null) const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) @@ -200,12 +224,40 @@ function NewWorktreeModalContent({ const [error, setError] = useState('') const [loading, setLoading] = useState(initialRepos == null) const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible) + const selectedRepoWorktreeBranches = useMemo( + () => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null), + [existingWorktrees, selectedRepo] + ) - // Why: matches the desktop UI — the input shows a generic "Workspace name" - // placeholder, not the suggested creature. The creature name is only used - // as a server-bound fallback when the user submits with a blank field, so - // it's recomputed lazily inside handleCreate() to stay fresh against - // existingWorktreePaths at submission time. + useEffect(() => { + return () => { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + } + }, []) + + function transitionDrawer(nextView: Exclude): void { + if (drawerTransitionTimerRef.current) { + clearTimeout(drawerTransitionTimerRef.current) + } + setDrawerView('transition') + drawerTransitionTimerRef.current = setTimeout(() => { + drawerTransitionTimerRef.current = null + setDrawerView(nextView) + }, NEW_WORKTREE_DRAWER_TRANSITION_MS) + } + + // The Smart source picker owns the workspace name AND the linked-source + // selection: typing names the workspace and drives source search, and picking + // a source resolves the base/branch/push metadata (matching desktop). The + // creature-name fallback is only computed lazily at submit for a blank name. + const composer = useMobileComposerSource({ + client, + selectedRepoId: selectedRepo?.id ?? null, + worktreeBranches: selectedRepoWorktreeBranches, + onError: setError + }) const selectedRepoConnectionId = selectedRepo?.connectionId ?? null const sshGate = deriveWorkspaceSshGate({ @@ -242,6 +294,25 @@ function NewWorktreeModalContent({ } const selectedAgent = selectedAgentResolution.selectedAgent + const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true + const sourceAvailability: SmartModeAvailabilityInput = { + textOnly: selectedRepo != null && !selectedRepoIsGit, + tasksSupported, + hasRepo: selectedRepo != null, + githubAvailable: availableProviders.includes('github'), + gitlabAvailable: availableProviders.includes('gitlab'), + linearAvailable: availableProviders.includes('linear') + } + const pasteRepos = useMemo( + () => + repos.map((repo) => ({ + id: repo.id, + displayName: repo.displayName, + slug: deriveRepoSlug(repo) + })), + [repos] + ) + useEffect(() => { if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) { return @@ -298,27 +369,68 @@ function NewWorktreeModalContent({ }) void (async () => { - try { - const [settingsResponse, uiResponse] = await Promise.all([ - client.sendRequest('settings.get'), - client.sendRequest('ui.get') - ]) - if (stale) { - return - } - if (settingsResponse.ok) { - const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings } - setRuntimeSettings(result.settings) - } - if (uiResponse.ok) { - const result = (uiResponse as RpcSuccess).result as { - ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } - } - setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {}) - } - } catch { - // Non-critical; repo.list owns the visible loading state. + // Why: settle each RPC independently so a flaky availability probe (e.g. a + // linear.status timeout, which rejects rather than resolving {ok:false}) + // can't discard the already-resolved critical settings/ui results. + const probes = Promise.allSettled([ + client.sendRequest('status.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + const okResult = (entry: PromiseSettledResult): RpcSuccess | null => + entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null + // Why: hydrate settings/trust the moment their own RPCs settle — gating them + // on the probes (a first-open preflight.check can take seconds) widens the + // window where an already-trusted setup hook spuriously re-prompts on create. + const [settingsRes, uiRes] = await Promise.allSettled([ + client.sendRequest('settings.get'), + client.sendRequest('ui.get') + ]) + if (stale) { + return } + + const settingsResult = okResult(settingsRes) + const settingsValue = settingsResult + ? ( + settingsResult.result as { + settings: RuntimeSettings & { visibleTaskProviders?: unknown } + } + ).settings + : null + if (settingsValue) { + setRuntimeSettings(settingsValue) + } + const uiResult = okResult(uiRes) + if (uiResult) { + const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui + setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {}) + } + + const [statusRes, preflightRes, linearRes] = await probes + if (stale) { + return + } + // Tasks is an additive RPC surface, so older paired desktops without the + // capability fall back to branch + blank sources only. + const statusResult = okResult(statusRes) + const capabilities = + (statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? [] + setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY)) + const glabInstalled = + (okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab + ?.installed === true + const linearConnected = + (okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true + const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders) + setAvailableProviders( + // Drop filterAvailableTaskProviders' forced 'github' fallback when the user + // hid GitHub; the Branch tab always guarantees at least one tab remains. + filterAvailableTaskProviders(visibleProviders, { + gitlabInstalled: glabInstalled, + linearConnected + }).filter((provider) => visibleProviders.includes(provider)) + ) })() return () => { stale = true @@ -486,31 +598,11 @@ function NewWorktreeModalContent({ } } - async function persistSetupHookTrust( - repoId: string, - contentHash: string, - alwaysTrust: boolean - ): Promise { - if (!client) { - return - } - const next = trustedOrcaHooksWithSetupApproval({ - trust: trustedOrcaHooks, - repoId, - contentHash, - alwaysTrust - }) - const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) - if (!response.ok) { - throw new Error(response.error.message) - } - setTrustedOrcaHooks(next) - } - async function handleCreate(options: CreateOptions = {}) { - if (!client || !selectedRepo) { + if (!client || !selectedRepo || createInFlightRef.current) { return } + createInFlightRef.current = true setCreating(true) setError('') @@ -555,24 +647,9 @@ function NewWorktreeModalContent({ // server invent one. The pre-flight basename dedupe is only a hint; // the authoritative collision is checked server-side against git // branches/remotes/PRs, so we also retry-with-suffix on conflict. - const trimmedName = name.trim() + const trimmedName = composer.name.trim() const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? []) - // Why: mirrors src/renderer/src/store/slices/worktrees.ts - // (createWorktree retry loop). Server-side checks (Branch X already - // exists locally / on a remote / already has PR #N) can fire even - // after the pre-flight basename dedupe — branches outlive worktrees - // in git, and remote branches/PRs aren't visible from worktree.ps. - // Retry up to 25 times by appending -2, -3, ... before surfacing - // the error. The desktop applies this to user-typed names too, so - // mobile follows suit for parity. - const retryablePatterns = [ - /already exists locally/i, - /already exists on a remote/i, - /already has pr #\d+/i - ] - const candidateFor = (attempt: number): string => - attempt === 0 ? baseName : `${baseName}-${attempt + 1}` let setupDecision: SetupDecision = 'inherit' if (setupCommand) { if (options.setupOverride) { @@ -602,44 +679,46 @@ function NewWorktreeModalContent({ contentHash: setupTrust.contentHash, previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id) }) + transitionDrawer('trust') return } - let lastError: string | null = null - for (let attempt = 0; attempt < 25; attempt += 1) { - const candidateName = candidateFor(attempt) - const params: Record = { - repo: `id:${selectedRepo.id}`, - startupCommand: command, - setupDecision, - name: candidateName - } - if (selectedAgent.id !== '__blank__') { - params.createdWithAgent = selectedAgent.id - } - if (note.trim()) { - params.comment = note.trim() - } - - const response = await client.sendRequest('worktree.create', params, { - timeoutMs: WORKTREE_CREATE_TIMEOUT_MS - }) - if (response.ok) { - const result = (response as RpcSuccess).result as { worktree: { id: string } } - onClose() - onCreated(result.worktree.id, candidateName) - return - } - - lastError = response.error.message - if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) { - break - } + const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined + const trimmedNote = note.trim() || undefined + const createSelection = composer.createSelection + const result = createSelection + ? await createWorkspaceFromComposerSource({ + client, + selection: createSelection, + targetRepoId: selectedRepo.id, + setupDecision, + agent: { + choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank', + startupCommand: command + }, + workspaceName: trimmedName || undefined, + note: trimmedNote, + nameIsAutoManaged: composer.isNameAutoManaged + }) + : await createBlankWorkspace({ + client, + repoId: selectedRepo.id, + baseName, + startupCommand: command, + createdWithAgentId, + comment: trimmedNote, + setupDecision + }) + if ('error' in result) { + setError(result.error) + return } - setError(lastError ?? 'Failed to create workspace') + onClose() + onCreated(result.worktreeId, result.name) } catch (e) { setError(e instanceof Error ? e.message : 'Failed to create workspace') } finally { + createInFlightRef.current = false setCreating(false) } } @@ -670,15 +749,90 @@ function NewWorktreeModalContent({ ) function prepareSelectionPickerOpen(): void { - // Why: picker taps can beat the delayed name-field focus; suppressing it - // prevents the keyboard from reopening under the picker drawer. - setNameAutoFocusEnabled(false) + // Why: picker taps can beat an open soft keyboard; dismissing it prevents the + // keyboard from reopening under the picker drawer. Keyboard.dismiss() } + function handleRepoSelected(repo: Repo): void { + const repoChanged = repo.id !== selectedRepo?.id + setSelectedRepo(repo) + // Branch and provider-backed sources are repo-scoped; Linear/Jira are global + // work context and survive choosing a different implementation repo. + if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) { + composer.handleClearSmartNameSelection() + } + } + + async function approveSetupTrust(alwaysTrust: boolean): Promise { + if ( + !client || + !setupTrustPrompt || + setupTrustActionInFlightRef.current || + createInFlightRef.current + ) { + return + } + setupTrustActionInFlightRef.current = true + setCreating(true) + try { + const nextTrust = await persistSetupHookTrustApproval({ + client, + trust: trustedOrcaHooks, + repoId: setupTrustPrompt.repoId, + contentHash: setupTrustPrompt.contentHash, + alwaysTrust + }) + setTrustedOrcaHooks(nextTrust) + const approvedHash = setupTrustPrompt.contentHash + setSetupTrustPrompt(null) + transitionDrawer('form') + await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } finally { + setupTrustActionInFlightRef.current = false + if (!createInFlightRef.current) { + setCreating(false) + } + } + } + + function closeSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + setSetupTrustPrompt(null) + transitionDrawer('form') + } + + function skipSetupTrust(): void { + if (setupTrustActionInFlightRef.current || createInFlightRef.current) { + return + } + closeSetupTrust() + void handleCreate({ setupOverride: 'skip' }) + } + return ( - <> - + // Why: hosting the form and every picker in one persistent native Modal makes + // form → repo/agent transitions in-window view swaps, avoiding the iOS + // dismiss-then-present race that left the dropdowns unresponsive. Native back + // closes the flow from the form, routes the trust prompt through its in-flight + // guard, and otherwise returns to the form from a picker. + { + if (drawerView === 'form') { + onClose() + } else if (drawerView === 'trust') { + closeSetupTrust() + } else { + transitionDrawer('form') + } + }} + > + Create Workspace @@ -702,7 +856,7 @@ function NewWorktreeModalContent({ style={styles.fieldButton} onPress={() => { prepareSelectionPickerOpen() - setShowRepoPicker(true) + transitionDrawer('repo') }} > {selectedRepo ? ( @@ -720,6 +874,18 @@ function NewWorktreeModalContent({ + setError('')} + onOpenDrawer={() => transitionDrawer('source')} + /> + + {composer.forkPushWarning ? ( + {composer.forkPushWarning} + ) : null} + {selectedRepoConnectionId ? ( SSH Connection @@ -763,28 +929,6 @@ function NewWorktreeModalContent({ ) : null} - - - Workspace Name [Optional] - - { - setName(t) - setError('') - }} - placeholderTextColor={colors.textMuted} - shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0} - returnKeyType="done" - onSubmitEditing={() => { - if (canCreate) { - void handleCreate() - } - }} - /> - - Agent { prepareSelectionPickerOpen() - setShowAgentPicker(true) + transitionDrawer('agent') }} > @@ -814,6 +958,11 @@ function NewWorktreeModalContent({ {showAdvanced && ( <> + + Note - {/* Sub-modals for pickers — rendered outside the main modal so they - layer on top and scroll without touch conflicts. */} + {/* Why: list drawers stay outside the form's ScrollView, and the transition + state lets each hosted overlay finish hiding before the next appears. */} + { + const nextRepo = repos.find((repo) => repo.id === repoId) + if (nextRepo) { + setSelectedRepo(nextRepo) + } + }} + onClose={() => transitionDrawer('form')} + /> + setSelectedRepo(item.repo)} - onClose={() => setShowRepoPicker(false)} + onSelect={(item) => handleRepoSelected(item.repo)} + onClose={() => transitionDrawer('form')} renderIcon={(item) => { return }} /> setShowAgentPicker(false)} + onClose={() => transitionDrawer('form')} renderIcon={(agent) => } /> - setSetupTrustPrompt(null)} - > - {setupTrustPrompt ? ( - - - - {setupTrustPrompt.previouslyApproved - ? `${setupTrustPrompt.repoName}'s setup script changed` - : `Run setup from ${setupTrustPrompt.repoName}?`} - - - This repository's orca.yaml runs before the workspace starts. Only run it if you - trust this repository. - - - - - - {setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} - - {setupTrustPrompt.scriptContent} - - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - false - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Run hooks - - - - void (async () => { - try { - await persistSetupHookTrust( - setupTrustPrompt.repoId, - setupTrustPrompt.contentHash, - true - ) - const approvedHash = setupTrustPrompt.contentHash - setSetupTrustPrompt(null) - await handleCreate({ - setupOverride: 'run', - approvedSetupContentHash: approvedHash - }) - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to trust setup script.') - } - })() - } - > - - Always trust and run - - - { - setSetupTrustPrompt(null) - void handleCreate({ setupOverride: 'skip' }) - }} - > - Don't run - - - - ) : null} - - + void approveSetupTrust(false)} + onAlwaysTrust={() => void approveSetupTrust(true)} + onDontRun={skipSetupTrust} + onClose={closeSetupTrust} + /> + ) } @@ -1164,6 +1245,12 @@ const styles = StyleSheet.create({ fontSize: 13, marginBottom: spacing.md }, + sourceWarning: { + marginTop: -spacing.sm, + marginBottom: spacing.md, + fontSize: 12, + color: colors.statusAmber + }, advancedToggle: { flexDirection: 'row', alignItems: 'center', @@ -1247,52 +1334,6 @@ const styles = StyleSheet.create({ fontFamily: typography.monoFamily, color: colors.textPrimary }, - trustHeader: { - paddingHorizontal: spacing.xs, - marginBottom: spacing.md - }, - trustScriptBox: { - backgroundColor: colors.bgRaised, - borderRadius: radii.input, - borderWidth: 1, - borderColor: colors.borderSubtle, - padding: spacing.md, - marginBottom: spacing.md - }, - trustScriptLabel: { - fontSize: 12, - fontWeight: '600', - color: colors.textSecondary, - marginBottom: spacing.sm - }, - trustScriptText: { - fontSize: 13, - fontFamily: typography.monoFamily, - color: colors.textPrimary - }, - trustActionGroup: { - backgroundColor: colors.bgPanel, - borderRadius: radii.input, - overflow: 'hidden' - }, - trustActionRow: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.md, - paddingHorizontal: spacing.md - }, - trustActionText: { - flex: 1, - fontSize: typography.bodySize, - color: colors.textPrimary, - fontWeight: '500' - }, - trustActionSeparator: { - height: StyleSheet.hairlineWidth, - backgroundColor: colors.borderSubtle, - marginHorizontal: spacing.md - }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/src/components/NewWorktreeModalController.tsx b/mobile/src/components/NewWorktreeModalController.tsx index df422642951..9a062692d17 100644 --- a/mobile/src/components/NewWorktreeModalController.tsx +++ b/mobile/src/components/NewWorktreeModalController.tsx @@ -12,6 +12,7 @@ type Props = { client: RpcClient | null hostId?: string existingWorktreePaths?: readonly string[] + existingWorktrees?: readonly { repoId: string; branch: string }[] onVisibleChange?: (visible: boolean) => void onRouteVisibleChange: (visible: boolean) => void onCreated: (worktreeId: string, name: string) => void @@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef diff --git a/mobile/src/components/SetupHookTrustDrawer.tsx b/mobile/src/components/SetupHookTrustDrawer.tsx new file mode 100644 index 00000000000..6095aaa174f --- /dev/null +++ b/mobile/src/components/SetupHookTrustDrawer.tsx @@ -0,0 +1,137 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { Check } from 'lucide-react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer } from './BottomDrawer' + +export type SetupTrustPrompt = { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean +} + +type Props = { + visible: boolean + prompt: SetupTrustPrompt | null + busy: boolean + onRunOnce: () => void + onAlwaysTrust: () => void + onDontRun: () => void + onClose: () => void +} + +// The repo-owned orca.yaml setup-hook trust prompt, shown before a workspace +// create that would run an untrusted setup script. Extracted from NewWorktreeModal +// to keep that file focused; the async persist/create logic stays with the caller. +export function SetupHookTrustDrawer({ + visible, + prompt, + busy, + onRunOnce, + onAlwaysTrust, + onDontRun, + onClose +}: Props) { + return ( + + {prompt ? ( + + + + {prompt.previouslyApproved + ? `${prompt.repoName}'s setup script changed` + : `Run setup from ${prompt.repoName}?`} + + + This repository's orca.yaml runs before the workspace starts. Only run it if you trust + this repository. + + + + + + {prompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + {prompt.scriptContent} + + + + + + Run hooks + + + + + Always trust and run + + + + Don't run + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + subtitle: { + fontSize: 13, + color: colors.textMuted, + marginTop: 2 + }, + trustHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + trustScriptBox: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md + }, + trustScriptLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary, + marginBottom: spacing.sm + }, + trustScriptText: { + fontSize: 13, + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + trustActionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + overflow: 'hidden' + }, + trustActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + }, + trustActionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '500' + }, + trustActionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + } +}) diff --git a/mobile/src/components/SmartSourceModeIcon.tsx b/mobile/src/components/SmartSourceModeIcon.tsx new file mode 100644 index 00000000000..3d066369ed6 --- /dev/null +++ b/mobile/src/components/SmartSourceModeIcon.tsx @@ -0,0 +1,18 @@ +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes' +import { TaskProviderLogo } from './TaskProviderLogo' + +// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes, +// lucide glyphs for the neutral modes. +export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) { + if (icon.type === 'provider') { + return + } + if (icon.name === 'sparkles') { + return + } + if (icon.name === 'git-branch') { + return + } + return +} diff --git a/mobile/src/components/SmartWorkspaceAdvancedFields.tsx b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx new file mode 100644 index 00000000000..4af0ad682b5 --- /dev/null +++ b/mobile/src/components/SmartWorkspaceAdvancedFields.tsx @@ -0,0 +1,102 @@ +import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + composer: MobileComposerSource + selectedRepoIsGit: boolean +} + +// The Advanced-section source controls: the editable Name appears once a source +// pill is shown (the field itself is no longer the name input); the branch-name +// override and reuse toggle mirror the desktop composer's advanced branch fields. +export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) { + const selection = composer.smartNameSelection + const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch') + return ( + <> + {selection ? ( + + Name + + + ) : null} + + {showBranchOverride ? ( + + Branch name + + + ) : null} + + {composer.reuseEligibleBranch ? ( + + + + Reuse branch “{composer.reuseEligibleBranch}” + + + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + input: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + reuseRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + reuseLabel: { + flex: 1, + fontSize: 13, + color: colors.textSecondary + }, + reuseSwitch: { + transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }] + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx new file mode 100644 index 00000000000..c5f1a6c47db --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx @@ -0,0 +1,425 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import type { RpcClient } from '../transport/rpc-client' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { + MR_STATE_FILTER_OPTIONS, + resolveAvailableSmartModes, + resolveDefaultSmartMode, + SMART_MODE_OPTIONS, + type SmartModeAvailabilityInput, + type SmartModeOption +} from '../tasks/mobile-smart-source-modes' +import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types' +import { + lookupGitHubItemByOwnerRepo, + type PasteRepoCandidate +} from '../tasks/smart-source-paste-intent' +import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer' +import { SmartSourceModeIcon } from './SmartSourceModeIcon' +import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow' + +type Props = { + visible: boolean + client: RpcClient | null + composer: MobileComposerSource + availability: SmartModeAvailabilityInput + repoId: string | null + repos: readonly PasteRepoCandidate[] + linearWorkspaceId?: string | null + sshReady: boolean + onRepoChange: (repoId: string) => void + onClose: () => void +} + +export function SmartWorkspaceSourceDrawer({ + visible, + client, + composer, + availability, + repoId, + repos, + linearWorkspaceId, + sshReady, + onRepoChange, + onClose +}: Props) { + const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability]) + const [mode, setMode] = useState(() => resolveDefaultSmartMode(availability)) + const [mrStateFilter, setMrStateFilter] = useState('opened') + // Why: read latest availability inside the open effect without making it a + // reactive dep (the object is recreated each render), so re-seeding happens + // only on open, not on every availability recompute. + const availabilityRef = useRef(availability) + availabilityRef.current = availability + + // Reset to the default mode each time the drawer opens. + useEffect(() => { + if (visible) { + setMode(resolveDefaultSmartMode(availabilityRef.current)) + } + }, [visible]) + + // Snap the chosen mode back into the available set if availability changes. + const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text') + + // Linear searches without a repo; every other provider/branch search needs a + // connected repo-backed target. + const searchEnabled = visible && (effectiveMode === 'linear' || sshReady) + + const { + rows, + loading, + error, + needsGitHubRemote, + emptyHint, + crossRepoPrompt, + dismissCrossRepoPrompt + } = useSmartWorkspaceSource({ + client, + enabled: searchEnabled, + mode: effectiveMode, + query: composer.name, + repoId, + githubAvailable: availability.githubAvailable, + gitlabAvailable: availability.gitlabAvailable, + linearAvailable: availability.linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + }) + + function closeSoon(): void { + setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS) + } + + function handleSelectRow(row: SourceRow): void { + switch (row.kind) { + case 'use-name': + composer.setName(row.name) + break + case 'create-branch': + composer.handleSmartCreateBranch(row.name) + break + case 'github': + composer.handleSmartGitHubItemSelect(row.item) + break + case 'gitlab': + composer.handleSmartGitLabItemSelect(row.item) + break + case 'branch': + composer.handleSmartBranchSelect(row.refName, row.localBranchName) + break + case 'linear': + composer.handleSmartLinearIssueSelect(row.issue) + break + } + onClose() + } + + async function handleAcceptCrossRepo(): Promise { + if (!client || !crossRepoPrompt) { + return + } + const { link, matchingRepo } = crossRepoPrompt + try { + const item = await lookupGitHubItemByOwnerRepo( + client, + matchingRepo.id, + link.slug, + link.number, + link.type + ) + if (item) { + onRepoChange(matchingRepo.id) + composer.handleSmartGitHubItemSelect(item) + onClose() + } + } catch { + dismissCrossRepoPrompt() + } + } + + const showEmpty = + !loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0 + + return ( + + + Name or 'Create From' + + Done + + + + + + + {SMART_MODE_OPTIONS.filter((option: SmartModeOption) => + availableModes.includes(option.id) + ).map((option) => { + const selected = option.id === effectiveMode + const tint = selected ? colors.textPrimary : colors.textSecondary + return ( + setMode(option.id)} + > + + + {option.label} + + + ) + })} + + + {effectiveMode === 'gitlab' ? ( + + {MR_STATE_FILTER_OPTIONS.map((option) => { + const selected = option.id === mrStateFilter + return ( + setMrStateFilter(option.id)} + > + + {option.label} + + + ) + })} + + ) : null} + + {crossRepoPrompt ? ( + + + This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}. + + + + Cancel + + void handleAcceptCrossRepo()}> + + Switch to {crossRepoPrompt.matchingRepo.displayName} + + + + + ) : null} + + {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? ( + Connect the repository to search sources. + ) : needsGitHubRemote ? ( + + This SSH repo needs a GitHub remote to list issues and PRs. + + ) : error ? ( + {error} + ) : null} + + row.value} + style={styles.list} + keyboardShouldPersistTaps="handled" + nestedScrollEnabled + ListFooterComponent={ + loading ? ( + + + + ) : showEmpty ? ( + {emptyHint || 'No results found.'} + ) : null + } + renderItem={({ item }) => ( + handleSelectRow(item)} /> + )} + /> + + ) +} + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + title: { + fontSize: 15, + fontWeight: '600', + color: colors.textPrimary + }, + done: { + fontSize: typography.bodySize, + fontWeight: '600', + color: colors.accentBlue + }, + search: { + backgroundColor: colors.bgRaised, + color: colors.textPrimary, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: typography.bodySize, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginBottom: spacing.sm + }, + tabRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginBottom: spacing.sm + }, + tab: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + tabSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + tabText: { + fontSize: 13, + color: colors.textSecondary + }, + tabTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + chipRow: { + flexDirection: 'row', + gap: spacing.xs, + marginBottom: spacing.sm + }, + chip: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + chipSelected: { + backgroundColor: colors.bgPanel, + borderColor: colors.textSecondary + }, + chipText: { + fontSize: 12, + color: colors.textSecondary + }, + chipTextSelected: { + color: colors.textPrimary, + fontWeight: '600' + }, + crossRepo: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.sm, + gap: spacing.sm + }, + crossRepoText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm + }, + crossRepoDismiss: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + crossRepoDismissText: { + fontSize: 13, + color: colors.textSecondary + }, + crossRepoSwitch: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + borderWidth: 1, + borderColor: colors.textSecondary + }, + crossRepoSwitchText: { + fontSize: 13, + fontWeight: '600', + color: colors.textPrimary + }, + notice: { + fontSize: 12, + color: colors.textMuted, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + errorNotice: { + fontSize: 12, + color: colors.statusRed, + paddingHorizontal: spacing.xs, + paddingBottom: spacing.sm + }, + list: { + backgroundColor: colors.bgPanel, + borderRadius: radii.card, + overflow: 'hidden', + maxHeight: 420, + flexGrow: 0 + }, + loading: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + empty: { + paddingVertical: spacing.lg, + textAlign: 'center', + color: colors.textMuted, + fontSize: 13 + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx new file mode 100644 index 00000000000..eb08ddca66a --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceField.tsx @@ -0,0 +1,145 @@ +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native' +import { + CircleDot, + ExternalLink, + GitBranch, + GitMerge, + GitPullRequest, + X +} from 'lucide-react-native' +import type { SmartNameSelection } from '../tasks/mobile-composer-source-types' +import type { MobileComposerSource } from '../tasks/use-mobile-composer-source' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + composer: MobileComposerSource + label: string + disabled?: boolean + onBeforeOpen?: () => void + onOpenDrawer: () => void +} + +function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) { + if (kind === 'github-pr') { + return + } + if (kind === 'gitlab-mr') { + return + } + if (kind === 'github-issue' || kind === 'gitlab-issue') { + return + } + if (kind === 'branch') { + return + } + return +} + +export function SmartWorkspaceSourceField({ + composer, + label, + disabled, + onBeforeOpen, + onOpenDrawer +}: Props) { + const selection = composer.smartNameSelection + + function openDrawer(): void { + if (disabled) { + return + } + onBeforeOpen?.() + onOpenDrawer() + } + + return ( + + + {label} [Optional] + + {selection ? ( + + + + {selection.label} + + {selection.url ? ( + selection.url && void Linking.openURL(selection.url).catch(() => {})} + > + + + ) : null} + + + + + ) : ( + + + {composer.name || 'Type a name or search a source'} + + + )} + + ) +} + +const styles = StyleSheet.create({ + field: { + marginBottom: spacing.md + }, + label: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + labelHint: { + fontWeight: '400', + color: colors.textMuted + }, + input: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + disabled: { + opacity: 0.55 + }, + inputText: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + inputPlaceholder: { + color: colors.textMuted + }, + pill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + pillLabel: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + } +}) diff --git a/mobile/src/components/SmartWorkspaceSourceRow.tsx b/mobile/src/components/SmartWorkspaceSourceRow.tsx new file mode 100644 index 00000000000..ee601c2ebde --- /dev/null +++ b/mobile/src/components/SmartWorkspaceSourceRow.tsx @@ -0,0 +1,134 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native' +import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' +import { TaskProviderLogo } from './TaskProviderLogo' + +type Props = { + row: SourceRow + onPress: () => void +} + +type RowContent = { + icon: React.ReactNode + title: string + subtitle?: string + status?: string +} + +function resolveRowContent(row: SourceRow): RowContent { + switch (row.kind) { + case 'use-name': + return { + icon: , + title: `Use "${row.name}"`, + subtitle: 'Name this workspace' + } + case 'create-branch': + return { + icon: , + title: `Create branch "${row.name}"`, + subtitle: 'New branch' + } + case 'github': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'gitlab': + return { + icon: , + title: row.item.title, + subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`, + status: row.item.state + } + case 'branch': + return { + icon: , + title: row.localBranchName || row.refName, + subtitle: row.refName + } + case 'linear': + return { + icon: , + title: row.issue.title, + subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`, + status: row.issue.state?.name + } + default: + return { icon: , title: '' } + } +} + +export function SmartWorkspaceSourceRow({ row, onPress }: Props) { + const content = resolveRowContent(row) + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={onPress} + > + {content.icon} + + + {content.title} + + {content.subtitle ? ( + + {content.subtitle} + + ) : null} + + {content.status ? ( + + + {content.status} + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.md, + paddingHorizontal: spacing.md + 2 + }, + rowPressed: { + backgroundColor: colors.bgRaised + }, + icon: { + width: 18, + alignItems: 'center' + }, + copy: { + flex: 1, + minWidth: 0 + }, + title: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + subtitle: { + fontSize: 12, + color: colors.textMuted, + marginTop: 1 + }, + pill: { + backgroundColor: colors.bgRaised, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + pillText: { + fontSize: 11, + fontWeight: '600', + color: colors.textSecondary, + textTransform: 'capitalize' + } +}) diff --git a/mobile/src/components/account-usage-state.test.ts b/mobile/src/components/account-usage-state.test.ts index f567143584e..3e128b7b099 100644 --- a/mobile/src/components/account-usage-state.test.ts +++ b/mobile/src/components/account-usage-state.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest' import { getInactiveProviderUsage, getUsageBarState, + getWindowResetLabel, hasActiveProviderUsage, hasRenderableUsage, type AccountsSnapshot, @@ -117,6 +118,65 @@ describe('getInactiveProviderUsage', () => { }) }) +describe('getWindowResetLabel', () => { + const now = 1_700_000_000_000 + const min = 60_000 + const hour = 60 * min + const day = 24 * hour + + function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] { + return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null } + } + + it('is null when there are no limits or the window has no reset timestamp', () => { + expect(getWindowResetLabel(null, 'session', now)).toBe(null) + expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null) + expect( + getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now) + ).toBe(null) + }) + + it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now) + ).toBe('Resets in 47m') + expect( + getWindowResetLabel( + makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }), + 'session', + now + ) + ).toBe('Resets in 3h 54m') + expect( + getWindowResetLabel( + makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }), + 'weekly', + now + ) + ).toBe('Resets in 6d 7h') + }) + + it('formats exact hours and exact days without a zero remainder', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now) + ).toBe('Resets in 2h') + expect( + getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now) + ).toBe('Resets in 7d') + }) + + it('reports "Resets now" for a reset timestamp in the past', () => { + expect( + getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now) + ).toBe('Resets now') + }) + + it('reads the requested window only', () => { + const limits = makeLimits({ session: makeWindow(now + hour) }) + expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null) + }) +}) + describe('getUsageBarState', () => { it('keeps stale window data visible during a transient error', () => { const bar = getUsageBarState( diff --git a/mobile/src/components/account-usage-state.ts b/mobile/src/components/account-usage-state.ts index c2bee731cd0..fa5b4a516b3 100644 --- a/mobile/src/components/account-usage-state.ts +++ b/mobile/src/components/account-usage-state.ts @@ -5,6 +5,8 @@ // Pure state/selectors live here (no React Native imports) so they can be // unit-tested directly; AccountUsage.tsx re-exports them alongside the // UsageBar component. +import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format' + export type RateLimitWindow = { usedPercent: number windowMinutes: number @@ -117,6 +119,27 @@ export function getUsageBarState( } } +/** + * Reset countdown for one window, e.g. "Resets in 3h 54m" / "Resets now", + * or null when the window has no reset timestamp (so the UI degrades to + * today's bars-only layout). + * + * Why: shares formatResetCountdown with the desktop status-bar tooltip so the + * copy stays identical across surfaces. `now` is a parameter so the function + * stays pure and unit-testable. + */ +export function getWindowResetLabel( + limits: ProviderRateLimits | null, + windowKey: 'session' | 'weekly', + now: number +): string | null { + const resetsAt = limits?.[windowKey]?.resetsAt + if (resetsAt == null) { + return null + } + return formatResetCountdown(resetsAt - now) +} + // Why: the usage UI must render for the system-default login, not only for // Orca-managed accounts. Show a provider when it has at least one managed // account OR active rate-limit data for the system-default target. diff --git a/mobile/src/components/bottom-drawer-modal-host.tsx b/mobile/src/components/bottom-drawer-modal-host.tsx new file mode 100644 index 00000000000..b31a31136e0 --- /dev/null +++ b/mobile/src/components/bottom-drawer-modal-host.tsx @@ -0,0 +1,40 @@ +import { createContext, useContext, type ReactNode } from 'react' +import { Modal } from 'react-native' + +const BottomDrawerModalHostContext = createContext(false) + +/** True when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and + * must therefore skip its own native Modal (the host owns the single Modal). */ +export function useInsideBottomDrawerModalHost(): boolean { + return useContext(BottomDrawerModalHostContext) +} + +type Props = { + visible: boolean + onRequestClose: () => void + children: ReactNode +} + +// Why: iOS cannot reliably dismiss one native modal and present another in the same +// beat. Flows that swap between sibling drawer modals (e.g. the Create Workspace form +// → its repository/agent pickers) dropped the incoming modal, leaving the sheet dead +// to taps. Hosting every drawer in ONE persistent native Modal makes those swaps +// in-window view changes instead, so no present/dismiss race can eat the transition. +export function BottomDrawerModalHost({ visible, onRequestClose, children }: Props) { + if (!visible) { + return null + } + return ( + + + {children} + + + ) +} diff --git a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts index 5a7dd594393..05e05b1f188 100644 --- a/mobile/src/session/mobile-native-chat-answer-stepping.test.ts +++ b/mobile/src/session/mobile-native-chat-answer-stepping.test.ts @@ -10,14 +10,14 @@ import { describe('mobileNativeChatQuestionOffsets', () => { it('matches the desktop cadence constants', () => { expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) - expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(300) - expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(800) + expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) + expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) }) it('paces each question a full step apart, Enter 500ms after its body', () => { expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 }) - expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 800, enterAt: 1300 }) - expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 1600, enterAt: 2100 }) + expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 }) + expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 }) }) }) diff --git a/mobile/src/source-control/mobile-pr-create.ts b/mobile/src/source-control/mobile-pr-create.ts index 675df20bedb..b9a3817f4d2 100644 --- a/mobile/src/source-control/mobile-pr-create.ts +++ b/mobile/src/source-control/mobile-pr-create.ts @@ -66,6 +66,8 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string return `A ${copy.reviewLabel} already exists for this branch.` case 'fork_head_unsupported': return `Creating a ${copy.reviewLabel} from this fork is not supported.` + case 'base_not_on_remote': + return `Push the base branch before creating a ${copy.reviewLabel}.` case 'needs_push': case null: case undefined: diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts new file mode 100644 index 00000000000..1d35d29f758 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createBlankWorkspace } from './blank-workspace-create' + +type Call = { method: string; params: unknown } + +function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params }) + const result = script(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('createBlankWorkspace', () => { + it('assembles exactly the params the modal historically sent, omitting empty extras', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' }) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + method: 'worktree.create', + params: { + repo: 'id:repo-1', + startupCommand: undefined, + setupDecision: 'inherit', + name: 'octopus' + } + }) + const params = calls[0]?.params as Record + expect('createdWithAgent' in params).toBe(false) + expect('comment' in params).toBe(false) + }) + + it('includes createdWithAgent and comment only when provided', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + + await createBlankWorkspace({ + client, + repoId: 'repo-2', + baseName: 'manatee', + startupCommand: 'claude', + createdWithAgentId: 'claude', + comment: 'spike', + setupDecision: 'run' + }) + + expect(calls[0]?.params).toMatchObject({ + repo: 'id:repo-2', + name: 'manatee', + startupCommand: 'claude', + setupDecision: 'run', + createdWithAgent: 'claude', + comment: 'spike' + }) + }) + + it('retries with a numeric suffix on a branch-collision error', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists locally. Pick a different branch name.') + } + return { worktree: { id: 'wt-3' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + const retryParams = calls[1]?.params as Record + expect(retryParams.name).toBe('octopus-2') + }) + + it('retries on the bare older-runtime collision message', async () => { + const calls: Call[] = [] + const client = fakeClient((_method, call) => { + if (call === 1) { + return new Error('Branch "octopus" already exists.') + } + return { worktree: { id: 'wt-4' } } + }, calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'inherit' + }) + + expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' }) + expect(calls).toHaveLength(2) + }) + + it('surfaces a non-collision error without retrying', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('SSH connection is not available'), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + startupCommand: undefined, + createdWithAgentId: undefined, + comment: undefined, + setupDecision: 'skip' + }) + + expect(result).toEqual({ error: 'SSH connection is not available' }) + expect(calls).toHaveLength(1) + }) +}) diff --git a/mobile/src/tasks/blank-workspace-create.ts b/mobile/src/tasks/blank-workspace-create.ts new file mode 100644 index 00000000000..014af558198 --- /dev/null +++ b/mobile/src/tasks/blank-workspace-create.ts @@ -0,0 +1,37 @@ +import type { TuiAgent } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' +import type { WorkspaceCreateSetupDecision } from './workspace-create-params' + +// The blank/named create path, extracted from NewWorktreeModal so the modal keeps +// only the UI-coupled setup-trust flow. Assembles worktree.create params and +// applies the shared name-collision retry. +export async function createBlankWorkspace(args: { + client: RpcClient + repoId: string + baseName: string + startupCommand: string | undefined + createdWithAgentId: TuiAgent | undefined + comment: string | undefined + setupDecision: WorkspaceCreateSetupDecision +}): Promise { + return createWorktreeWithNameRetry({ + client: args.client, + baseName: args.baseName, + buildParams: (name) => { + const params: Record = { + repo: `id:${args.repoId}`, + startupCommand: args.startupCommand, + setupDecision: args.setupDecision, + name + } + if (args.createdWithAgentId) { + params.createdWithAgent = args.createdWithAgentId + } + if (args.comment) { + params.comment = args.comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/composer-linked-work-item.test.ts b/mobile/src/tasks/composer-linked-work-item.test.ts new file mode 100644 index 00000000000..d08d1388a0e --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' + +describe('linked work item builders', () => { + it('maps a GitHub PR into a linked work item', () => { + const linked = buildGitHubLinkedWorkItem({ + type: 'pr', + number: 42, + title: 'Fix bug', + url: 'https://github.com/o/r/pull/42', + repoId: 'repo-1' + }) + expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' }) + }) + + it('maps a GitLab MR into a linked work item', () => { + const linked = buildGitLabLinkedWorkItem({ + type: 'mr', + number: 7, + title: 'Add feature', + url: 'https://gitlab.com/g/p/-/merge_requests/7', + repoId: 'repo-2' + }) + expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' }) + }) + + it('maps a Linear issue with identifier, workspace, and org key', () => { + const linked = buildLinearLinkedWorkItem({ + identifier: 'ENG-9', + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + workspaceId: 'ws-1' + }) + expect(linked).toMatchObject({ + provider: 'linear', + type: 'issue', + number: 0, + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + }) + }) +}) + +describe('shouldApplyAutoName', () => { + it('applies when the name is empty or the previous auto-name', () => { + expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true) + }) + + it('applies when the name is a lookup query (URL / #N)', () => { + expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true) + expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false) + }) + + it('keeps a deliberately typed name', () => { + expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe( + false + ) + }) +}) + +describe('resolveWorkItemAutoName', () => { + it('slugifies the title subject', () => { + expect( + resolveWorkItemAutoName({ + type: 'issue', + number: 3, + title: 'Fix the Login Bug', + provider: 'github' + }) + ).toBe('fix-the-login-bug') + }) +}) + +describe('buildSmartNameSelection', () => { + const base = (over: Record) => ({ + provider: 'github' as const, + type: 'pr' as const, + number: 12, + title: 'T', + url: 'u', + ...over + }) + + it('maps GitHub PR / issue kinds and numbers the label', () => { + expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({ + kind: 'github-pr', + label: '#12 T', + url: 'u' + }) + expect( + buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined }) + ).toMatchObject({ kind: 'github-issue' }) + }) + + it('maps GitLab MR / issue kinds', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-mr' }) + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }), + baseBranch: undefined + }) + ).toMatchObject({ kind: 'gitlab-issue' }) + }) + + it('maps Linear with a bare title label', () => { + expect( + buildSmartNameSelection({ + linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }), + baseBranch: undefined + }) + ).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' }) + }) + + it('falls back to a branch pill', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({ + kind: 'branch', + label: 'main' + }) + }) + + it('returns null when nothing is selected', () => { + expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull() + }) +}) + +describe('resolveComposerCreateSelection', () => { + const baseCreateArgs = { + branch: null, + reuseEligibleBranch: null, + reuseSelectedBranch: false, + branchCreateIntent: false, + name: '' + } + + it('prefers a linked work item and passes resolved base fields', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: { + provider: 'github', + type: 'pr', + number: 5, + title: 'T', + url: 'u', + repoId: 'repo-1' + }, + base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' } + }) + expect(selection).toMatchObject({ + kind: 'work-item', + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'pr-5' + }) + }) + + it('marks reuse when the eligible branch is toggled on', () => { + const selection = resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: { baseBranch: 'feature', branchNameOverride: 'feature' }, + branch: { refName: 'feature', localBranchName: 'feature' }, + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true + }) + expect(selection).toEqual({ + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + }) + }) + + it('returns a new-branch selection when create-branch intent is set', () => { + expect( + resolveComposerCreateSelection({ + ...baseCreateArgs, + linkedWorkItem: null, + base: {}, + branchCreateIntent: true, + name: 'feature/login' + }) + ).toEqual({ kind: 'new-branch', branchName: 'feature/login' }) + }) + + it('returns null with no work item, no branch base, and no intent', () => { + expect( + resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} }) + ).toBeNull() + }) +}) + +describe('resolveComposerBranchPick', () => { + it('auto-names and enables reuse for an unused local branch', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: [] + }) + expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' }) + expect(pick).toMatchObject({ + reuseEligibleBranch: 'feature', + reuseSelectedBranch: true, + name: 'feature' + }) + }) + + it('does not reuse a branch already checked out elsewhere', () => { + const pick = resolveComposerBranchPick({ + refName: 'feature', + localBranchName: 'feature', + currentName: '', + lastAutoName: '', + worktreeBranches: ['refs/heads/feature'] + }) + expect(pick.reuseEligibleBranch).toBeNull() + expect(pick.reuseSelectedBranch).toBe(false) + expect(pick.base.branchNameOverride).toBeUndefined() + }) +}) + +// Keep the exported type aliases referenced so the module surface stays covered. +export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue] diff --git a/mobile/src/tasks/composer-linked-work-item.ts b/mobile/src/tasks/composer-linked-work-item.ts new file mode 100644 index 00000000000..ed2bc4dbf2d --- /dev/null +++ b/mobile/src/tasks/composer-linked-work-item.ts @@ -0,0 +1,153 @@ +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name' +import { + buildGitHubWorkspaceSource, + buildGitLabWorkspaceSource, + buildLinearWorkspaceSource, + buildWorkspaceSourceSelection, + getWorkspaceSourceName, + shouldApplyWorkspaceSourceAutoName +} from '../../../src/shared/new-workspace/workspace-source' +import { resolveComposerBranchPick as resolveSharedComposerBranchPick } from '../../../src/shared/composer-branch-selection' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export function buildGitHubLinkedWorkItem(item: { + type: 'issue' | 'pr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitHubWorkspaceSource(item) +} + +export function buildGitLabLinkedWorkItem(item: { + type: 'issue' | 'mr' + number: number + title: string + url: string + repoId: string +}): MobileLinkedWorkItem { + return buildGitLabWorkspaceSource(item) +} + +export function buildLinearLinkedWorkItem(issue: { + identifier: string + title: string + url: string + workspaceId?: string +}): MobileLinkedWorkItem { + return buildLinearWorkspaceSource(issue) +} + +// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name +// replaces the current field only when it's empty, still the last auto-name, or +// a lookup query — never a name the user deliberately typed. +export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean { + return shouldApplyWorkspaceSourceAutoName(args) +} + +export function resolveWorkItemAutoName(item: { + type: 'issue' | 'pr' | 'mr' + number: number + title: string + provider: 'github' | 'gitlab' | 'linear' + linearIdentifier?: string +}): string { + return getWorkspaceSourceName({ ...item, url: '' }).seedName +} + +export function resolveLinearAutoName(issue: { identifier: string; title: string }): string { + return getLinearIssueWorkspaceName(issue) +} + +// Derives the pill descriptor from the linked item (or a plain branch base), +// mirroring desktop's smartNameSelection memo. +export function buildSmartNameSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + baseBranch: string | undefined +}): SmartNameSelection | null { + return buildWorkspaceSourceSelection(args) as SmartNameSelection | null +} + +// Derives the create-time selection from composer state: a linked work item wins +// (carrying its resolved base/push fields), else a picked branch, else null (a +// name-only/blank create). +export function resolveComposerCreateSelection(args: { + linkedWorkItem: MobileLinkedWorkItem | null + base: { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + branch: { refName: string; localBranchName: string } | null + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + branchCreateIntent: boolean + name: string +}): MobileComposerCreateSelection | null { + const { linkedWorkItem, base, branch, reuseEligibleBranch, reuseSelectedBranch } = args + if (linkedWorkItem) { + return { + kind: 'work-item', + item: linkedWorkItem, + baseBranch: base.baseBranch, + compareBaseRef: base.compareBaseRef, + pushTarget: base.pushTarget, + branchNameOverride: base.branchNameOverride + } + } + if (branch && base.baseBranch) { + return { + kind: 'branch', + baseBranch: base.baseBranch, + refName: branch.refName, + localBranchName: branch.localBranchName, + reuse: reuseSelectedBranch && reuseEligibleBranch === branch.localBranchName, + branchNameOverride: base.branchNameOverride + } + } + if (args.branchCreateIntent && args.name.trim()) { + return { kind: 'new-branch', branchName: args.name.trim() } + } + return null +} + +export type ComposerBranchPick = { + base: { baseBranch: string; branchNameOverride?: string } + reuseEligibleBranch: string | null + reuseSelectedBranch: boolean + name?: string + lastAutoName?: string +} + +// Pure port of desktop handleSmartBranchSelect's derivation: base + reuse +// eligibility/default + the auto-name to apply, from the shared branch helpers. +export function resolveComposerBranchPick(args: { + refName: string + localBranchName: string + currentName: string + lastAutoName: string + worktreeBranches: readonly string[] +}): ComposerBranchPick { + const selection = resolveSharedComposerBranchPick(args) + return { + base: { + baseBranch: selection.baseBranch, + branchNameOverride: selection.branchNameOverride + }, + reuseEligibleBranch: selection.reuseEligibleBranch, + reuseSelectedBranch: selection.defaultReuse, + ...(selection.name !== undefined && selection.lastAutoName !== undefined + ? { name: selection.name, lastAutoName: selection.lastAutoName } + : {}) + } +} + +export type { GitHubWorkItem, GitLabWorkItem, LinearIssue } diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts new file mode 100644 index 00000000000..73c7eaa1839 --- /dev/null +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -0,0 +1,76 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import type { GitHubPrStartPoint } from '../../../src/shared/types' + +// The resolved start point for a linked PR/MR: the base branch to create from +// plus the optional review-compare ref, push target, and exact branch name. +export type ComposerHostedBase = Pick< + GitHubPrStartPoint, + 'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify' +> + +type HostedBaseResult = ComposerHostedBase | { error: string } + +// Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's +// select-time resolution. The runtime returns a soft { error } payload rather +// than an RPC error for provider failures. +export async function resolveComposerPrBase(args: { + client: RpcClient + repoId: string + prNumber: number + headRefName?: string + baseRefName?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolvePrBase', + { + repo: `id:${repoId}`, + prNumber, + ...(headRefName ? { headRefName } : {}), + ...(baseRefName ? { baseRefName } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + return result +} + +// Resolves a GitLab MR's base via worktree.resolveMrBase. +export async function resolveComposerMrBase(args: { + client: RpcClient + repoId: string + mrIid: number + sourceBranch?: string + targetBranch?: string + isCrossRepository?: boolean +}): Promise { + const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args + const response = await client.sendRequest( + 'worktree.resolveMrBase', + { + repo: `id:${repoId}`, + mrIid, + ...(sourceBranch ? { sourceBranch } : {}), + ...(targetBranch ? { targetBranch } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}) + }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as HostedBaseResult + if ('error' in result) { + throw new Error(result.error) + } + return result +} diff --git a/mobile/src/tasks/mobile-composer-source-types.ts b/mobile/src/tasks/mobile-composer-source-types.ts new file mode 100644 index 00000000000..d36215189af --- /dev/null +++ b/mobile/src/tasks/mobile-composer-source-types.ts @@ -0,0 +1,64 @@ +import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { + WorkspaceSourceLinkedItem, + WorkspaceSourceSelection +} from '../../../src/shared/new-workspace/workspace-source' +import type { WorkspaceCreateGitPushTarget } from './workspace-create-params' + +export type { SmartNameMode } + +export type ComposerBaseState = { + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string +} + +// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask +// superset): the one work item a Smart selection pins the workspace to. Linear +// items carry the workspace/org routing the runtime needs to relink the issue. +export type MobileLinkedWorkItem = Omit & { + provider: Exclude +} + +export type SmartNameSelectionKind = + | 'github-pr' + | 'github-issue' + | 'gitlab-mr' + | 'gitlab-issue' + | 'branch' + | 'linear' + +// The pill descriptor the field renders once a source is selected. Same shape +// as desktop's `SmartWorkspaceNameSelection`. +export type SmartNameSelection = Omit & { + kind: SmartNameSelectionKind +} + +// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default +// is 'opened' (Open). +export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all' + +// The resolved selection the create flow consumes. Work-item selections carry +// the base/push fields the composer resolved at select time; branch selections +// carry the ref + reuse intent. +export type MobileComposerCreateSelection = + | { + kind: 'work-item' + item: MobileLinkedWorkItem + baseBranch?: string + compareBaseRef?: string + pushTarget?: WorkspaceCreateGitPushTarget + branchNameOverride?: string + } + | { + kind: 'branch' + baseBranch: string + refName: string + localBranchName: string + reuse: boolean + branchNameOverride?: string + } + // A brand-new branch created by name (no ref picked); the branch is created off + // the repo's default base and the typed name is kept verbatim as the branch. + | { kind: 'new-branch'; branchName: string } diff --git a/mobile/src/tasks/mobile-smart-source-modes.test.ts b/mobile/src/tasks/mobile-smart-source-modes.test.ts new file mode 100644 index 00000000000..dc726569095 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MR_STATE_FILTER, + MR_STATE_FILTER_OPTIONS, + normalizeSmartMode, + resolveAvailableSmartModes, + resolveDefaultSmartMode +} from './mobile-smart-source-modes' + +const fullyAvailable = { + textOnly: false, + tasksSupported: true, + hasRepo: true, + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true +} + +describe('resolveAvailableSmartModes', () => { + it('lists every mode in desktop order when all are available', () => { + expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([ + 'smart', + 'github', + 'linear', + 'gitlab', + 'branches', + 'text' + ]) + }) + + it('collapses to Name for a non-git (text-only) repo', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text']) + }) + + it('drops provider + smart modes when the tasks RPC surface is missing', () => { + expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([ + 'branches', + 'text' + ]) + }) + + it('gates provider tabs on their availability and a selected repo', () => { + expect( + resolveAvailableSmartModes({ + ...fullyAvailable, + githubAvailable: false, + gitlabAvailable: false + }) + ).toEqual(['smart', 'linear', 'branches', 'text']) + expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([ + 'smart', + 'linear', + 'text' + ]) + }) +}) + +describe('resolveDefaultSmartMode', () => { + it('defaults to smart for a git repo with search', () => { + expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart') + }) + + it('defaults to text for a non-git repo', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text') + }) + + it('defaults to branches for a git repo without the tasks surface', () => { + expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches') + }) +}) + +describe('normalizeSmartMode', () => { + it('keeps a valid mode and snaps an unavailable one back to default', () => { + expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab') + expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe( + 'smart' + ) + }) +}) + +describe('MR state filters', () => { + it('exposes Open/Merged/Closed/All with an Open default', () => { + expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all']) + expect(DEFAULT_MR_STATE_FILTER).toBe('opened') + }) +}) diff --git a/mobile/src/tasks/mobile-smart-source-modes.ts b/mobile/src/tasks/mobile-smart-source-modes.ts new file mode 100644 index 00000000000..911219d28c4 --- /dev/null +++ b/mobile/src/tasks/mobile-smart-source-modes.ts @@ -0,0 +1,93 @@ +import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types' + +// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand +// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand +// icons. +export type SmartModeIcon = + | { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' } + | { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' } + +export type SmartModeOption = { + id: SmartNameMode + label: string + icon: SmartModeIcon +} + +// Order + labels + icons mirror desktop getSmartWorkspaceNameModes(): +// Smart · GitHub · Linear · GitLab · Branch · Name. +export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [ + { id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } }, + { id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } }, + { id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } }, + { id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } }, + { id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } }, + { id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } } +] + +export type SmartModeAvailabilityInput = { + textOnly: boolean + tasksSupported: boolean + hasRepo: boolean + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean +} + +// Faithful port of the desktop availableModes filter. Non-git repos collapse to +// the Name tab; provider tabs gate on availability + a selected repo + the tasks +// RPC surface; branches only need a git repo (new-branch-by-name works without +// the search capability). +export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] { + if (input.textOnly) { + return ['text'] + } + return SMART_MODE_OPTIONS.filter((option) => { + switch (option.id) { + case 'smart': + return input.tasksSupported + case 'github': + return input.tasksSupported && input.hasRepo && input.githubAvailable + case 'gitlab': + return input.tasksSupported && input.hasRepo && input.gitlabAvailable + case 'linear': + return input.tasksSupported && input.linearAvailable + case 'branches': + return input.hasRepo + case 'text': + return true + } + }).map((option) => option.id) +} + +// Default mode when the picker opens: 'smart' for a git repo when search is +// available, else the first available mode (branches for git without tasks, +// 'text' for non-git). +export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode { + const available = resolveAvailableSmartModes(input) + if (available.includes('smart')) { + return 'smart' + } + return available[0] ?? 'text' +} + +// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a +// non-git folder), mirroring desktop's snap-to-available effect. +export function normalizeSmartMode( + mode: SmartNameMode, + input: SmartModeAvailabilityInput +): SmartNameMode { + const available = resolveAvailableSmartModes(input) + return available.includes(mode) ? mode : resolveDefaultSmartMode(input) +} + +export type MrStateFilterOption = { id: MrStateFilter; label: string } + +// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'. +export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [ + { id: 'opened', label: 'Open' }, + { id: 'merged', label: 'Merged' }, + { id: 'closed', label: 'Closed' }, + { id: 'all', label: 'All' } +] + +export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened' diff --git a/mobile/src/tasks/mobile-tasks-capability.ts b/mobile/src/tasks/mobile-tasks-capability.ts new file mode 100644 index 00000000000..f8498bfd968 --- /dev/null +++ b/mobile/src/tasks/mobile-tasks-capability.ts @@ -0,0 +1,5 @@ +// Runtime capability the desktop advertises when it supports the mobile Tasks RPC +// surface (github/gitlab/linear work items + repo.searchRefs). Older paired +// desktops omit it, so mobile must degrade to blank/new-branch sources only. +// Mirrors the 'mobile.tasks.v1' entry in src/shared/protocol-version.ts. +export const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1' diff --git a/mobile/src/tasks/setup-hook-trust.test.ts b/mobile/src/tasks/setup-hook-trust.test.ts index b09bdd0d4b3..0e2d91bb7ac 100644 --- a/mobile/src/tasks/setup-hook-trust.test.ts +++ b/mobile/src/tasks/setup-hook-trust.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest' import { isSetupHookTrusted, normalizeSetupHookTrust, + persistSetupHookTrustApproval, trustedOrcaHooksWithSetupApproval, wasSetupHookPreviouslyApproved } from './setup-hook-trust' import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' describe('setup hook trust', () => { it('trusts a setup script only when the approved hash matches', () => { @@ -71,6 +73,27 @@ describe('setup hook trust', () => { }) }) + it('persists and returns the approved trust state', async () => { + let persisted: unknown + const client = { + sendRequest: async (_method: string, params: unknown) => { + persisted = params + return { ok: true, result: null } + } + } as unknown as RpcClient + + const next = await persistSetupHookTrustApproval({ + client, + trust: {}, + repoId: 'repo-1', + contentHash: 'setup-hash', + alwaysTrust: false + }) + + expect(persisted).toEqual({ trustedOrcaHooks: next }) + expect(isSetupHookTrusted(next, 'repo-1', 'setup-hash')).toBe(true) + }) + it('detects previous setup approval and ignores incomplete trust payloads', () => { expect( wasSetupHookPreviouslyApproved( diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index 847420f1e86..04c52152f45 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -1,4 +1,5 @@ import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' export type SetupHookTrust = { contentHash: string @@ -36,6 +37,21 @@ export function trustedOrcaHooksWithSetupApproval(args: { return { ...args.trust, [args.repoId]: nextRepo } } +export async function persistSetupHookTrustApproval(args: { + client: RpcClient + trust: PersistedTrustedOrcaHooks + repoId: string + contentHash: string + alwaysTrust: boolean +}): Promise { + const next = trustedOrcaHooksWithSetupApproval(args) + const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!response.ok) { + throw new Error(response.error.message) + } + return next +} + export function normalizeSetupHookTrust( setupTrust: SetupHookTrust | null | undefined ): SetupHookTrust | null { diff --git a/mobile/src/tasks/smart-source-fan-out.test.ts b/mobile/src/tasks/smart-source-fan-out.test.ts new file mode 100644 index 00000000000..cffdc287e22 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch } from './smart-source-fan-out' + +type Call = { method: string; params: Record } + +function fakeClient(byMethod: Record, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = byMethod[method] + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const smartArgs = { + mode: 'smart' as const, + query: 'bug', + repoId: 'repo-1', + githubAvailable: true, + gitlabAvailable: true, + linearAvailable: true, + mrStateFilter: 'opened' as const, + linearWorkspaceId: null +} + +describe('fanOutSmartSearch', () => { + it('fans out to every provider in smart mode and stamps repoId', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] }, + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] }, + 'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(calls.map((c) => c.method).sort()).toEqual([ + 'github.listWorkItems', + 'gitlab.listWorkItems', + 'linear.searchIssues', + 'repo.searchRefs' + ]) + expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' }) + expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' }) + expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' }) + expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }]) + expect(result.error).toBe('') + }) + + it('swallows a single provider failure in smart mode (best-effort)', async () => { + const calls: Call[] = [] + const client = fakeClient( + { + 'github.listWorkItems': new Error('gh down'), + 'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] }, + 'linear.searchIssues': { items: [] }, + 'repo.searchRefs': { refDetails: [] } + }, + calls + ) + const result = await fanOutSmartSearch({ client, ...smartArgs }) + expect(result.error).toBe('') + expect(result.gitlabItems).toHaveLength(1) + }) + + it('surfaces the error for a single-provider mode', async () => { + const calls: Call[] = [] + const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls) + const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client }) + expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems']) + expect(result.error).toBe('gl boom') + }) + + it('only searches branches in smart mode when the query is non-empty', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, query: '', client }) + expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs') + }) + + it('skips GitHub in smart mode when GitHub is unavailable', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client }) + expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems') + }) + + it('does not send oversized source queries to any provider', async () => { + const calls: Call[] = [] + const client = fakeClient({}, calls) + const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client }) + expect(calls).toEqual([]) + expect(result).toMatchObject({ + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] + }) + }) +}) diff --git a/mobile/src/tasks/smart-source-fan-out.ts b/mobile/src/tasks/smart-source-fan-out.ts new file mode 100644 index 00000000000..e2217597ab9 --- /dev/null +++ b/mobile/src/tasks/smart-source-fan-out.ts @@ -0,0 +1,141 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import { + isSmartWorkspaceSourceQueryWithinLimit, + type SmartNameMode +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + searchBranches, + searchGitHubItems, + searchGitLabItems, + searchLinearIssues +} from './smart-source-search-requests' + +export type SmartFanOutResult = { + githubItems: GitHubWorkItem[] + gitlabItems: GitLabWorkItem[] + linearIssues: LinearIssue[] + branches: BaseRefSearchResult[] + needsGitHubRemote: boolean + error: string +} + +const EMPTY: Omit = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [] +} + +function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean { + return githubAvailable && (mode === 'smart' || mode === 'github') +} + +function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean { + return gitlabAvailable && (mode === 'smart' || mode === 'gitlab') +} + +function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean { + return linearAvailable && (mode === 'smart' || mode === 'linear') +} + +function shouldSearchBranches(mode: SmartNameMode, query: string): boolean { + return mode === 'branches' || (mode === 'smart' && query.trim().length > 0) +} + +type FanOutArgs = { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined +} + +// Runs every provider search the active mode needs, concurrently. Smart mode is +// best-effort (a single provider failure never blocks the others); single-provider +// modes surface the failure. No cross-provider ranking/dedup — the shared row +// builder concatenates in provider order. +export async function fanOutSmartSearch(args: FanOutArgs): Promise { + if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) { + // Why: the source limit is an outbound-request boundary, not only a render + // limit; pasted payloads must never fan out to provider CLIs or SSH hosts. + return { ...EMPTY, needsGitHubRemote: false, error: '' } + } + const { + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter + } = args + const isSmart = mode === 'smart' + const tasks = { + github: + shouldSearchGitHub(mode, githubAvailable) && repoId + ? searchGitHubItems(client, repoId, query) + : null, + gitlab: + shouldSearchGitLab(mode, gitlabAvailable) && repoId + ? searchGitLabItems(client, repoId, query, mrStateFilter) + : null, + linear: shouldSearchLinear(mode, linearAvailable) + ? searchLinearIssues(client, query, args.linearWorkspaceId) + : null, + branches: + shouldSearchBranches(mode, query) && repoId ? searchBranches(client, repoId, query) : null + } + const [github, gitlab, linear, branches] = await Promise.allSettled([ + tasks.github ?? Promise.resolve([]), + tasks.gitlab ?? Promise.resolve([]), + tasks.linear ?? Promise.resolve([]), + tasks.branches ?? Promise.resolve([]) + ]) + + let needsGitHubRemote = false + let error = '' + const fail = (reason: unknown) => { + if (!isSmart) { + error = reason instanceof Error ? reason.message : 'Search failed' + } + } + if (github.status === 'rejected') { + if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) { + needsGitHubRemote = true + } else { + fail(github.reason) + } + } + if (gitlab.status === 'rejected') { + fail(gitlab.reason) + } + if (linear.status === 'rejected') { + fail(linear.reason) + } + if (branches.status === 'rejected') { + fail(branches.reason) + } + + return { + ...EMPTY, + githubItems: github.status === 'fulfilled' ? github.value : [], + gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [], + linearIssues: linear.status === 'fulfilled' ? linear.value : [], + branches: branches.status === 'fulfilled' ? branches.value : [], + needsGitHubRemote, + error + } +} diff --git a/mobile/src/tasks/smart-source-paste-intent.test.ts b/mobile/src/tasks/smart-source-paste-intent.test.ts new file mode 100644 index 00000000000..f7c724e7637 --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { + deriveRepoSlug, + findRepoMatchingSlug, + findRepoMatchingSlugForPaste, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' +import type { RpcClient } from '../transport/rpc-client' + +describe('resolvePasteIntent', () => { + it('classifies a GitHub issue/PR URL as a github-link', () => { + expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({ + kind: 'github-link', + link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' } + }) + }) + + it('classifies a bare #number as a github-number', () => { + expect(resolvePasteIntent('#42')).toEqual({ kind: 'github-number', number: 42 }) + }) + + it('classifies a GitLab MR URL as a gitlab-link', () => { + const intent = resolvePasteIntent('https://gitlab.com/group/proj/-/merge_requests/8') + expect(intent?.kind).toBe('gitlab-link') + if (intent?.kind === 'gitlab-link') { + expect(intent.link).toMatchObject({ number: 8, type: 'mr' }) + } + }) + + it('returns null for plain search text', () => { + expect(resolvePasteIntent('login bug')).toBeNull() + }) + + it('rejects oversized paste intents before any exact lookup', () => { + expect( + resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`) + ).toBeNull() + }) +}) + +describe('deriveRepoSlug', () => { + it('prefers the upstream identity', () => { + expect(deriveRepoSlug({ upstream: { owner: 'up', repo: 'stream' } })).toEqual({ + owner: 'up', + repo: 'stream' + }) + }) + + it('parses an SSH remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('parses an HTTPS remote URL', () => { + expect( + deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'https://github.com/acme/widgets' } }) + ).toEqual({ owner: 'acme', repo: 'widgets' }) + }) + + it('returns null when no slug can be derived', () => { + expect(deriveRepoSlug({})).toBeNull() + }) +}) + +describe('findRepoMatchingSlug', () => { + const repos: PasteRepoCandidate[] = [ + { id: 'a', displayName: 'A', slug: { owner: 'acme', repo: 'widgets' } }, + { id: 'b', displayName: 'B', slug: null } + ] + + it('matches case-insensitively', () => { + expect(findRepoMatchingSlug(repos, { owner: 'Acme', repo: 'Widgets' })?.id).toBe('a') + }) + + it('returns null when no repo matches', () => { + expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull() + }) + + it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => { + const calls: string[] = [] + const client = { + sendRequest: async (_method: string, params: unknown) => { + const repo = (params as { repo: string }).repo + calls.push(repo) + return { + ok: true, + result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null + } + } + } as unknown as RpcClient + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toMatchObject({ id: 'b' }) + expect(calls).toEqual(['id:a', 'id:b']) + }) + + it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => { + let calls = 0 + const client = { + sendRequest: async () => { + calls += 1 + return { + ok: false, + error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' } + } + } + } as unknown as RpcClient + const cache = new Map() + + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'widgets' }, cache) + ).resolves.toBeNull() + await expect( + findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'other' }, cache) + ).resolves.toBeNull() + expect(calls).toBe(1) + }) + + it('keeps local matching usable when the optional repo slug lookup rejects', async () => { + const client = { + sendRequest: async () => { + throw new Error('connection closed') + } + } as unknown as RpcClient + + await expect( + findRepoMatchingSlugForPaste( + client, + repos, + { owner: 'enterprise', repo: 'widgets' }, + new Map() + ) + ).resolves.toBeNull() + }) +}) diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts new file mode 100644 index 00000000000..72c23a3737f --- /dev/null +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -0,0 +1,178 @@ +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + normalizeGitHubLinkQuery, + parseGitHubIssueOrPRLink, + type GitHubIssueOrPRLink, + type RepoSlug +} from '../../../src/shared/new-workspace/github-links' +import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links' +import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' + +// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived +// best-effort from the repo's remote metadata. +export type PasteRepoCandidate = { + id: string + displayName: string + slug: RepoSlug | null +} + +export type GitHubPasteIntent = + | { kind: 'github-link'; link: GitHubIssueOrPRLink } + | { kind: 'github-number'; number: number } + +export type GitLabPasteIntent = { + kind: 'gitlab-link' + link: NonNullable> +} + +export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null + +// Pure: classify pasted text into a work-item lookup intent. A slug-bearing +// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number'; +// a GitLab issue/MR URL becomes 'gitlab-link'. +export function resolvePasteIntent(query: string): PasteIntent { + if (!isSmartWorkspaceSourceQueryWithinLimit(query)) { + return null + } + const trimmed = query.trim() + if (!trimmed) { + return null + } + const ghLink = parseGitHubIssueOrPRLink(trimmed) + if (ghLink) { + return { kind: 'github-link', link: ghLink } + } + const normalizedGh = normalizeGitHubLinkQuery(trimmed) + if (normalizedGh.directNumber !== null && !/^https?:\/\//i.test(trimmed)) { + return { kind: 'github-number', number: normalizedGh.directNumber } + } + const glLink = parseGitLabIssueOrMRLink(trimmed) + if (glLink) { + return { kind: 'gitlab-link', link: glLink } + } + return null +} + +// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted +// cross-repo URL can be matched to a locally known repo. +export function deriveRepoSlug(repo: { + upstream?: { owner: string; repo: string } | null + gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null +}): RepoSlug | null { + if (repo.upstream?.owner && repo.upstream.repo) { + return { owner: repo.upstream.owner, repo: repo.upstream.repo } + } + const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? '' + const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source) + if (match) { + return { owner: match[1], repo: match[2] } + } + return null +} + +function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean { + if (!a || !b) { + return false + } + return ( + a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase() + ) +} + +export function findRepoMatchingSlug( + repos: readonly PasteRepoCandidate[], + slug: RepoSlug +): PasteRepoCandidate | null { + return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null +} + +export async function findRepoMatchingSlugForPaste( + client: RpcClient, + repos: readonly PasteRepoCandidate[], + slug: RepoSlug, + cache: Map +): Promise { + const projected = findRepoMatchingSlug(repos, slug) + if (projected) { + return projected + } + // Why: projected remote metadata is incomplete for SSH and GitHub Enterprise; + // ask each repo's owning runtime instead of assuming github.com URL syntax. + for (const repo of repos) { + let resolved = cache.get(repo.id) + if (!cache.has(repo.id)) { + try { + const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` }) + if (!response.ok && response.error.code === 'method_not_found') { + // Why: RPC availability is host-wide; avoid repeating an unsupported + // probe for every repo or on the next paste attempt. + repos.forEach((candidate) => cache.set(candidate.id, null)) + return null + } + resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null + } catch { + resolved = null + } + cache.set(repo.id, resolved ?? null) + } + if (slugsEqual(resolved ?? null, slug)) { + return repo + } + } + return null +} + +export async function lookupGitHubItemByNumber( + client: RpcClient, + repoId: string, + number: number +): Promise { + const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitHubItemByOwnerRepo( + client: RpcClient, + repoId: string, + slug: RepoSlug, + number: number, + type: 'issue' | 'pr' +): Promise { + const response = await client.sendRequest('github.workItemByOwnerRepo', { + repo: `id:${repoId}`, + owner: slug.owner, + ownerRepo: slug.repo, + number, + type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitHubWorkItem | null + return item ? { ...item, repoId } : null +} + +export async function lookupGitLabItemByPath( + client: RpcClient, + repoId: string, + link: NonNullable> +): Promise { + const response = await client.sendRequest('gitlab.workItemByPath', { + repo: `id:${repoId}`, + host: link.slug.host, + path: link.slug.path, + iid: link.number, + type: link.type + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const item = (response as RpcSuccess).result as GitLabWorkItem | null + return item ? { ...item, repoId } : null +} diff --git a/mobile/src/tasks/smart-source-search-requests.test.ts b/mobile/src/tasks/smart-source-search-requests.test.ts new file mode 100644 index 00000000000..ecf029feb70 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests' + +type Call = { method: string; params: Record } + +function fakeClient(result: unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +describe('scopeGitHubQuery', () => { + it('passes the raw trimmed query so BOTH issues and PRs are returned', () => { + // Empty stays empty (runtime lists recent issues + PRs); no forced is:issue. + expect(scopeGitHubQuery('')).toBe('') + expect(scopeGitHubQuery(' login bug ')).toBe('login bug') + }) + + it('preserves an explicit is:pr / is:issue scope the user typed', () => { + expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth') + expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth') + }) +}) + +describe('searchLinearIssues', () => { + it('lists assigned issues for an empty query (desktop default)', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, '', null) + expect(calls[0]!.method).toBe('linear.listIssues') + expect(calls[0]!.params).toMatchObject({ filter: 'assigned' }) + }) + + it('searches when a query is present', async () => { + const calls: Call[] = [] + const client = fakeClient({ items: [] }, calls) + await searchLinearIssues(client, 'bug', 'ws-1') + expect(calls[0]!.method).toBe('linear.searchIssues') + expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' }) + }) +}) diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts new file mode 100644 index 00000000000..5875cd4d642 --- /dev/null +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -0,0 +1,119 @@ +import type { + BaseRefSearchResult, + GitHubWorkItem, + GitLabWorkItem, + LinearIssue +} from '../../../src/shared/types' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { extractLinearIssueReadItems } from './linear-mobile-issue-read' +import { PER_REPO_FETCH_LIMIT } from './mobile-work-items' +import type { MrStateFilter } from './mobile-composer-source-types' + +const GITLAB_PER_PAGE = 50 +const LINEAR_LIMIT = 50 +const BRANCH_LIMIT = 20 + +// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's +// parseTaskQuery defaults scope 'all', and an empty query lists recent items of +// both types. So pass the raw trimmed query straight through (an explicit +// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty +// so the runtime lists recent issues + PRs. +export function scopeGitHubQuery(query: string): string { + return query.trim() +} + +export async function searchGitHubItems( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest('github.listWorkItems', { + repo: `id:${repoId}`, + limit: PER_REPO_FETCH_LIMIT, + query: scopeGitHubQuery(query) + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] } + // Stamp repoId so the shared row builder + create flow can attribute each item + // to the searched repo (the runtime omits it, like the desktop fetcher). + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchGitLabItems( + client: RpcClient, + repoId: string, + query: string, + state: MrStateFilter +): Promise { + const response = await client.sendRequest('gitlab.listWorkItems', { + repo: `id:${repoId}`, + state, + page: 1, + perPage: GITLAB_PER_PAGE, + query: query.trim() || undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + const envelope = (response as RpcSuccess).result as { + items: GitLabWorkItem[] + error?: { type?: string; message: string } + } + if (envelope.error?.type && envelope.error.type !== 'not_found') { + throw new Error(envelope.error.message) + } + return (envelope.items ?? []).map((item) => ({ ...item, repoId })) +} + +export async function searchLinearIssues( + client: RpcClient, + query: string, + linearWorkspaceId: string | null | undefined +): Promise { + const trimmed = query.trim() + const response = trimmed + ? await client.sendRequest('linear.searchIssues', { + query: trimmed, + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + : await client.sendRequest('linear.listIssues', { + // Empty query lists the viewer's assigned issues, matching desktop's + // Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')). + filter: 'assigned', + limit: LINEAR_LIMIT, + workspaceId: linearWorkspaceId ?? undefined + }) + if (!response.ok) { + throw new Error(response.error.message) + } + // extractLinearIssueReadItems yields the mobile issue-read shape; the fields the + // row builder/create flow read (id/identifier/title/url/state/team) are a subset. + return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[] +} + +export async function searchBranches( + client: RpcClient, + repoId: string, + query: string +): Promise { + const response = await client.sendRequest( + 'repo.searchRefs', + { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, + { timeoutMs: 30_000 } + ) + if (!response.ok) { + throw new Error(response.error.message) + } + const result = (response as RpcSuccess).result as { + refDetails?: BaseRefSearchResult[] + refs?: string[] + } + return ( + result.refDetails ?? + (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) + ) +} diff --git a/mobile/src/tasks/source-workspace-create.test.ts b/mobile/src/tasks/source-workspace-create.test.ts new file mode 100644 index 00000000000..d23e0c66317 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { createWorkspaceFromComposerSource } from './source-workspace-create' +import type { MobileComposerCreateSelection } from './mobile-composer-source-types' + +type Call = { method: string; params: Record } + +function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient { + return { + sendRequest: async (method: string, params?: unknown) => { + calls.push({ method, params: (params ?? {}) as Record }) + const result = handle(method, calls.length) + if (result instanceof Error) { + return { + id: '1', + ok: false, + error: { code: 'x', message: result.message }, + _meta: { runtimeId: 'r' } + } + } + return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } } + } + } as unknown as RpcClient +} + +const agent = { choice: 'blank' as const, startupCommand: undefined } + +const baseArgs = { + targetRepoId: 'repo-1', + setupDecision: 'inherit' as const, + agent, + workspaceName: undefined, + note: undefined +} + +describe('createWorkspaceFromComposerSource', () => { + it('creates a GitHub issue workspace linking the issue to its own repo', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-9' + } + } + // The composer supplies the title-derived name as workspaceName; with none, + // buildTaskWorkspaceCreateParams falls back to the "-" slug. + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' }) + expect(calls).toHaveLength(1) + expect(calls[0]!.method).toBe('worktree.create') + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-9', + linkedIssue: 7, + displayName: 'Bug' + }) + }) + + it('passes composer-resolved PR base fields straight through (no re-resolve)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'pr', + number: 3, + title: 'Feat', + url: 'u', + repoId: 'repo-1' + }, + baseBranch: 'main', + compareBaseRef: 'origin/main', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' }, + branchNameOverride: 'feat-3' + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.create']) + expect(calls[0]!.params).toMatchObject({ + linkedPR: 3, + baseBranch: 'main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feat-3', + pushTarget: { remoteName: 'origin', branchName: 'feat-3' } + }) + }) + + it('resolves a PR base as a fallback when the selection carries none', async () => { + const calls: Call[] = [] + const client = fakeClient( + (method) => + method === 'worktree.resolvePrBase' + ? { baseBranch: 'develop' } + : { worktree: { id: 'wt-3' } }, + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { provider: 'github', type: 'pr', number: 4, title: 'X', url: 'u', repoId: 'repo-1' } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls.map((c) => c.method)).toEqual(['worktree.resolvePrBase', 'worktree.create']) + expect(calls[1]!.params).toMatchObject({ baseBranch: 'develop', linkedPR: 4 }) + }) + + it('creates a Linear workspace with workspace + org routing', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-4' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Ship it', + url: 'https://linear.app/acme/issue/ENG-9', + linearIdentifier: 'ENG-9', + linearWorkspaceId: 'ws-1', + linearOrganizationUrlKey: 'acme' + } + } + await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + linkedLinearIssue: 'ENG-9', + linkedLinearIssueWorkspaceId: 'ws-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) + + it('reuses an existing branch with a single attempt (no suffix retry)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => new Error('Branch "feature" already exists.'), calls) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'feature', + refName: 'feature', + localBranchName: 'feature', + reuse: true, + branchNameOverride: 'feature' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect('error' in result).toBe(true) + expect(calls).toHaveLength(1) + expect(calls[0]!.params).toMatchObject({ + baseBranch: 'feature', + branchNameOverride: 'feature' + }) + }) + + it('creates a brand-new branch by name, keeping a slashy name as the branch', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-nb' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'new-branch', + branchName: 'feature/login' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature/login' }) + expect(calls[0]!.params).toMatchObject({ + repo: 'id:repo-1', + name: 'feature/login', + branchNameOverride: 'feature/login' + }) + }) + + it('suppresses displayName when the name is user-edited (not auto-managed)', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls) + const selection: MobileComposerCreateSelection = { + kind: 'work-item', + item: { + provider: 'github', + type: 'issue', + number: 7, + title: 'Bug', + url: 'u', + repoId: 'repo-1' + } + } + await createWorkspaceFromComposerSource({ + client, + selection, + ...baseArgs, + workspaceName: 'my-name', + nameIsAutoManaged: false + }) + expect(calls[0]!.params.displayName).toBeUndefined() + expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 }) + }) + + it('creates a new branch off a ref, bumping the branch on collision', async () => { + const calls: Call[] = [] + const client = fakeClient( + (_m, n) => (n === 1 ? new Error('already exists locally') : { worktree: { id: 'wt-5' } }), + calls + ) + const selection: MobileComposerCreateSelection = { + kind: 'branch', + baseBranch: 'main', + refName: 'main', + localBranchName: 'topic', + reuse: false, + branchNameOverride: 'topic' + } + const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs }) + expect(result).toEqual({ worktreeId: 'wt-5', name: 'topic-2' }) + expect(calls).toHaveLength(2) + expect(calls[1]!.params).toMatchObject({ + baseBranch: 'main', + branchNameOverride: 'topic-2', + name: 'topic-2' + }) + }) +}) diff --git a/mobile/src/tasks/source-workspace-create.ts b/mobile/src/tasks/source-workspace-create.ts new file mode 100644 index 00000000000..d16cc174142 --- /dev/null +++ b/mobile/src/tasks/source-workspace-create.ts @@ -0,0 +1,250 @@ +import type { RpcClient } from '../transport/rpc-client' +import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve' +import type { + MobileComposerCreateSelection, + MobileLinkedWorkItem +} from './mobile-composer-source-types' +import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' +import type { WorkspaceAgentChoice } from './workspace-agent-selection' +import { + buildTaskWorkspaceCreateParams, + type WorkspaceCreateSetupDecision, + type WorkspaceCreateTaskItem +} from './workspace-create-params' +import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry' + +// The agent bundle the modal already resolved: the choice drives +// buildTaskWorkspaceCreateParams for work-item sources; the explicit launch +// command is used for branch sources (which have no work-item URL to seed the draft). +export type WorkspaceCreateAgentBundle = { + choice: WorkspaceAgentChoice + startupCommand: string | undefined +} + +export type CreateWorkspaceFromComposerArgs = { + client: RpcClient + selection: MobileComposerCreateSelection + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +} + +export async function createWorkspaceFromComposerSource( + args: CreateWorkspaceFromComposerArgs +): Promise { + if (args.selection.kind === 'branch') { + return createBranchWorkspace({ ...args, selection: args.selection }) + } + if (args.selection.kind === 'new-branch') { + return createNewBranchWorkspace({ ...args, selection: args.selection }) + } + return createWorkItemWorkspace({ ...args, selection: args.selection }) +} + +function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem { + if (item.provider === 'github') { + return { + provider: 'github', + source: { + type: item.type === 'pr' ? 'pr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + if (item.provider === 'gitlab') { + return { + provider: 'gitlab', + source: { + type: item.type === 'mr' ? 'mr' : 'issue', + repoId: item.repoId ?? targetRepoId, + number: item.number, + title: item.title, + url: item.url + } + } + } + return { + provider: 'linear', + source: { + identifier: item.linearIdentifier ?? '', + title: item.title, + url: item.url, + ...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}), + ...(item.linearOrganizationUrlKey + ? { organizationUrlKey: item.linearOrganizationUrlKey } + : {}) + } + } +} + +async function createWorkItemWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined + nameIsAutoManaged?: boolean +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const item = selection.item + const taskItem = toTaskItem(item, targetRepoId) + + // The composer resolves PR/MR base at select time; only re-resolve as a + // fallback when a linked PR/MR reached create without one. + let baseBranch = selection.baseBranch + let compareBaseRef = selection.compareBaseRef + let pushTarget = selection.pushTarget + let branchNameOverride = selection.branchNameOverride + if (!baseBranch && item.provider !== 'linear' && (item.type === 'pr' || item.type === 'mr')) { + const repoId = item.repoId ?? targetRepoId + const resolved = + item.type === 'pr' + ? await resolveComposerPrBase({ client, repoId, prNumber: item.number }).catch(() => null) + : await resolveComposerMrBase({ client, repoId, mrIid: item.number }).catch(() => null) + if (resolved) { + baseBranch = resolved.baseBranch + compareBaseRef = resolved.compareBaseRef + pushTarget = resolved.pushTarget + branchNameOverride = resolved.branchNameOverride ?? branchNameOverride + } + } + + const params = buildTaskWorkspaceCreateParams({ + item: taskItem, + targetRepoId, + setupDecision, + agent: agent.choice, + workspaceName, + note, + baseBranch, + compareBaseRef, + branchNameOverride, + pushTarget, + nameIsAutoManaged: args.nameIsAutoManaged + }) + // buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base + // so collisions still append -2, -3, ... like the blank path does. + const baseName = String(params.name) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (name) => ({ ...params, name }) + }) +} + +async function createBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + const applyCommon = (params: Record): Record => { + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + + if (selection.reuse) { + // Reusing a fixed existing branch: branchNameOverride is pinned to the reused + // branch, so a branch collision can't be cleared by suffixing the display + // name — fail fast instead of burning the retry budget. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + maxAttempts: 1, + buildParams: (name) => + applyCommon({ + repo: `id:${targetRepoId}`, + name, + setupDecision, + baseBranch: selection.refName, + branchNameOverride: selection.localBranchName, + startupCommand: agent.startupCommand + }) + }) + } + + // New branch off the selected ref. The retry base is the branch name so a + // collision bumps the branch itself. + const baseName = resolveMobileWorkspaceCreateName({ + draft: workspaceName, + fallback: selection.branchNameOverride || selection.localBranchName + }) + return createWorktreeWithNameRetry({ + client, + baseName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + baseBranch: selection.baseBranch, + startupCommand: agent.startupCommand + } + if (selection.branchNameOverride) { + params.branchNameOverride = candidate + } + return applyCommon(params) + } + }) +} + +async function createNewBranchWorkspace(args: { + client: RpcClient + selection: Extract + targetRepoId: string + setupDecision: WorkspaceCreateSetupDecision + agent: WorkspaceCreateAgentBundle + workspaceName: string | undefined + note: string | undefined +}): Promise { + const { client, selection, targetRepoId, setupDecision, agent, note } = args + const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice + const comment = note?.trim() + // A brand-new branch off the repo's default base. The typed name is kept as the + // git branch (via branchNameOverride) so a slash like `feature/login` survives; + // the runtime sanitizes the worktree folder from the same name. The retry base is + // the branch name so a collision bumps the branch (and folder) together. + return createWorktreeWithNameRetry({ + client, + baseName: selection.branchName, + buildParams: (candidate) => { + const params: Record = { + repo: `id:${targetRepoId}`, + name: candidate, + setupDecision, + branchNameOverride: candidate, + startupCommand: agent.startupCommand + } + if (createdWithAgentId) { + params.createdWithAgent = createdWithAgentId + } + if (comment) { + params.comment = comment + } + return params + } + }) +} diff --git a/mobile/src/tasks/use-mobile-composer-source.ts b/mobile/src/tasks/use-mobile-composer-source.ts new file mode 100644 index 00000000000..6785e2fe30b --- /dev/null +++ b/mobile/src/tasks/use-mobile-composer-source.ts @@ -0,0 +1,330 @@ +import { useCallback, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types' +import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection' +import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity' +import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning' +import type { RpcClient } from '../transport/rpc-client' +import { + buildGitHubLinkedWorkItem, + buildGitLabLinkedWorkItem, + buildLinearLinkedWorkItem, + buildSmartNameSelection, + resolveComposerBranchPick, + resolveComposerCreateSelection, + resolveLinearAutoName, + resolveWorkItemAutoName, + shouldApplyAutoName +} from './composer-linked-work-item' +import { + resolveComposerMrBase, + resolveComposerPrBase, + type ComposerHostedBase +} from './composer-source-base-resolve' +import type { + ComposerBaseState, + MobileComposerCreateSelection, + MobileLinkedWorkItem, + SmartNameSelection +} from './mobile-composer-source-types' +const EMPTY_BASE: ComposerBaseState = {} + +export type UseMobileComposerSourceArgs = { + client: RpcClient | null + selectedRepoId: string | null + worktreeBranches?: readonly string[] + onError?: (message: string) => void +} + +export function useMobileComposerSource(args: UseMobileComposerSourceArgs) { + const { client, selectedRepoId, worktreeBranches = [], onError } = args + const [name, setNameState] = useState('') + const [linkedWorkItem, setLinkedWorkItem] = useState(null) + const [base, setBase] = useState(EMPTY_BASE) + const [reuseEligibleBranch, setReuseEligibleBranch] = useState(null) + const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false) + const [forkPushWarning, setForkPushWarning] = useState(null) + const [resolvingBase, setResolvingBase] = useState(false) + // Set when the "Create branch " row is picked, so the typed name (which + // may contain slashes) is kept verbatim as the git branch (folder is sanitized). + const [branchCreateIntent, setBranchCreateIntent] = useState(false) + + const lastAutoNameRef = useRef('') + const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null) + // Guards async base resolution: only the latest selection applies its result. + const resolveTokenRef = useRef(0) + + const setName = useCallback((value: string) => setNameState(value), []) + + const applyAutoName = useCallback((suggested: string, currentName: string) => { + if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + }, []) + + const clearBaseAndBranch = useCallback(() => { + branchSelectionRef.current = null + setBranchCreateIntent(false) + setBase(EMPTY_BASE) + setReuseEligibleBranch(null) + setReuseSelectedBranch(false) + setForkPushWarning(null) + // Why: a superseding selection bumps the resolve token, so an in-flight base + // resolve's token-gated finally can no longer clear this — reset it here so + // resolvingBase never sticks true after switching sources. + setResolvingBase(false) + }, []) + + // Applies an async PR/MR base resolution guarded by the current token so only + // the latest selection wins; failures clear the base and surface the error. + const runBaseResolve = useCallback( + (token: number, resolve: Promise) => { + setResolvingBase(true) + void resolve + .then((result) => { + if (resolveTokenRef.current !== token) { + return + } + setBase({ + baseBranch: result.baseBranch, + compareBaseRef: result.compareBaseRef, + pushTarget: result.pushTarget, + branchNameOverride: result.branchNameOverride + }) + setForkPushWarning(getForkPushWarning(result)) + }) + .catch((error: unknown) => { + if (resolveTokenRef.current !== token) { + return + } + setBase(EMPTY_BASE) + onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.') + }) + .finally(() => { + if (resolveTokenRef.current === token) { + setResolvingBase(false) + } + }) + }, + [onError] + ) + + const handleSmartGitHubItemSelect = useCallback( + (item: GitHubWorkItem) => { + const token = (resolveTokenRef.current += 1) + const identity = resolveGitHubWorkItemIdentity(item) + // Resolve the PR base against the item's OWN repo — a cross-repo accept + // switches repos then selects synchronously, so selectedRepoId is stale. + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitHubLinkedWorkItem({ + type: identity.type, + number: identity.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ ...identity, title: item.title, provider: 'github' }), + name + ) + clearBaseAndBranch() + if (identity.type !== 'pr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerPrBase({ + client, + repoId, + prNumber: identity.number, + ...(item.branchName ? { headRefName: item.branchName } : {}), + ...(item.baseRefName ? { baseRefName: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartGitLabItemSelect = useCallback( + (item: GitLabWorkItem) => { + const token = (resolveTokenRef.current += 1) + // Resolve the MR base against the item's OWN repo (see the GitHub handler). + const repoId = item.repoId || selectedRepoId + setLinkedWorkItem( + buildGitLabLinkedWorkItem({ + type: item.type, + number: item.number, + title: item.title, + url: item.url, + repoId: item.repoId + }) + ) + applyAutoName( + resolveWorkItemAutoName({ + type: item.type, + number: item.number, + title: item.title, + provider: 'gitlab' + }), + name + ) + clearBaseAndBranch() + if (item.type !== 'mr' || !client || !repoId) { + return + } + runBaseResolve( + token, + resolveComposerMrBase({ + client, + repoId, + mrIid: item.number, + ...(item.branchName ? { sourceBranch: item.branchName } : {}), + ...(item.baseRefName ? { targetBranch: item.baseRefName } : {}), + ...(item.isCrossRepository !== undefined + ? { isCrossRepository: item.isCrossRepository } + : {}) + }) + ) + }, + [applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId] + ) + + const handleSmartLinearIssueSelect = useCallback( + (issue: LinearIssue) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(buildLinearLinkedWorkItem(issue)) + const suggested = resolveLinearAutoName(issue) + const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase() + if ( + suggested && + (identifierTyped || + shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current })) + ) { + setNameState(suggested) + lastAutoNameRef.current = suggested + } + clearBaseAndBranch() + }, + [clearBaseAndBranch, name] + ) + + const handleSmartBranchSelect = useCallback( + (refName: string, localBranchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + setForkPushWarning(null) + setBranchCreateIntent(false) + setResolvingBase(false) + const pick = resolveComposerBranchPick({ + refName, + localBranchName, + currentName: name, + lastAutoName: lastAutoNameRef.current, + worktreeBranches + }) + setReuseEligibleBranch(pick.reuseEligibleBranch) + setReuseSelectedBranch(pick.reuseSelectedBranch) + setBase(pick.base) + branchSelectionRef.current = { refName, localBranchName } + if (pick.name !== undefined) { + setNameState(pick.name) + lastAutoNameRef.current = pick.lastAutoName ?? '' + } + }, + [name, worktreeBranches] + ) + + // Picking "Create branch ": name the workspace and mark a new-branch + // intent so the typed (possibly slashy) name is kept verbatim as the git branch. + const handleSmartCreateBranch = useCallback( + (branchName: string) => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setNameState(branchName) + lastAutoNameRef.current = branchName + setBranchCreateIntent(true) + }, + [clearBaseAndBranch] + ) + + const handleClearSmartNameSelection = useCallback(() => { + resolveTokenRef.current += 1 + setLinkedWorkItem(null) + clearBaseAndBranch() + setResolvingBase(false) + if (name === lastAutoNameRef.current) { + setNameState('') + lastAutoNameRef.current = '' + } + }, [clearBaseAndBranch, name]) + + const handleBranchNameOverrideChange = useCallback( + (value: string) => { + const next = resolveComposerManualBranchNameChange({ + value, + pushTarget: base.pushTarget, + forkPushWarning + }) + setBase({ + ...base, + branchNameOverride: next.branchNameOverride, + pushTarget: next.pushTarget + }) + setForkPushWarning(next.forkPushWarning) + }, + [base, forkPushWarning] + ) + + const smartNameSelection = useMemo( + () => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }), + [base.baseBranch, linkedWorkItem] + ) + + const createSelection = useMemo( + () => + resolveComposerCreateSelection({ + linkedWorkItem, + base, + branch: branchSelectionRef.current, + reuseEligibleBranch, + reuseSelectedBranch, + branchCreateIntent, + name + }), + [base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch] + ) + + // Auto-managed until the user edits the name away from the last derived value; + // desktop suppresses the workspace displayName once the name is user-edited. + const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current + + return { + name, + setName, + linkedWorkItem, + branchNameOverride: base.branchNameOverride, + handleBranchNameOverrideChange, + reuseEligibleBranch, + reuseSelectedBranch, + setReuseSelectedBranch, + forkPushWarning, + resolvingBase, + isNameAutoManaged, + smartNameSelection, + createSelection, + handleSmartGitHubItemSelect, + handleSmartGitLabItemSelect, + handleSmartLinearIssueSelect, + handleSmartBranchSelect, + handleSmartCreateBranch, + handleClearSmartNameSelection + } +} + +export type MobileComposerSource = ReturnType diff --git a/mobile/src/tasks/use-smart-workspace-source.ts b/mobile/src/tasks/use-smart-workspace-source.ts new file mode 100644 index 00000000000..1d0c88dafd5 --- /dev/null +++ b/mobile/src/tasks/use-smart-workspace-source.ts @@ -0,0 +1,233 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types' +import { + buildSmartWorkspaceSourceRows, + getSmartWorkspaceEmptyHint, + type SmartNameMode, + type SmartWorkspaceSourceRow +} from '../../../src/shared/new-workspace/smart-workspace-source-results' +import type { RpcClient } from '../transport/rpc-client' +import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out' +import type { MrStateFilter } from './mobile-composer-source-types' +import { + findRepoMatchingSlugForPaste, + lookupGitHubItemByNumber, + lookupGitHubItemByOwnerRepo, + lookupGitLabItemByPath, + resolvePasteIntent, + type PasteRepoCandidate +} from './smart-source-paste-intent' + +const DEBOUNCE_MS = 200 +const RESULT_LIMIT = 36 + +export type SmartCrossRepoPrompt = { + link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' } + matchingRepo: PasteRepoCandidate +} + +export type UseSmartWorkspaceSourceArgs = { + client: RpcClient | null + enabled: boolean + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId?: string | null + repos: readonly PasteRepoCandidate[] +} + +const EMPTY_FAN: SmartFanOutResult = { + githubItems: [], + gitlabItems: [], + linearIssues: [], + branches: [], + needsGitHubRemote: false, + error: '' +} + +type PasteResolved = { github: GitHubWorkItem | null; gitlab: GitLabWorkItem | null } + +export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) { + const { + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + } = args + const [fan, setFan] = useState(EMPTY_FAN) + const [paste, setPaste] = useState({ github: null, gitlab: null }) + const [loading, setLoading] = useState(false) + const [crossRepoPrompt, setCrossRepoPrompt] = useState(null) + // Why: preserve results across keystrokes (debounce) but drop them the moment + // the mode/repo changes so one provider's rows never render under another tab. + const scopeRef = useRef('') + const dismissedPasteRef = useRef('') + const repoSlugCacheRef = useRef>(new Map()) + + useEffect(() => { + if (!client || !enabled || mode === 'text') { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setLoading(false) + setCrossRepoPrompt(null) + return + } + const scope = `${mode}:${repoId ?? ''}` + const scopeChanged = scopeRef.current !== scope + scopeRef.current = scope + if (scopeChanged) { + setFan(EMPTY_FAN) + setPaste({ github: null, gitlab: null }) + setCrossRepoPrompt(null) + } + setLoading(true) + let stale = false + const timer = setTimeout(() => { + void runSmartSearch({ + client, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos, + dismissedPasteRef, + repoSlugCache: repoSlugCacheRef.current + }) + .then((result) => { + if (stale) { + return + } + setFan(result.fan) + setPaste(result.paste) + setCrossRepoPrompt(result.crossRepoPrompt) + setLoading(false) + }) + .catch(() => { + if (!stale) { + setLoading(false) + } + }) + }, DEBOUNCE_MS) + return () => { + stale = true + clearTimeout(timer) + } + }, [ + client, + enabled, + mode, + query, + repoId, + githubAvailable, + gitlabAvailable, + linearAvailable, + mrStateFilter, + linearWorkspaceId, + repos + ]) + + const rows = useMemo( + () => + buildSmartWorkspaceSourceRows({ + branches: fan.branches, + githubItems: paste.github ? [paste.github] : fan.githubItems, + gitlabAvailable, + gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems, + linearAvailable, + linearIssues: fan.linearIssues, + mode, + resultLimit: RESULT_LIMIT, + value: query + }), + [fan, gitlabAvailable, linearAvailable, mode, paste, query] + ) + + const dismissCrossRepoPrompt = useCallback(() => { + dismissedPasteRef.current = query.trim() + setCrossRepoPrompt(null) + }, [query]) + + return { + rows, + loading, + error: fan.error, + needsGitHubRemote: fan.needsGitHubRemote, + emptyHint: getSmartWorkspaceEmptyHint(mode), + crossRepoPrompt, + dismissCrossRepoPrompt + } +} + +async function runSmartSearch(args: { + client: RpcClient + mode: SmartNameMode + query: string + repoId: string | null + githubAvailable: boolean + gitlabAvailable: boolean + linearAvailable: boolean + mrStateFilter: MrStateFilter + linearWorkspaceId: string | null | undefined + repos: readonly PasteRepoCandidate[] + dismissedPasteRef: { current: string } + repoSlugCache: Map +}): Promise<{ + fan: SmartFanOutResult + paste: PasteResolved + crossRepoPrompt: SmartCrossRepoPrompt | null +}> { + const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args + const fan = await fanOutSmartSearch(args) + const paste: PasteResolved = { github: null, gitlab: null } + let crossRepoPrompt: SmartCrossRepoPrompt | null = null + + const intent = + mode === 'branches' || dismissedPasteRef.current === query.trim() + ? null + : resolvePasteIntent(query) + if (intent && repoId) { + try { + if (intent.kind === 'github-number') { + paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number) + } else if (intent.kind === 'github-link') { + const matchingRepo = await findRepoMatchingSlugForPaste( + client, + repos, + intent.link.slug, + repoSlugCache + ) + if (matchingRepo && matchingRepo.id !== repoId) { + crossRepoPrompt = { link: intent.link, matchingRepo } + } else { + paste.github = await lookupGitHubItemByOwnerRepo( + client, + repoId, + intent.link.slug, + intent.link.number, + intent.link.type + ) + } + } else if (intent.kind === 'gitlab-link') { + paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link) + } + } catch { + // Best-effort paste resolution; fall back to the fan-out results. + } + } + return { fan, paste, crossRepoPrompt } +} diff --git a/mobile/src/tasks/work-item-lookup-text.test.ts b/mobile/src/tasks/work-item-lookup-text.test.ts new file mode 100644 index 00000000000..bd37f46d606 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { isWorkItemLookupText } from './work-item-lookup-text' + +describe('isWorkItemLookupText', () => { + it('treats references as lookup text, not names', () => { + expect(isWorkItemLookupText('#42')).toBe(true) + expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true) + expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true) + expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true) + expect(isWorkItemLookupText('ENG-9')).toBe(false) + }) + + it('treats plain names as non-lookup text', () => { + expect(isWorkItemLookupText('')).toBe(false) + expect(isWorkItemLookupText('fix the login bug')).toBe(false) + expect(isWorkItemLookupText('https://linear.app/acme/project/mobile')).toBe(false) + }) +}) diff --git a/mobile/src/tasks/work-item-lookup-text.ts b/mobile/src/tasks/work-item-lookup-text.ts new file mode 100644 index 00000000000..b130292e8d3 --- /dev/null +++ b/mobile/src/tasks/work-item-lookup-text.ts @@ -0,0 +1 @@ +export * from '../../../src/shared/new-workspace/work-item-lookup-text' diff --git a/mobile/src/tasks/workspace-create-params.test.ts b/mobile/src/tasks/workspace-create-params.test.ts index f15c7afce30..e53c7965116 100644 --- a/mobile/src/tasks/workspace-create-params.test.ts +++ b/mobile/src/tasks/workspace-create-params.test.ts @@ -144,7 +144,7 @@ describe('task workspace create params', () => { ).toMatchObject({ repo: 'id:repo-linear', name: 'eng-42', - displayName: 'Ship Linear parity', + displayName: 'ENG-42 Ship Linear parity', linkedLinearIssue: 'ENG-42', startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity', createdWithAgent: 'grok' diff --git a/mobile/src/tasks/workspace-create-params.ts b/mobile/src/tasks/workspace-create-params.ts index ec945a11828..c6667470279 100644 --- a/mobile/src/tasks/workspace-create-params.ts +++ b/mobile/src/tasks/workspace-create-params.ts @@ -1,19 +1,16 @@ -import type { TuiAgent } from '../../../src/shared/types' +import type { + CreateSparseCheckoutRequest, + GitPushTarget, + SetupDecision, + TuiAgent +} from '../../../src/shared/types' +import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source' import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name' import type { WorkspaceAgentChoice } from './workspace-agent-selection' -export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip' - -export type WorkspaceCreateSparseCheckout = { - directories: string[] - presetId?: string -} - -export type WorkspaceCreateGitPushTarget = { - remoteName: string - branchName: string - remoteUrl?: string -} +export type WorkspaceCreateSetupDecision = SetupDecision +export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest +export type WorkspaceCreateGitPushTarget = GitPushTarget export type WorkspaceCreateHostedStartPoint = { baseBranch: string @@ -48,6 +45,8 @@ type WorkspaceCreateLinearItem = { identifier: string title: string url: string + workspaceId?: string + organizationUrlKey?: string } } @@ -66,9 +65,12 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName?: string note?: string baseBranch?: string + compareBaseRef?: string branchNameOverride?: string + pushTarget?: WorkspaceCreateGitPushTarget sparseCheckout?: WorkspaceCreateSparseCheckout hostedStartPoint?: WorkspaceCreateHostedStartPoint + nameIsAutoManaged?: boolean }): WorkspaceCreateParams { const { item, @@ -78,22 +80,41 @@ export function buildTaskWorkspaceCreateParams(args: { workspaceName, note, baseBranch, + compareBaseRef, branchNameOverride, + pushTarget, sparseCheckout, - hostedStartPoint + hostedStartPoint, + nameIsAutoManaged = true } = args const shouldLaunchAgent = agent !== 'blank' const createdWithAgent = shouldLaunchAgent ? (agent as TuiAgent) : undefined const comment = note?.trim() const selectedBaseBranch = baseBranch || hostedStartPoint?.baseBranch + const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget + // Why: desktop only sends displayName while the name is still auto-derived; a + // user-edited name suppresses it so the runtime keeps the user's chosen name. + const sourceName = + item.provider === 'linear' + ? getWorkspaceSourceName({ + provider: 'linear', + type: 'issue', + number: 0, + title: item.source.title, + url: item.source.url, + linearIdentifier: item.source.identifier + }) + : getWorkspaceSourceName({ provider: item.provider, ...item.source }) + const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {} const common = { setupDecision, activate: true, ...(shouldLaunchAgent ? { startupDraft: item.source.url } : {}), ...(createdWithAgent ? { createdWithAgent } : {}), ...(selectedBaseBranch ? { baseBranch: selectedBaseBranch } : {}), + ...(compareBaseRef ? { compareBaseRef } : {}), ...(branchNameOverride ? { branchNameOverride } : {}), - ...(hostedStartPoint?.pushTarget ? { pushTarget: hostedStartPoint.pushTarget } : {}), + ...(selectedPushTarget ? { pushTarget: selectedPushTarget } : {}), ...(sparseCheckout ? { sparseCheckout } : {}), ...(comment ? { comment } : {}) } @@ -103,7 +124,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedIssue: item.source.number } @@ -116,7 +137,7 @@ export function buildTaskWorkspaceCreateParams(args: { return { repo: `id:${item.source.repoId}`, name: resolveMobileWorkspaceCreateName({ draft: workspaceName, fallback }), - displayName: item.source.title, + ...displayName, ...common, ...(item.source.type === 'issue' ? { linkedGitLabIssue: item.source.number } @@ -130,8 +151,12 @@ export function buildTaskWorkspaceCreateParams(args: { draft: workspaceName, fallback: item.source.identifier.toLowerCase() }), - displayName: item.source.title, + ...displayName, linkedLinearIssue: item.source.identifier, + ...(item.source.workspaceId ? { linkedLinearIssueWorkspaceId: item.source.workspaceId } : {}), + ...(item.source.organizationUrlKey + ? { linkedLinearIssueOrganizationUrlKey: item.source.organizationUrlKey } + : {}), ...common } } diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts new file mode 100644 index 00000000000..4eb749f5c23 --- /dev/null +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -0,0 +1,46 @@ +import type { RpcClient } from '../transport/rpc-client' +import type { RpcSuccess } from '../transport/types' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../src/shared/new-workspace/worktree-create-retry-policy' +import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout' + +// Why: server-side collision checks (branch already exists locally / on a remote +// / already has PR #N) can fire even after a pre-flight basename dedupe — +// branches outlive worktrees in git, and remote branches/PRs aren't visible from +// worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree +// loop in src/renderer/src/store/slices/worktrees.ts. +export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string } + +// Creates a worktree, retrying with a numeric suffix on a name-collision error. +// buildParams receives the candidate name so callers can assemble source-specific +// params (linked issue/PR, base branch, etc.) around it. Callers that can't clear +// a collision by re-suffixing (e.g. reusing a fixed existing branch) pass +// maxAttempts: 1 to fail fast instead of burning the full retry budget. +export async function createWorktreeWithNameRetry(args: { + client: RpcClient + baseName: string + buildParams: (name: string) => Record + maxAttempts?: number +}): Promise { + const { client, baseName, buildParams } = args + const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS + let lastError: string | null = null + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(baseName, attempt) + const response = await client.sendRequest('worktree.create', buildParams(candidateName), { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + if (response.ok) { + const result = (response as RpcSuccess).result as { worktree: { id: string } } + return { worktreeId: result.worktree.id, name: candidateName } + } + lastError = response.error.message + if (!isRetryableWorktreeCreateConflict(lastError ?? '')) { + break + } + } + return { error: lastError ?? 'Failed to create workspace' } +} diff --git a/mobile/src/transport/protocol-compat.test.ts b/mobile/src/transport/protocol-compat.test.ts new file mode 100644 index 00000000000..d70ffd61bb1 --- /dev/null +++ b/mobile/src/transport/protocol-compat.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { evaluateCompat } from './protocol-compat' + +describe('evaluateCompat', () => { + it('allows the current mobile app to connect before a protocol-2 desktop updates', () => { + expect( + evaluateCompat({ + desktopProtocolVersion: 2, + desktopMinCompatibleMobileVersion: 2 + }) + ).toEqual({ kind: 'ok' }) + }) +}) diff --git a/notes/garble-fuzz-divergences.md b/notes/garble-fuzz-divergences.md deleted file mode 100644 index dde77ac2450..00000000000 --- a/notes/garble-fuzz-divergences.md +++ /dev/null @@ -1,296 +0,0 @@ -# Garble differential fuzz — divergence log - -Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz -(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is -a case where restoring a hidden terminal from its main-side snapshot -(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal) -produces a screen that differs from an always-visible renderer terminal fed the -same bytes. Any such diff is a user-visible garble on reveal. - -## Method - -- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane - sizes, PTY-style random chunk splitting. -- Differential: production `HeadlessEmulator` snapshot replayed into a fresh - renderer-parity terminal, compared cell-by-cell (text, per-cell style, - cursor, modes, scrollback) against an always-visible renderer-parity twin. -- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's - buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`, - `vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified - against `buildDefaultTerminalOptions` in - `src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and - `pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`, - `drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored - cell attributes, so their omission is not a source of false diffs. - `windowsMode` is unset in both (matches renderer). Addon versions: - `@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all - `*-beta.287` (headless `6.1.0-beta.287`). -- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug - (predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is - listed below. - -## Inventory - -| bug | found by | seeds | classification | -| --- | --- | --- | --- | -| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN | -| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) | -| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) | -| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) | -| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) | - -Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and -their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D -carries position only (saved SGR/charset are not re-established — the synthetic -ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate -snapshot field written LAST by restorers because any later ESC (e.g. the -post-replay reset) would abort the dangling sequence; its bytes are already -counted by the snapshot seq, so tail-slice arithmetic is unchanged. - -All five bug classes are reproduced by dedicated minimal `test.skip` repros so -they cannot silently regress, AND each is tolerated + counted by its suite's -corpus loop so deep mode surfaces only genuinely NEW divergences: - -- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via - `snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized - snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a - full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`. -- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the - Bug-C cursor cascade are kept out of the corpus by an append-only racing tail - (no DECSC/cursor motion) and pinned only as standalone repros. Green at the - default 200 and at `FUZZ_ITERATIONS=1000`. - -Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts -tripping on most seeds fails the suite instead of silently swallowing it. - -Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored -text mid-line") does not diverge on the current harness. It is a `savedCursor -Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the -emulator and the serializer here. It was most likely an earlier observation -folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a -transient during harness construction. No live divergence at 113. - ---- - -## Bug A — SerializeAddon drops null cells at a soft-wrap boundary - -**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found -and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz -suite (V1 seed 31, V2 seed 157). Full mechanism documented in -`bufferHasSerializeHostileWrappedRow` and the suite's headline comment. - -- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell - passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps - at the right margin, overwriting the previous row's last cell and shifting the - tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`. -- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null - takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so - the ECH erase lands one cell right and the first filler `-` survives. - -**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake, -mobile subscribe replay) paints lost/shifted characters or stray `-` fillers -when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite; -unskip the repros when upstream fixes or a local serialize post-processor lands. - ---- - -## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition - -**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. - -**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops). - -**Minimal repro (isolated, no fuzz corpus needed), cols=20:** - -``` -live bytes: "\x1b[2mA\x1b[22m\x1b[1mB" -SerializeAddon → : "\x1b[2mA\x1b[1;22mB" -live cell B: bold=1 dim=0 (style flags 100000) -restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST -``` - -**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen -from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in -xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the -emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is -neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`. -(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is -specific to a dim-cell → bold-only-cell attribute transition.) - -**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a -bold status/spinner line (Claude Code, Codex). On the live screen the status -line is bold; after a hide→reveal snapshot restore it renders normal-weight. -The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored. - -**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → -`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`. -Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`. - ---- - -## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin - -**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. - -**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK). - -**Minimal repro (isolated, pure serializer replay), cols=10:** - -``` -live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5) -SerializeAddon → : "0123456789\x1b[2B\x1b[6D" -live cursor: { x: 4, y: 2 } -restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT -``` - -Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"` -and round-trips the cursor exactly, isolating the trigger to a full-width final -content row. - -**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in -the *wrap-pending* state (cursor visually on the last column, logically "one -past"). The serializer computes its final cursor-restore as relative -`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed -one column short, so the restored cursor lands at `x-1`. Reproduced with pure -`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble -or normalization involved**, confirming it is upstream, not Orca's snapshot -path. - -**Why it garbles a real pane:** the cursor is where the next keystroke echoes -and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line -reached the right edge (wide status lines, long prompts), the cursor sits one -cell left of where the live pane had it — visible as a mispositioned prompt -caret or spinner, and subsequent input can overwrite the wrong cell. - -**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → -`it.skip('restores the cursor exactly when the last content row fills the right margin')`. -Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`. - ---- - -## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary - -**Classification: (a) real bug — a structural snapshot limitation.** New -finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity -fuzz. - -**Minimal repro (cols=20):** - -``` -hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2, - move to r3c9, write CD) -tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X) - -live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 } -reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 } - ^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2 -snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all) -``` - -**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few -rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` / -`CSI u`) is runtime state that never appears in the serialized buffer, so it -cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before -the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or -`\x1b[u`), the restore targets the fresh terminal's default saved position -(home) instead of where the TUI saved it — the next writes land at the wrong -cell and overwrite live content. - -**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and -status-line redraws (save cursor, jump to a corner to paint a clock/token -counter, restore). If the save happens while the pane is hidden and the restore -fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2 -seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and -whose `\x1b8` fell in the racing tail after chunk-splitting). - -**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor -motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is -tested in isolation from this and the other terminal-state-loss garbles. Bug D -is instead pinned as a standalone repro, -`hidden-reveal-reconciliation.fuzz.test.ts` → -`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`. - -**Fix (applied):** the snapshot epilogue re-establishes the register with -`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`, -reading the active buffer's core register via `readSavedCursorRegister` -(alt screen yields its own register). Position-only: saved SGR/charset are not -carried. - ---- - -## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence - -**Classification: (a) real bug — a structural snapshot limitation.** New -finding, surfaced by the reveal-reconciliation fuzz (suite 2). - -**Minimal repro (cols=20):** - -``` -hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet) -tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD) - -live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic) -reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character -snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone) -``` - -**Mechanism:** a PTY read (one delivery record) can split an escape sequence. -If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the -serialized SCREEN cannot carry the partial sequence (it lives in the parser -state machine, not the buffer). The racing tail supplies the sequence's -remaining bytes, but with the prefix gone the terminal parses them as literal -text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`. - -**Why it garbles a real pane:** any TUI whose output is heavy with escape -sequences (all of them) can have a read boundary fall mid-escape; if a reveal -lands in that window the continuation renders as stray literal bytes (a rogue -`m`, `H`, digits) injected into the visible text. - -**Reachability:** requires the reveal/snapshot to fire in the gap between the two -halves of a split escape. `main` writes each PTY read to the emulator and -records it as one delivery unit (`session.ts emitSubprocessOutput`), and the -snapshot is taken synchronously at a drain — so the window is a single delivered -record that ended mid-escape. Narrow but real. - -**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends -mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the -serialize wrap bug — it fired on ~24% of the corpus, confirming the class is -common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` → -`it.skip('completes an escape sequence split across the hide/reveal boundary')`. - -**Fix (applied):** the emulator tracks the unparsed trailing partial escape at -ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse -mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers -write it LAST, after their post-replay resets, so the racing tail's -continuation completes it exactly as live. Snapshot seq already counted those -ingested bytes, so reconcile slicing is unchanged. - ---- - -## Known-legitimate normalization (NOT bugs) - -- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link - cells underlined; SerializeAddon never re-emits OSC 8. Production restores the - link ranges out-of-band via `snapshot.oscLinks` - (`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the - underline by design. Pinned by the passing - `it('drops OSC 8 underline from byte replay but preserves the range …')`. -- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette - indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored - cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the - same 16 theme slots — no visual difference. Canonicalized by - `canonicalColorMode` in the parity fixture. - ---- - -## Corpus vs deep mode - -- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default - `FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A, - B, and C are each tolerated + counted by a predicate, so the corpus fails only - on a genuinely new divergence. -- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default - `FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail - is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`). -- Combined default runtime is ~19s (well under the 60s gate). -- `FUZZ_SEED=`: re-run exactly one seed for a repro (both suites). diff --git a/notes/orca-performance-branch-guide.md b/notes/orca-performance-branch-guide.md deleted file mode 100644 index 870c3813911..00000000000 --- a/notes/orca-performance-branch-guide.md +++ /dev/null @@ -1,532 +0,0 @@ -# orca-performance Branch Guide - -Agent-facing map of every optimization on this branch: what it does, why it exists, -where it lives, and the invariants you must not break when adding to it. The -chronological evidence trail (benchmarks, retractions, A/B protocols) is in -`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view. - -**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load -(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s). -As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput -11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms -(10× Terminal.app). - -## The pipeline - -``` -shell → pty → daemon (persistence, headless model) → unix socket - → main (ipc/pty.ts: batching, delivery gate, flow control, snapshots) - → IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm) -``` - -Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer -MessagePort). The daemon owns sessions so they survive app restarts; it also runs -a headless xterm emulator per pty — the _model_ — which is the source of truth -for screen contents. The renderer terminal is a _view_ that can be discarded and -rebuilt from model snapshots. - -## Optimization inventory - -### 1. Renderer parse-path fixes (the original 16× on agent TUIs) - -- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain - cadence follows xterm's actual parse completion instead of fixed timers, so the - queue never outruns the parser. -- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only - re-process a bounded window instead of the full retained tail. Guarded by - differential fuzz `retained-tail-redraw-window.equivalence.test.ts`. -- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent - wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at - 50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's - per-chunk cost. - -### 2. term-speed-2 chain (model/view contract — the architecture) - -Revived from ~38 never-merged branches; kill-switched, default ON. Docs: -`docs/reference/terminal-model-view-contract.md`. - -- **Hidden view parking**: hidden tabs tear down their xterm view entirely - (memory: parked panes cost ~0). -- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are - dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a - model snapshot + live chunks after the snapshot's seq. -- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd) - from the model so parked panes stay live in the UI without a view. -- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC - colors) deterministically from the model for hidden panes. -- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal - reconciliation drops duplicates already covered by the snapshot baseline. - -### 3. Batching & scheduling cadence - -- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts` - (`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`). - At 9% utilization there is no queue — latency was literally the sum of fixed - batch windows. This one change took dev DSR-load 19→8ms. -- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`): - Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks - without the clamp, preserving cooperative yield (input/paint still serviced). - Vitest keeps the timer path (fake timers can't advance channel posts). -- **Input write coalescing** (from main, #7205): renderer input writes coalesce - instead of queuing macrotask-per-keystroke. - -### 4. Backpressure (the correctness spine — read before touching delivery) - -Three cooperating layers, innermost first: - -- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler - `ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the - chunk is legitimately discarded), not when IPC delivered it. - **INVARIANT: every delivered chunk credits exactly once — parsed or - discarded.** Every scheduler/pty-connection discard path (backlog replacement, - disposed terminal, reconcile drop, split remainders) must fire the credit. -- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`, - `applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals - (TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a - fully-gated pty triggers a resync probe instead of a timeout reset. The only - timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight - gate + 2MB pendingData cap sit on top. -- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`, - `pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed - wedge where every main→renderer PUSH channel dies while invoke stays alive - (v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden - resync probe can never reach. The 15s heartbeat costs one Map upsert per - received chunk and does no IPC while output flows; mutation stays - verified-state-only (the timer decides when to REPORT; the write-off derives - entirely from the renderer's cumulative received totals, never wall-clock, - and a received-but-unparsed window is never written off). Heal = re-attach - push listeners + pull restore markers through the modelRestoreNeeded router. - E2e blackhole harness: `__terminalDeliveryWatchdog`, - `terminal-push-delivery-loss-recovery.spec.ts`. -- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts` - and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts): - recovers the field-confirmed wedge where macOS occlusion tracking pins - `document.visibilityState` at `'hidden'` after display sleep and never fires - another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate - dropped across 2 pane-level-visible ptys, transport healthy). Real user - input (keydown/pointerdown/window focus) while the document claims hidden is - a physical contradiction — it latches an override, runs each pane's existing - visibilitychange resync (gate unhide + hidden-output restore), and a genuine - visibilitychange hands authority back. No timers; recovery is purely - event-proven, and the failure bias is safe (a wrong override only restores - pre-gate delivery cost, never drops bytes). Hot-path cost: zero when - visible (same single comparison); one property read per user-interaction - event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the - freeze repro and the keystroke recovery, plus the - `hiddenDeliveryGatedVisiblePtyCount` field discriminator). -- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed - `await window.__orcaTerminalFreezeReport()`): a single DevTools command that - returns renderer state (visibilityState + stale override, pty:data listener - count, watchdog totals), main's snapshot with a per-pty delivery table - (sent/acked/pending, hidden vs visible-set membership, last send/ACK ages, - window focus flags, power suspend/resume ages, app version), and bounded - breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared - ring: 100 entries, same-kind coalescing) recording gate marks, visibility - trust changes, watchdog stalls/heals, restore markers, heal write-offs, - renderer lifecycle resets. Pty ids are redacted to their `@@` suffix — - daemon session ids embed worktree paths. Recording happens only on rare - transitions; the table/report is built only when read. This exists so a - field freeze report never needs a follow-up ask. -- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit - guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that - keep a split pane's death near the hidden/park boundary from stranding state - (field incident: a closed setup-split leaf persisted in `root` with no - binding and remounted as a permanently blank pane, unreachable by - dead-session reconcile — it skips ptyId-null panes by design). - (1) The "keep a fresh split whose newborn PTY died" branch is **gated on - `isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output" - proves nothing there; a hidden newborn death must `closePane`, or the kept - pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked** - reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a - mounted TerminalPane), so the sidecar itself collapses the dead leaf out of - the stored layout via `detachTerminalLayoutLeaf` — a stale binding left - behind reattaches on reveal and the daemon re-creates the exited session id - as a fresh shell (silent pane resurrection). E2e: - `terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every - lifecycle phase and asserts leaves(root) == bindings == live panes. -- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause / - 32KB resume watermarks, keyed off **pendingData only** — never renderer - counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's - buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous - (drops impossible); for daemon ptys the pause notify has ~20-30ms socket - latency, so wire-speed bursts can still cross the 2MB cap (known follow-up: - daemon-side self-pacing watermark). -- **Shallow stream-socket write gate + per-session fairness** - (`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices / - 4KB small-session bypass / 32MB write-through valve; kill switch - `ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for - every session — bytes already written can never be overtaken, so a deep - user-space buffer buries a visible pane's echo behind other panes' bulk - (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at - the gate and hold in the batcher, where the interactive flushSession path - and the deterministic small-session bypass still jump them; socket `drain` - refills. This layer alone bounds echo latency by the shallow depth. -- **Background keep-tail stream thinning + daemon fact authority** - (`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail, - `daemon-background-transient-facts.ts`; kill switch - `ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from - pendingData flow control (main drops their bytes after ingestion), which - let N background agents run unbounded ahead of main. Main mirrors the - hidden-delivery gate to the daemon via the wire-tolerated - `setSessionBackground` notification (introduced in v19; authoritative - thinning requires v20 snapshots, so preserved v19 sessions are explicitly - unthinned; older daemons swallow it) — but a live remote view subscriber - (mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`). - Backgrounded sessions' queued output is keep-tail dropped (oldest bytes - replaced by an in-order `dataGap` event; reply-eliciting query bytes are - salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused, - so reveal stays instant with zero catch-up. Un-background neither discards - nor force-flushes the queued tail — restore paths read MAIN's model - (hidden-output recovery buffer), so a discarded tail loses a finished - program's last output forever (caught by the ACK-backpressure e2e), and a - 16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own - bytes; the ordered drain loop delivers it within the budget below. Two - aggregate bounds make that budget real: (1) a GLOBAL background keep - budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more - backgrounded sessions hold queued data, and tighten retroactively when the - count grows — without this, N sub-cap sessions queue N×cap and a worktree - switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden - restore vs the 1.5s budget, probe-verified drain overlap); (2) a - kernel-flush refill sentinel: a held flush pass arms one ~90B empty data - event whose write callback re-flushes when the kernel accepts the - in-flight bytes — without it, held bulk advances one gate-depth per - 'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy - daemon). NOTE: an empty `socket.write('')`'s callback fires immediately - even with megabytes buffered (verified) — the sentinel must be a real - protocol no-op line. Notifications are - structurally lossless: while backgrounded, the DAEMON runs the same shared - scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133 - command-finished / pr-link / DECSET 2031) over every byte BEFORE drop - decisions and relays facts as in-order `transientFact` events; ordered - `sessionBackgroundMarker` events hand scan authority back and forth - (main suppresses just those four scanners in between), and the emulator's - `partialEscapeTailAnsi` seeds each side's fresh scanner carry so a - sequence split across the handoff neither phantom-fires nor goes missing. - Titles/agent-status stay main-side (they converge from the kept tail and - fuse with synthetic spinner frames). On `dataGap`, main resets its - cross-chunk parse carries, drops the mobile headless mirror (rebuilds from - tail/seeds), and sends the model-restore-needed marker so any - renderer-side buffer heals from the snapshot. Visible ptys are never - touched by this layer. Backlog observability: - `ORCA_DAEMON_STREAM_BACKLOG_FILE=` JSONL - (`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`, - `setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`). - Causation A/B (`bench:multi-workspace-typing`): realistic steady rates - (8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine - do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50 - 29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms. - -### 5. Flood resilience (why bulk output can't wedge or lie anymore) - -- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop - abandons immediately when a foreground pane's live-chunk queue overflows - (3-iteration hard cap), and a 2s flood-suppression window stops main's own - backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming - restore — bytes write through, ONE deferred repaint heals after the flood. - This killed a positive feedback loop (restore starves ACKs → main drops → - drop re-arms restore) that caused multi-second renderer stalls. -- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal - queries are extracted (`terminal-reply-query-extraction.ts`) and answered by - _synthesizing replies on the input path_ (CPR from live buffer, DA1 canned, - OSC via direct responder) — probes and TUIs never hang on a dropped reply. -- Drops are downstream of the model: the daemon ingests every byte, so the - post-flood repaint restores complete, correct content. - -### 6. Wake/sleep recovery - -- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes - WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed). -- Cumulative ACKs make the historical "lost ACKs across suspend pin the global - window forever" wedge (BMW user bug) structurally impossible. - -### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned) - -Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs -found by differential fuzzing; four fixed, one tolerated: - -- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted - `1;22` (22 clears the bold 1 just set). Patched via pnpm patch - (`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the - bold/dim group (+2 sibling bare-22 defects). -- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely: - `serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`) - appends absolute CUP from the source terminal's authoritative cursor - (skipped when wrap-pending, where CUP would corrupt). -- **D: DECSC saved-cursor register not serialized** — snapshot appends - `CUP(saved) + ESC 7 + CUP(actual)` when a register exists. -- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) — - `terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the - unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written - LAST on restore so continuation bytes complete the sequence. Seq accounting - unchanged (the tail is a suffix of bytes ≤ snapshot seq). -- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by - `bufferHasSerializeHostileWrappedRow`, the only remaining tolerance. - -## Correctness infrastructure (run these before merging delivery/restore changes) - -- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs - reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep, - `FUZZ_SEED=n` to replay. -- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random - hide/reveal boundaries × snapshot seq × racing chunks must equal an - always-visible reference. -- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped - = regression alarms). -- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test — - byte-identical vs control), `terminal-parked-memory`, - `terminal-sleep-wake-restore`. -- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests - (pane-manager / terminal-pane suites). - -## Benchmarking protocol (hard-won rules) - -- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under - 1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the - terminal under test. -- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12 ---rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label ` - — real keystrokes (CDP) into a visible pane while N hidden-worktree panes - replay paced agent-TUI streams through real daemon ptys; decomposes each key - into input-half (keydown→pty, sidecar timestamps) and echo-half - (pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s: - p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer - timer drift staying ~15ms while echo-half grows means the backlog is - upstream of the renderer (daemon socket / main ingest). -- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev - benches used 3MB and never tripped the cap. -- Load-controlled A/B only: alternate builds within one session; dev carries ~2× - day-to-day variance. Never conclude from runs while agents/builds hammer the - machine (two false convictions came from this). -- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang). -- Packaged builds are truth; dev has ~2× overhead. - -## Release mechanics - -- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance -version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above - its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter - (`release-rc-history.mjs`), telemetry identity classifier, and build guard are - all suffix-aware — a suffixed rc classifies as `rc`. -- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release - (PR #7278; merged here) — perf-line users self-update after one manual install. - -## Syncing with main: MERGE, never rebase - -`orca-performance` is a long-lived, shared, continuously-pushed integration -branch — RCs are cut from it and agents branch off it. **Always -`git merge origin/main`; never rebase** (rebasing rewrites pushed history and -strands every RC tag, fix branch, and worktree based on the old commits). -Conflict pattern, established over ~6 syncs: - -1. **Our structure wins; main's semantics graft in.** This branch deliberately - restructures terminal code (shared scanners, single-policy handlers, - model/view split). When main adds a feature inside code we've restructured, - keep our shape and port their new behavior into it. Example: main inlined an - OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared - `createOsc133CommandFinishedScanner` (main's side-effect tracker must parse - byte-identically) and added 133;C support to the shared scanner instead. -2. Preserve the invariants in **Guardrails** below through every resolution — - especially chunk-credit, `pendingData`-keyed flow control, and the - single `handleCommandFinished` policy (byte path AND sideEffect-fact path - route through it). -3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon - suites, and both fuzz suites. Commit the merge with a message stating what - was kept from each side; push. If the push races a moved remote, merge the - remote tip — never `pull --rebase` a merge. -4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench - before the next RC cut. - -## Known limits / next levers (in rough priority order) - -1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at - wire speed before `pausePty` bites. Fix: daemon enforces its own watermark - locally (VS Code does this server-side for remotes). -2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining - drain cadence dominate the 13.3ms prod p50. -3. utilityProcess router endgame: take main off the per-byte hot path - (VS Code's ptyHost→renderer MessagePort shape). -4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up). -5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control → - term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK) - is open against main separately. - -## Guardrails for future agents - -- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path - that receives, defers, drops, or splits pty data in the renderer, prove it - credits exactly once. The credit-invariant unit tests are the gate. -- Flow control keys off `pendingData` only. Do not couple it to renderer - counters; the two layers compose because they are independent. -- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes - ≤ its seq (Bug E made this true; don't regress it). Chunks after restore are - reconciled by seq — off-by-N re-triggers duplicate-drop garble. -- Hidden panes must receive nothing (delivery gate) but side-effects and query - replies must stay live via the model. If you add a new query type, wire it - through model authority AND the drop-path synthesis. -- Never add timeout-based recovery that mutates counters (user requirement — - design decision from #7260). Deterministic resync or nothing; hygiene timers - may only log. -- Any change on the delivery/restore path: run both fuzz suites, the roundtrip - tests, the chain e2e trio, AND a 10MB bench before calling it done. - -## Audit 1 - -Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main → -renderer terminal path, with particular attention to hidden delivery, parking, -stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach, -mobile/remote composition, SSH routing, and teardown. The audit treated model -state and user scrollback as correctness requirements, not expendable memory. - -### Findings and fixes - -1. **Hidden-gate handoff owners could undo one another.** A parked watcher and - an unmounting/remounting pane shared one boolean hidden mark; likewise a - retiring pane could report `visible=false` after its replacement had already - reported `visible=true`. Hidden claims are now reference-counted and - visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager - pre-mount buffers no longer hold raw-byte delivery interest: they are not a - side-effect consumer, and model-backed hidden output can restore from a - snapshot. This removes the ownership and eager-interest races formerly - listed in Known limits item 6 without weakening parked side effects. The - remaining transient-visibility concern was checked separately: bind and - reconnect reports read `TerminalPane`'s synchronously refreshed - `isVisible && isWorktreeActive` ref, the global effect uses the same - expression, and owner counting prevents a retiring pane from overriding its - replacement. No hidden-worktree `visible=true` report site remains. - -2. **A natural/synthetic daemon exit could overtake final output.** When a - shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event - directly. The final bytes could therefore arrive after `exit`. Exit is now an - ordered control event in `DaemonStreamDataBatcher`; both natural and - synthetic exits flush through the same FIFO. A deep-socket regression test - pins final-data-before-exit ordering. - -3. **Keep-tail thinning could permanently reduce scrollback to the retained - tail.** On `dataGap`, main discarded its headless model, then rebuilt it from - later tail bytes even though the daemon still owned the complete model. Live - daemon snapshots now carry `outputSequence`; the provider exposes an - authoritative `getBufferSnapshot`, accepts the requested scrollback depth, - and main requires that provider snapshot after a gap. Reconciliation starts - in the pre-snapshot absolute sequence domain and runtime sequence accounting - advances across dropped bytes. The daemon remains the source of complete - scrollback instead of making a transport optimization destructive. If that - authoritative RPC is temporarily unavailable, main now returns no snapshot - and lets the renderer retry; it never paints main's known-incomplete tail as - a full recovery. - -4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA - bytes salvaged from a dropped region are copies of bytes already counted by - the daemon, not new output. Stream events now distinguish delivered text - from `sequenceChars`; salvaged query data advances by zero while the gap - advances by the original characters. Main can still parse/deliver the query - copy without shifting every later snapshot baseline. - -5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK - credit fired when bytes entered `terminal.write`, before xterm's callback. - Split scheduler chunks also attached `onParsed` to the first slice. ACK - credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the - final xterm parse callback, and is released exactly once on throw, discard, - or terminal disposal. Submitted-but-unparsed credit is retained until parse - or disposal, so main's flow-control window measures parser work rather than - renderer submission. - -6. **Hidden restore ignored configured scrollback.** The renderer always asked - for 5,000 rows, so users configured for 10k–50k silently lost older history - on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback - option and clamps it through the shared 0–50,000 policy - (`terminal-hidden-restore-scrollback.ts`). - -7. **Active alternate-screen snapshots discarded the normal shell buffer.** - SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old - normalization sliced away everything before the last `?1049h`. A restored - TUI looked correct until it exited alternate mode, then returned to empty - history. Snapshots now carry the normal buffer separately in - `scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose - both buffers; an already-alt renderer exits alt, clears/rebuilds the normal - buffer, then re-enters and rebuilds alt. History replay also composes both - buffers, including legacy empty-field compatibility. - - Deep fuzz then found a second two-buffer issue: normal-buffer serialization - can leave its SGR pen active while the separately serialized alternate body - assumes default SGR. The rehydrate boundary now emits `SGR 0` before - `?1049h`, preventing a shell color from tinting restored TUI cells. The - regression proves the TUI is visible immediately and `?1049l` returns to the - original shell history. - -8. **Daemon provider wrappers forwarded only part of the recovery contract.** - A preserved current/legacy daemon could emit `dataGap` through a provider - wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while - the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap - events, snapshots, and explicit `sequenceChars`. Both wrappers now route the - complete contract to the provider that owns the session, including requested - 50,000-row recovery and zero-advance query-salvage events. - -9. **Sequence-safe recovery was added without advancing the daemon protocol.** - An already-running v19 daemon could accept background-thinning hints but - could not return the new `outputSequence`, making any resulting gap - impossible to reconcile safely. The authoritative snapshot contract is now - protocol v20. Preserved v19 sessions remain live but are explicitly marked - unthinned; their stale background hint is cleared on the ordered control - socket before `createOrAttach`, while fresh v20 sessions retain keep-tail - performance and full-model recovery. - -### Static audit conclusions - -- Model query authority still captures ownership synchronously at ingestion; - seed/hydration/snapshot writes remain reply-silent, remote view subscribers - retain view authority, and replies use the provider input path (including - daemon shell-ready queuing and SSH routing). -- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared - by the centralized PTY teardown path. Parked watcher timers, byte sidecars, - fact consumers, exit subscriptions, hidden claims, and runtime-title slots - dispose on reveal/exit/worktree shutdown. -- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden - panes still have a main-owned headless model, so mounted hidden-gate restore - is valid; live remote viewers veto daemon background thinning. The new - two-buffer payload is recomposed before mobile/remote snapshot frames. -- Wake recovery keeps its focus/visibility/system-resume listener symmetry and - cancels its settled animation frame on cleanup. No timeout was added that - mutates ACK or delivery counters. -- The one documented upstream SerializeAddon null-cell/wrapped-row defect - remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row - predicate as fidelity fuzz, rather than misclassifying that known serializer - defect as sequence-reconciliation loss. - -### Validation evidence - -- Focused ownership/connection/dispatcher tests: 422 passed. -- Daemon server/batcher/order tests passed, including deep queued-socket exit. -- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale - mocks were updated to assert the new explicit `sequenceChars` argument; the - production behavior was already correct. -- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed. -- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed. -- Post-protocol completion sweep: 1,106 affected main/daemon tests passed; - 484 renderer/restore tests passed with 2 expected skips. -- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`. -- Required hidden-view parking, parked-memory, and sleep/wake E2E trio: - 7 passed on a fresh v20 Electron build, including byte-identical output - across 25 park/reveal cycles. -- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s). -- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It - reproducibly found the SGR boundary bug at seed 16 and the known upstream - wrapped-null-cell case at seed 1221 before the final green run. -- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and - remote incomplete-escape regression suites passed. -- Fullscreen real-app headful flow: a real shell wrote normal history, entered - a TUI while its worktree was hidden, restored on reveal, then exited with - `?1049l` back to the original history. The BrowserWindow was fullscreen; - no click/focus occurred before evidence; the restored frame settled for - 1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63; - `fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap - 11px (the scrollbar/remainder, with grid and proposed dimensions equal). - Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and - `.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`. -- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s - (986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms, - DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39, - reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result: - `tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`. -- `pnpm typecheck`, oxlint on every touched TypeScript file, - `pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production - build all passed. No max-lines bypass was added. diff --git a/notes/orca-serve-terminal-persistence.md b/notes/orca-serve-terminal-persistence.md deleted file mode 100644 index d96488572d4..00000000000 --- a/notes/orca-serve-terminal-persistence.md +++ /dev/null @@ -1,152 +0,0 @@ -# Orca Serve Terminal Persistence - -## Problem - -`orca serve` exposes terminal tabs to paired web clients through the runtime -`session.tabs` API, but the host-side terminal tab registry was process-local. -When the host published an empty session-tabs snapshot for a worktree, the web -client bootstrapped a new terminal, giving the user a fresh shell instead of -the previously running host session. - -The browser must remain stateless for terminal identity. Browser storage is -intentionally sanitized because remote handles become stale after a new pairing -or host restart. - -## Goals - -- Keep the host runtime as the source of truth for paired web terminal tabs. -- Persist `orca serve` terminal tab, leaf, and PTY/session bindings in the - existing workspace session model. -- Hydrate headless `session.tabs` snapshots from host persistence before a web - client decides a worktree has no terminals. -- Mirror the SSH persistence model where it applies, while keeping SSH relay - leases and local serve persistence behind their own provider checks. -- Preserve split-pane identity by routing all activation and attachment through - parent tab id plus leaf id. - -## Non-Goals - -- Do not persist remote handles in browser local storage. -- Do not make browser panes supported in headless `orca serve`. -- Do not redesign the terminal daemon or SSH relay. -- Do not treat a persisted PTY id alone as proof that a live process belongs to - a pane. - -## Design - -### 1. Persist Runtime-Owned Serve Spawns - -The runtime PTY spawn path accepts a main-only `persistHostSessionBinding` flag. -Headless serve sets it when creating session-tab terminals. The PTY handler -then calls `Store.persistPtyBinding` only after validating `worktreeId`, -`tabId`, and stable `leafId`. - -This keeps unrelated renderer-local PTY spawns from writing workspace-session -terminal bindings. - -### 2. Use Stable Session IDs - -Serve-created terminals pass `tabId`, `leafId`, optional `sessionId`, and -`persistHostSessionBinding` into `ptyController.spawn`. - -If a pending hydrated terminal has a persisted PTY/session id, activation -passes that id back to the provider. New serve-owned local sessions use a -nonnumeric `serve-${uuid}` id so they cannot collide with older numeric PTY ids -after restart. - -### 3. Hydrate Headless Snapshots - -Before `list`, `listAll`, subscribe initial emission, activation, close, or -move returns an empty headless state, the runtime hydrates -`mobileSessionTabsByWorktree` from `workspaceSession.tabsByWorktree` and -`terminalLayoutsByTabId`. - -Hydration preserves: - -- parent terminal tab id -- stable leaf id -- title fields -- active tab and active leaf -- split layout and `ptyIdsByLeafId` -- persisted PTY/session id - -Legacy terminal tabs without layout entries are still hydrated using a -deterministic stable leaf id derived from the parent tab id. - -### 4. Materialize Pending Tabs On Activation - -Hydrated terminal surfaces with no live trusted handle are exposed as -`pending-handle`. Activating one in headless serve materializes the exact -parent tab and leaf on the server, then returns a ready terminal surface. - -Explicit leaf activation is exact. If `leafId` is provided and the requested -leaf is missing, the runtime returns `tab_not_found` instead of falling back to -a sibling. - -### 5. Require Trusted PTY Identity - -For headless-hydrated persisted tabs, a live PTY is safe to expose only when -the runtime record already matches the same worktree, parent tab id, and pane -key. A process-list entry with the same PTY id but no pane identity stays -pending, preventing stale or numeric id collisions from attaching the wrong -terminal. - -Renderer-published authoritative session snapshots retain their existing -worktree-only daemon PTY adoption path. - -### 6. Close And Move Without A Renderer - -Headless close and move have no-renderer mutation paths: - -- close hydrates and refreshes first, removes the persisted terminal tab and - layout, updates active pointers, emits a new snapshot, and kills every live - trusted leaf under the closed parent tab -- move updates in-memory and persisted tab order without changing PTY bindings - -This prevents closed or reordered tabs from reverting on reconnect. - -### 7. Renderer Guards - -The web client still drops remote terminal identity from browser storage. It -mirrors the host snapshot and activates pending host mirrors through -`session.tabs.activate`. - -Bootstrap of a default web terminal is allowed only for a fresh empty snapshot -for the active worktree when no local terminal state already exists. Stale empty -snapshots and fresh empty snapshots racing with staged local terminals do not -create duplicates. - -### 8. SSH Parity - -Runtime-owned SSH spawns record remote PTY leases with target-local relay PTY -ids while workspace-session PTY bindings keep app-facing ids. Lease writes are -deferred until after binding persistence succeeds for persisted runtime-owned -spawns, so a failed binding save cannot leave durable SSH lease metadata for a -tab/leaf that was not saved. - -## Edge Cases Covered - -- Browser reload or WebSocket reconnect mirrors host-owned terminal tabs. -- Pending headless terminal activation reuses persisted tab and leaf identity. -- Split panes attach and activate the requested leaf only. -- Removed split leaves fail fast even when siblings remain. -- Legacy tabs without layouts hydrate instead of appearing empty. -- Stale empty snapshots do not bootstrap duplicate terminals. -- Fresh empty snapshots do not bootstrap when local terminal state already - exists. -- Numeric PTY id collisions remain pending until a safe reattach/spawn occurs. -- SSH reattach failure after binding persistence failure leaves no stale lease. - -## Verification Plan - -- Unit-test runtime hydration, activation, close, move, split-leaf exactness, - stale PTY id handling, and legacy no-layout tabs. -- Unit-test PTY persistence gates, local session-id reattach behavior, SSH - lease parity, and persistence failure cleanup. -- Unit-test renderer snapshot bootstrap guards and remote runtime PTY transport - pending-mirror behavior. -- Run adjacent session-tab RPC and web-runtime session tests. -- Run typecheck, lint, and `git diff --check`. -- Launch the Electron dev app from this worktree with an isolated profile and - verify CDP attachment, app identity, store availability, visible boot, and - zero console errors. diff --git a/notes/terminal-performance-initiative.md b/notes/terminal-performance-initiative.md deleted file mode 100644 index 2ac56bbf9e4..00000000000 --- a/notes/terminal-performance-initiative.md +++ /dev/null @@ -1,583 +0,0 @@ -# Terminal Performance Initiative - -Working plan for the `orca-performance` branch. Goal: make Orca's terminal as -performant as the architecture allows, with every claim backed by a number. -Started 2026-07-02. - -## Why (user-reported, from the team meeting) - -1. Typing in the terminal is sometimes laggy — occasionally seconds of delay. -2. Users say the terminal is slower than iTerm (unclear if typing or scrolling). -3. Scrolling in Claude Code / OpenCode is slow. -4. Idle memory is high (1–2 GB). -5. Battery usage is high. - -Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling -latency down significantly; lower memory with 0–1 agents. - -## Ground truth (verified against source, 2026-07-02) - -Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified -file:line claims) — see the archived digest and the "xterm.js vs Ghostty" -deep-dive. The Orca-specific findings below were re-verified against this -repo's code: - -- **Electron main sits on every terminal byte's path** (daemon → main → - renderer). VS Code ships the same xterm.js but bypasses main entirely: its - ptyHost is a UtilityProcess with a direct MessagePort to each renderer. -- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in - both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery - is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can - grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k - unacked chars (kernel backpressure blocks the shell). -- Renderer terminals share one thread with the entire React app; xterm.js - parses in 12 ms slices at a documented 5–35 MB/s ceiling. -- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`), - 5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines) - reflow on column resize. -- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms - (same-library reference), native class 5–10 ms. Realistic target: beat - VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99 - dominates perception). - -## Current state - -Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`): - -1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig - (see Benchmark protocol below). -2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards, - probe-certified replay release) + #7139 (cooperative drain: paced backlog - draining keeps typing responsive under floods). Post-merge on this base: - `pnpm typecheck` clean, 626 targeted tests green (scheduler, guards, - pty/pty-connection/pty-transport suites). #7153 itself is a disposable - testing PR; #7139 and #7150 land separately on main. - -## Workstreams - -### 1. Baseline benchmarks (now; human-in-terminal required) - -Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty, -Terminal.app, VS Code (T3Code if available): - -``` -node tools/benchmarks/terminal-pipeline-bench.mjs --label - -node tools/benchmarks/terminal-pipeline-bench.mjs report -``` - -These numbers answer "are we actually slower than iTerm, and where," and are -the before/after for everything below. - -### 2. Validate #7153 on orca-performance (this week, extended testing) - -Watch for: typing responsiveness under agent floods, bounded memory, -skip-notice + snapshot repaint on overflow, no permanent input loss. When -validated, land #7139 and #7150 as separate PRs on main. - -### 3. Revive term-speed-2 (the headline structural work) - -History: nwparker's ~38-branch chain (+20k lines) implementing the terminal -model/view contract — hidden view parking, hidden delivery gate, side-effect -authority in main, model query authority, skip-grammar deletion — all -kill-switched, documented in -`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`. -It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was -never rejected and never reached main. Directly targets complaints 3–5 -(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases). - -Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34 -conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16), -`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5). -`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the -chain touch different layers; runtime interaction (drain pacing × hidden -gate) still needs deliberate testing. - -Execution: dedicated focused session; resolve on `revive/term-speed-2` off -orca-performance; keep both sides' kill switches; validate with typecheck + -the contract tests listed in the model-view-contract doc + #7153's suites; -merge back to orca-performance for extended testing. Estimated ~1 day of -careful resolution + validation. - -### 4. Remaining stall-bug fixes (parallel, independently shippable) - -The "seconds of delay" class = discrete thread-blocking events, not -steady-state latency: - -- PR #7105 (open): skip synchronous cold-restore replay for live daemon - sessions in doSpawn. -- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback - rows (#5096 follow-up, never done). Call sites include the mobile snapshot - path (`pty-connection.ts:2861`) and sleep/hibernate serialization. -- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped - buffers (repro harness exists). -- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s - tick; small, part of the same program). - -### 5. Producer-side PTY flow control - -Ack-driven pause/resume of the actual PTY through the daemon protocol -(node-pty supports it), watermarks per the xterm.js flow-control guide -(≤500 KB). Converts flood-induced buffered lag into shell blocking — the -correct physics. Sequence after #7139 lands (interacts with its drain pacing). - -Design (2026-07-03, implement after the term-speed-2 revival merges — -same files): - -- Signal source: main already tracks per-pty pending + in-flight - (`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a - pty's pending exceeds HIGH (256 KB), main asks the producer to pause; - below LOW (32 KB), resume. -- Producer side: two new protocol notifications (`pausePty`/`resumePty`, - protocol vNext, version-gated like `supportsIncrementalCheckpoints`); - daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the - pty fd, kernel buffer fills, the shell blocks on write: true kernel - backpressure, identical physics to VS Code's 100k/5k design. - `LocalPtyProvider` calls pause/resume directly. -- Safety invariants: (1) failsafe auto-resume after 5 s regardless of - watermark, so a lost resume can never wedge a shell; (2) resume on - detach/exit/kill/daemon-reconnect; (3) pause must not suppress the - interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the - HIGH watermark is only reachable during genuine floods where echo is - already queued; (4) PTY reads never stop for model/tail ingestion - (term-speed-2 invariant #1) — pause gates the fd read, so daemon-side - emulator state pauses with it, which is correct (state = what was read). -- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit - cleanup, plus an e2e pressure scenario asserting bounded main memory and - a blocked producer (`yes` exits promptly on SIGINT while paused). - -### 6. Extend the measurement rig - -- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops - at the parser reply, before paint). -- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling - at 0/1/5 agents (goal-3 metric). -- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in - main/daemon/renderer behind a debug flag for pipeline attribution. - -### 7. utilityProcess terminal router (structural endgame; gated on data) - -An Electron UtilityProcess owns the daemon socket and hands each renderer a -MessagePort — VS Code's topology while keeping Orca's detached daemon (warm -reattach). Takes main off the terminal data path entirely; daemon-side -history persistence falls out naturally. Prototype only after baselines show -how much tail latency lives in the main hop. - -### 8. Production P90 telemetry - -Sampled keypress→echo latency + long-task/stall counts from real users; -defines the success criterion and becomes the permanent regression gate. -Design after the local rig stabilizes so the metrics match. - -## Benchmark protocol - -`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any -terminal: - -- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only - after the parser reaches the query, so it proxies the input pipeline - without keystroke injection. -- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`, - `agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints — - and labeled-pathological `styles-stress`), each run ended by a DSR fence so - xterm.js-class ingest queues can't flatter the result. -- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI - stream: "typing while the agent works," quantified. - -Rules: same machine, AC power, comparable window size, no tmux/screen, hands -off the keyboard during runs. Never compare numbers across machines. - -## Sequencing - -``` -now: [1] baselines [2] #7153 testing (parallel) -next: [3] term-speed-2 revival (dedicated session) -parallel: [4] stall fixes, [6] rig extensions -after 2/3: [5] flow control -gated: [7] utility router [8] telemetry -``` - -BMW-group crash work remains the team's priority gate above all of this -(#7150's wedge guards overlap it); this plan runs measurement and revival -prep in parallel without displacing it. - -## Findings log - -### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/) - -Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty -1.3.1; iTerm2 not installed, VS Code pending): - -| metric | Orca prod | Terminal.app | Ghostty | -|---|---|---|---| -| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 | -| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 | -| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 | -| ascii-log fenced throughput | 13 MB/s | 39 | 93 | - -Decomposition of the 51× agent-tui gap — both pipeline ends are fast: - -- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s** - (`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem. -- Daemon `Session` ingest (emulator + pending-output recording + fanout): - agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`, - `ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem. - -Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms, -so the utilityProcess router is deprioritized by data; (2) the crisis is -queueing between daemon egress and renderer parse completion — main -per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after -renderer write callbacks, so renderer slowness throttles delivery -multiplicatively), and renderer per-chunk layers above xterm; (3) the -agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain -text inside Orca while being equal-cost everywhere else — profile it in the -renderer first (task #9). - -### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only) - -Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode -overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** — -the cooperative-drain branch does not move the under-load class. In -hindsight this is structural: DSR replies are ordered within the output -stream, so the metric measures output-queue depth; #7139 paces draining to -protect input-send responsiveness but cannot reorder the queue. Implications: -(1) the 134 ms-class number is fixed only by shrinking the queue (producer -flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's -own wins (freeze class, bounded memory, input-loss guards) must be validated -with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs -are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom. - -### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9) - -The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained -at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background) -regardless of parse speed. Isolation bench (fake timers, instant-parse -terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`, -`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching -prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only -when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the -sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's -`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks -to the queue, so floods always ride the drip. - -Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** — -a pacer re-arms a 0ms drain when xterm's write callback confirms the batch -parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling: -27 → **117.6 MB/s** (parse-limited). Background cadence deliberately -unchanged (protects the focused pane; hidden panes are term-speed-2's job). -`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of -#7139 preserved; its budget-yield test still passes). 621 tests green. - -Open follow-ups from this attribution: (a) end-to-end dev verification (in -progress); (b) whether main's `background:true` delivery marking demotes -visible-pane floods to the background drip — check -`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log -gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect -scanning; profile after (a). - -### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk - -Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered -probes (renderer scheduler counters → main whole-method timer → per-section -timers → targeted micro-benches) attributed it fully: - -- Renderer receives only ~350–770 KB/s — it is **starved**, not slow. -- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the - flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk). -- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless - track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the - pty-record tail block. -- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame - containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32× - the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer` - materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and - `finalizeRetainedTerminalRows` allocates them all again plus runs a - trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes - per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code - frame (cursor-up + erase-below) takes this path; plain logs don't — which - is exactly the measured agent-tui vs ascii asymmetry. - -Chain: TUI flood → O(tail) work per chunk in main → main event loop -saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s → -deep queue → 134 ms DSR-under-load. - -Fix (in progress): run the existing algorithm on a lazy suffix window (the -cursor's maximum upward reach, computed from the chunk) with the untouched -prefix shared by reference; differential fuzz test proves output equality -against the original implementation. Worst case (pathological full-height -cursor-up) falls back to today's cost. - -### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued - -Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same -protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under- -load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the -first time, but the pipeline is still far from the renderer's 27–117 MB/s -capacity — another main-side consumer remains hot. - -Next cycle (exact recipe): re-apply the whole-method main probe -(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed -build. If onPtyData still dominates, the remaining O(tail)/per-chunk -suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full -tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in -dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured); -(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If -onPtyData no longer dominates, probe the main→renderer delivery batching -next. The probe/bench cycle is mechanical: relaunch dev -(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command -" --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log. - -### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale - -Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still -~90% of main's event loop (~930 ms/s). Bucket split per second: -**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed -fix confirmed), normalize ≈ 7, preview ≈ 0. - -Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its -callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText` -joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both -(another ~512KB of string allocation per chunk) and runs multi-pattern -blocked/ready scans (`findTerminalWaitBlockedSignal`, -`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text) -— all to timestamp `waitBlockedAt` for `terminal wait`. - -Fix design (next session): blocked/ready prompts are end-anchored — an -actionable prompt is at the END of output. (1) Run the check on a bounded -suffix of the wait text (last ~64 lines / 16KB) instead of the full tail; -(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small -carry for split keywords) can contain a blocked keyword; (3) build the two -wait texts only when the check runs. Verification mirrors the windowed-tail -pattern: keep the full-text check as reference + differential fuzz over -randomized tails/prompts (split-across-chunks cases included — the -`appendCandidateSignal` ordering semantics at :23146 must be preserved), -plus the terminal-wait contract tests. Expected effect: removes ~85% of -remaining onPtyData cost; combined with the two landed fixes should -finally unlock the pipeline toward the renderer's measured 27–117 MB/s. - -### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency - -Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243, -windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label -dev-blockedfix, same protocol/config as prior dev rows: - -| metric | pre-fix dev | +tail fix | +blocked fix | -|---|---|---|---| -| agent-tui MB/s | 0.7 | 1.0 | **11.5** | -| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** | -| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** | -| ascii-log MB/s | 6.4 | 4.7 | **9.6** | - -The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The -throttled blocked-check delivered the predicted ~85% cut. Dev mode carries -~2× overhead vs prod, so the prod build should land near ~10ms DSR-under- -load — from the 134ms baseline (~13×) — pending a packaged-build rerun. -Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain -ticks + xterm 12ms slices), which flow control (#6) does not target; -re-evaluate the "within 10× of Terminal.app" goal line after a prod -measurement. Next: term-speed-2 revival (#4), then flow control (#6). - -### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate) - -`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files, -typecheck clean, ~2,776 targeted tests green, all three of our fixes -verified present, chain features present and kill-switched (subagent's -six review risks recorded in its report). Bench verdict on the revived -build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35% -unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round -proved the kill switches are NOT the cost) and idle p50 doubled. - -Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s -≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon -CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×) -on the revive branch** (`session-ingest-throughput.bench.test.ts`, -ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator -restructure (scanner classes / query-reply forwarding / view-attribute -responder) added per-byte cost to the daemon hot path. Chunks reaching -main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger). - -NEXT (fast inner loop — pure unit bench, no app restarts): on -revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs -7839fb9db, find the per-chunk scanner cost, restore our bounded-parser -fast paths (the daemon emulator must never pay per-byte JS scanning for -bytes that contain no ESC — same pre-filter pattern as the blocked-check -keyword bypass), verify with the ingest bench back at ~100 MB/s, then -full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to -orca-performance. A residual renderer-side share is possible once the -daemon is fixed — re-attribute after. - -Merge gate: revive branch merges only at ≥ blockedfix numbers. - -**RETRACTION (2026-07-03, later):** the daemon conviction above was a -confounded measurement — the 39–48 MB/s ingest runs executed while a dev -app was still running. On a quiet machine the revive branch ingests at -**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s -vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end -revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs -were load-controlled, and unit benches show up to 2.6× machine-load -variance. Scanner pre-filters landed anyway on revive (71c89da9b; -strictly positive, 641 daemon tests green). - -**New measurement protocol (mandatory from here):** quiet machine (no dev -apps or benches concurrent), paired A/B runs back-to-back alternating -branches, n≥2 per side, report spread not just p50. The merge-gate -comparison (blockedfix vs revive) must be redone under this protocol -before any verdict. Next: run the controlled A/B; if the delta -disappears, merge revive into orca-performance and proceed to flow -control (#6); if it persists, resume attribution renderer-side (probe -pty-connection dataCallback additions per chunk). - -### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance - -Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui + -DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive -6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50 -tied, p99 better on revive, throughput within overlapping noise (revive2's -3.6 followed two runtime-busy create failures). The earlier "35% -regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs -11.5 yesterday — dev benches carry ~2x day-to-day machine variance; -absolute dev numbers are only comparable within one A/B session. - -Merged revive/term-speed-2 → orca-performance; typecheck clean, 288 -post-merge spot tests green. orca-performance now = main-ish base + #7153 -+ three perf fixes + full term-speed-2 chain (kill-switched, default ON) -+ scanner pre-filters. Extended user testing now covers everything. -Remaining from the revival agent's risk list: gate×drain e2e specs -(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them. -Next: producer flow control (#6) per design §5; prod packaged-build bench -for the real headline numbers. - -### 2026-07-03 — flow control merged; goal-state accounting - -Producer flow control merged to orca-performance (348aeb325): protocol -v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData, -node-pty kernel backpressure, 5s daemon-side lost-resume failsafe + -main-side pause re-assert, resume on every teardown path, version-gated -(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED` -(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests -green. - -**Definition-of-done accounting:** -- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s - and DSR-load p50 161→18.8 dev, results committed). -- term-speed-2: REVIVED AND MERGED (A/B gate passed). -- Flow control: IMPLEMENTED AND MERGED. -- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged - RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod - projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The - remaining gap is structural cadence (daemon 8ms batch, renderer drain - ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the - waste class this initiative eliminated. Prod verification path: - electron-vite preview CANNOT host the bench (CLI-created panes are not - adopted by the preview window's renderer → no ACKs → pending-cap drop; - two attempts, documented) — measure on the next packaged RC cut from - orca-performance using the committed rig + protocol instead. - -**Deferred, ordered:** (1) sync orca-performance with main — conflicts -incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era -`Metadata=12`; renumber chain side, audit mobile/web stream consumers); -(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) — -gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig -extensions + P90 telemetry (tasks #3/#8). - -### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers) - -Same rig, same protocol, same machine as the 1.4.91 baseline: - -| metric | 1.4.91 baseline | v1.4.121-rc.0 | change | -|---|---|---|---| -| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) | -| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x | -| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x | -| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x | -| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x | -| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x | -| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x | - -Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at -a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse -CPU (prod ≈ dev for both latency and throughput; the pipeline is -cadence-bound, so faster prod code changes nothing). That uniform cap -also explains plain-text dipping slightly below baseline: ascii/cjk used -to run unpaced ahead of the old scheduler; now everything flows through -the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app -under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at -parity. The remaining 4x is the named cadence stack (daemon 8 ms batch, -scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) — -next lever, tunable, tracked as follow-up. p99 tail (the freeze class) -is 29.7 ms — users cannot perceive it. - -Caveat: measured on the user's live app (this session active in it); -idle p99 118 ms reflects that activity, not the terminal path. - -### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig) - -| metric | Orca v1.4.121-rc.0 | VS Code | verdict | -|---|---|---|---| -| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster | -| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster | -| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail | -| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% | -| cjk-emoji | 12.2 | 11.3 | tie | -| agent-tui | 11.2 | 11.7 | tie | -| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x | - -Orca now beats or ties the best-known xterm.js terminal on 5 of 6 -metrics — including 16x at idle (what users feel all day) and 5x on -SGR-heavy output — and holds a better p99 tail under load. Throughput -sits at the shared engine ceiling (~9-12 MB/s), confirming the class -limit. - -The one loss (load p50) has a clean mechanism: VS Code's producer flow -control caps unacked output at ~100KB, so its standing queue is -~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue -(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window -(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested -setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel -drain scheduling (sub-ms re-arm; also raises the throughput ceiling); -(2) tighter effective in-flight window on the renderer delivery path. -Target: VS Code's ~7ms class or below without giving back throughput. - -### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms - -Lever results (dev, 3MB protocol, same session): -- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the - ~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util) - there is no standing queue. Kept (correct, removes a real clamp). -- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon - STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS): - **p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1). - Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239 - batcher+pty tests green after timing updates. - -Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should -land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining -fixed-latency terms are renderer/xterm-internal (12ms parse slices). -Note: main's interactive bypass (input-gated) means real keystroke echo -skips batching entirely — the DSR metric understates real typing -responsiveness; VS Code measured on the same freight path, comparison -fair. - -Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect -~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in -reach). - -### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green - -terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/ -title side effects live while parked), terminal-sleep-wake-restore -(output restored + input accepted after wake), terminal-parked-memory -(renderer memory released on park; views retained when kill-switched -off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay — -the revival's top flagged risk — now has e2e coverage on the exact -branch the RC ships from. Remaining garble-hardening: differential -hide/reveal fuzz harness (next build), reveal-time seq diagnostics. - -## Success criteria (baseline-relative; finalize after task 1) - -- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same - box; zero DSR timeouts (today's freeze class). -- Fenced agent-tui throughput ≥ VS Code on the same box. -- Idle RSS with 0–1 agents materially down (target set after the memory - harness lands; hidden-pane parking is the main lever). -- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood. -- Production P90 typing latency down and monitored continuously. diff --git a/notes/windows-perf-progress.md b/notes/windows-perf-progress.md deleted file mode 100644 index 51bee65e4f6..00000000000 --- a/notes/windows-perf-progress.md +++ /dev/null @@ -1,306 +0,0 @@ -# Windows Performance Investigation — Progress Log - -Goal: (1) significantly improve Windows startup time (~1 min cold start reported), -(2) fix OpenCode-driven UI freezes, (3) improve overall Windows performance. -All changes must be proven with before/after benchmark numbers. - -## Phase 2 (2026-07-02, branch Jinwoo-H/windows-performance-improvement) — terminal interaction latency - -Complaints: slow workspace switching, slow tab create/switch (terminal-related), occasional crashes. -Harness: `tools/benchmarks/terminal-perf-bench.mjs` (CDP-driven dev app, renderer-clock phase -timings; scenarios tab-create / tab-switch / workspace-switch; local git fixture). -Main-process spawn attribution: `ORCA_PTY_SPAWN_TIMING=1` → `[pty-spawn-timing]` lines -(pty.ts handler phases: preflight/auth/host_env/options/provider_spawn). - -Findings (baseline, this machine): -- Workspace switch: every hide disposed each pane's WebGL context; resume recreated it — - ~5ms macOS, 100-500ms/pane Windows ANGLE (the comment in terminal-visibility-resume.ts - admitted this). Premise (16-context budget) stale since #7064 raised budget to 128. -- Tab create: ~550ms steady state; main handler only ~115ms (host_env≈50ms, daemon - provider_spawn≈68ms). Remainder is renderer-side (xterm open + WebGL context for the new - pane + React mount). First-ever spawn paid +2.7s inside provider_spawn = daemon's first - ConPTY (native module + conpty.dll + OpenConsole + Defender), lazily on the user's first terminal. -- Tab switch: paint settle median 80-99ms; longtasks 64-151ms — every light tab resume runs - scheduleTerminalWebglAtlasRecovery: 3× (frame/120ms/500ms) global shared-atlas clear + - refresh of EVERY pane in EVERY manager. Parse-time recovery (pty-connection.ts - recoverWebglAtlasAfterParse / hiddenOutputNeedsAtlasRecoveryAfterParse) already covers - risky output including hidden. CAUTION: #7058 changed this area and was reverted (#7073) — - left as follow-up. -- LocalPtyProvider spawned without useConptyDll while the daemon path used it (legacy system - ConPTY corruption + perf differences on degraded-mode/fresh-local spawns). - -Fixes on this branch (PR #7080 merged in — WebGL release on dispose + stale pty:exit synthesis): -- A/D: WebGL context retention across hide/show and the suspended-pane atlas recovery scoping - were reverted/parked for more terminal lifecycle testing. Hidden workspaces return to the - previous dispose-on-hide behavior. -- B: useConptyDll for LocalPtyProvider spawns (local-pty-utils.ts) — parity with daemon. -- F: daemon boots a throwaway `cmd.exe /c exit` ConPTY (windows-conpty-warmup.ts) so the - first user terminal doesn't pay the ~2.7s first-ConPTY cost. - -Follow-ups (documented, not in this branch): -- Gate/scope the light-tab-switch atlas burst (see #7058/#7073 history first). Residual - tab-switch cost besides the burst: debounced ResizeObserver re-fit can reflow scrollback - when column count changed while hidden. -- Renderer-side tab-create cost (~400ms): mount chain runs new Terminal() + 5 eager addons - + synchronous attachWebgl (pane-lifecycle.ts:108) before the spawn IPC (deferred one rAF, - pty-connection.ts:5158). Candidate: defer WebGL attach for brand-new panes. -- Cold-restore respawn fan-out: reconnectPersistedTerminals does NOT spawn; the fan-out is - Terminal.tsx mounting a TerminalPane per restored tab at once — each fires connectPanePty - → rAF-deferred spawn IPC (pty-connection.ts:5158) with no concurrency cap. Cap belongs at - that renderer connect layer, not in reconnectPersistedTerminals. -- First terminal opened immediately after launch also waits on the one-time daemon-init - barrier (pty:spawn awaits getLocalPtyStartupPromise, ipc/pty.ts:2518; measured - preflight=0 in the bench because hydration had finished first, but an early Ctrl+T pays - it). In-daemon Windows shell resolution (pwsh -Version probe, PowerShell exe-chain - existsSync/statSync scan) is uncached per spawn; the conpty warm-up spawns cmd.exe so it - does not warm PowerShell resolution. Note the warm-up and an early first spawn serialize - on the daemon's single thread — the 1255ms post-fix first-spawn number is mostly queueing - behind the in-flight warm-up, not unwarmed cost. -- node-pty ≥1.2.0-beta defers conpty connect (spawn returns pid=0 fast) — would stop spawn - storms serializing the daemon loop. - -Pre-existing Windows-only test failures (also on main, CI is ubuntu-only): 5 attribution-shim -PATH assertions in src/main/ipc/pty.test.ts (path-separator artifacts). - -## Status - -- [x] Benchmark harness for startup time (`tools/benchmarks/startup-time-bench.mjs`) -- [x] Startup bottleneck FIXED + verified: **19.31s → 1.80s median** (fixture); - real-world profile was 62s of blocked main thread → now 0 icacls spawns steady-state -- [x] OpenCode freeze ROOT CAUSE found + fixed: MessagePart hook flood (see F5/D2). - Benchmark: 22.9 MB / 540 ms / 400 main-process fanouts per turn → 469 KB / 79 ms / 120 - (legacy vs throttled plugin behavior through the real hook HTTP pipeline) -- [x] General Windows sync-work audit (results below); audit item #2 (readHooksJson per - status IPC) investigated and found NOT hot — renderer barely calls those handlers. - Fixed pre-existing Windows-only test failures (hydrate-shell-path delimiter). -- [x] Windows ConPTY e2e perf validation (F7 below) - -## Key facts / environment - -- Branch: `Jinwoo-H/windows-launch-time` -- Electron app, entry: `src/main/index.ts` (~1557 lines) -- Existing startup diagnostics: `ORCA_STARTUP_DIAGNOSTICS=1` writes `[startup] ` lines to stderr - (`src/main/startup/startup-diagnostics.ts`) -- Prior art: PR #4618 "perf: speed up desktop startup", #5011 "stop main-thread PowerShell ACL storm - on env-store reads", #4526 "Avoid OpenCode config cleanup freezes on Windows", b240d5eee - "Measure startup hydration phases" - -## Follow-ups / known issues (out of scope for this branch) - -- Pre-existing Windows-only unit test failures: `daemon-pty-adapter.test.ts` (61) and - `history-manager.test.ts` (3, chmod-based fs-error simulation is a no-op on Windows). - Identical with/without this branch's changes. CI never sees them (ubuntu-only). -- Consider a Windows CI lane for the terminal-perf e2e suite (F6/F7) and these unit suites. -- Typing-latency load-sensitivity (F7): possible deeper work on daemon checkpoint - scheduling/priority if user reports persist after D3. -- Audit leftovers (F4): non-recursive `grantDirAcl` execFileSync on hook install - (installer-utils.ts:210) could be async; readHooksJson caching unnecessary (not hot). - -### D3 — Async checkpoint writes (implemented) - -`HistoryManager.checkpoint` (every ~5s per dirty session, Electron main process) switched -from writeFileSync+renameSync (~1MB snapshot JSON, inflated by Defender on Windows) to -fs.promises with the same tmp+rename atomicity; ordering preserved by the adapter's -checkpointInFlight guard. - -## Suspects (startup) - -1. **`grantDirAcl(userData, { recursive: true })`** — `src/main/index.ts:517-523`, win32 only, - runs **synchronously on the main process inside `openMainWindow()` before window creation**. - Spawns `icacls /grant:r :(OI)(CI)(F) /T /C` with a **60s timeout**. - The comment itself admits large userData dirs (tens of thousands of Chromium cache files) - can take >10s. This blocks first paint for the whole walk. Matches "1 minute launch" and - "Windows only". -2. Windows Defender real-time scan of exe/asar/native modules on cold start (environmental, - can't fix in code, but reducing file count / sync IO helps). -3. TBD: store sync load, daemon init, i18n init, sherpa-onnx native module load. - -### F3 — Baseline benchmark (2026-06-10) - -Harness: `node tools/benchmarks/startup-time-bench.mjs --label baseline --iterations 3 --files 28000` -(28k-file synthetic Chromium-cache-shaped userData fixture in %TEMP%, headless launch of -the electron-vite build with `ORCA_STARTUP_DIAGNOSTICS=1`, milestones parsed from stderr). - -| phase (median of 3) | baseline | -|---|---| -| spawnToAppReady | 857ms | -| appReadyToServices | 178ms | -| servicesToI18n | 2ms | -| i18nToOpenWindow | 7ms | -| **aclGrantMs** | **15.65s** | -| windowCreatedToLoaded | 1.06s | -| **totalToDidFinishLoad** | **19.31s** | - -ACL walk = 81% of total. (Fixture is kinder than the real profile: same file count but -freshly-written small files → real %APPDATA%\Orca measured 62s for the same command.) -JSON: tools/benchmarks/results/startup-baseline-2026-06-10T19-36-01-305Z.json - -### F4 — Sync main-thread audit (subagent, 2026-06-10) - -Ranked offenders beyond the ACL grant (#1): -2. `readHooksJson` + JSON.parse re-read per agent-status IPC call across ~10 hook services - (`src/main/*/hook-service.ts` via `agent-hooks/installer-utils.ts:50`) — 10-100ms per - status snapshot, all platforms. Remediation: in-memory cache. -3. `whoami.exe` SID resolution (win32-utils.ts:92) — already cached, OK. -4. macOS-only `defaults read` per browser probe — not Windows. -5. `installer-utils.ts:210` non-recursive grantDirAcl on hook install (execFileSync, - 500ms-2s) — infrequent write path, low priority. -6. `secure-file.ts` sync PowerShell on credential write path — by design (#5011), leave. - -## Suspects (OpenCode freeze) - -- User report: UI freezes ~5s after sending prompt; OpenCode session itself continues fine - (visible from external terminal). So the agent process is healthy — the freeze is in Orca's - main process or renderer. Spinner in left panel still animates (= renderer compositor alive? - or just that one timer). Need to find sync main-process work triggered by OpenCode activity. -- Prior fix #4526 "Avoid OpenCode config cleanup freezes on Windows" — re-check that path. - -### Research results (subagent, 2026-06-10) — ranked candidates - -1. **ConPTY output flood vs PTY batching/backpressure** (HIGH): Windows ConPTY re-renders - full TUI frames → 10-100x output volume vs macOS. Batching in `src/main/ipc/pty.ts` - (16KB chunks / 8ms flush, 512KB renderer in-flight window). If renderer xterm.write is - slow, ACKs stall → in-flight fills → main stalls. Tests: terminal-foreground-redraw-freeze, - artificial-opencode-terminal-load e2e. -2. **Sync `runtime.onPtyData` per data event before batching** (MED-HIGH): - `src/main/ipc/pty.ts:1376-1430` → `orca-runtime.ts:3256-3420`: normalizeTerminalChunk + - tail-buffer append + agent-status OSC parsing run synchronously per chunk on main. - Daemon PTY path. High event rate × per-event cost can saturate the main loop. -3. **`mirrorUserConfig` recursive fs work in `buildPtyEnv` on PTY spawn** (MED): - `src/main/opencode/hook-service.ts:359-524` + `pty/overlay-mirror.ts:63-110` — - readdir/safeRemoveTree/symlinks on main thread at spawn; #4526 fixed only clearPty side. - Timing mismatch with "5s after prompt" though. -4. Agent-status event fan-out per OSC title (LOW-MED). 5. Tail-buffer O(n²) (LOW). - -Gap in coverage: no test exercises rapid continuous ConPTY-scale data + sync onPtyData -accumulation on Windows. - -### F5 — ROOT CAUSE (2026-06-10): OpenCode MessagePart hook flood - -Eliminated candidates first: ran `terminal-foreground-redraw-freeze.spec.ts` on THIS Windows -machine (real ConPTY + daemon provider) — passes; renderer output scheduler protections hold. -The raw TUI-output-flood theory doesn't explain an OpenCode-specific permanent freeze. - -The actual mechanism (src/main/opencode/hook-service.ts plugin source): -- OpenCode publishes `message.part.updated` with the FULL accumulated text of the part on - every streamed append (architecture: parts are republished, not deltas). -- Orca's plugin POSTed that full text to the agent-hook server on EVERY event → - **O(n²) bytes per streaming turn**. A 120KB reply in 400 updates = ~23 MB through - loopback HTTP + main-process JSON.parse; real turns are worse (per-token updates). -- Main process spends its whole loop on HTTP receive + parse + normalize + fanout. UI symptom - matches the user report exactly: everything dead (window close needs main + renderer - round-trip), EXCEPT the sidebar agent indicator — which is the one thing fed by the very - agentStatus:set flood that's starving everything else. -- Why Windows-biased: same flood exists on macOS but combines on Windows with ConPTY - full-frame redraw volume and generally slower process IO; also Windows daemon-PTY path - adds main-process onPtyData work. -- Why "5 seconds after sending the prompt": that's when the accumulated text gets big. -- Why OpenCode keeps working: plugin POST failures are swallowed; the session is healthy. -- Downstream payloads were already bounded (prompt 200 chars, lastAssistantMessage 8000 - chars via agent-status-types normalization) — the renderer wasn't the bottleneck; the - main-process ingest was. - -### F7 — Windows ConPTY e2e perf validation (2026-06-10) - -Ran the terminal-perf budget specs on this Windows machine (real ConPTY + daemon PTY -provider — a path CI never exercises): -- `terminal-output-scheduler.spec.ts`: PASS (all tests) -- `terminal-foreground-redraw-freeze.spec.ts`: PASS -- `terminal-typing-latency.spec.ts`: PASSES in isolation, repeatedly — median 13.6-23.1ms, - worst 34-42ms (budgets: 250ms median / 1000ms worst). Two earlier runs that exceeded the - worst-key budget (1054.9ms, 2016.1ms outlier on a single key) occurred while other heavy - tooling (vitest/tsgo/builds) ran concurrently on the machine → load-sensitivity, not a - deterministic product defect. Note the product implication: under heavy host load - (exactly what coding agents generate), a keystroke can stall >1s on Windows. Plausible - contributors for follow-up: daemon checkpoint ticks (5s interval; snapshot serialize in - daemon + sync writeFileSync of checkpoint JSON on main — daemon-pty-adapter.ts:592, - history-manager.ts:109), Defender scanning fresh build artifacts. - -### F6 — Windows e2e perf coverage gap - -All terminal-perf e2e specs run on ubuntu-latest in CI. Verified they DO run on a Windows -dev machine (`npx playwright test ... --project electron-headless` works locally). Consider -a Windows CI lane for the terminal-perf suite. - -## OpenCode fix (D2) - -1. **Plugin throttle + cap (source fix)** — `src/main/opencode/hook-service.ts`: - assistant MessagePart posts are trailing-edge coalesced to ≥250ms apart and text is - capped at 4000 chars (leading edge posts immediately so previews stay snappy; pending - snapshot flushed before SessionIdle so the done-row preview is the final message; user - prompts bypass the throttle slot). Plugin file is rewritten on every Orca-launched - OpenCode spawn, so the fix deploys to new sessions immediately. -2. **Listener-side cap (stale-plugin defense)** — `src/shared/agent-hook-listener.ts`: - OpenCode MessagePart text capped at 8000 chars at ingest (OPENCODE_HOOK_TEXT_MAX_CHARS) - so pre-fix plugins in long-running OpenCode processes can't blow up state maps. -3. **Benchmark/regression test** — `src/main/agent-hooks/opencode-message-part-flood-bench.test.ts` - drives the real hook HTTP pipeline with both behaviors. Measured on this machine: - | metric/turn | legacy plugin | throttled plugin | - |---|---|---| - | posts | 400 | 120 | - | bytes through main | 22.9 MB | 469 KB (49x less) | - | wall time | 540 ms | 79 ms | - | listener fanouts | 400 | 120 | -4. Behavioral plugin tests — `src/main/opencode/hook-plugin-message-part-throttle.test.ts` - executes the generated plugin with fake timers + stubbed fetch. - -## Findings - -### F1 — Recursive icacls walk is the ~1 min startup (CONFIRMED, 2026-06-10) - -- This machine's real packaged-Orca userData: `%APPDATA%\Orca` = **28,650 files / 2.06 GB** - (mostly Chromium caches: Cache, Code Cache, GPUCache, blob_storage…). -- Measured the exact command Orca runs in `openMainWindow()` (src/main/index.ts:517-523): - - `icacls /grant:r :(OI)(CI)(F) /T /C` → **62.0 s** - - App runs it with `execFileSync` (main thread, BLOCKING, before BrowserWindow creation) - with a **60s timeout** → every cold launch freezes ~60s, then the grant *times out and - silently fails* (execFileSync throws, caught). Users pay the full minute and get nothing. - - Non-recursive root-only grant: **4.8 s** (NTFS propagates inheritable ACE internally). - - `icacls \* /grant:r …` (immediate children, 48 entries): **4.7 s**. -- Why it exists (PR #1152): Chromium's BrowserWindow ctor resets userData DACL with - Inherit-Only ACEs → EPERM on writes in existing subdirs (codex-runtime-home, agent-hooks…). - Explicit child ACEs survive propagation. Per-write EPERM retries exist as backstop in - `codex-accounts/fs-utils.ts` + `agent-hooks/installer-utils.ts`. -- Windows ACL inheritance recalculates from the immediate parent during propagation, so - explicit ACEs on userData + immediate children are sufficient; per-file ACEs on 28k - Chromium cache files are useless work. - -### F2 — Instrumentation prior art - -- `ORCA_STARTUP_DIAGNOSTICS=1` → `[startup] ` lines on stderr (startup-diagnostics.ts). - Only 2 events exist today (single-instance lock). Commit b240d5eee (branch - perf/startup-first-window, NOT merged here) has a full StartupPhaseTimer framework — - too large to cherry-pick; adding minimal milestone logs instead. -- Hermetic benchmark launch path: `ORCA_E2E_USER_DATA_DIR=` redirects userData - (works packaged + dev), `ORCA_E2E_HEADLESS=1` keeps window hidden. Dev/preview mode - skips single-instance lock → safe alongside installed Orca. - -## Decisions / fixes - -### D0 — RESULTS: ACL fix benchmark (2026-06-10) - -| phase (median) | baseline (3 it.) | after fix (4 it.) | steady state (3 it.) | -|---|---|---|---| -| aclGrantMs | **15.65s sync/blocking** | async (off critical path) | **0ms (marker hit)** | -| totalToWindowCreated | 18.25s | 930ms | 814ms | -| totalToDidFinishLoad | **19.31s** | **2.04s** | **1.80s** | - -- First launch after fix: total 2.06s while the background grant ran 6.81s concurrently. -- Marker verified written by real icacls run; subsequent launches log `acl-grant-done - mode=marker-hit` with zero spawns. -- JSON evidence: tools/benchmarks/results/startup-{baseline,acl-fix,acl-fix-steady}-*.json -- Files: src/main/startup/windows-user-data-acl.ts (+tests), src/main/index.ts (wire-up + - startup milestones), src/main/win32-utils.ts (export identity resolver), - tools/benchmarks/startup-time-bench.mjs (harness). - -### D1 — ACL grant fix (implemented as planned) - -Replace the synchronous recursive walk with: -1. A persisted marker (`windows-acl-grant.json` in userData, keyed on identity + scheme - version): when present → skip everything (steady-state launches: 0 icacls spawns, 0 ms). -2. When marker missing (first launch after install/profile import): grant root + - immediate children via **async spawn** (never blocks window creation); write marker - on success. Per-write EPERM retries remain the backstop during the async window — - that's exactly what they're for (#1152 comment says so). -3. Drop the /T full-tree walk entirely; it grants nothing the immediate-children - ACEs + inheritance propagation don't already cover. diff --git a/package.json b/package.json index dff61b9eed2..22c7335d5c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.138-rc.7", + "version": "1.4.139", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/cli/format.ts b/src/cli/format.ts index 80c621418e8..9d608a5b0f4 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -168,6 +168,7 @@ export function formatCliStatus(status: CliStatusResult): string { return [ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/cli/handlers/core.test.ts b/src/cli/handlers/core.test.ts new file mode 100644 index 00000000000..ac5444566d9 --- /dev/null +++ b/src/cli/handlers/core.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })) + +// The claude-teams handler spawns `claude` via node:child_process; mock it so we +// can inspect the child env without launching a real process. +vi.mock('node:child_process', () => ({ spawn: spawnMock })) + +// Keep the socket runtime client out of the import graph; only the error type +// and serveOrcaApp binding are referenced by the module under test. +vi.mock('../runtime-client', () => ({ + RuntimeClientError: class RuntimeClientError extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.code = code + } + }, + serveOrcaApp: vi.fn() +})) + +import { CORE_HANDLERS } from './core' +import type { HandlerContext } from '../dispatch' +import type { RuntimeClient } from '../runtime-client' + +type SpawnEnv = Record + +// Minimal child stub: the handler only awaits `exit`, so resolve it on the next +// microtask to complete the spawned-process promise deterministically. +function mockClaudeChild(): { once: (event: string, cb: (...args: unknown[]) => void) => unknown } { + const child = { + once(event: string, cb: (...args: unknown[]) => void) { + if (event === 'exit') { + queueMicrotask(() => cb(0, null)) + } + return child + } + } + return child +} + +describe('orca claude-teams CLI handler', () => { + const isWindows = process.platform === 'win32' + let previousRunAsNode: string | undefined + let previousPaneKey: string | undefined + let previousExitCode: typeof process.exitCode + + const callMock = vi.fn() + const client = { call: callMock } as unknown as RuntimeClient + + function runClaudeTeams(): Promise { + const ctx: HandlerContext = { + flags: new Map(), + client, + cwd: '/tmp/repo', + json: false, + rawArgs: [] + } + return CORE_HANDLERS['claude-teams'](ctx) + } + + beforeEach(() => { + spawnMock.mockReset() + spawnMock.mockImplementation(() => mockClaudeChild()) + callMock.mockReset() + callMock.mockResolvedValue({ + result: { + launch: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', PATH: '/shim:/usr/bin' } } + } + }) + previousRunAsNode = process.env.ELECTRON_RUN_AS_NODE + previousPaneKey = process.env.ORCA_PANE_KEY + previousExitCode = process.exitCode + // The `orca` launcher runs Orca's Electron binary as Node, so the CLI process + // itself carries ELECTRON_RUN_AS_NODE=1. Reproduce that inherited flag here. + process.env.ELECTRON_RUN_AS_NODE = '1' + process.env.ORCA_PANE_KEY = 'tab-1:leaf-1' + }) + + afterEach(() => { + if (previousRunAsNode === undefined) { + delete process.env.ELECTRON_RUN_AS_NODE + } else { + process.env.ELECTRON_RUN_AS_NODE = previousRunAsNode + } + if (previousPaneKey === undefined) { + delete process.env.ORCA_PANE_KEY + } else { + process.env.ORCA_PANE_KEY = previousPaneKey + } + process.exitCode = previousExitCode + }) + + // Guarded to non-Windows: the handler early-returns unsupported_platform on + // win32, so the leak path never runs there. + it.skipIf(isWindows)( + 'does not leak ELECTRON_RUN_AS_NODE into the spawned claude child', + async () => { + await runClaudeTeams() + + expect(spawnMock).toHaveBeenCalledWith('claude', expect.any(Array), expect.any(Object)) + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ELECTRON_RUN_AS_NODE).toBeUndefined() + + // The prepareLaunch request env is built from the same helper, so it must + // be sanitized too. + const prepareLaunchEnv = (callMock.mock.calls[0][1] as { env: SpawnEnv }).env + expect(prepareLaunchEnv.ELECTRON_RUN_AS_NODE).toBeUndefined() + } + ) + + it.skipIf(isWindows)( + 'still forwards non-Electron parent env and prepareLaunch env to claude', + async () => { + const previousMarker = process.env.ORCA_TEST_MARKER + process.env.ORCA_TEST_MARKER = 'keep-me' + try { + await runClaudeTeams() + } finally { + if (previousMarker === undefined) { + delete process.env.ORCA_TEST_MARKER + } else { + process.env.ORCA_TEST_MARKER = previousMarker + } + } + + const spawnEnv = spawnMock.mock.calls.at(-1)?.[2].env as SpawnEnv + expect(spawnEnv.ORCA_TEST_MARKER).toBe('keep-me') + expect(spawnEnv.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBe('1') + expect(spawnEnv.PATH).toBe('/shim:/usr/bin') + } + ) +}) diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 63a18484f0b..145540bb627 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -2,10 +2,16 @@ import { spawn } from 'node:child_process' import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' +import { stripElectronRunAsNode } from '../runtime/launch' function envRecord(): Record { + // Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI + // process carries ELECTRON_RUN_AS_NODE=1. Strip it before it reaches the + // spawned `claude` (and any nested Electron it launches), which would + // otherwise be forced into headless plain-Node mode. + const env = stripElectronRunAsNode(process.env) return Object.fromEntries( - Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) + Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined) ) } diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 7243d19fa94..c11292453c1 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -2,13 +2,19 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client' +import { launchOrcaApp } from './runtime/launch' + +vi.mock('./runtime/launch', () => ({ + launchOrcaApp: vi.fn() +})) const servers = new Set>() const sockets = new Set() afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() for (const socket of sockets) { socket.destroy() } @@ -170,7 +176,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.graph.state).toBe('unavailable') }) - it('openOrca succeeds immediately when the runtime is already reachable', async () => { + it('openOrca activates the app even when a desktop runtime is already reachable', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') const server = createServer((socket) => { @@ -204,6 +210,87 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(status.result.runtime.state).toBe('ready') expect(status.result.runtime.reachable).toBe(true) + expect(launchOrcaApp).toHaveBeenCalledOnce() + }) + + it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + let statusRequests = 0 + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + statusRequests += 1 + const available = statusRequests > 1 + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: available ? 1 : 0, + graphStatus: available ? 'reloading' : 'ready', + authoritativeWindowId: available ? 1 : 0, + desktopWindowStatus: available ? 'available' : 'initializing', + liveTabCount: 0, + liveLeafCount: 0 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + const status = await client.openOrca(1_000) + + expect(launchOrcaApp).toHaveBeenCalledOnce() + expect(status.result.app.desktopWindowStatus).toBe('available') + expect(statusRequests).toBeGreaterThan(1) + }) + + it('openOrca fails explicitly when the serve owner cannot promote safely', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as { id: string } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-1', + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: 0, + desktopWindowStatus: 'blocked', + liveTabCount: 1, + liveLeafCount: 1 + }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 100) + + await expect(client.openOrca(100)).rejects.toMatchObject({ + code: 'desktop_activation_blocked' + }) + // A blocked runtime can't promote, so we bail before spawning the app. + expect(launchOrcaApp).not.toHaveBeenCalled() }) it('times out if the runtime never responds', async () => { diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index f9211173be2..db0744aaa86 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -2,7 +2,7 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' -import { getCliStatus } from './status' +import { getCliStatus, resolveDesktopWindowStatus } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { sendWebSocketRequest } from './websocket-transport' @@ -115,7 +115,13 @@ export class RuntimeClient { // that this client machine has a local Orca desktop process. app: { running: false, - pid: null + pid: null, + // Why: reuse the shared resolver so remote status honors the same + // authoritativeWindowId fallback as local status for old runtimes. + ...(() => { + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) + return desktopWindowStatus ? { desktopWindowStatus } : {} + })() }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -169,15 +175,27 @@ export class RuntimeClient { async openOrca(timeoutMs = 15_000): Promise> { const initial = await this.getCliStatus() - if (initial.result.runtime.reachable) { + if (this.remotePairing) { return initial } + // Why: a blocked runtime can't open a window, so spawning the app would + // only hit the single-instance lock and exit — bail before launching. + if (initial.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } launchOrcaApp() + if (initial.result.app.desktopWindowStatus === 'available') { + return initial + } + const startedAt = Date.now() while (Date.now() - startedAt < timeoutMs) { const status = await this.getCliStatus() - if (status.result.runtime.reachable) { + if (status.result.app.desktopWindowStatus === 'blocked') { + throwDesktopActivationBlocked() + } + if (status.result.app.desktopWindowStatus === 'available') { return status } await delay(250) @@ -185,11 +203,18 @@ export class RuntimeClient { throw new RuntimeClientError( 'runtime_open_timeout', - 'Timed out waiting for Orca to start. Run the Orca app manually and try again.' + 'Timed out waiting for an Orca desktop window. The runtime may still be running headlessly.' ) } } +function throwDesktopActivationBlocked(): never { + throw new RuntimeClientError( + 'desktop_activation_blocked', + 'Orca is running headlessly, but it cannot open a desktop window safely because the persistent terminal provider is unavailable. Quit Orca normally and start the app again; do not use open -n.' + ) +} + function resolveRemotePairing( userDataPath: string, pairingCode: string | null, diff --git a/src/cli/runtime/launch.ts b/src/cli/runtime/launch.ts index cdb20f194ab..94791306c95 100644 --- a/src/cli/runtime/launch.ts +++ b/src/cli/runtime/launch.ts @@ -266,7 +266,7 @@ function resolveForegroundOrcaExecutable(): string { ) } -function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function stripElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const next = { ...env } delete next.ELECTRON_RUN_AS_NODE return next diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index dff8c231fc9..189297bf05f 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -35,10 +35,12 @@ export async function getCliStatus( throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus + const desktopWindowStatus = resolveDesktopWindowStatus(response.result) return buildCliStatusResponse({ app: { running: true, - pid: metadata.pid + pid: metadata.pid, + ...(desktopWindowStatus ? { desktopWindowStatus } : {}) }, runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', @@ -68,6 +70,19 @@ export async function getCliStatus( } } +export function resolveDesktopWindowStatus( + status: RuntimeStatus +): CliStatusResult['app']['desktopWindowStatus'] { + if (status.desktopWindowStatus) { + return status.desktopWindowStatus + } + // Why: older desktop runtimes predate the explicit status but a positive + // Electron id still proves that a real window owns the graph. + return status.authoritativeWindowId !== null && status.authoritativeWindowId > 0 + ? 'available' + : undefined +} + function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess { return { id: 'local-status', diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 6a60c97b310..364fc05c508 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from 'node:http' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { WebSocketServer } from 'ws' import { encodePairingOffer, type PairingOffer } from '../../shared/pairing' import { @@ -13,6 +13,7 @@ import { publicKeyToBase64 } from '../../shared/e2ee-crypto' import { RuntimeClient } from './client' +import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { @@ -20,6 +21,10 @@ import { RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' +vi.mock('./launch', () => ({ + launchOrcaApp: vi.fn() +})) + type TestRuntime = { endpoint: string publicKeyB64: string @@ -31,6 +36,7 @@ describe('CLI remote WebSocket transport', () => { const servers: TestRuntime[] = [] afterEach(async () => { + vi.mocked(launchOrcaApp).mockClear() await Promise.all(servers.splice(0).map((server) => server.close())) }) @@ -79,6 +85,28 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') }) + it('does not launch a local desktop app for remote-paired open', async () => { + const runtime = await startTestRuntime('runtime-remote-headless', { + desktopWindowStatus: 'initializing' + }) + servers.push(runtime) + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + const status = await client.openOrca() + + expect(status.result.app.desktopWindowStatus).toBe('initializing') + expect(launchOrcaApp).not.toHaveBeenCalled() + }) + it('connects through a saved environment selector', async () => { const runtime = await startTestRuntime('runtime-env-1') servers.push(runtime) @@ -128,6 +156,7 @@ async function startTestRuntime( statusOverrides: { runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number + desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' } = {} ): Promise { const serverKeyPair = generateKeyPair() @@ -177,6 +206,7 @@ async function startTestRuntime( rendererGraphEpoch: 1, graphStatus: 'ready', authoritativeWindowId: null, + desktopWindowStatus: statusOverrides.desktopWindowStatus, liveTabCount: 0, liveLeafCount: 0, runtimeProtocolVersion: diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 93623967e57..008ab2eb764 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -1,4 +1,4 @@ -import { isAbsolute, relative, resolve as resolvePath } from 'node:path' +import { resolve as resolvePath } from 'node:path' import type { ComputerAppQuery, RuntimeWorktreeListResult, @@ -43,14 +43,6 @@ function assertLocalCwdWorktreeSelector(selector: string, client: RuntimeClient) ) } -function isWithinPath(parentPath: string, childPath: string): boolean { - if (isPathInsideOrEqual(parentPath, childPath)) { - return true - } - const relativePath = relative(parentPath, childPath) - return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) -} - export async function resolveCurrentWorktreeSelector( cwd: string, client: RuntimeClient @@ -65,7 +57,10 @@ export async function resolveCurrentWorktreeSelector( let enclosingPathLength = -1 for (const worktree of worktrees.result.worktrees) { const worktreePath = resolvePath(worktree.path) - if (!isWithinPath(worktreePath, currentPath) || worktreePath.length <= enclosingPathLength) { + if ( + !isPathInsideOrEqual(worktreePath, currentPath) || + worktreePath.length <= enclosingPathLength + ) { continue } enclosingWorktree = worktree diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 6247ee9f45c..c1f46a602fb 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -185,6 +185,19 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { expect(onRenamed).toHaveBeenCalledWith(REPO_ID) }) + it('runs Git against the backing folder for a folder-workspace instance id', async () => { + // Why: instance ids carry a synthetic `::workspace:` suffix that is not + // a real directory. The Git cwd must resolve to the folder or `rev-parse` + // spawns against a nonexistent path (ENOENT). + const instanceId = `${WORKTREE_ID}::workspace:123e4567-e89b-12d3-a456-426614174000` + const { deps } = makeDeps({ resolveWorktreeIdForTab: () => instanceId }) + await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['branch', '-m', 'you/fix-auth'], + expect.objectContaining({ cwd: '/repo/wt' }) + ) + }) + it('skips when no worktree can be resolved for the tab', async () => { const { deps } = makeDeps({ resolveWorktreeIdForTab: () => undefined }) await maybeAutoRenameBranchOnFirstWork(workingEvent({ worktreeId: undefined }), deps) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index 1619cf9a9ef..c6e7fb8d10e 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -4,7 +4,7 @@ // owns the orchestration: gate on the signal, enforce the safety guardrails, // summarize the prompt via the configured agent, and rename. import type { GlobalSettings, Repo } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { getRepoIdFromWorktreeId, splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { parseWorkspaceKey } from '../../shared/workspace-scope' import { parsePaneKey } from '../../shared/stable-pane-id' import { @@ -188,7 +188,10 @@ async function runAutoRename( } const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - const parsed = splitWorktreeId(worktreeId) + // Why: worktreePath is a Git subprocess cwd. Folder-workspace instance IDs + // carry a synthetic `::workspace:` suffix that is not a real directory, + // so resolve to the backing folder or Git spawns against a nonexistent cwd. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return stop('unresolved repo or worktree id') } diff --git a/src/main/agent-hooks/first-work-folder-rename.test.ts b/src/main/agent-hooks/first-work-folder-rename.test.ts index f495f91b5e7..e9a88921943 100644 --- a/src/main/agent-hooks/first-work-folder-rename.test.ts +++ b/src/main/agent-hooks/first-work-folder-rename.test.ts @@ -8,6 +8,7 @@ import { const REPO = { id: 'repo1', path: '/repos/orca', connectionId: null } as unknown as Repo const SETTINGS = { nestWorkspaces: false, workspaceDir: '/ws' } as unknown as GlobalSettings const OLD_ID = 'repo1::/ws/cunner' +const FOLDER_WORKSPACE_ID = 'repo1::/ws/cunner::workspace:12345678-1234-1234-1234-123456789abc' function makeDeps(overrides: Partial = {}): FirstWorkFolderRenameDeps { return { @@ -50,6 +51,25 @@ describe('renameWorktreeFolderOnFirstWork', () => { ) }) + it('preserves the folder-workspace instance suffix in the migrated identity', async () => { + const deps = makeDeps() + const result = await renameWorktreeFolderOnFirstWork( + FOLDER_WORKSPACE_ID, + 'worktree-creation-spinner', + deps + ) + expect(result).toBe(true) + expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith( + FOLDER_WORKSPACE_ID, + 'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc' + ) + expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith( + 'repo1', + FOLDER_WORKSPACE_ID, + 'repo1::/ws/worktree-creation-spinner::workspace:12345678-1234-1234-1234-123456789abc' + ) + }) + it('skips (no move) when the destination already exists', async () => { const deps = makeDeps({ pathExists: vi.fn(async () => true) }) expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'taken', deps)).toBe(false) diff --git a/src/main/agent-hooks/first-work-folder-rename.ts b/src/main/agent-hooks/first-work-folder-rename.ts index 8cc8f029b2e..c7050310834 100644 --- a/src/main/agent-hooks/first-work-folder-rename.ts +++ b/src/main/agent-hooks/first-work-folder-rename.ts @@ -6,7 +6,11 @@ // best-effort and local-only — remote/Windows/locked/dest-taken all degrade to // "folder kept" without disturbing the rename that already succeeded. import type { GlobalSettings, Repo } from '../../shared/types' -import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id' +import { + FOLDER_WORKSPACE_INSTANCE_SEPARATOR, + getRepoIdFromWorktreeId, + splitWorktreeIdForFilesystem +} from '../../shared/worktree-id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target' @@ -35,7 +39,10 @@ export async function renameWorktreeFolderOnFirstWork( deps: FirstWorkFolderRenameDeps ): Promise { const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId)) - const parsed = splitWorktreeId(worktreeId) + // Why: oldWorktreePath feeds an on-disk folder move. Resolve the synthetic + // `::workspace:` suffix to the backing folder; identity is migrated + // separately via the untouched worktreeId. + const parsed = splitWorktreeIdForFilesystem(worktreeId) if (!repo || !parsed) { return false } @@ -43,6 +50,9 @@ export async function renameWorktreeFolderOnFirstWork( repoId: repo.id, repoPath: repo.path, oldWorktreePath: parsed.worktreePath, + worktreeIdSuffix: worktreeId.includes(FOLDER_WORKSPACE_INSTANCE_SEPARATOR) + ? `${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${worktreeId.split(FOLDER_WORKSPACE_INSTANCE_SEPARATOR).at(-1)}` + : undefined, newLeaf, settings: deps.getSettings(), platform: process.platform, diff --git a/src/main/browser/popup-origin-bar-window.test.ts b/src/main/browser/popup-origin-bar-window.test.ts index c0161dd94e8..3e816e74b07 100644 --- a/src/main/browser/popup-origin-bar-window.test.ts +++ b/src/main/browser/popup-origin-bar-window.test.ts @@ -53,6 +53,11 @@ const { fakeElectron } = vi.hoisted(() => { webContents: FakeWebContents setBounds = vi.fn() constructor(options: { webContents?: FakeWebContents; webPreferences?: unknown }) { + // Why: Electron rejects explicit undefined instead of treating it as + // omitted, which the popup fallback path depends on. + if (Object.hasOwn(options, 'webContents') && options.webContents === undefined) { + throw new TypeError('options.webContents must be a WebContents') + } this.options = options this.webContents = options.webContents ?? createFakeWebContents() FakeWebContentsView.instances.push(this) @@ -186,6 +191,7 @@ describe('openPopupWithOriginBar', () => { it('loads the target itself only when no pre-created contents were provided', () => { const popup = openPopupWithOriginBar({}, 'https://example.com/login') + expect(lastViews().content.options).not.toHaveProperty('webContents') expect(popup.contentWebContents.loadURL).toHaveBeenCalledWith('https://example.com/login') }) diff --git a/src/main/browser/popup-origin-bar-window.ts b/src/main/browser/popup-origin-bar-window.ts index 504c862a5b8..c9ac057c177 100644 --- a/src/main/browser/popup-origin-bar-window.ts +++ b/src/main/browser/popup-origin-bar-window.ts @@ -120,7 +120,9 @@ export function openPopupWithOriginBar( webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true } }) const contentView = new WebContentsView({ - webContents: options.webContents, + // Why: Electron rejects an explicitly undefined webContents; omitting it + // lets WebContentsView create contents for Cmd/Ctrl-click popups. + ...(options.webContents === undefined ? {} : { webContents: options.webContents }), webPreferences: options.webPreferences }) window.contentView.addChildView(contentView) diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 596cb8bbfc8..a42805313c0 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -1174,10 +1174,10 @@ describe('CliInstaller', () => { } ) - it('resolves packaged Windows command path to resources/bin/orca.exe', async () => { + it('resolves custom-install packaged Windows command path from resourcesPath', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(fixture.root, 'resources') + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') await mkdir(join(resourcesPath, 'bin'), { recursive: true }) await writeFile(join(resourcesPath, 'bin', 'orca.exe'), 'native launcher', 'utf8') @@ -1187,22 +1187,20 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => null, userPathWriter: async () => {} }) const status = await installer.getStatus() - expect(status.commandPath).toBe( - join(localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') - ) + expect(status.commandPath).toBe(join(resourcesPath, 'bin', 'orca.exe')) }) it('does not overwrite the packaged Windows launcher while registering PATH', async () => { const fixture = await makeFixture() - const localAppDataPath = fixture.root - const resourcesPath = join(localAppDataPath, 'Programs', 'Orca', 'resources') + const localAppDataPath = join(fixture.root, 'AppData', 'Local') + const resourcesPath = join(fixture.root, 'D Custom Orca', 'resources') const bundledLauncher = join(resourcesPath, 'bin', 'orca.exe') const bundledContent = 'native launcher' await mkdir(dirname(bundledLauncher), { recursive: true }) @@ -1215,7 +1213,7 @@ describe('CliInstaller', () => { resourcesPath, localAppDataPath, userDataPath: fixture.userDataPath, - execPath: join(localAppDataPath, 'Programs', 'Orca', 'Orca.exe'), + execPath: join(fixture.root, 'D Custom Orca', 'Orca.exe'), appPath: fixture.appPath, userPathReader: async () => userPath, userPathWriter: async (value) => { diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index da8a3cc076a..c5f1286126b 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -356,7 +356,9 @@ export class CliInstaller { } if (this.platform === 'win32') { - return join(this.localAppDataPath, 'Programs', 'Orca', 'resources', 'bin', 'orca.exe') + // Why: NSIS /D installs can live outside LOCALAPPDATA. The packaged + // resources directory is the authoritative native launcher location. + return getBundledLauncherPath(this.platform, this.resourcesPath) } return null diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 6324e75174e..547679df46f 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -194,8 +194,15 @@ describe('WslCliInstaller', () => { ) expect(wsl.getBridge()).toBe(_internals.buildWslBridgeScript()) const installCommand = wsl.calls.find((command) => command.includes('cat > "$command_tmp"')) + expect(installCommand).toBeDefined() expect(installCommand).toContain("legacy_command_path='/home/alice/.local/bin/orca'") expect(installCommand).toContain('rm -f "$legacy_command_path"') + // Why: the new bridge accepts the old launcher's positional arguments, so + // publishing it first keeps interrupted upgrades usable. + const bridgePublishIndex = installCommand?.indexOf('mv -f "$bridge_tmp"') ?? -1 + const launcherPublishIndex = installCommand?.indexOf('mv -f "$command_tmp"') ?? -1 + expect(bridgePublishIndex).toBeGreaterThan(-1) + expect(bridgePublishIndex).toBeLessThan(launcherPublishIndex) expect(installCommand).toContain('[ ! -L "$legacy_command_path" ]') }) @@ -296,12 +303,34 @@ describe('WslCliInstaller', () => { 'Orca WSL CLI requires Windows interop and could not find powershell.exe.' ) expect(launcher).toContain('"$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File') - expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" "$@"') + expect(launcher).toContain('ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || {') + expect(launcher).toContain('ORCA_WSL_CWD=/') + expect(launcher).toContain('cd /') + expect(launcher).toContain('ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD")') + expect(launcher.indexOf('ORCA_WSL_CWD=$(pwd -P')).toBeLessThan( + launcher.indexOf('ORCA_BRIDGE_PS1_WIN=$(wslpath') + ) + expect(launcher).toContain('"$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@"') expect(launcher).not.toContain('-Command') + expect(bridge).toContain('[CmdletBinding(PositionalBinding=$false)]') + expect(bridge).toContain('[Parameter(Mandatory=$true, Position=0)]') + expect(bridge).toContain('[string]$WslCwd') expect(bridge).toContain('[Parameter(ValueFromRemainingArguments=$true)]') + expect(bridge).toContain('if ([string]::IsNullOrEmpty($WslCwd))') + expect(bridge).toContain('$env:ORCA_CLI_CWD = $WslCwd') + expect(bridge).toContain('Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher)') expect(bridge).toContain('& $OrcaLauncher @ForwardArgs') + const nullExitCodeBranch = bridge.indexOf('if ($null -eq $LASTEXITCODE)') + const invocationFailureBranch = bridge.indexOf('if (-not $?)') + expect(nullExitCodeBranch).toBeGreaterThan(-1) + // Why: native launchers can set a non-zero LASTEXITCODE while $? is false; + // checking the native status first preserves that specific exit code. + expect(nullExitCodeBranch).toBeLessThan(invocationFailureBranch) + expect(bridge).toContain('$exitCode = $LASTEXITCODE') + expect(bridge).toContain('Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue') expect(bridge).toContain('catch') - expect(bridge).toContain('exit 1') + expect(bridge).toContain('$exitCode = 1') + expect(bridge).toContain('exit $exitCode') }) it('wraps WSL bash scripts as a single encoded command line', () => { diff --git a/src/main/cli/wsl-cli-scripts.ts b/src/main/cli/wsl-cli-scripts.ts index 136a2a682c9..3297b9be9c1 100644 --- a/src/main/cli/wsl-cli-scripts.ts +++ b/src/main/cli/wsl-cli-scripts.ts @@ -20,34 +20,54 @@ else echo "Orca WSL CLI requires Windows interop and could not find powershell.exe." >&2 exit 1 fi +# Why: a shell can outlive a deleted worktree; keep explicit CLI selectors and +# help usable, and repair cwd before any WSL interop tool tries to resolve it. +ORCA_WSL_CWD=$(pwd -P 2>/dev/null) || { + ORCA_WSL_CWD=/ + cd / +} ORCA_BRIDGE_PS1_WIN=$(wslpath -w "$ORCA_BRIDGE_PS1") -exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" "$@" +ORCA_WSL_CWD_WIN=$(wslpath -w "$ORCA_WSL_CWD") +exec "$ORCA_POWERSHELL" -NoProfile -ExecutionPolicy Bypass -File "$ORCA_BRIDGE_PS1_WIN" "$ORCA_WIN_LAUNCHER" -WslCwd "$ORCA_WSL_CWD_WIN" "$@" ` } export function buildWslBridgeScript(): string { return `${BRIDGE_MANAGED_MARKER} +[CmdletBinding(PositionalBinding=$false)] param( - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$true, Position=0)] [string]$OrcaLauncher, + [string]$WslCwd, + [Parameter(ValueFromRemainingArguments=$true)] [string[]]$ForwardArgs ) +$exitCode = 0 try { + if ([string]::IsNullOrEmpty($WslCwd)) { + Remove-Item Env:ORCA_CLI_CWD -ErrorAction SilentlyContinue + } else { + $env:ORCA_CLI_CWD = $WslCwd + } + Push-Location -LiteralPath (Split-Path -Parent $OrcaLauncher) & $OrcaLauncher @ForwardArgs - if (-not $?) { - exit 1 - } if ($null -eq $LASTEXITCODE) { - exit 0 + if (-not $?) { + $exitCode = 1 + } else { + $exitCode = 0 + } + } else { + $exitCode = $LASTEXITCODE } - exit $LASTEXITCODE } catch { Write-Error $_ - exit 1 + $exitCode = 1 } +exit $exitCode ` } diff --git a/src/main/emulator/serve-sim-state-watcher.test.ts b/src/main/emulator/serve-sim-state-watcher.test.ts index 69b57d60b3b..e528ea324bb 100644 --- a/src/main/emulator/serve-sim-state-watcher.test.ts +++ b/src/main/emulator/serve-sim-state-watcher.test.ts @@ -2,7 +2,11 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { ServeSimStateWatcher, type ServeSimStateDetectedEvent } from './serve-sim-state-watcher' +import { + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS, + ServeSimStateWatcher, + type ServeSimStateDetectedEvent +} from './serve-sim-state-watcher' const TEST_UDID = '11111111-2222-3333-4444-555555555555' @@ -45,7 +49,9 @@ describe('ServeSimStateWatcher', () => { const parentDir = await mkdtemp(join(tmpdir(), 'orca-serve-sim-watch-')) cleanupPaths.push(parentDir) const stateDir = join(parentDir, 'serve-sim') - const watcher = new ServeSimStateWatcher({ stateDir }) + // Force darwin (the poll is macOS-only) and a fast interval so this + // cross-platform test exercises the existence-poll -> attach path quickly. + const watcher = new ServeSimStateWatcher({ stateDir, platform: 'darwin', existencePollMs: 50 }) const events: ServeSimStateDetectedEvent[] = [] watcher.bindPty('pty-1', 'worktree-1') @@ -78,6 +84,29 @@ describe('ServeSimStateWatcher', () => { watcher.stop() }) + it('does not arm the existence poll on non-macOS platforms', () => { + // serve-sim state never appears off macOS, so start() must not leave a + // recurring timer waking the daemon for a directory that can never exist. + for (const platform of ['win32', 'linux'] as const) { + const watcher = new ServeSimStateWatcher({ + stateDir: join(tmpdir(), `orca-serve-sim-nonmac-${process.pid}-${platform}`), + platform + }) + watcher.start() + const poll = (watcher as unknown as { stateDirPoll: unknown }).stateDirPoll + expect(poll).toBeNull() + watcher.stop() + } + }) + + it('defaults the existence poll to a coarse (battery-friendly) interval', () => { + expect(SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS).toBe(2_000) + const watcher = new ServeSimStateWatcher() + expect((watcher as unknown as { existencePollMs: number }).existencePollMs).toBe( + SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS + ) + }) + it('does not buffer repeated brace-free PTY output while waiting for metadata', () => { const watcher = createIsolatedWatcher() const buffers = (watcher as unknown as { ptyBuffers: Map }).ptyBuffers diff --git a/src/main/emulator/serve-sim-state-watcher.ts b/src/main/emulator/serve-sim-state-watcher.ts index 1a42b2d0353..dae8e57bc39 100644 --- a/src/main/emulator/serve-sim-state-watcher.ts +++ b/src/main/emulator/serve-sim-state-watcher.ts @@ -23,6 +23,10 @@ export type ServeSimStateDetectedEvent = { } const DEFAULT_STATE_DIR = join(tmpdir(), 'serve-sim') +// Why: this only waits for a rarely-created dir to appear; sub-second detection +// of a detached emulator is not user-perceptible, so a coarse interval avoids +// waking the daemon 4x/sec for the whole session on machines that never use it. +export const SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS = 2_000 const STATE_FILE_RE = /^server-([0-9A-F-]{36})\.json$/i const PTY_JSON_RE = /\{[^{}]*"streamUrl"\s*:\s*"[^"]+"[^{}]*"wsUrl"\s*:\s*"[^"]+"[^{}]*\}/g @@ -73,6 +77,8 @@ function trailingIncompletePtyJsonObject(data: string): string { export class ServeSimStateWatcher { private readonly stateDir: string + private readonly platform: NodeJS.Platform + private readonly existencePollMs: number private readonly ptyToWorktree = new Map() private readonly ptyBuffers = new Map() private readonly seenExternalKeys = new Set() @@ -81,8 +87,12 @@ export class ServeSimStateWatcher { private stateWatcher: FSWatcher | null = null private stateDirPoll: ReturnType | null = null - constructor(options: { stateDir?: string } = {}) { + constructor( + options: { stateDir?: string; platform?: NodeJS.Platform; existencePollMs?: number } = {} + ) { this.stateDir = options.stateDir ?? DEFAULT_STATE_DIR + this.platform = options.platform ?? process.platform + this.existencePollMs = options.existencePollMs ?? SERVE_SIM_STATE_DIR_EXISTENCE_POLL_MS } onDetected(listener: (event: ServeSimStateDetectedEvent) => void): () => void { @@ -171,6 +181,13 @@ export class ServeSimStateWatcher { if (this.stateDirPoll || this.stateWatcher) { return } + // Why: serve-sim (the iOS Simulator bridge) only ever writes state on macOS, + // so $TMPDIR/serve-sim never appears on Windows/Linux. Skip arming the + // existence poll there instead of waking the daemon every interval for a + // directory that can never exist. + if (this.platform !== 'darwin') { + return + } try { // Why: $TMPDIR/serve-sim/ may not exist until the first terminal `serve-sim --detach`. // Poll for it instead of fs.watch on the parent tmpdir: watching $TMPDIR @@ -182,13 +199,16 @@ export class ServeSimStateWatcher { return } + // attachStateDirWatch() clears this poll once the dir appears and the + // native watcher takes over, so it only ticks while waiting for a + // rarely-created dir — a coarse interval keeps that wait off the idle floor. this.stateDirPoll = setInterval(() => { this.attachStateDirWatch() this.scanExistingStateFiles() - }, 250) + }, this.existencePollMs) this.stateDirPoll.unref?.() } catch { - // Non-mac or permission issues: watcher is best-effort. + // Permission issues: watcher is best-effort. } } diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index 6c64e4b8066..79d614eb0d0 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -4,6 +4,7 @@ import { readFile } from 'node:fs/promises' const { ghExecFileAsyncMock, getOwnerRepoMock, + getEnterpriseGitHubRepoSlugMock, extractExecErrorMock, acquireMock, releaseMock, @@ -11,6 +12,7 @@ const { } = vi.hoisted(() => ({ ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn(), extractExecErrorMock: vi.fn((error: unknown) => { const value = error as { stderr?: string; stdout?: string; message?: string } return { @@ -52,12 +54,18 @@ vi.mock('../git/runner', () => ({ gitExecFileAsync: vi.fn() })) +vi.mock('./github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + import { createGitHubPullRequest } from './client' describe('createGitHubPullRequest', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) extractExecErrorMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() @@ -115,6 +123,78 @@ describe('createGitHubPullRequest', () => { expect(releaseMock).toHaveBeenCalledOnce() }) + it('host-qualifies --repo for a GHES remote so gh targets the Enterprise server (#8312)', async () => { + // github.com-only slug parsing misses GHES, so creation comes from the + // enterprise resolver, which carries the host. + getOwnerRepoMock.mockResolvedValueOnce(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // gh prints the PR URL (not JSON); the GHES host must still parse directly. + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'https://github.acme-corp.com/team/orca/pull/7\n' + }) + + await expect( + createGitHubPullRequest('/repo-root', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'GHES PR' + }) + ).resolves.toEqual({ + ok: true, + number: 7, + url: 'https://github.acme-corp.com/team/orca/pull/7' + }) + + const [args] = ghExecFileAsyncMock.mock.calls[0] + // Bare "team/orca" would resolve against gh's default host (github.com); + // the host prefix pins the command to the Enterprise server. + expect(args[args.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + }) + + it('host-qualifies --repo for the GHES existing-PR fallback lookup (#8312)', async () => { + getOwnerRepoMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // Create reports "already exists", forcing the pr-list fallback. + ghExecFileAsyncMock + .mockRejectedValueOnce( + Object.assign(new Error('exists'), { + stderr: 'a pull request for branch "feature/create-pr" already exists', + stdout: '' + }) + ) + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } + ]) + }) + + await expect( + createGitHubPullRequest('/repo-root', { + provider: 'github', + base: 'main', + head: 'feature/create-pr', + title: 'GHES PR' + }) + ).resolves.toMatchObject({ + ok: false, + code: 'already_exists', + existingReview: { number: 9, url: 'https://github.acme-corp.com/team/orca/pull/9' } + }) + + const [listArgs] = ghExecFileAsyncMock.mock.calls[1] + expect(listArgs).toEqual(expect.arrayContaining(['pr', 'list'])) + expect(listArgs[listArgs.indexOf('--repo') + 1]).toBe('github.acme-corp.com/team/orca') + }) + it('runs local WSL project pull request creation through the selected distro', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 66e728908df..d0e93eb306c 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -189,7 +189,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--assignee', - '@me' + '@me', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -205,7 +207,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--assignee', - '@me' + '@me', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -406,7 +410,9 @@ describe('listWorkItems', () => { 'acme/widgets', '--state', 'open', - '--draft' + '--draft', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -464,7 +470,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--state', - 'merged' + 'merged', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -528,7 +536,7 @@ describe('listWorkItems', () => { const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--state', 'closed', '--search', '-is:merged']), + expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']), { cwd: '/repo-root' } ) expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }]) @@ -597,7 +605,7 @@ describe('listWorkItems', () => { expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( - expect.arrayContaining(['--search', 'review-requested:@me']), + expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']), { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( @@ -606,6 +614,34 @@ describe('listWorkItems', () => { ) }) + it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue is:open') + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--search', 'sort:updated-desc']), + { cwd: '/repo-root' } + ) + }) + + it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }) + + await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z') + + // Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's + // exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']), + { cwd: '/repo-root' } + ) + }) + it('returns open issues and PRs for the all-open preset query', async () => { getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) @@ -652,7 +688,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--state', - 'open' + 'open', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) @@ -667,7 +705,9 @@ describe('listWorkItems', () => { '--repo', 'acme/widgets', '--state', - 'open' + 'open', + '--search', + 'sort:updated-desc' ], { cwd: '/repo-root' } ) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index b4702f28128..5330de5f701 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -79,6 +79,7 @@ import { rememberGhCwdResolutionFailure } from './gh-cwd-repo-negative-cache' import type { GitHubRepoContext } from './github-repository-identity' +import { getEnterpriseGitHubRepoSlug } from './github-enterprise-repository' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -454,6 +455,12 @@ type MainWorkItem = Omit const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees' +// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch +// must return rows newest-updated-first for the cursor to advance correctly. +// This is the search-qualifier spelling of the same contract the REST list +// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649). +const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc' + const WORK_ITEM_PR_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' @@ -995,11 +1002,13 @@ function buildWorkItemListArgs(args: { if (excludeMergedFromClosed) { searchParts.push('-is:merged') } - // Why: cursor-based pagination. GitHub search supports updated: 0) { - out.push('--search', searchParts.join(' ')) - } + // Why: pagination cursors slice on updatedAt, but `gh issue list` defaults + // to created-desc and `--search` defaults to best-match. `gh issue/pr list` + // has no --sort flag, so the only lever is the search query — which forces us + // to always emit --search. Without pinning the sort, recently-updated old + // items never appear on any page, so the pager advertises pages the fetch + // chain can never reach (#8649). + searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER) + out.push('--search', searchParts.join(' ')) return out } @@ -1750,16 +1764,29 @@ function parseCreatePRPayload(stdout: string): { number: number; url: string } | } catch { // Fall through to URL parsing for older gh versions without --json support. } - const urlMatch = trimmed.match(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/) + // Why: gh prints the PR URL (not JSON) here; match any host, not just + // github.com, so a GitHub Enterprise Server URL still parses directly (#8312). + const urlMatch = trimmed.match(/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/(\d+)/) if (!urlMatch) { return null } return { number: Number(urlMatch[1]), url: urlMatch[0] } } +// Why: `gh --repo OWNER/REPO` resolves the shorthand against gh's default host +// (usually github.com), not the repo's remote — so a GHES repo would target a +// same-named github.com repo, or fail. Qualify with the host for GHES so gh hits +// the Enterprise server; this is the only host signal for SSH repos, which run +// gh with no cwd context (#8312). github.com keeps the bare shorthand. +function ghRepoArg(slug: { owner: string; repo: string; host?: string }): string { + return slug.host && slug.host.toLowerCase() !== 'github.com' + ? `${slug.host}/${slug.owner}/${slug.repo}` + : `${slug.owner}/${slug.repo}` +} + async function findOpenPRByHeadBase(args: { repoPath: string - ownerRepo: OwnerRepo + repoArg: string head: string base: string connectionId?: string | null @@ -1771,7 +1798,7 @@ async function findOpenPRByHeadBase(args: { 'pr', 'list', '--repo', - `${args.ownerRepo.owner}/${args.ownerRepo.repo}`, + args.repoArg, '--head', args.head, '--base', @@ -1844,11 +1871,11 @@ export async function createGitHubPullRequest( } } - const ownerRepo = await getOwnerRepo( - repoPath, - connectionId, - ...hostedReviewLocalGitOptionArgs(options) - ) + // Why: github.com-only slug parsing returns null for GHES, so fall back to the + // enterprise resolver (gh-authenticated custom host) before giving up (#8312). + const ownerRepo = + (await getOwnerRepo(repoPath, connectionId, ...hostedReviewLocalGitOptionArgs(options))) ?? + (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) if (!ownerRepo) { return { ok: false, @@ -1856,6 +1883,8 @@ export async function createGitHubPullRequest( error: 'Creating pull requests requires a GitHub remote.' } } + // Host-qualified for GHES so gh targets the Enterprise server, not github.com. + const repoArg = ghRepoArg(ownerRepo) const base = normalizeHostedReviewBaseRef(input.base) const head = input.head ? normalizeHostedReviewHeadRef(input.head) || undefined : undefined @@ -1888,7 +1917,7 @@ export async function createGitHubPullRequest( 'pr', 'create', '--repo', - `${ownerRepo.owner}/${ownerRepo.repo}`, + repoArg, '--base', base, '--title', @@ -1917,7 +1946,7 @@ export async function createGitHubPullRequest( const found = head ? await findOpenPRByHeadBase({ repoPath, - ownerRepo, + repoArg, head, base, connectionId, @@ -1941,7 +1970,7 @@ export async function createGitHubPullRequest( ) { const existing = await findOpenPRByHeadBase({ repoPath, - ownerRepo, + repoArg, head, base, connectionId, diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts new file mode 100644 index 00000000000..73de02ddae9 --- /dev/null +++ b/src/main/github/github-enterprise-repository.test.ts @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn() +})) + +// Mock only the exec boundary so the real remote-identity parsing, runtime +// option resolution, and `gh auth status` parsing run against controlled output. +vi.mock('../git/runner', () => ({ + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +import { + _resetGitHubHostAuthCache, + getEnterpriseGitHubRepoSlug, + isGitHubHostAuthenticated +} from './github-enterprise-repository' + +function mockOriginRemote(url: string): void { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'remote' && args[1] === 'get-url') { + return { stdout: `${url}\n`, stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +// gh exit 0 for `auth status --hostname ` means logged in to that host. +function mockHostAuthenticated(host = 'github.acme-corp.com'): void { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: `${host}\n ✓ Logged in to ${host} account kelora (keyring)`, + stderr: '' + }) +} + +// gh exits non-zero and reports no matching host when not logged in. +function mockHostNotAuthenticated(): void { + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { + stdout: '', + stderr: 'You are not logged into any GitHub hosts. To log in, run: gh auth login' + }) + ) +} + +describe('getEnterpriseGitHubRepoSlug', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + _resetGitHubHostAuthCache() + }) + + it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => { + mockOriginRemote('https://github.acme-corp.com/team/orca.git') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + // The auth probe targets the remote's host, not a hardcoded github.com. + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + { cwd: '/repo' } + ) + }) + + it('resolves a GHES SCP-style SSH remote', async () => { + mockOriginRemote('git@github.acme-corp.com:team/orca.git') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + }) + + it('probes gh in the repository WSL runtime, not the host/default distro', async () => { + mockOriginRemote('https://github.acme-corp.com/team/orca.git') + mockHostAuthenticated() + + await getEnterpriseGitHubRepoSlug('/repo', null, { + localGitExecOptions: { wslDistro: 'Ubuntu' } + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + { cwd: '/repo', wslDistro: 'Ubuntu' } + ) + }) + + it('leaves github.com to getOwnerRepo without probing gh auth', async () => { + mockOriginRemote('https://github.com/team/orca.git') + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('declines a custom host the user is not gh-authenticated to (leaves it for Gitea)', async () => { + mockOriginRemote('https://gitea.example.com/team/orca.git') + mockHostNotAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + }) + + it('returns null for an unparseable remote', async () => { + mockOriginRemote('not-a-remote-url') + mockHostAuthenticated() + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('returns null when the origin remote lookup fails', async () => { + gitExecFileAsyncMock.mockRejectedValue(new Error('no such remote')) + + await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull() + }) +}) + +describe('isGitHubHostAuthenticated', () => { + beforeEach(() => { + ghExecFileAsyncMock.mockReset() + gitExecFileAsyncMock.mockReset() + _resetGitHubHostAuthCache() + }) + + it('runs gh in the SSH-local runtime (no cwd) for connection-backed repos', async () => { + mockHostAuthenticated() + + await expect( + isGitHubHostAuthenticated('github.acme-corp.com', '/remote/repo', 'ssh-1') + ).resolves.toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'github.acme-corp.com'], + {} + ) + }) + + it('caches per runtime+host so detection polling does not re-spawn gh', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('does not share cache state across WSL distros', async () => { + mockHostAuthenticated() + + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Ubuntu' }) + await isGitHubHostAuthenticated('github.acme-corp.com', '/repo', null, { wslDistro: 'Debian' }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('treats a listed host as authenticated even when gh exits non-zero', async () => { + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('exit 1'), { + stdout: '', + stderr: + 'github.acme-corp.com\n ✓ Logged in to github.acme-corp.com account kelora (keyring)\n X github.com: token expired' + }) + ) + + await expect(isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).resolves.toBe(true) + }) + + it('does not cache a hard gh failure so a later probe can recover', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('not installed'), { stdout: '', stderr: '' }) + ) + expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(false) + + mockHostAuthenticated() + expect(await isGitHubHostAuthenticated('github.acme-corp.com', '/repo')).toBe(true) + }) +}) diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts new file mode 100644 index 00000000000..94cffe725fa --- /dev/null +++ b/src/main/github/github-enterprise-repository.ts @@ -0,0 +1,130 @@ +import { ghExecFileAsync } from '../git/runner' +import type { GitHubOwnerRepo } from '../../shared/types' +import { + getHostedReviewLocalGitOptions, + type HostedReviewExecutionOptions +} from '../source-control/hosted-review-git-options' +import { parseAuthStatus } from './auth-diagnose' +import { + ghRepoExecOptions, + getRemoteUrlForRepo, + githubRepoContext, + parseGitHubRemoteIdentity, + type LocalGitExecOptions +} from './github-repository-identity' + +export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string } + +// Why: `gh` only ever manages github.com / GitHub Enterprise credentials, so a +// host `gh auth status` reports as logged-in is definitively a GitHub host. This +// mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a +// GHES remote is not left to fall through to Gitea (#8312). +const HOST_AUTH_TTL_MS = 60_000 + +type HostAuthCacheEntry = { + authenticated: boolean + expiresAt: number +} + +const hostAuthCache = new Map() + +// Why: gh's authenticated hosts live in per-runtime config — a WSL distro and an +// SSH host each carry their own `hosts.yml` — so cache state must be keyed by the +// runtime that executes gh, not shared under one "local" bucket. Mirrors the +// runtime scoping used by owner/repo resolution. +function runtimeCacheKey(connectionId?: string | null, wslDistro?: string): string { + return connectionId ?? `local:${wslDistro ?? 'host'}` +} + +/** @internal - exposed for tests only */ +export function _resetGitHubHostAuthCache(): void { + hostAuthCache.clear() +} + +// Only gh's own stdout/stderr — not the Error.message — counts as an +// authoritative answer. A spawn failure (gh missing, ENOENT) carries just a +// message and no command output, and must stay indeterminate rather than be +// read as "host not authenticated". +function ghCommandOutput(error: unknown): string { + const execErr = error as { stdout?: unknown; stderr?: unknown } + return [execErr?.stdout, execErr?.stderr] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join('\n') +} + +/** + * Whether `gh` is authenticated to `host` from the repository's own runtime. + * + * The probe runs `gh auth status --hostname ` with the repo's execution + * options (cwd / WSL distro, or SSH-local like the create path), so a GHES login + * stored only in that runtime's gh config — or a `GH_ENTERPRISE_TOKEN` inferred + * from it — is honored instead of the host/default-distro gh. Cached briefly per + * runtime+host so provider-detection polling does not re-spawn gh each time. + */ +export async function isGitHubHostAuthenticated( + host: string, + repoPath: string, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const normalizedHost = host.toLowerCase() + const cacheKey = `${runtimeCacheKey(connectionId, localGitOptions.wslDistro)}\0${normalizedHost}` + const now = Date.now() + const cached = hostAuthCache.get(cacheKey) + if (cached && cached.expiresAt > now) { + return cached.authenticated + } + const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + let authenticated: boolean + try { + await ghExecFileAsync(['auth', 'status', '--hostname', normalizedHost], execOptions) + authenticated = true + } catch (error) { + const output = ghCommandOutput(error) + if (!output) { + // Indeterminate (gh missing / spawn failure) — do not cache so a later + // probe (gh installed, tunnel ready, token added) can recover. + return false + } + // gh exits non-zero when a host has a token problem but still prints the + // per-host status; treat the host as GitHub only when it is actually listed. + authenticated = parseAuthStatus(output).some( + (account) => account.host.toLowerCase() === normalizedHost + ) + } + hostAuthCache.set(cacheKey, { authenticated, expiresAt: now + HOST_AUTH_TTL_MS }) + return authenticated +} + +/** + * Resolve owner/repo for a GitHub Enterprise Server `origin` remote — a custom + * host the user is gh-authenticated to. Returns null for github.com (already + * handled by {@link getOwnerRepo}) and for hosts gh is not logged in to + * (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider + * without a GitHub provider stealing another forge's remote. + */ +export async function getEnterpriseGitHubRepoSlug( + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const localGitOptions = getHostedReviewLocalGitOptions(options) + const context = githubRepoContext(repoPath, connectionId, localGitOptions) + let remoteUrl: string | null + try { + remoteUrl = await getRemoteUrlForRepo(context, 'origin') + } catch { + return null + } + const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null + if (!identity || identity.host === 'github.com') { + return null + } + const authenticated = await isGitHubHostAuthenticated( + identity.host, + repoPath, + connectionId, + localGitOptions + ) + return authenticated ? { owner: identity.owner, repo: identity.repo, host: identity.host } : null +} diff --git a/src/main/index.ts b/src/main/index.ts index 5196b7eac01..eb2ac4a7668 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -92,7 +92,8 @@ import { acquireSingleInstanceLock, logSingleInstanceLockBypass, logSingleInstanceLockFailure, - shouldBypassSingleInstanceLock + shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock } from './startup/single-instance-lock' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' @@ -103,6 +104,7 @@ import { } from './startup/startup-diagnostics' import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl' import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy' +import { createServeDesktopActivationGate } from './startup/serve-desktop-activation' import { RateLimitService } from './rate-limits/service' import { readMiniMaxSessionCookie } from './minimax/minimax-cookie-store' import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target' @@ -183,6 +185,11 @@ import { import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../shared/runtime-types' +import { LocalPtyProvider } from './providers/local-pty-provider' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -244,6 +251,16 @@ let gpuFallbackActiveThisLaunch = false let localPtyStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 const isServeMode = process.argv.includes('--serve') +const desktopActivationGate = createServeDesktopActivationGate({ + initialState: isServeMode ? 'initializing' : 'ready', + activateWindow: () => { + // Why: an updater replacement must not resurrect the old app bundle. + if (!isQuittingForUpdate()) { + focusExistingWindow() + } + }, + onBlocked: (reason) => console.error(`[serve] Desktop activation blocked: ${reason}`) +}) // Why: on Windows a CLI-shaped launch (Orca.exe ) that lost // ELECTRON_RUN_AS_NODE would otherwise boot the GUI, lose the single-instance // lock to a running window, and exit silently. Redirect it to node mode here, @@ -432,7 +449,7 @@ if (app.isPackaged && process.platform !== 'win32') { configureDevUserDataPath(is.dev) configureOrcaUserDataPathEnv() -// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback, +// Why: just past createMainWindow's 10s ready-to-show reveal fallback, // so a window revealed on that path still gets its tray icon. const TRAY_CREATE_FALLBACK_MS = 12_000 @@ -461,6 +478,23 @@ function focusExistingWindow(): void { }) } +function requestDesktopActivation(): void { + desktopActivationGate.requestActivation() +} + +function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { + const state = desktopActivationGate.getState() + return state === 'ready' ? 'openable' : state +} + +function settleServeDesktopActivation(): void { + if (getLocalPtyProvider() instanceof LocalPtyProvider) { + desktopActivationGate.markBlocked('persistent PTY provider unavailable') + return + } + desktopActivationGate.markReady() +} + // Why: a webContents-scoped flag that auto-expires so an intent set for one renderer // can't leak to a later load. `consume` clears on a positive match for one-shot // signals (the recovery reload fires exactly one did-finish-load). @@ -551,22 +585,25 @@ const bypassSingleInstanceLock = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode }) +const skipSingleInstanceLock = shouldSkipSingleInstanceLock({ + isDev: is.dev, + isServeMode +}) if (bypassSingleInstanceLock) { // Why: this is an explicit diagnostic escape hatch for macOS builds where // Electron reports a false lock loss before any normal app logs exist. logSingleInstanceLockBypass() } -const hasSingleInstanceLock = - is.dev && !isServeMode +const hasSingleInstanceLock = skipSingleInstanceLock + ? true + : bypassSingleInstanceLock ? true - : bypassSingleInstanceLock - ? true - : acquireSingleInstanceLock(app, focusExistingWindow) + : acquireSingleInstanceLock(app, requestDesktopActivation) if (startupDiagnosticsEnabled) { logStartupDiagnostic('single-instance-lock-result', { acquired: hasSingleInstanceLock, bypassed: bypassSingleInstanceLock, - skippedForDev: is.dev && !isServeMode + skippedForDev: skipSingleInstanceLock }) } if (!hasSingleInstanceLock) { @@ -633,12 +670,11 @@ ipcMain.handle( } ) -function startDesktopFirstWindowStartupServices(): Promise { +function startTerminalRuntimeStartupServices(): Promise { logStartupMilestone('first-window-startup-services-start') const startupServices = startFirstWindowStartupServices({ - // Why: the persistent-terminal daemon is desktop-only. Headless `orca serve` - // registers its PTY runtime separately and must not spawn the desktop daemon - // or hook loopback listener. + // Why: desktop and headless serve must adopt the same persistent provider + // before either path is allowed to create terminals or a renderer. startDaemonPtyProvider: async (signal) => { logStartupMilestone('startup-service-start', { service: 'daemon-pty-provider' }) await initDaemonPtyProvider(signal) @@ -647,6 +683,9 @@ function startDesktopFirstWindowStartupServices(): Promise { // Why: PTY spawn env reads ORCA_AGENT_HOOK_* from the live server state, so // the renderer awaits this barrier before restored terminals reconnect. startAgentHookServer: async () => { + if (!isAgentStatusHooksEnabled(store?.getSettings())) { + return + } logStartupMilestone('startup-service-start', { service: 'agent-hook-server' }) await agentHookServer.start({ env: app.isPackaged ? 'production' : 'development', @@ -687,23 +726,6 @@ function startDesktopFirstWindowStartupServices(): Promise { return firstWindowStartupServicesReady } -async function startServeAgentHookServer(): Promise { - if (!isAgentStatusHooksEnabled(store?.getSettings())) { - return - } - try { - await agentHookServer.start({ - env: app.isPackaged ? 'production' : 'development', - userDataPath: app.getPath('userData'), - endpointNamespace: devAgentHookEndpointNamespace - }) - } catch (error) { - // Why: remote hook callbacks enrich agent status only. A headless runtime - // should still serve terminals if the loopback receiver cannot bind. - console.error('[agent-hooks] Failed to start serve hook server:', error) - } -} - function prepareCodexRuntimeHomeForLaunch(target?: CodexAccountSelectionTarget): string | null { const runtimeHomePath = codexRuntimeHome!.prepareForCodexLaunch(target) const hookTarget = @@ -875,7 +897,7 @@ function openMainWindow(): BrowserWindow { // seconds while explorer.exe's notification area is busy (part of issue // #7225's pre-paint stall), so create it after first paint. The timer // fallback covers windows revealed without ready-to-show ever firing - // (createMainWindow's win32 10s reveal fallback) — those can still be + // (createMainWindow's 10s reveal fallback) — those can still be // hidden to the tray on close, so the icon must exist by then. let trayCreated = false const createSystemTrayDeferred = (): void => { @@ -1791,20 +1813,14 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, - // Why: derived title/bell/agent facts ride one batched main→renderer - // channel (terminal-side-effect-authority.md). The renderer's authority - // kill switch decides whether to consume. Headless serve never creates a - // window, so the dep is omitted entirely — the runtime then skips fact - // batch construction and the per-chunk bell walk. - ...(isServeMode - ? {} - : { - onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('pty:sideEffect', batch) - } - } - }), + // Why: serve can be promoted in place, so keep the listener wired from + // startup; runtime enables desktop-only scanners only for a ready renderer. + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + }, + getDesktopWindowStatus: getDesktopWindowStatus, // 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(), @@ -2095,9 +2111,8 @@ app.whenReady().then(async () => { }) registerMobileHandlers(runtimeRpc) - if (!isServeMode) { - startDesktopFirstWindowStartupServices() - } + startTerminalRuntimeStartupServices() + app.on('activate', requestDesktopActivation) if (serveOptions) { // Why: give managed WSL launchers a brief chance to migrate before headless @@ -2107,7 +2122,9 @@ app.whenReady().then(async () => { logStartupMilestone('wsl-cli-barrier-resolved', { reconciliation: managedWslCliReconciliationStatus }) - await startServeAgentHookServer() + // Why: headless PTYs must never start on the fallback provider and then be + // swept when an activated renderer registers desktop lifecycle handlers. + await localPtyStartupReady registerHeadlessPtyRuntime( runtime, prepareCodexRuntimeHomeForLaunch, @@ -2125,11 +2142,12 @@ app.whenReady().then(async () => { // Why: headless servers have no renderer graph publisher. Publish an // explicit empty graph so status clients see a ready server while // renderer-only operations still fail at their own window boundary. - runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) await runtimeRpc.start().catch((error) => { console.error('[runtime] Failed to start headless RPC transport:', error) throw error }) + settleServeDesktopActivation() installServeSignalHandlers() // Why: the orca CLI command is normally installed by the renderer onboarding / // Settings "Install CLI" flow via the cli:install IPC. Headless serve has no @@ -2212,15 +2230,6 @@ app.whenReady().then(async () => { triggerStartupNotificationRegistration(store) } }) - - app.on('activate', () => { - // Don't re-open a window while Squirrel's ShipIt is replacing the .app - // bundle. Without this guard the old version gets resurrected and the - // update never applies. - if (BrowserWindow.getAllWindows().length === 0 && !isQuittingForUpdate()) { - openMainWindow() - } - }) }) app.on('before-quit', () => { diff --git a/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts new file mode 100644 index 00000000000..3e22cb6b835 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-remote-cancellation.test.ts @@ -0,0 +1,299 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ + handleMock: vi.fn(), + getSshFilesystemProviderMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { handle: handleMock } +})) + +vi.mock('fs/promises', () => ({ stat: vi.fn() })) +vi.mock('@parcel/watcher', () => ({ subscribe: vi.fn() })) +vi.mock('./filesystem-watcher-wsl', () => ({ createWslWatcher: vi.fn() })) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: getSshFilesystemProviderMock +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' + +type HandlerMap = Record Promise | unknown> + +describe('remote filesystem watcher cancellation', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + handleMock.mockReset() + getSshFilesystemProviderMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('aborts pending SSH setup after the last same-root listener leaves and cleans late success', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + try { + expect(watchMock).toHaveBeenCalledTimes(1) + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + await Promise.resolve() + expect(installSignal?.aborted).toBe(true) + } finally { + resolveInstall?.(lateUnwatch) + await Promise.all([first, second]) + } + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('starts a fresh same-root generation when a listener arrives after physical abort', async () => { + let firstSignal: AbortSignal | undefined + let secondCallback: ((events: unknown[]) => void) | undefined + const secondUnwatch = vi.fn() + const watchMock = vi + .fn() + .mockImplementationOnce( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((_resolve, reject) => { + firstSignal = options?.signal + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + .mockImplementationOnce((_rootPath, callback) => { + secondCallback = callback + return Promise.resolve(secondUnwatch) + }) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + + await Promise.all([first, second]) + expect(watchMock).toHaveBeenCalledTimes(2) + secondCallback?.([{ kind: 'update', absolutePath: '/home/me/repo/file.ts' }]) + expect(secondSender.send).toHaveBeenCalledTimes(1) + + handlers['fs:unwatchWorktree']({ sender: { id: 2 } }, args) + expect(secondUnwatch).toHaveBeenCalledTimes(1) + }) + + it('aborts pending SSH setup on sender destruction and watcher shutdown', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const destroyedCallbacks: (() => void)[] = [] + const destroyedSender = { + isDestroyed: () => false, + send: vi.fn(), + once: vi.fn((event: string, callback: () => void) => { + if (event === 'destroyed') { + destroyedCallbacks.push(callback) + } + }), + id: 1 + } + const destroyedArgs = { worktreePath: '/destroyed', connectionId: 'conn-1' } + const destroyedWatch = handlers['fs:watchWorktree']( + { sender: destroyedSender }, + destroyedArgs + ) as Promise + + await Promise.resolve() + destroyedCallbacks[0]() + await Promise.resolve() + expect(installs.get('/destroyed')?.signal?.aborted).toBe(true) + installs.get('/destroyed')?.resolve(vi.fn()) + await destroyedWatch + + const shutdownSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const shutdownArgs = { worktreePath: '/shutdown', connectionId: 'conn-1' } + const shutdownWatch = handlers['fs:watchWorktree']( + { sender: shutdownSender }, + shutdownArgs + ) as Promise + + await Promise.resolve() + await closeAllWatchers() + expect(installs.get('/shutdown')?.signal?.aborted).toBe(true) + installs.get('/shutdown')?.resolve(vi.fn()) + await shutdownWatch + }) + + it('keeps the shared install alive when a replacement sender joins before the deferred abort fires', async () => { + let installSignal: AbortSignal | undefined + let resolveInstall: ((unwatch: () => void) => void) | undefined + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installSignal = options?.signal + resolveInstall = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const senderOne = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const senderTwo = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: senderOne }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // The last listener leaves and a replacement joins in the SAME tick — before + // the queued abort microtask runs. The shared install must survive. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise + + await Promise.resolve() + await Promise.resolve() + expect(installSignal?.aborted).toBe(false) + expect(watchMock).toHaveBeenCalledTimes(1) + + resolveInstall?.(vi.fn()) + await Promise.all([first, second]) + }) + + it('refuses a post-shutdown joiner recursion instead of resurrecting the install', async () => { + let firstSignal: AbortSignal | undefined + let resolveFirst: ((unwatch: () => void) => void) | undefined + const lateUnwatch = vi.fn() + const watchMock = vi.fn( + (_rootPath, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + firstSignal = options?.signal + resolveFirst = resolve + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const secondSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(firstSignal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it awaits the + // 'cancelled' resolution and would recurse into a fresh install. + const second = handlers['fs:watchWorktree']({ sender: secondSender }, args) as Promise + await Promise.resolve() + + // Shutdown latches the subsystem before the joiner's recursion runs. + await closeAllWatchers() + + // Late success of the aborted generation must be unwatched, not registered. + resolveFirst?.(lateUnwatch) + await Promise.all([first, second]) + + // The recursion is refused post-shutdown: provider.watch() is never called again. + expect(watchMock).toHaveBeenCalledTimes(1) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) + + it('refuses a pre-shutdown joiner recursion even after a new watch reopens the subsystem', async () => { + const installs = new Map< + string, + { signal: AbortSignal | undefined; resolve: (unwatch: () => void) => void } + >() + const watchMock = vi.fn( + (rootPath: string, _callback, options?: { signal?: AbortSignal }) => + new Promise<() => void>((resolve) => { + installs.set(rootPath, { signal: options?.signal, resolve }) + }) + ) + getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) + const args = { worktreePath: '/home/me/repo', connectionId: 'conn-1' } + const reopenArgs = { worktreePath: '/home/me/other', connectionId: 'conn-1' } + const lateUnwatch = vi.fn() + const firstSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } + const joinerSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 2 } + const reopenSender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 3 } + const first = handlers['fs:watchWorktree']({ sender: firstSender }, args) as Promise + + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(1) + + // Last listener leaves -> deferred abort fires while the install is still pending. + handlers['fs:unwatchWorktree']({ sender: { id: 1 } }, args) + await vi.waitFor(() => expect(installs.get('/home/me/repo')?.signal?.aborted).toBe(true)) + + // Joiner arrives after physical abort (canJoinInstall === false); it captures the + // current lifecycle generation and awaits the 'cancelled' resolution. + const joiner = handlers['fs:watchWorktree']({ sender: joinerSender }, args) as Promise + await Promise.resolve() + + // Shutdown bumps the generation, then a genuine new watch reopens the subsystem + // (clearing the boolean latch) before the joiner resumes. + await closeAllWatchers() + const reopen = handlers['fs:watchWorktree']( + { sender: reopenSender }, + reopenArgs + ) as Promise + await Promise.resolve() + expect(watchMock).toHaveBeenCalledTimes(2) + + // Now let the aborted install resolve; the joiner recurses on the stale generation. + installs.get('/home/me/repo')?.resolve(lateUnwatch) + installs.get('/home/me/other')?.resolve(vi.fn()) + await Promise.all([first, joiner, reopen]) + + // The joiner's recursion is refused despite the reopen: no third provider.watch(). + expect(watchMock).toHaveBeenCalledTimes(2) + expect(watchMock.mock.calls.filter(([rootPath]) => rootPath === '/home/me/repo')).toHaveLength( + 1 + ) + expect(lateUnwatch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 0b3b07ca071..f776e0d494d 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -124,7 +124,9 @@ describe('registerFilesystemWatcherHandlers', () => { getSshFilesystemProviderMock.mockReturnValue({ watch: watchMock }) await vi.advanceTimersByTimeAsync(1_000) - expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(watchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) const onEvents = watchMock.mock.calls[0][1] onEvents([{ path: '/home/me/repo/file.txt', type: 'update' }]) expect(sendMock).toHaveBeenCalledWith('fs:changed', { @@ -161,7 +163,9 @@ describe('registerFilesystemWatcherHandlers', () => { await vi.advanceTimersByTimeAsync(1_000) - expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function)) + expect(retryWatchMock).toHaveBeenCalledWith('/home/me/repo', expect.any(Function), { + signal: expect.any(AbortSignal) + }) handlers['fs:unwatchWorktree']( { sender: { id: 1 } }, { worktreePath: '/home/me/repo', connectionId: 'conn-1' } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 983fc9ce985..665b543779f 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -652,22 +652,31 @@ type RemoteWatcherState = { type RemoteWatcherInstallToken = { cancelled: boolean listeners: Map + abortController: AbortController + abortScheduled: boolean } // Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state. const remoteWatchers = new Map() const loggedUnavailableRemoteWatchers = new Set() const pendingRemoteWatcherRetries = new Map>() -// Why: track in-flight `provider.watch()` calls so an unwatch/shutdown that -// arrives while a watch is still resolving can mark the install cancelled. -// Without this, the awaited unwatch handle would be installed after the -// renderer thinks the watch is gone, leaking a native watcher. +// Why: track in-flight `provider.watch()` calls so last-listener cleanup can +// abort relay setup, while late success is still unwatched instead of leaked. const inFlightRemoteInstalls = new Map() // Why: dedupe concurrent installRemoteWatcher calls for the same key so // overlapping fs:watchWorktree IPCs share one native watcher and one listener // map, instead of each call independently invoking provider.watch() and // overwriting the per-key state on resolution. const pendingRemoteInstallPromises = new Map>() +// Why: block installs that begin AFTER closeAllWatchers — an in-flight joiner +// recursion or a fired retry tick calls installRemoteWatcher directly, bypassing +// the token-abort loop. A genuine new fs:watchWorktree clears the latch. +let remoteWatchersClosed = false +// Why: the boolean latch alone can't tell a pre-shutdown waiter apart from a +// fresh call once a genuine new watch reopens the subsystem. Each call captures +// the generation at entry; closeAllWatchers bumps it, so a joiner that awaited +// across a shutdown+reopen recurses on a stale generation and is refused. +let remoteWatcherLifecycleGeneration = 0 const REMOTE_WATCH_RETRY_MS = 1_000 const REMOTE_WATCH_RETRY_TIMEOUT_MS = 60_000 @@ -675,7 +684,7 @@ function addInFlightRemoteInstallListener( token: RemoteWatcherInstallToken, sender: WebContents ): void { - if (sender.isDestroyed()) { + if (sender.isDestroyed() || token.abortController.signal.aborted) { return } token.listeners.set(sender.id, sender) @@ -683,12 +692,26 @@ function addInFlightRemoteInstallListener( registerSenderCleanup(sender) } +function cancelInFlightRemoteInstallIfUnowned(token: RemoteWatcherInstallToken): void { + token.cancelled = token.listeners.size === 0 + if (!token.cancelled || token.abortScheduled || token.abortController.signal.aborted) { + return + } + token.abortScheduled = true + // Why: a replacement sender can synchronously revive the shared install + // during a renderer handoff; otherwise stop the relay crawl next microtask. + queueMicrotask(() => { + token.abortScheduled = false + if (token.cancelled && token.listeners.size === 0) { + token.abortController.abort() + } + }) +} + function cleanupInFlightRemoteInstallsForSender(senderId: number): void { for (const token of inFlightRemoteInstalls.values()) { token.listeners.delete(senderId) - if (token.listeners.size === 0) { - token.cancelled = true - } + cancelInFlightRemoteInstallIfUnowned(token) } } @@ -726,8 +749,16 @@ type RemoteWatcherInstallResult = 'installed' | 'unavailable' | 'cancelled' async function installRemoteWatcher( sender: WebContents, connectionId: string, - worktreePath: string + worktreePath: string, + generation = remoteWatcherLifecycleGeneration ): Promise { + // Why: refuse installs racing in after teardown (joiner recursion, fired retry + // tick) so provider.watch() is never called and registered post-shutdown. The + // generation guard also refuses a waiter that captured an earlier lifecycle, + // even after a new watch reopened the subsystem. + if (remoteWatchersClosed || generation !== remoteWatcherLifecycleGeneration) { + return 'cancelled' + } const provider = getSshFilesystemProvider(connectionId) if (!provider || sender.isDestroyed()) { return 'unavailable' @@ -747,7 +778,8 @@ async function installRemoteWatcher( const pendingInstall = pendingRemoteInstallPromises.get(key) if (pendingInstall) { const inFlight = inFlightRemoteInstalls.get(key) - if (inFlight) { + const canJoinInstall = inFlight && !inFlight.abortController.signal.aborted + if (canJoinInstall) { // Why: a new watcher can join after all previous pending listeners // unwatched but before provider.watch() resolves; revive that install // instead of inheriting the stale cancellation. @@ -762,9 +794,27 @@ async function installRemoteWatcher( ) { addRemoteWatchListener(key, sender) } + if ( + result === 'cancelled' && + !canJoinInstall && + !sender.isDestroyed() && + generation === remoteWatcherLifecycleGeneration + ) { + // Why: AbortSignal cannot be revived. A listener arriving after physical + // cancellation waits out that generation, then owns a fresh install. + if (pendingRemoteInstallPromises.get(key) === pendingInstall) { + pendingRemoteInstallPromises.delete(key) + } + return installRemoteWatcher(sender, connectionId, worktreePath, generation) + } return result } - const cancelToken: RemoteWatcherInstallToken = { cancelled: false, listeners: new Map() } + const cancelToken: RemoteWatcherInstallToken = { + cancelled: false, + listeners: new Map(), + abortController: new AbortController(), + abortScheduled: false + } inFlightRemoteInstalls.set(key, cancelToken) addInFlightRemoteInstallListener(cancelToken, sender) const installPromise = doInstallRemoteWatcher(provider, key, worktreePath, cancelToken) @@ -786,22 +836,29 @@ async function doInstallRemoteWatcher( ): Promise { let unwatch: () => void try { - unwatch = await provider.watch(worktreePath, (events) => { - const state = remoteWatchers.get(key) - if (!state) { - return - } - for (const listener of state.listeners.values()) { - if (listener.isDestroyed()) { - continue + unwatch = await provider.watch( + worktreePath, + (events) => { + const state = remoteWatchers.get(key) + if (!state) { + return } - listener.send('fs:changed', { - worktreePath, - events - } satisfies FsChangedPayload) - } - }) + for (const listener of state.listeners.values()) { + if (listener.isDestroyed()) { + continue + } + listener.send('fs:changed', { + worktreePath, + events + } satisfies FsChangedPayload) + } + }, + { signal: cancelToken.abortController.signal } + ) } catch (err) { + if (cancelToken.cancelled || cancelToken.abortController.signal.aborted) { + return 'cancelled' + } console.warn(`[filesystem-watcher] SSH watcher unavailable for ${key}:`, err) return 'unavailable' } finally { @@ -884,6 +941,9 @@ export function registerFilesystemWatcherHandlers(): void { 'fs:watchWorktree', async (event, args: { worktreePath: string; connectionId?: string }): Promise => { if (args.connectionId) { + // Why: a real new watch reopens the subsystem after closeAllWatchers + // latched it shut (also how tests reset between cases). + remoteWatchersClosed = false const key = `${args.connectionId}:${args.worktreePath}` const result = await installRemoteWatcher( event.sender, @@ -923,7 +983,7 @@ export function registerFilesystemWatcherHandlers(): void { const inFlight = inFlightRemoteInstalls.get(key) if (inFlight) { inFlight.listeners.delete(_event.sender.id) - inFlight.cancelled = inFlight.listeners.size === 0 + cancelInFlightRemoteInstallIfUnowned(inFlight) } loggedUnavailableRemoteWatchers.delete(key) releaseRemoteWatchListener(key, _event?.sender?.id ?? 0) @@ -951,10 +1011,19 @@ export async function closeAllWatchers(): Promise { } pendingRemoteWatcherRetries.clear() loggedUnavailableRemoteWatchers.clear() + // Why: latch the subsystem shut and drop the dedup map so a late install that + // begins after teardown is refused instead of registering post-shutdown. Bump + // the generation so a waiter that resumes after a later reopen still recurses + // on a stale lifecycle and is refused. + remoteWatchersClosed = true + remoteWatcherLifecycleGeneration += 1 + pendingRemoteInstallPromises.clear() // Why: cancel any in-flight provider.watch() calls so their resolved // unwatch handles are discarded instead of being installed after shutdown. for (const token of inFlightRemoteInstalls.values()) { + token.listeners.clear() token.cancelled = true + token.abortController.abort() } for (const token of inFlightLocalInstalls.values()) { token.listeners.clear() diff --git a/src/main/ipc/runtime-watcher-process-pool.test.ts b/src/main/ipc/runtime-watcher-process-pool.test.ts index e43112e4ef4..5cd624589b2 100644 --- a/src/main/ipc/runtime-watcher-process-pool.test.ts +++ b/src/main/ipc/runtime-watcher-process-pool.test.ts @@ -162,6 +162,30 @@ describe('RuntimeWatcherProcessPool', () => { expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow']) }) + it('allows only one quarantine generation when isolated setup also times out', async () => { + const timeout = new WatcherProcessFailure( + 'file watcher subscription timed out', + 'subscription', + 'subscribe_timeout' + ) + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisor.subscribeError = timeout + supervisors.push(supervisor) + return supervisor + } + }) + + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toBe(timeout) + await expect(pool.subscribe('/slow', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + it('moves a live root into quarantine when crash resubscription times out', async () => { const timeout = new WatcherProcessFailure( 'file watcher resubscription timed out', @@ -179,6 +203,37 @@ describe('RuntimeWatcherProcessPool', () => { expect(supervisors[1].subscriptions.map(({ dir }) => dir)).toEqual(['/slow-recovery']) }) + it('does not replace an isolated live root after its resubscription times out', async () => { + pool = new RuntimeWatcherProcessPool({ + maxSharedSupervisors: 1, + createSupervisor: () => { + const supervisor = new FakeSupervisor() + supervisors.push(supervisor) + return supervisor + } + }) + const fused = new WatcherProcessFailure( + 'file watcher process crashed repeatedly', + 'supervisor', + 'supervisor_crash_fuse' + ) + const timeout = new WatcherProcessFailure( + 'file watcher resubscription timed out', + 'subscription', + 'subscribe_timeout' + ) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + supervisors[0].subscriptions[0].hooks.onTerminalError?.(fused) + await pool.subscribe('/slow-recovery', vi.fn(), {}, {}) + + supervisors[1].subscriptions[0].hooks.onTerminalError?.(timeout) + + await expect(pool.subscribe('/slow-recovery', vi.fn(), {}, {})).rejects.toMatchObject({ + code: 'supervisor_crash_fuse' + }) + expect(supervisors).toHaveLength(2) + }) + it('keeps healthy shard assignments after a root-specific failure', async () => { pool = new RuntimeWatcherProcessPool({ maxSharedSupervisors: 1, diff --git a/src/main/ipc/runtime-watcher-process-pool.ts b/src/main/ipc/runtime-watcher-process-pool.ts index b5a2bf45d44..b4e46cb3d2c 100644 --- a/src/main/ipc/runtime-watcher-process-pool.ts +++ b/src/main/ipc/runtime-watcher-process-pool.ts @@ -77,10 +77,7 @@ export class RuntimeWatcherProcessPool { if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { this.retireSlot(slot) } else { - releaseAssignment() - if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') { - this.isolatedRoots.add(dir) - } + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) } hooks.onTerminalError?.(error) } @@ -94,10 +91,7 @@ export class RuntimeWatcherProcessPool { if (isWatcherProcessFailure(error) && error.scope === 'supervisor') { this.retireSlot(slot) } else { - releaseAssignment() - if (isWatcherProcessFailure(error) && error.code === 'subscribe_timeout') { - this.isolatedRoots.add(dir) - } + this.releaseFailedRoot(assignment, dir, error, releaseAssignment) } throw error } @@ -207,14 +201,7 @@ export class RuntimeWatcherProcessPool { if (this.assignments.get(root)?.slot === slot) { this.assignments.delete(root) } - if (slot.isolated) { - // Why: one bounded quarantine attempt is the recovery budget for a - // watch lifetime; repeated fused replacements would recreate churn. - this.isolatedRoots.delete(root) - this.failedQuarantineRoots.add(root) - } else { - this.isolatedRoots.add(root) - } + this.quarantineOrFuse(root, slot.isolated) } slot.roots.clear() // Why: failAllSubscriptions is still iterating callbacks; defer disposal @@ -240,6 +227,31 @@ export class RuntimeWatcherProcessPool { } } + private releaseFailedRoot( + assignment: RuntimeWatcherPoolAssignment, + dir: string, + error: unknown, + releaseAssignment: () => void + ): void { + releaseAssignment() + if (!isWatcherProcessFailure(error) || error.code !== 'subscribe_timeout') { + return + } + this.quarantineOrFuse(dir, assignment.slot.isolated) + } + + // Why: one bounded quarantine attempt is the recovery budget per watch + // lifetime; an already-isolated root that fails again is fused, not re-isolated, + // so it cannot spawn another child generation. + private quarantineOrFuse(dir: string, isolated: boolean): void { + if (isolated) { + this.isolatedRoots.delete(dir) + this.failedQuarantineRoots.add(dir) + return + } + this.isolatedRoots.add(dir) + } + private disposeSlot(slot: RuntimeWatcherPoolSlot): void { if (slot.disposed) { return diff --git a/src/main/ipc/worktree-folder-rename-target.ts b/src/main/ipc/worktree-folder-rename-target.ts index 9e52fc15fa7..5725b4efd75 100644 --- a/src/main/ipc/worktree-folder-rename-target.ts +++ b/src/main/ipc/worktree-folder-rename-target.ts @@ -26,6 +26,7 @@ export function planWorktreeFolderRename(args: { repoId: string repoPath: string oldWorktreePath: string + worktreeIdSuffix?: string newLeaf: string settings: WorktreePathSettings platform: NodeJS.Platform @@ -49,6 +50,8 @@ export function planWorktreeFolderRename(args: { return { oldPath: args.oldWorktreePath, newPath, - newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}` + // Why: folder workspaces can have multiple live instances backed by the + // same folder path, so preserve any synthetic instance suffix when re-keying. + newWorktreeId: `${args.repoId}${WORKTREE_ID_SEPARATOR}${newPath}${args.worktreeIdSuffix ?? ''}` } } diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 90ccae11ea6..3d9962fcb48 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -15,7 +15,7 @@ import { resolveProcessCwd } from './process-cwd' import { existsSync } from 'node:fs' import * as pty from 'node-pty' import { parseWslPath, isWslAvailable } from '../wsl' -import { splitWorktreeId } from '../../shared/worktree-id' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id' import { injectHistoryEnv, updateHistFileForFallback, @@ -154,7 +154,11 @@ function runPtyCleanup(id: string): void { function getWslContextFromWorktreeId( worktreeId: string | undefined ): { distro: string; treatPosixCwdAsWsl: true } | undefined { - const worktreePath = worktreeId ? splitWorktreeId(worktreeId)?.worktreePath : undefined + // Why: strip any synthetic `::workspace:` folder-workspace suffix so WSL + // detection parses the real path, not a nonexistent identifier. + const worktreePath = worktreeId + ? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath + : undefined const wslInfo = worktreePath ? parseWslPath(worktreePath) : null return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined } diff --git a/src/main/rate-limits/grok-fetcher.test.ts b/src/main/rate-limits/grok-fetcher.test.ts index f816d5c552f..07c536f32a0 100644 --- a/src/main/rate-limits/grok-fetcher.test.ts +++ b/src/main/rate-limits/grok-fetcher.test.ts @@ -159,6 +159,10 @@ describe('fetchGrokRateLimits', () => { const result = await fetchGrokRateLimits() expect(result.status).toBe('error') expect(result.error).toMatch(/expired/i) + // Why: a stored-but-expired access token is refreshed by Grok CLI on next + // use (a genuine sign-out returns 'missing'), so the message must not tell + // users to re-run `grok login` (#8497). + expect(result.error).not.toMatch(/grok login/i) expect(netFetchMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/rate-limits/grok-fetcher.ts b/src/main/rate-limits/grok-fetcher.ts index 963a2fb1350..099298a56c4 100644 --- a/src/main/rate-limits/grok-fetcher.ts +++ b/src/main/rate-limits/grok-fetcher.ts @@ -145,7 +145,10 @@ export async function fetchGrokRateLimits( } const session = readResult.session if (!isGrokAccessTokenFresh(session)) { - return result('error', 'Grok session expired — run grok login to refresh') + // Why: a genuine sign-out returns 'missing' earlier, so reaching here always + // means a stored, refreshable session — Grok CLI refreshes the access token + // on its next run, so don't tell users to re-run `grok login` (#8497). + return result('error', 'Grok access token expired — Grok CLI will refresh it on next use') } try { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 99e97985656..01e280274fb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,7 +56,10 @@ import { type RuntimeTerminalAgentStatusEvent } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, @@ -1454,6 +1457,7 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus()).toMatchObject({ graphStatus: 'unavailable', authoritativeWindowId: null, + desktopWindowStatus: 'openable', rendererGraphEpoch: 0 }) expect(runtime.getRuntimeId()).toBeTruthy() @@ -1548,6 +1552,118 @@ describe('OrcaRuntimeService', () => { expect(runtime.getStatus().authoritativeWindowId).toBe(TEST_WINDOW_ID) }) + it('transfers authority from the headless sentinel to the first real window', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) => + windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null + ) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.attachWindow(TEST_WINDOW_ID) + runtime.attachWindow(2) + + expect(runtime.getStatus()).toMatchObject({ + authoritativeWindowId: TEST_WINDOW_ID, + desktopWindowStatus: 'available', + graphStatus: 'reloading', + rendererGraphEpoch: 1 + }) + }) + + it('marks live headless PTYs for renderer reattach before desktop promotion', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('marks live bindings again when reopening after a promoted window closes', () => { + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [] + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty('persisted-pty', TEST_WORKTREE_ID, null, { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.attachWindow(TEST_WINDOW_ID) + ;(runtimeStore.setWorkspaceSession as unknown as (next: WorkspaceSessionState) => void)({ + ...getSession(), + activeWorktreeIdsOnShutdown: [] + }) + runtime.markGraphUnavailable(TEST_WINDOW_ID) + + runtime.attachWindow(2) + + expect(getSession().activeWorktreeIdsOnShutdown).toEqual([TEST_WORKTREE_ID]) + }) + + it('preserves live SSH session identities when promoting a headless runtime', () => { + const remotePtyId = 'ssh:ssh-1@@persisted-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + activeWorktreeIdsOnShutdown: [], + activeConnectionIdsAtShutdown: [], + remoteSessionIdsByTabId: {}, + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: remotePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: remotePtyId }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + runtime.registerPty(remotePtyId, TEST_WORKTREE_ID, 'ssh-1', { + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID + }) + + runtime.attachWindow(TEST_WINDOW_ID) + + expect(getSession()).toMatchObject({ + activeWorktreeIdsOnShutdown: [TEST_WORKTREE_ID], + activeConnectionIdsAtShutdown: ['ssh-1'], + remoteSessionIdsByTabId: { 'host-tab': remotePtyId } + }) + }) + + it('reports the activation gate state while no desktop window is available', () => { + const runtime = new OrcaRuntimeService(store, undefined, { + getDesktopWindowStatus: () => 'blocked' + }) + + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + expect(runtime.getStatus().desktopWindowStatus).toBe('blocked') + }) + it('bumps the epoch and enters reloading when the authoritative window reloads', () => { const runtime = createRuntime() @@ -6875,6 +6991,34 @@ describe('OrcaRuntimeService', () => { return { runtime, batches } } + it('defers desktop-only output scanners until a headless runtime is promoted', () => { + const { runtime, batches } = createSideEffectRuntime() + const trackerEntries = ( + runtime as unknown as { + ptyTitleTrackersByPtyId: Map + } + ).ptyTitleTrackersByPtyId + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + + runtime.onPtyData('pty-1', '\x07', 100) + + expect(batches).toEqual([]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onPtyData('pty-1', '\x07', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: 'bell' }]) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).not.toBeNull() + + runtime.markGraphUnavailable(1) + runtime.onPtyData('pty-1', '\x07', 102) + + expect(batches).toHaveLength(1) + expect(trackerEntries.get('pty-1')?.commandCodeDetector).toBeNull() + }) + it('emits one batched event per chunk with facts in byte order and attribution', () => { const { runtime, batches } = createSideEffectRuntime() syncSinglePty(runtime) @@ -9940,11 +10084,37 @@ describe('OrcaRuntimeService', () => { }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ - worktreeId: TEST_WORKTREE_ID + worktreeId: TEST_WORKTREE_ID, + persistHostSessionBinding: true }) ) }) + it('keeps ordinary desktop background terminal persistence opt-in', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) + const runtime = new OrcaRuntimeService(store) + const webContents = { send: vi.fn() } + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents + } as never) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + + await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const spawnOptions = spawn.mock.calls[0]?.[0] as + | { persistHostSessionBinding?: boolean } + | undefined + expect(spawnOptions?.persistHostSessionBinding).toBeUndefined() + }) + it('falls back to background terminal creation for renderer-backed requests without a renderer window', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-bg' }) const runtime = new OrcaRuntimeService(store) @@ -17074,6 +17244,16 @@ describe('OrcaRuntimeService', () => { it('creates mobile session terminals in a headless runtime server', async () => { const spawn = vi.fn().mockResolvedValue({ id: 'pty-headless' }) const runtime = new OrcaRuntimeService(store) + const persistViewMode = vi.spyOn( + runtime as unknown as { + persistHeadlessSessionTabProps: ( + worktreeId: string, + tabId: string, + props: { viewMode: 'terminal' | 'chat' } + ) => void + }, + 'persistHeadlessSessionTabProps' + ) runtime.setPtyController({ spawn, write: () => true, @@ -17082,7 +17262,9 @@ describe('OrcaRuntimeService', () => { }) runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) - const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { + viewMode: 'chat' + }) expect(spawn).toHaveBeenCalledWith( expect.objectContaining({ @@ -17098,8 +17280,12 @@ describe('OrcaRuntimeService', () => { type: 'terminal', status: 'ready', terminal: expect.stringMatching(/^term_/), + viewMode: 'chat', isActive: true }) + expect(persistViewMode).toHaveBeenCalledWith(TEST_WORKTREE_ID, result.tab.parentTabId, { + viewMode: 'chat' + }) const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(listed.tabs).toEqual([ @@ -20146,7 +20332,8 @@ describe('OrcaRuntimeService', () => { }) const result = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: false + activate: false, + viewMode: 'chat' }) expect(send).toHaveBeenCalledWith( @@ -20154,7 +20341,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ worktreeId: TEST_WORKTREE_ID, activate: false, - source: 'runtime-session' + source: 'runtime-session', + viewMode: 'chat' }) ) expect(focusTerminal).not.toHaveBeenCalled() @@ -20512,7 +20700,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'terminal' }) let settled = false const settledCreate = create.finally(() => { @@ -20558,6 +20747,7 @@ describe('OrcaRuntimeService', () => { leafId: pendingLeafId, status: 'ready', terminal: expect.stringMatching(/^term_/), + viewMode: 'terminal', isActive: true }) expect(spawn).toHaveBeenCalledWith( @@ -20575,7 +20765,8 @@ describe('OrcaRuntimeService', () => { expect.objectContaining({ ptyId: 'pty-materialized', tabId: 'tab-pending', - leafId: pendingLeafId + leafId: pendingLeafId, + viewMode: 'terminal' }) ) expect(closeTerminal).not.toHaveBeenCalled() @@ -20769,7 +20960,8 @@ describe('OrcaRuntimeService', () => { }) const create = runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`, { - activate: true + activate: true, + viewMode: 'chat' }) let settled = false const settledCreate = create.finally(() => { @@ -20777,8 +20969,35 @@ describe('OrcaRuntimeService', () => { }) await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(1)) + // A shell-only renderer snapshot can win the first race but still omit + // launch props. The later PTY rescue must fill the explicit mode. + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-shell', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: `tab-alive::${leafId}`, + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: `tab-alive::${leafId}`, + parentTabId: 'tab-alive', + leafId, + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + // The renderer's own PTY spawn registers with the tab binding — the same - // call the pty IPC layer now makes — without any mobileSessionTabs sync. + // call the pty IPC layer now makes — after the shell-only snapshot. runtime.registerPty('pty-alive', TEST_WORKTREE_ID, null, { tabId: 'tab-alive', leafId @@ -20794,7 +21013,8 @@ describe('OrcaRuntimeService', () => { parentTabId: 'tab-alive', leafId, status: 'ready', - terminal: expect.stringMatching(/^term_/) + terminal: expect.stringMatching(/^term_/), + viewMode: 'chat' }) expect(closeTerminal).not.toHaveBeenCalled() } finally { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3b695505bc1..648b800e41a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -171,6 +171,10 @@ import type { LinearTeamStatesResult, LinearStatusSetResult } from '../../shared/linear-agent-access' +import { + HEADLESS_RUNTIME_WINDOW_ID, + type RuntimeDesktopWindowStatus +} from '../../shared/runtime-types' import { LINEAR_SEARCH_MAX_LIMIT, LINEAR_WRITE_BODY_CAP, @@ -1020,6 +1024,7 @@ type TerminalCreateOptions = { launchConfig?: WorktreeStartupLaunch['launchConfig'] launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] telemetry?: WorktreeStartupLaunch['telemetry'] title?: string @@ -1318,6 +1323,7 @@ type RuntimeNotifier = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean presentation?: RuntimeTerminalPresentation tabId?: string @@ -2139,7 +2145,11 @@ export class OrcaRuntimeService { // creates so ordinary renderer spawns never publish here. private pendingMobileTerminalCreatesByKey = new Map< string, - { activate: boolean; selectIfNoActiveTab: boolean } + { + activate: boolean + selectIfNoActiveTab: boolean + viewMode?: 'terminal' | 'chat' + } >() private mobileSessionTabListeners = new Set<(snapshot: RuntimeMobileSessionTabsResult) => void>() // Why: coalesces title/status-driven session.tabs emits so spinner churn @@ -2500,8 +2510,10 @@ export class OrcaRuntimeService { private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null + private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null + private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private accountServices: RuntimeAccountServices | null = null private commitMessageAgentEnv: CommitMessageAgentEnvironmentResolvers | null = null private automationService: AutomationService | null = null @@ -2535,6 +2547,7 @@ export class OrcaRuntimeService { // managed-Codex sessions. The runtime ctor runs in BOTH window and serve. getAdditionalAiVaultCodexHomePaths?: () => readonly string[] buildAgentHookPtyEnv?: () => Record + getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus } ) { this.store = store @@ -2561,6 +2574,7 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.getDesktopWindowStatusFn = deps?.getDesktopWindowStatus ?? (() => 'openable') this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null // Why: the ConPTY spawn mark can land after daemon stream data already // created this PTY's emulator; the mark retrofits the DA1 override here @@ -2963,6 +2977,7 @@ export class OrcaRuntimeService { rendererGraphEpoch: this.rendererGraphEpoch, graphStatus: this.graphStatus, authoritativeWindowId: this.authoritativeWindowId, + desktopWindowStatus: hasRenderer ? 'available' : this.getDesktopWindowStatusFn(), liveTabCount: this.tabs.size, liveLeafCount: this.leaves.size, runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, @@ -3077,11 +3092,78 @@ export class OrcaRuntimeService { } attachWindow(windowId: number): void { + if (this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID) { + // Why: promotion is a renderer reload of the same graph owner, not a new + // runtime; stale handles must transition before the real window publishes. + this.persistWindowlessPtyBindingsForDesktopAttach() + this.markRendererReloading(HEADLESS_RUNTIME_WINDOW_ID) + this.authoritativeWindowId = windowId + return + } if (this.authoritativeWindowId === null) { + // Why: a promoted serve can close and later reopen its window while new + // background PTYs keep arriving; every windowless gap needs this handoff. + this.persistWindowlessPtyBindingsForDesktopAttach() this.authoritativeWindowId = windowId } } + private persistWindowlessPtyBindingsForDesktopAttach(): void { + const session = this.store?.getWorkspaceSession?.() + if (!session || !this.store?.setWorkspaceSession) { + return + } + const promotablePtys = [...this.ptysById.values()].filter((pty) => { + if (!pty.connected || !pty.tabId) { + return false + } + const tab = session.tabsByWorktree[pty.worktreeId]?.find( + (candidate) => candidate.id === pty.tabId + ) + if (!tab) { + return false + } + const layoutPtyIds = Object.values( + session.terminalLayoutsByTabId[pty.tabId]?.ptyIdsByLeafId ?? {} + ) + return tab.ptyId === pty.ptyId || layoutPtyIds.includes(pty.ptyId) + }) + if (promotablePtys.length === 0) { + return + } + + // Why: renderer hydration treats an explicitly-present shutdown list as + // authoritative. A windowless owner has no renderer shutdown pass, so seed + // that existing reattach contract before its next desktop window loads. + const activeWorktreeIdsOnShutdown = [ + ...new Set([ + ...(session.activeWorktreeIdsOnShutdown ?? []), + ...promotablePtys.map((pty) => pty.worktreeId) + ]) + ] + const activeConnectionIdsAtShutdown = [ + ...new Set([ + ...(session.activeConnectionIdsAtShutdown ?? []), + ...promotablePtys + .map((pty) => pty.connectionId) + .filter((connectionId): connectionId is string => connectionId !== null) + ]) + ] + const remoteSessionIdsByTabId = { ...session.remoteSessionIdsByTabId } + for (const pty of promotablePtys) { + if (pty.connectionId && pty.tabId) { + remoteSessionIdsByTabId[pty.tabId] = pty.ptyId + } + } + + this.store.setWorkspaceSession({ + ...session, + activeWorktreeIdsOnShutdown, + ...(activeConnectionIdsAtShutdown.length > 0 ? { activeConnectionIdsAtShutdown } : {}), + ...(Object.keys(remoteSessionIdsByTabId).length > 0 ? { remoteSessionIdsByTabId } : {}) + }) + } + syncWindowGraph(windowId: number, graph: RuntimeSyncWindowGraph): RuntimeSyncWindowGraphResult { if (this.authoritativeWindowId === null) { this.authoritativeWindowId = windowId @@ -3206,6 +3288,7 @@ export class OrcaRuntimeService { this.rebuildLeafPtyIndex() this.notifyMobileSessionTabSnapshots() this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() for (const leaf of this.leaves.values()) { this.adoptPreAllocatedHandle(leaf) @@ -3667,6 +3750,7 @@ export class OrcaRuntimeService { activate: boolean selectIfNoActiveTab?: boolean startupCwd?: string + viewMode?: 'terminal' | 'chat' split?: { splitFromLeafId: string; direction: 'horizontal' | 'vertical' } } ): void { @@ -3698,6 +3782,17 @@ export class OrcaRuntimeService { baseLayout, args.split ) + // Why: a main-side PTY rescue or split publication must not erase the + // host's explicit tab mode before the renderer graph catches up. + const viewMode = + args.viewMode ?? + existingTab?.viewMode ?? + existing?.tabs.find( + (candidate): candidate is RuntimeMobileSessionTerminalTab => + candidate.type === 'terminal' && + candidate.parentTabId === args.tabId && + candidate.viewMode !== undefined + )?.viewMode const tab: RuntimeMobileSessionTerminalTab = { type: 'terminal', id: `${args.tabId}::${args.leafId}`, @@ -3707,6 +3802,7 @@ export class OrcaRuntimeService { title, ...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}), ...(args.startupCwd ? { startupCwd: args.startupCwd } : {}), + ...(viewMode ? { viewMode } : {}), parentLayout, isActive: args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null) @@ -5972,7 +6068,7 @@ export class OrcaRuntimeService { /** Record one derived side-effect fact: batched per chunk while applying * bytes, emitted immediately for between-chunk facts (stale-title timer). */ private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { - if (!this.onTerminalSideEffects) { + if (!this.onTerminalSideEffects || !this.terminalSideEffectConsumerAvailable) { return } const entry = this.ptyTitleTrackersByPtyId.get(ptyId) @@ -5988,7 +6084,11 @@ export class OrcaRuntimeService { facts: TerminalSideEffectFact[], options: { replay?: boolean } = {} ): void { - if (!this.onTerminalSideEffects || facts.length === 0) { + if ( + !this.onTerminalSideEffects || + !this.terminalSideEffectConsumerAvailable || + facts.length === 0 + ) { return } const batch: TerminalSideEffectBatch = { @@ -6167,7 +6267,7 @@ export class OrcaRuntimeService { // Why: bell/command-finished/pr-link/2031 facts exist only for the // pty:sideEffect channel. Headless serve has no consumer, so skip the // per-chunk bell walk and 133/URL/2031 scans entirely. - ...(this.onTerminalSideEffects + ...(this.terminalSideEffectConsumerAvailable ? { onBell: () => { this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) @@ -6200,7 +6300,7 @@ export class OrcaRuntimeService { // headless serve skips the per-chunk scrape entirely. The detector // self-arms on the Command Code banner; the spawn command (when main // saw one) mirrors the renderer detector's startupCommand fast-arm. - commandCodeDetector: this.onTerminalSideEffects + commandCodeDetector: this.terminalSideEffectConsumerAvailable ? createCommandCodeOutputStatusDetector({ startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, onWorking: (prompt) => { @@ -6299,6 +6399,19 @@ export class OrcaRuntimeService { this.ptyTitleTrackersByPtyId.delete(ptyId) } + private setTerminalSideEffectConsumerAvailable(available: boolean): void { + const nextAvailable = available && this.onTerminalSideEffects !== null + if (nextAvailable === this.terminalSideEffectConsumerAvailable) { + return + } + this.terminalSideEffectConsumerAvailable = nextAvailable + // Why: optional bell/command/link scanners are selected when a tracker is + // created. Rebuild at the window boundary so pure headless output stays cheap. + for (const ptyId of [...this.ptyTitleTrackersByPtyId.keys()]) { + this.disposePtyTitleTracker(ptyId) + } + } + private extractLastOsc7CwdForPty( ptyId: string, data: string @@ -17647,12 +17760,12 @@ export class OrcaRuntimeService { ): Promise { const presentation = resolveTerminalPresentation(opts) const requiresRendererFocus = opts.presentation === 'focused' || opts.focus === true + const availableAuthoritativeWindow = this.getAvailableAuthoritativeWindow() // Why: pre-diff createTerminal fell back to the renderer's active worktree // when no selector was provided. The new background-spawn branch hard- // requires a resolvable selector, so route the no-selector case through // the renderer IPC path to preserve that behavior. - const rendererWindow = - opts.rendererBacked === true ? this.getAvailableAuthoritativeWindow() : null + const rendererWindow = opts.rendererBacked === true ? availableAuthoritativeWindow : null const shouldCreateInBackground = worktreeSelector !== undefined && ((!requiresRendererFocus && opts.rendererBacked !== true) || @@ -17773,7 +17886,14 @@ export class OrcaRuntimeService { tabId, leafId, ...(launchOpts.sessionId ? { sessionId: launchOpts.sessionId } : {}), - ...(launchOpts.persistHostSessionBinding ? { persistHostSessionBinding: true } : {}) + // Why: a headless-created pane has no renderer session writer. Persist + // its tab/leaf binding at spawn so a later promoted window reattaches + // the live daemon or SSH PTY instead of replacing it with a fresh one. + // Re-check freshly: the entry-time snapshot can go stale across the + // awaits above if the authoritative window is destroyed mid-spawn. + ...(launchOpts.persistHostSessionBinding || this.getAvailableAuthoritativeWindow() === null + ? { persistHostSessionBinding: true } + : {}) }) this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle) this.registerPty(result.id, workspace.id, workspace.connectionId) @@ -17806,6 +17926,7 @@ export class OrcaRuntimeService { // Why: explicit background presentation may carry legacy activate // metadata from an already-owned renderer pane; don't select it on mobile. selectIfNoActiveTab: presentation !== 'background', + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), ...(cwd !== workspace.path ? { startupCwd: cwd } : {}) }) } @@ -17824,6 +17945,7 @@ export class OrcaRuntimeService { ...(effectiveLaunchConfig ? { launchConfig: effectiveLaunchConfig } : {}), ...(launchToken ? { launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), activate: presentation === 'focused', ...(presentation ? { presentation } : {}), tabId, @@ -17899,6 +18021,7 @@ export class OrcaRuntimeService { ...(launchOpts.launchConfig ? { launchConfig: launchOpts.launchConfig } : {}), ...(launchOpts.launchToken ? { launchToken: launchOpts.launchToken } : {}), ...(launchOpts.launchAgent ? { launchAgent: launchOpts.launchAgent } : {}), + ...(launchOpts.viewMode ? { viewMode: launchOpts.viewMode } : {}), startupCommandDelivery: launchOpts.startupCommandDelivery, title: launchOpts.title, activate: presentation === 'focused', @@ -17957,6 +18080,7 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal @@ -18001,6 +18125,7 @@ export class OrcaRuntimeService { agent?: TuiAgent launchConfig?: SleepingAgentLaunchConfig launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' activate?: boolean clientMutationId?: string signal?: AbortSignal @@ -18034,6 +18159,7 @@ export class OrcaRuntimeService { env: startupCommand.env, startupCommandDelivery: startupCommand.startupCommandDelivery, launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig } @@ -18082,6 +18208,7 @@ export class OrcaRuntimeService { ...(startupCommand.env ? { env: startupCommand.env } : {}), ...(startupCommand.launchConfig ? { launchConfig: startupCommand.launchConfig } : {}), ...(startupCommand.launchAgent ? { launchAgent: startupCommand.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: startupCommand.startupCommandDelivery, source: 'runtime-session', activate: opts.activate @@ -18100,7 +18227,8 @@ export class OrcaRuntimeService { // requested group, so any wrong-group placement is cosmetic and stall-window-only. this.pendingMobileTerminalCreatesByKey.set(pendingCreateKey, { activate: opts.activate !== false, - selectIfNoActiveTab: true + selectIfNoActiveTab: true, + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}) }) try { // Why: the PTY spawn and the tabCreate reply race on independent IPC @@ -18145,6 +18273,7 @@ export class OrcaRuntimeService { startupCommandDelivery: startupCommand.startupCommandDelivery, identity: { tabId: pendingSurface.tab.parentTabId, leafId: pendingSurface.tab.leafId }, launchAgent: startupCommand.launchAgent, + viewMode: opts.viewMode, targetGroupId: opts.targetGroupId, launchConfig: startupCommand.launchConfig } @@ -18268,6 +18397,7 @@ export class OrcaRuntimeService { startupCommandDelivery?: WorktreeStartupLaunch['startupCommandDelivery'] identity?: { tabId: string; leafId: string; sessionId?: string } launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' targetGroupId?: string launchConfig?: SleepingAgentLaunchConfig } = {} @@ -18285,6 +18415,7 @@ export class OrcaRuntimeService { env: opts.env, ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), startupCommandDelivery: opts.startupCommandDelivery, ...(opts.identity ? { @@ -18306,6 +18437,11 @@ export class OrcaRuntimeService { } const parentTabId = livePty.pty.tabId ?? `pty:${livePty.pty.ptyId}` const leafId = parsePaneKey(livePty.pty.paneKey ?? '')?.leafId ?? randomUUID() + if (opts.viewMode) { + // Why: the runtime-owned binding must survive a serve restart with the + // same initial mode, not fall back to a later client's local default. + this.persistHeadlessSessionTabProps(worktreeId, parentTabId, { viewMode: opts.viewMode }) + } const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const existingSurface = existing?.tabs.find( @@ -18328,6 +18464,7 @@ export class OrcaRuntimeService { title: terminal.title ?? livePty.pty.title ?? 'Terminal', ...(cwd ? { startupCwd: cwd } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), parentLayout, isActive: activate } @@ -18474,21 +18611,27 @@ export class OrcaRuntimeService { return null } const existing = this.findMobileTerminalSurface(worktreeId, tabId) - if (existing) { - // Why: the renderer's own publication already landed; stay idempotent. + if ( + existing && + this.isReadyMobileTerminalSurface(existing) && + (pending.viewMode === undefined || existing.tab.viewMode === pending.viewMode) + ) { + // Why: the renderer's ready publication already landed with the intended + // mode; only a pending shell still needs the main-side PTY rescue. return existing } const pty = this.findLiveRegisteredPtyForRendererTab(worktreeId, tabId) const leafId = pty ? parsePaneKey(pty.paneKey ?? '')?.leafId : undefined if (!pty || !leafId) { - return null + return existing } this.publishPtyBackedMobileSessionTerminal(worktreeId, pty, { tabId, leafId, title: null, activate: pending.activate, - selectIfNoActiveTab: pending.selectIfNoActiveTab + selectIfNoActiveTab: pending.selectIfNoActiveTab, + ...(pending.viewMode ? { viewMode: pending.viewMode } : {}) }) // Why: waitForMobileTerminalSurface's check closures are drained only inside // syncWindowGraph; a main-side publish must drain them too or the pending @@ -19095,6 +19238,7 @@ export class OrcaRuntimeService { // against whatever the renderer rebuilds next. this.rendererGraphEpoch += 1 this.graphStatus = 'reloading' + this.setTerminalSideEffectConsumerAvailable(false) this.rememberDetachedPreAllocatedLeaves() this.handles.clear() this.handleByLeafKey.clear() @@ -19111,6 +19255,7 @@ export class OrcaRuntimeService { return } this.graphStatus = 'ready' + this.setTerminalSideEffectConsumerAvailable(windowId !== HEADLESS_RUNTIME_WINDOW_ID) this.refreshWritableFlags() } @@ -19124,6 +19269,7 @@ export class OrcaRuntimeService { this.rendererGraphEpoch += 1 } this.graphStatus = 'unavailable' + this.setTerminalSideEffectConsumerAvailable(false) this.authoritativeWindowId = null this.rememberDetachedPreAllocatedLeaves() this.tabs.clear() diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 8856101e1ee..15fb0a06a00 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -6,6 +6,20 @@ import Database from '../../sqlite/sync-database' import { OrchestrationDb } from './db' import type { MessageType } from './db' +// Overwrites the datetime('now')-seeded timestamps with explicit fixture values +// so stale-detection assertions stay deterministic (no wall clock). +function setDispatchTimes( + d: OrchestrationDb, + id: string, + dispatchedAt: string, + heartbeatAt: string | null = null +): void { + const sqlite = (d as unknown as { db: Database.Database }).db + sqlite + .prepare('UPDATE dispatch_contexts SET dispatched_at = ?, last_heartbeat_at = ? WHERE id = ?') + .run(dispatchedAt, heartbeatAt, id) +} + describe('OrchestrationDb', () => { let db: OrchestrationDb | undefined @@ -696,6 +710,67 @@ describe('OrchestrationDb', () => { expect(stale.map((s) => s.id)).toEqual([ctxB.id]) }) + // Regression for #8452: dispatched_at / last_heartbeat_at are written by + // datetime('now') (space-format, e.g. "2026-07-12 12:00:00") while the + // threshold is ISO ("...T11:55:00.000Z"). Raw TEXT ordering ranks the space + // (0x20) below the 'T' (0x54) at index 10, flagging fresh same-date rows. + it('getStaleDispatches ignores fresh SQLite space-format timestamps (#8452)', () => { + const d = createDb() + + // Fresh worker: dispatched 12:00, heartbeat 12:05 (space-format), both + // after the 11:55 threshold → NOT stale. + const fresh = d.createDispatchContext(d.createTask({ spec: 'fresh' }).id, 'term_fresh') + setDispatchTimes(d, fresh.id, '2026-07-12 12:00:00', '2026-07-12 12:05:00') + + // Legacy ISO-format fresh row (mixed-format table) stays fresh too. + const legacy = d.createDispatchContext(d.createTask({ spec: 'legacy' }).id, 'term_legacy') + setDispatchTimes(d, legacy.id, '2026-07-12T12:00:00.000Z', '2026-07-12T12:05:00.000Z') + + // Genuinely hung: dispatched + heartbeated at 10:00, ~2h before threshold. + const hung = d.createDispatchContext(d.createTask({ spec: 'hung' }).id, 'term_hung') + setDispatchTimes(d, hung.id, '2026-07-12 10:00:00', '2026-07-12 10:00:00') + + const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') + expect(stale.map((s) => s.id)).toEqual([hung.id]) + }) + + it('getStaleDispatches keeps a just-dispatched space-format row in the grace window (#8452)', () => { + const d = createDb() + + // Space-format dispatched_at one minute after the threshold, no heartbeat + // yet → still inside the grace window, must not be flagged. + const ctx = d.createDispatchContext(d.createTask({ spec: 'x' }).id, 'term_x') + setDispatchTimes(d, ctx.id, '2026-07-12 12:00:00') + + const stale = d.getStaleDispatches('2026-07-12T11:59:00.000Z') + expect(stale).toEqual([]) + }) + + // Same-UTC-date midnight threshold: keeps the buggy space-vs-'T' compare in + // play so this guards the fix at a day boundary (#8452; idea from @KMGeon's #8453). + it('getStaleDispatches keeps a fresh row just after a UTC-midnight threshold (#8452)', () => { + const d = createDb() + + const ctx = d.createDispatchContext(d.createTask({ spec: 'midnight' }).id, 'term_midnight') + setDispatchTimes(d, ctx.id, '2026-05-04 00:04:00') + + const stale = d.getStaleDispatches('2026-05-04T00:00:00.000Z') + expect(stale).toEqual([]) + }) + + // Guards the last_heartbeat_at half of the fix on its own: a worker + // dispatched long before the threshold (stale under either format) that + // just sent a fresh space-format heartbeat must stay fresh (#8452). + it('getStaleDispatches keeps a live worker with a fresh space-format heartbeat (#8452)', () => { + const d = createDb() + + const ctx = d.createDispatchContext(d.createTask({ spec: 'live' }).id, 'term_live') + setDispatchTimes(d, ctx.id, '2026-07-12 10:00:00', '2026-07-12 11:59:00') + + const stale = d.getStaleDispatches('2026-07-12T11:55:00.000Z') + expect(stale).toEqual([]) + }) + it('getThreadMessagesFor returns only same-thread replies to a handle', () => { const d = createDb() const outbound = d.insertMessage({ diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index 80a842da530..2525d30c851 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -798,17 +798,20 @@ export class OrchestrationDb { // failed / circuit_broken row with an old-or-null last_heartbeat_at would // warn every tick (warning storm). Without `dispatched_at < :threshold`, // a freshly-dispatched worker would trip the warning during its first - // heartbeat interval (false positive). Callers supply the threshold as an - // ISO timestamp so the SQLite string-compare ordering works correctly - // (ISO-8601 compares lexicographically in time order). + // heartbeat interval (false positive). The stored columns are space-format + // (datetime('now'), "2026-07-12 12:00:00") while the threshold is ISO-Z, so + // raw TEXT ordering compares ' ' (0x20) below 'T' (0x54) at index 10 and + // flags fresh same-date rows as stale (#8452). julianday() parses both + // formats as UTC for a correct numeric comparison; a malformed timestamp + // yields NULL, so that row simply isn't flagged. getStaleDispatches(thresholdIso: string): DispatchContextRow[] { return this.db .prepare( `SELECT * FROM dispatch_contexts WHERE status = 'dispatched' AND dispatched_at IS NOT NULL - AND dispatched_at < ? - AND (last_heartbeat_at IS NULL OR last_heartbeat_at < ?)` + AND julianday(dispatched_at) < julianday(?) + AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))` ) .all(thresholdIso, thresholdIso) as DispatchContextRow[] } diff --git a/src/main/runtime/rpc/methods/session-tabs-schemas.ts b/src/main/runtime/rpc/methods/session-tabs-schemas.ts index 0c21af7de72..1b8afbd2f46 100644 --- a/src/main/runtime/rpc/methods/session-tabs-schemas.ts +++ b/src/main/runtime/rpc/methods/session-tabs-schemas.ts @@ -130,6 +130,7 @@ export const CreateTerminalTab = WorktreeTabSelector.extend({ message: 'Unknown launch agent' }) .optional(), + viewMode: z.enum(['terminal', 'chat']).optional(), activate: z.boolean().optional(), // Why: idempotency key so a retried create (double-tap, reconnect replay) // returns the in-flight operation instead of spawning a duplicate terminal. diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index 625959f6d64..d81faa169cd 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -148,6 +148,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) ) @@ -167,6 +168,7 @@ describe('session tab RPC methods', () => { agentEnv: { CODEX_PROFILE: 'captured' } }, launchAgent: 'codex', + viewMode: 'chat', activate: true }) }) diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index d27d771f321..55be4d0b21e 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -53,6 +53,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchConfig ? { launchConfig: params.launchConfig } : {}), ...(params.launchToken ? { launchToken: params.launchToken } : {}), ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), + ...(params.viewMode ? { viewMode: params.viewMode } : {}), activate: params.activate, clientMutationId: params.clientMutationId, // Why: a dead client connection must cancel the surface wait instead diff --git a/src/main/runtime/rpc/schemas.test.ts b/src/main/runtime/rpc/schemas.test.ts index 9712f1c23d5..010ef2bea11 100644 --- a/src/main/runtime/rpc/schemas.test.ts +++ b/src/main/runtime/rpc/schemas.test.ts @@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => { }) expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' }) }) + + it('accepts worktree.create payloads sent by the previous mobile protocol', () => { + const create = methodParams(WORKTREE_METHODS, 'worktree.create') + + expectParses(create, { + repo: 'id:repo-github', + name: 'fix-mobile-tasks', + displayName: 'Fix mobile tasks', + setupDecision: 'run', + activate: true, + startupDraft: 'https://github.com/acme/app/pull/123', + createdWithAgent: 'codex', + linkedPR: 123, + baseBranch: 'origin/main', + compareBaseRef: 'origin/main', + branchNameOverride: 'feature/mobile-tasks', + pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' } + }) + expectParses(create, { + repo: 'id:repo-gitlab', + name: 'mr-7', + linkedGitLabMR: 7, + sparseCheckout: { directories: ['mobile'], presetId: 'mobile' }, + comment: 'keep mobile parity' + }) + expectParses(create, { + repo: 'id:repo-linear', + name: 'eng-42', + linkedLinearIssue: 'ENG-42', + linkedLinearIssueWorkspaceId: 'workspace-1', + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + }) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 2d2862cbe5c..76d3a344534 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -233,11 +233,17 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.updatePRState', 'github.repoSlug', 'github.workItem', + // Cross-repo GitHub work-item lookup: lets the mobile create-workspace Smart + // picker resolve a pasted github.com URL that points at a different repo. + 'github.workItemByOwnerRepo', 'github.workItemDetails', 'gitlab.createIssue', 'gitlab.addIssueComment', 'gitlab.addMRComment', 'gitlab.listWorkItems', + // Mobile create-workspace Smart picker: resolve a pasted GitLab URL to an exact + // issue/MR. (MR listing reuses gitlab.listWorkItems, which returns issues + MRs.) + 'gitlab.workItemByPath', 'gitlab.mergeMR', 'gitlab.resolveMRDiscussion', 'gitlab.todos', diff --git a/src/main/runtime/windows-firewall-remote-scope.test.ts b/src/main/runtime/windows-firewall-remote-scope.test.ts new file mode 100644 index 00000000000..6e2462c0334 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' + +type RuleScope = { remoteAddresses: unknown } + +function rule(remoteAddresses: unknown): RuleScope { + return { remoteAddresses } +} + +describe('Windows firewall remote-address scope', () => { + it.each([['Any'], ['any'], ['LocalSubnet']])('accepts the documented %s scope', (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(scope)], undefined, undefined)).toBe(true) + }) + + it.each([['Any4'], ['LocalSubnet4']])( + 'accepts address-family-specific %s on the selected IPv4 interface', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + true + ) + } + ) + + it('keeps address-family-specific keywords on the selected interface family', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], 'fd7a:115c:a1e0::5', 64)).toBe( + true + ) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], 'fd7a:115c:a1e0::5', 64) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet6'])], '192.168.0.108', 24) + ).toBe(false) + }) + + it.each([ + ['192.168.0.0/24', '192.168.0.108', 24], + ['192.168.0.0-192.168.0.255', '192.168.0.108', 24], + ['fd7a:115c:a1e0::/64', 'fd7a:115c:a1e0::5', 64], + ['fd7a:115c:a1e0::-fd7a:115c:a1e0:0:ffff:ffff:ffff:ffff', 'fd7a:115c:a1e0::5', 64] + ])('accepts explicit scope %s covering the selected local subnet', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(true) + }) + + it.each([ + ['192.168.1.0/24', '192.168.0.108', 24], + ['192.168.0.64/26', '192.168.0.108', 24], + ['192.168.0.108', '192.168.0.108', 24], + ['fd7a:115c:a1e1::/64', 'fd7a:115c:a1e0::5', 64], + ['Internet', '192.168.0.108', 24] + ])('rejects restrictive or unsupported scope %s', (scope, address, prefix) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], address, prefix)).toBe(false) + }) + + it('does not infer explicit scope coverage without selected interface subnet data', () => { + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], undefined, undefined) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['192.168.0.0/24'])], '192.168.0.108', 40) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it.each([ + undefined, + null, + [], + {}, + [rule(undefined)], + [rule([])], + [rule([''])], + [rule(['not-an-address'])], + [{ remoteAddresses: [42] }] + ])('rejects malformed or empty structured output %#', (rules) => { + expect(hasSufficientWindowsFirewallRemoteScope(rules, '192.168.0.108', 24)).toBe(false) + }) + + it('evaluates each rule independently instead of merging partial ranges', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0-192.168.0.127']), rule(['192.168.0.128-192.168.0.255'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.1.0/24']), rule(['192.168.0.0/24'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts PowerShell single-object and single-string JSON shapes', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + { remoteAddresses: '192.168.0.0/24' }, + '192.168.0.108', + 24 + ) + ).toBe(true) + }) + + it('accepts dotted-netmask CIDR with a contiguous mask and rejects a holey one', () => { + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.255.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope( + [rule(['192.168.0.0/255.0.255.0'])], + '192.168.0.108', + 24 + ) + ).toBe(false) + }) + + it('treats a single-host (/32) interface as coverable only by Any/LocalSubnet keywords', () => { + // A /32 subnet is just the desktop itself, so an explicit range cannot prove + // the phone (a different host) is allowed — only the keywords can. + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], '100.64.1.20', 32)).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['LocalSubnet'])], '100.64.1.20', 32) + ).toBe(true) + expect( + hasSufficientWindowsFirewallRemoteScope([rule(['100.64.0.0/10'])], '100.64.1.20', 32) + ).toBe(false) + }) + + it('fails address-family keywords closed when the interface family is unknown', () => { + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any'])], undefined, undefined)).toBe( + true + ) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any4'])], undefined, undefined)).toBe( + false + ) + expect(hasSufficientWindowsFirewallRemoteScope([rule(['Any6'])], undefined, undefined)).toBe( + false + ) + }) + + it.each([['Intranet'], ['DNS'], ['DHCP'], ['DefaultGateway'], ['PlayToDevice']])( + 'fails the policy-defined %s keyword closed rather than assuming subnet coverage', + (scope) => { + expect(hasSufficientWindowsFirewallRemoteScope([rule([scope])], '192.168.0.108', 24)).toBe( + false + ) + } + ) +}) diff --git a/src/main/runtime/windows-firewall-remote-scope.ts b/src/main/runtime/windows-firewall-remote-scope.ts new file mode 100644 index 00000000000..9845413d079 --- /dev/null +++ b/src/main/runtime/windows-firewall-remote-scope.ts @@ -0,0 +1,216 @@ +type IpVersion = 4 | 6 + +type ParsedIpAddress = { + version: IpVersion + bits: 32 | 128 + value: bigint +} + +type IpRange = { + version: IpVersion + start: bigint + end: bigint +} + +export function hasSufficientWindowsFirewallRemoteScope( + ruleScopes: unknown, + localAddress: unknown, + localPrefixLength: unknown +): boolean { + const rules = Array.isArray(ruleScopes) ? ruleScopes : [ruleScopes] + const localSubnet = parseSubnet(localAddress, localPrefixLength) + + // Why: coverage is checked per scope, never unioned across rules — this + // advisory check fails safe, so accepting fragmented rules only adds risk. + return rules.some((rule) => ruleHasSufficientScope(rule, localSubnet)) +} + +function ruleHasSufficientScope(rule: unknown, localSubnet: IpRange | null): boolean { + if (!isRecord(rule)) { + return false + } + const addresses = Array.isArray(rule.remoteAddresses) + ? rule.remoteAddresses + : [rule.remoteAddresses] + + return addresses.some( + (address) => typeof address === 'string' && addressScopeIsSufficient(address, localSubnet) + ) +} + +function addressScopeIsSufficient(scope: string, localSubnet: IpRange | null): boolean { + const normalized = scope.trim().toLowerCase() + if (normalized === 'any' || normalized === 'localsubnet') { + return true + } + if ( + normalized === 'any4' || + normalized === 'any6' || + normalized === 'localsubnet4' || + normalized === 'localsubnet6' + ) { + // Why: these keywords cover a single family, so they need the selected + // interface's family; an unknown family (null subnet) fails closed. + return localSubnet?.version === (normalized.endsWith('4') ? 4 : 6) + } + if (!localSubnet) { + return false + } + + // Why: without a phone IP we can only prove coverage when the rule spans the + // whole selected subnet; a single-host subnet (/32, /128 VPN/Tailscale) is just + // the desktop, so start !== end blocks a desktop-only rule from a false-allow. + const explicitRange = parseIpRange(scope) + return ( + localSubnet.start !== localSubnet.end && + explicitRange?.version === localSubnet.version && + explicitRange.start <= localSubnet.start && + explicitRange.end >= localSubnet.end + ) +} + +function parseSubnet(address: unknown, prefixLength: unknown): IpRange | null { + if (typeof address !== 'string' || typeof prefixLength !== 'number') { + return null + } + const parsed = parseIpAddress(address) + return parsed ? subnetFromParsed(parsed, prefixLength) : null +} + +function subnetFromParsed(parsed: ParsedIpAddress, prefixLength: number): IpRange | null { + if (!Number.isInteger(prefixLength) || prefixLength < 0 || prefixLength > parsed.bits) { + return null + } + const hostBits = BigInt(parsed.bits - prefixLength) + const hostMask = hostBits === 0n ? 0n : (1n << hostBits) - 1n + const start = parsed.value & ~hostMask + return { version: parsed.version, start, end: start | hostMask } +} + +// Why: Windows also accepts dotted-netmask CIDR (192.168.0.0/255.255.255.0); +// convert a contiguous mask to a prefix length and fail closed on holey masks. +function maskPrefixLength(maskText: string, version: IpVersion): number | null { + const mask = parseIpAddress(maskText) + if (!mask || mask.version !== version) { + return null + } + const fullMask = (1n << BigInt(mask.bits)) - 1n + const hostPart = ~mask.value & fullMask + if ((hostPart & (hostPart + 1n)) !== 0n) { + return null + } + let hostBits = 0 + for (let remaining = hostPart; remaining > 0n; remaining >>= 1n) { + hostBits += 1 + } + return mask.bits - hostBits +} + +function parseIpRange(scope: string): IpRange | null { + const trimmed = scope.trim() + const dashIndex = trimmed.indexOf('-') + if (dashIndex >= 0) { + if (dashIndex !== trimmed.lastIndexOf('-')) { + return null + } + const start = parseIpAddress(trimmed.slice(0, dashIndex)) + const end = parseIpAddress(trimmed.slice(dashIndex + 1)) + if (!start || !end || start.version !== end.version || start.value > end.value) { + return null + } + return { version: start.version, start: start.value, end: end.value } + } + + const slashIndex = trimmed.indexOf('/') + if (slashIndex >= 0) { + if (slashIndex !== trimmed.lastIndexOf('/')) { + return null + } + const address = parseIpAddress(trimmed.slice(0, slashIndex)) + if (!address) { + return null + } + const suffix = trimmed.slice(slashIndex + 1) + const prefixLength = /^\d+$/.test(suffix) + ? Number(suffix) + : maskPrefixLength(suffix, address.version) + return prefixLength === null ? null : subnetFromParsed(address, prefixLength) + } + + const address = parseIpAddress(trimmed) + return address ? { version: address.version, start: address.value, end: address.value } : null +} + +function parseIpAddress(input: string): ParsedIpAddress | null { + const trimmed = input.trim() + const bracketed = trimmed.startsWith('[') || trimmed.endsWith(']') + if (bracketed && !(trimmed.startsWith('[') && trimmed.endsWith(']'))) { + return null + } + const address = (bracketed ? trimmed.slice(1, -1) : trimmed).split('%', 1)[0] ?? '' + return address.includes(':') ? parseIpv6(address) : parseIpv4(address) +} + +function parseIpv4(address: string): ParsedIpAddress | null { + const octets = address.split('.') + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return null + } + const values = octets.map(Number) + if (values.some((octet) => octet > 255)) { + return null + } + const value = values.reduce((result, octet) => (result << 8n) | BigInt(octet), 0n) + return { version: 4, bits: 32, value } +} + +function parseIpv6(address: string): ParsedIpAddress | null { + const expandedAddress = expandEmbeddedIpv4(address) + if (!expandedAddress) { + return null + } + const halves = expandedAddress.split('::') + if (halves.length > 2) { + return null + } + const left = splitIpv6Half(halves[0] ?? '') + const right = splitIpv6Half(halves[1] ?? '') + if (!left || !right) { + return null + } + + const hasCompression = halves.length === 2 + const missingGroups = 8 - left.length - right.length + if ((!hasCompression && missingGroups !== 0) || (hasCompression && missingGroups < 1)) { + return null + } + const groups = [...left, ...Array(missingGroups).fill('0'), ...right] + const value = groups.reduce((result, group) => (result << 16n) | BigInt(`0x${group}`), 0n) + return { version: 6, bits: 128, value } +} + +function expandEmbeddedIpv4(address: string): string | null { + if (!address.includes('.')) { + return address + } + const lastColon = address.lastIndexOf(':') + const ipv4 = parseIpv4(address.slice(lastColon + 1)) + if (lastColon < 0 || !ipv4) { + return null + } + const high = ((ipv4.value >> 16n) & 0xffffn).toString(16) + const low = (ipv4.value & 0xffffn).toString(16) + return `${address.slice(0, lastColon)}:${high}:${low}` +} + +function splitIpv6Half(half: string): string[] | null { + if (half === '') { + return [] + } + const groups = half.split(':') + return groups.every((group) => /^[\da-f]{1,4}$/i.test(group)) ? groups : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/main/runtime/windows-mobile-firewall.test.ts b/src/main/runtime/windows-mobile-firewall.test.ts index 4a7cc8a1219..56464c15bf1 100644 --- a/src/main/runtime/windows-mobile-firewall.test.ts +++ b/src/main/runtime/windows-mobile-firewall.test.ts @@ -24,7 +24,9 @@ describe('windows mobile firewall', () => { it('inspects the exact executable, port, and selected interface profile', async () => { const runPowerShell = vi.fn().mockResolvedValue( JSON.stringify({ - ruleAllowed: true, + matchingRuleScopes: [{ remoteAddresses: ['192.168.0.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, privateFirewallEnabled: true, networkCategory: 'Private' }) @@ -47,6 +49,29 @@ describe('windows mobile firewall', () => { expect(script).toContain("C:\\Users\\O''Brien\\Orca\\Orca.exe") expect(script).toContain("$profile -match 'Private'") expect(script).toContain("Get-NetIPAddress -IPAddress '192.168.0.108'") + expect(script).toContain('Get-NetFirewallAddressFilter') + expect(script).toContain('remoteAddresses = @($addressFilter.RemoteAddress') + expect(script).toContain('$localPrefixLength = [int]$ip.PrefixLength') + }) + + it('does not accept a qualifying rule whose remote-address scope excludes the phone subnet', async () => { + const runPowerShell = vi.fn().mockResolvedValue( + JSON.stringify({ + matchingRuleScopes: [{ remoteAddresses: ['192.168.1.0/24'] }], + localAddress: '192.168.0.108', + localPrefixLength: 24, + privateFirewallEnabled: true, + networkCategory: 'Private' + }) + ) + + await expect( + inspectWindowsMobileFirewall(6768, '192.168.0.108', environment(runPowerShell)) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: true + }) }) it('does not support non-Windows or unpackaged development builds', async () => { @@ -81,6 +106,22 @@ describe('windows mobile firewall', () => { }) }) + it('returns an actionable status for malformed or empty PowerShell output', async () => { + for (const stdout of ['', 'not json']) { + await expect( + inspectWindowsMobileFirewall( + 6768, + undefined, + environment(vi.fn().mockResolvedValue(stdout)) + ) + ).resolves.toMatchObject({ + supported: true, + ruleAllowed: false, + inspectionAvailable: false + }) + } + }) + it('repairs only Orca mobile pairing on private networks after elevation', async () => { const runPowerShell = vi.fn().mockResolvedValue('{"launched":true,"exitCode":0}') await expect(repairWindowsMobileFirewall(6769, environment(runPowerShell))).resolves.toEqual({ diff --git a/src/main/runtime/windows-mobile-firewall.ts b/src/main/runtime/windows-mobile-firewall.ts index b6419da9f5d..36797b83eb2 100644 --- a/src/main/runtime/windows-mobile-firewall.ts +++ b/src/main/runtime/windows-mobile-firewall.ts @@ -5,6 +5,7 @@ import type { WindowsMobileFirewallStatus, WindowsNetworkCategory } from '../../shared/windows-mobile-firewall' +import { hasSufficientWindowsFirewallRemoteScope } from './windows-firewall-remote-scope' const FIREWALL_RULE_NAME = 'Orca.MobilePairing' const FIREWALL_RULE_DISPLAY_NAME = 'Orca Mobile Pairing' @@ -22,7 +23,9 @@ export type WindowsMobileFirewallEnvironment = { } type FirewallInspection = { - ruleAllowed: boolean + matchingRuleScopes?: unknown + localAddress?: unknown + localPrefixLength?: unknown privateFirewallEnabled: boolean networkCategory: string } @@ -51,7 +54,11 @@ export async function inspectWindowsMobileFirewall( return { supported: true, port, - ruleAllowed: result.ruleAllowed === true, + ruleAllowed: hasSufficientWindowsFirewallRemoteScope( + result.matchingRuleScopes, + result.localAddress, + result.localPrefixLength + ), privateFirewallEnabled: result.privateFirewallEnabled !== false, networkCategory: parseNetworkCategory(result.networkCategory), inspectionAvailable: true @@ -152,12 +159,16 @@ function buildInspectionScript(port: number, executablePath: string, address?: s ? ` try { $ip = Get-NetIPAddress -IPAddress ${quotePowerShell(address)} -ErrorAction Stop | Select-Object -First 1 + $localAddress = [string]$ip.IPAddress + $localPrefixLength = [int]$ip.PrefixLength $profile = Get-NetConnectionProfile -InterfaceIndex $ip.InterfaceIndex -ErrorAction Stop | Select-Object -First 1 if ($profile) { $networkCategory = [string]$profile.NetworkCategory } } catch {}` : '' + // Why: NetSecurity filter properties are stable across localized Windows + // display output and keep every rule's address scope independent. return `$ErrorActionPreference = 'Stop' -$ruleAllowed = $false +$matchingRuleScopes = @() $rules = @(Get-NetFirewallApplicationFilter -Program ${quotePowerShell(executablePath)} -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { $_.Enabled -eq 'True' -and $_.Direction -eq 'Inbound' -and $_.Action -eq 'Allow' }) foreach ($rule in $rules) { $portFilter = $rule | Get-NetFirewallPortFilter @@ -165,16 +176,21 @@ foreach ($rule in $rules) { $profile = [string]$rule.Profile $portMatches = @($portFilter.LocalPort | Where-Object { [string]$_ -eq 'Any' -or [string]$_ -eq '${port}' }).Count -gt 0 if (($protocol -eq 'Any' -or $protocol -eq 'TCP' -or $protocol -eq '6') -and ($profile -eq 'Any' -or $profile -match 'Private') -and $portMatches) { - $ruleAllowed = $true + $addressFilter = $rule | Get-NetFirewallAddressFilter + $matchingRuleScopes += [pscustomobject]@{ + remoteAddresses = @($addressFilter.RemoteAddress | ForEach-Object { [string]$_ }) + } } } $privateFirewallEnabled = [bool](Get-NetFirewallProfile -Name Private).Enabled $networkCategory = 'Unknown'${addressLookup} [pscustomobject]@{ - ruleAllowed = $ruleAllowed + matchingRuleScopes = @($matchingRuleScopes) + localAddress = $localAddress + localPrefixLength = $localPrefixLength privateFirewallEnabled = $privateFirewallEnabled networkCategory = $networkCategory -} | ConvertTo-Json -Compress` +} | ConvertTo-Json -Depth 4 -Compress` } function buildRepairScript(port: number, executablePath: string): string { diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts index dcee058f8e2..68ff5e9d017 100644 --- a/src/main/skills/discovery.test.ts +++ b/src/main/skills/discovery.test.ts @@ -57,6 +57,32 @@ describe('skill discovery', () => { expect(rootPaths).toContain('/workspace/current/.claude/skills') }) + it('scans each provider home skill root that npx skills --global writes to', () => { + const roots = buildSkillDiscoverySources({ + homeDir: '/home/test', + cwd: '/workspace/current' + }) + + const rootPaths = roots.map((root) => root.path.replace(/\\/g, '/')) + expect(rootPaths).toEqual( + expect.arrayContaining([ + '/home/test/.grok/skills', + '/home/test/.config/opencode/skills', + '/home/test/.pi/agent/skills', + '/home/test/.gemini/skills', + '/home/test/.gemini/antigravity/skills', + '/home/test/.cursor/skills' + ]) + ) + // Why: these live outside ~/.agents/skills, so they must carry the shared + // agent-skills provider to feed per-agent orchestration coverage. + for (const root of roots) { + if (root.path.replace(/\\/g, '/') === '/home/test/.grok/skills') { + expect(root.providers).toEqual(['agent-skills']) + } + } + }) + it('discovers skill packages through symlinked skill directories', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') @@ -77,6 +103,27 @@ describe('skill discovery', () => { expect(skill?.directoryPath).toBe(linkedSkill) }) + it('discovers a symlinked skill inside a provider home root (#8256/#8503)', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) + const home = join(root, 'home') + const realSkill = join(root, 'central-skills', 'orchestration') + const linkedSkill = join(home, '.pi', 'agent', 'skills', 'orchestration') + await mkdir(realSkill, { recursive: true }) + await mkdir(join(home, '.pi', 'agent', 'skills'), { recursive: true }) + await writeFile(join(realSkill, 'SKILL.md'), '# orchestration\n\nCoordinate agents.') + await symlink(realSkill, linkedSkill, process.platform === 'win32' ? 'junction' : 'dir') + + const result = await discoverSkills({ + homeDir: home, + cwd: join(root, 'missing-cwd') + }) + + const skill = result.skills.find((entry) => entry.name === 'orchestration') + expect(skill?.sourceKind).toBe('home') + expect(skill?.directoryPath).toBe(linkedSkill) + expect(skill?.providers).toEqual(['agent-skills']) + }) + it('discovers worktree .agents skill symlinks from the requested cwd', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-skills-')) const home = join(root, 'home') diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index c1bb1072b2e..bec34117c4c 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -41,7 +41,23 @@ export function buildSkillDiscoverySources( join(home, '.codex', 'plugins', 'cache'), 'plugin', ['codex', 'agent-skills'] - ) + ), + // Why: `npx skills add --global` writes into each agent's own home skills + // directory, so coverage misses them unless we scan every provider root. + source('home-grok', 'Grok home', join(home, '.grok', 'skills'), 'home', ['agent-skills']), + source('home-opencode', 'OpenCode home', join(home, '.config', 'opencode', 'skills'), 'home', [ + 'agent-skills' + ]), + source('home-pi', 'Pi home', join(home, '.pi', 'agent', 'skills'), 'home', ['agent-skills']), + source('home-gemini', 'Gemini home', join(home, '.gemini', 'skills'), 'home', ['agent-skills']), + source( + 'home-antigravity', + 'Antigravity home', + join(home, '.gemini', 'antigravity', 'skills'), + 'home', + ['agent-skills'] + ), + source('home-cursor', 'Cursor home', join(home, '.cursor', 'skills'), 'home', ['agent-skills']) ] const projectPaths = new Set() diff --git a/src/main/source-control/forge-provider.test.ts b/src/main/source-control/forge-provider.test.ts index 0317d6fc9e9..95c5c9d2676 100644 --- a/src/main/source-control/forge-provider.test.ts +++ b/src/main/source-control/forge-provider.test.ts @@ -11,7 +11,8 @@ const { getMergeRequestForBranchMock, getProjectSlugMock, getPRForBranchOutcomeMock, - getRepoSlugMock + getRepoSlugMock, + getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), @@ -23,7 +24,8 @@ const { getMergeRequestForBranchMock: vi.fn(), getProjectSlugMock: vi.fn(), getPRForBranchOutcomeMock: vi.fn(), - getRepoSlugMock: vi.fn() + getRepoSlugMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../gitlab/client', () => ({ @@ -42,6 +44,10 @@ vi.mock('../github/client', () => ({ getPRForBranchOutcome: getPRForBranchOutcomeMock })) +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + vi.mock('../bitbucket/client', () => ({ getBitbucketRepoSlug: getBitbucketRepoSlugMock, getBitbucketPullRequestForBranch: vi.fn(), @@ -88,6 +94,7 @@ describe('forge provider interface', () => { getProjectSlugMock.mockReset() getPRForBranchOutcomeMock.mockReset() getRepoSlugMock.mockReset() + getEnterpriseGitHubRepoSlugMock.mockReset() }) it('preserves the existing hosted provider detection order', async () => { @@ -101,6 +108,45 @@ describe('forge provider interface', () => { expect(getRepoSlugMock).not.toHaveBeenCalled() }) + it('detects a GitHub Enterprise Server remote as the GitHub provider, not Gitea', async () => { + // Regression for #8312: a GHES host is not github.com, so github.com-only + // slug parsing returns null. Detection must claim it via the enterprise + // resolver instead of falling through to Gitea's demand for ORCA_GITEA_TOKEN. + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'team', + repo: 'orca', + host: 'github.acme-corp.com' + }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('github') + await expect(getForgeProviderForRepository({ repoPath: '/repo' })).resolves.toMatchObject({ + id: 'github' + }) + // Gitea must never be consulted once GitHub claims the enterprise host. + expect(getGiteaRepoSlugMock).not.toHaveBeenCalled() + }) + + it('leaves a genuinely non-GitHub remote for later providers when gh is not authenticated', async () => { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + // gh is not logged in to this host, so the enterprise resolver declines and + // the Gitea provider is free to claim its own self-hosted remote. + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'gitea.example.com', + owner: 'team', + repo: 'orca', + apiBaseUrl: 'https://gitea.example.com/api/v1', + webBaseUrl: 'https://gitea.example.com' + }) + + await expect(detectHostedReviewProvider({ repoPath: '/repo' })).resolves.toBe('gitea') + }) + it('keeps review creation capability scoped to providers with creation support', async () => { expect( FORGE_PROVIDERS.map((provider) => [provider.id, provider.supportsReviewCreation]) diff --git a/src/main/source-control/forge-provider.ts b/src/main/source-control/forge-provider.ts index d87efe9dcf1..a7d22ad48ae 100644 --- a/src/main/source-control/forge-provider.ts +++ b/src/main/source-control/forge-provider.ts @@ -22,6 +22,7 @@ import { } from '../gitea/client' import { createGiteaPullRequest } from '../gitea/pull-request-creation' import { createGitHubPullRequest, getPRForBranchOutcome, getRepoSlug } from '../github/client' +import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { getMergeRequest, getMergeRequestForBranch, getProjectSlug } from '../gitlab/client' import { createGitLabMergeRequest } from '../gitlab/merge-request-creation' import { @@ -122,8 +123,25 @@ function unwrapGitHubPRForBranchOutcome( const gitHubForgeProvider = { id: 'github', supportsReviewCreation: true, - resolveRepository: (context) => - getRepoSlug(context.repoPath, context.connectionId, ...hostedReviewExecutionArgs(context)), + resolveRepository: async (context) => { + const slug = await getRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ) + if (slug) { + return slug + } + // Why: GHES remotes live on a custom host, so github.com-only slug parsing + // misses them and detection would otherwise fall through to Gitea (#8312). + // Claim the repo when gh is authenticated to its host — the same signal + // GitLab uses for self-hosted instances. + return getEnterpriseGitHubRepoSlug( + context.repoPath, + context.connectionId, + ...hostedReviewExecutionArgs(context) + ) + }, async getReviewForBranch(input) { const fallbackReviewNumber = input.linkedReviewNumber == null ? (input.fallbackReviewNumber ?? null) : null diff --git a/src/main/source-control/hosted-review-creation-eligibility.test.ts b/src/main/source-control/hosted-review-creation-eligibility.test.ts new file mode 100644 index 00000000000..010d950d50f --- /dev/null +++ b/src/main/source-control/hosted-review-creation-eligibility.test.ts @@ -0,0 +1,529 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock +} = vi.hoisted(() => ({ + createGitHubPullRequestMock: vi.fn(), + createGitLabMergeRequestMock: vi.fn(), + createAzureDevOpsPullRequestMock: vi.fn(), + createGiteaPullRequestMock: vi.fn(), + isAzureDevOpsReviewCreationAuthenticatedMock: vi.fn(), + isGiteaReviewCreationAuthenticatedMock: vi.fn(), + getRepoSlugMock: vi.fn(), + getProjectSlugMock: vi.fn(), + getBitbucketRepoSlugMock: vi.fn(), + getAzureDevOpsRepoSlugMock: vi.fn(), + getGiteaRepoSlugMock: vi.fn(), + getHostedReviewForBranchMock: vi.fn(), + ghExecFileAsyncMock: vi.fn(), + glabExecFileAsyncMock: vi.fn(), + gitExecFileAsyncMock: vi.fn(), + getUpstreamStatusMock: vi.fn(), + getSshGitProviderMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() +})) + +vi.mock('../github/client', () => ({ + createGitHubPullRequest: createGitHubPullRequestMock, + getRepoSlug: getRepoSlugMock, + getPRForBranch: vi.fn() +})) + +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + +vi.mock('../gitlab/client', () => ({ + getProjectSlug: getProjectSlugMock, + getMergeRequestForBranch: vi.fn(), + getMergeRequest: vi.fn() +})) + +vi.mock('../gitlab/merge-request-creation', () => ({ + createGitLabMergeRequest: createGitLabMergeRequestMock +})) + +vi.mock('../bitbucket/client', () => ({ + getBitbucketRepoSlug: getBitbucketRepoSlugMock, + getBitbucketPullRequestForBranch: vi.fn(), + getBitbucketPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/client', () => ({ + getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock, + getAzureDevOpsPullRequestForBranch: vi.fn(), + getAzureDevOpsPullRequest: vi.fn() +})) + +vi.mock('../azure-devops/pull-request-creation', () => ({ + createAzureDevOpsPullRequest: createAzureDevOpsPullRequestMock, + isAzureDevOpsReviewCreationAuthenticated: isAzureDevOpsReviewCreationAuthenticatedMock +})) + +vi.mock('../gitea/client', () => ({ + getGiteaRepoSlug: getGiteaRepoSlugMock, + getGiteaPullRequestForBranch: vi.fn(), + getGiteaPullRequest: vi.fn() +})) + +vi.mock('../gitea/pull-request-creation', () => ({ + createGiteaPullRequest: createGiteaPullRequestMock, + isGiteaReviewCreationAuthenticated: isGiteaReviewCreationAuthenticatedMock +})) + +vi.mock('../github/gh-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + ghExecFileAsync: ghExecFileAsyncMock, + gitExecFileAsync: gitExecFileAsyncMock +})) + +vi.mock('../gitlab/gl-utils', () => ({ + acquire: vi.fn(), + release: vi.fn(), + glabExecFileAsync: glabExecFileAsyncMock, + glabRepoExecOptions: (repoPath: string, connectionId?: string | null) => + connectionId ? {} : { cwd: repoPath } +})) + +vi.mock('../git/upstream', () => ({ + getUpstreamStatus: getUpstreamStatusMock +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: getSshGitProviderMock +})) + +vi.mock('./hosted-review', () => ({ + getHostedReviewForBranch: getHostedReviewForBranchMock +})) + +import { getHostedReviewCreationEligibility } from './hosted-review-creation' + +function resetMocks(): void { + for (const mock of [ + createGitHubPullRequestMock, + createGitLabMergeRequestMock, + createAzureDevOpsPullRequestMock, + createGiteaPullRequestMock, + isAzureDevOpsReviewCreationAuthenticatedMock, + isGiteaReviewCreationAuthenticatedMock, + getRepoSlugMock, + getProjectSlugMock, + getBitbucketRepoSlugMock, + getAzureDevOpsRepoSlugMock, + getGiteaRepoSlugMock, + getHostedReviewForBranchMock, + ghExecFileAsyncMock, + glabExecFileAsyncMock, + gitExecFileAsyncMock, + getUpstreamStatusMock, + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock + ]) { + mock.mockReset() + } +} + +function mockGitHubProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue({ owner: 'acme', repo: 'orca' }) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) +} + +// GHES: github.com-only slug parsing misses the custom host, so the enterprise +// resolver claims the repo and reports the host for the gh auth probe (#8312). +function mockGitHubEnterpriseProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) +} + +function mockGitLabProvider(): void { + getProjectSlugMock.mockResolvedValue({ host: 'gitlab.com', path: 'acme/orca' }) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockAzureDevOpsProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue({ + host: 'dev.azure.com', + project: 'Project', + repository: 'orca', + apiBaseUrl: 'https://dev.azure.com/acme/Project', + webBaseUrl: 'https://dev.azure.com/acme/Project/_git/orca' + }) + getGiteaRepoSlugMock.mockResolvedValue(null) +} + +function mockGiteaProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue({ + host: 'git.example.com', + owner: 'acme', + repo: 'orca', + apiBaseUrl: 'https://git.example.com/api/v1', + webBaseUrl: 'https://git.example.com' + }) +} + +describe('getHostedReviewCreationEligibility', () => { + beforeEach(() => { + resetMocks() + + mockGitHubProvider() + getHostedReviewForBranchMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) + isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) + isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) + }) + + it('treats short remote base refs as the default branch name', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'main', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'default_branch', + defaultBaseRef: 'origin/main' + }) + }) + + // Stacked-worktree base resolution (Change 1/2). `stackedArgs` defaults to a + // bare local-only parent; `mockRefs` controls the remote-tracking snapshot. + const stackedArgs = ( + overrides: Partial[0]> = {} + ): Parameters[0] => ({ + repoPath: '/repo', + branch: 'feature/stacked', + base: 'stacked-parent', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0, + ...overrides + }) + + const mockRefs = (opts: { + symbolicRef?: string + forEachRef?: string + forEachThrows?: boolean + revParseThrows?: boolean + }): void => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: opts.symbolicRef ?? '', stderr: '' } + } + if (args[0] === 'for-each-ref') { + if (opts.forEachThrows) { + throw new Error('ssh: connect: connection refused') + } + return { stdout: opts.forEachRef ?? '', stderr: '' } + } + if (args[0] === 'rev-parse' && opts.revParseThrows) { + throw new Error('unknown revision') + } + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + }) + } + + it('falls back to the repo default when a stacked parent base is local-only', async () => { + mockRefs({ symbolicRef: 'refs/remotes/origin/main\n' }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'origin/main' + }) + }) + + it('preserves a stacked parent base that exists on the remote', async () => { + mockRefs({ forEachRef: 'refs/remotes/origin/parent-pushed\n' }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'parent-pushed' + }) + }) + + it('keeps the candidate base when no repo default can be resolved', async () => { + mockRefs({ revParseThrows: true }) + await expect(getHostedReviewCreationEligibility(stackedArgs())).resolves.toMatchObject({ + canCreate: true, + blockedReason: null, + defaultBaseRef: 'stacked-parent' + }) + }) + + it('preserves the candidate base when the remote probe cannot reach the host', async () => { + // Transport failure must not be read as "absent" — that would demote a + // legitimately-pushed parent to the repo default on a transient SSH blip. + mockRefs({ forEachThrows: true }) + await expect( + getHostedReviewCreationEligibility(stackedArgs({ base: 'parent-pushed' })) + ).resolves.toMatchObject({ canCreate: true, defaultBaseRef: 'parent-pushed' }) + }) + + it('blocks dirty tracked GitHub branches before PR creation', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { + getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: true, + hasUpstream: false, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: false, + blockedReason: 'dirty', + nextAction: 'commit', + head: 'feature/create-pr' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'refs/heads/feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null, + defaultBaseRef: 'origin/main', + head: 'feature/create-pr' + }) + }) + + it('detects a GitHub Enterprise Server branch as the GitHub provider (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + blockedReason: null, + nextAction: null + }) + + // Enterprise auth was already confirmed during detection; the gate must not + // fire a redundant gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { + const remoteGit = { + exec: vi.fn(async () => ({ stdout: '', stderr: '' })) + } + getSshGitProviderMock.mockReturnValue(remoteGit) + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/remote/repo', + connectionId: 'ssh-1', + branch: 'feature/create-pr', + base: 'origin/main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'github', + canCreate: true, + head: 'feature/create-pr' + }) + + expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') + expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) + ) + // Why: the base-on-remote probe must run on the SSH host that will execute + // the provider create, so it flows through the relay exec, not local git. + expect(remoteGit.exec).toHaveBeenCalledWith( + ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/remotes/*/main'], + '/remote/repo' + ) + }) + + it('offers push as the next action for authenticated branches with local-only commits', async () => { + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/create-pr', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 2, + behind: 0 + }) + ).resolves.toMatchObject({ + canCreate: false, + blockedReason: 'needs_push', + nextAction: 'push' + }) + }) + + it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { + mockGitLabProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitlab', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitlab', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitlab' + }) + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).toHaveBeenCalledWith( + ['auth', 'status', '--hostname', 'gitlab.com'], + { cwd: '/repo' } + ) + }) + + it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { + mockAzureDevOpsProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/azure', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'azure-devops', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/azure' + }) + expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) + + it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { + mockGiteaProvider() + + await expect( + getHostedReviewCreationEligibility({ + repoPath: '/repo', + branch: 'feature/gitea', + base: 'main', + hasUncommittedChanges: false, + hasUpstream: true, + ahead: 0, + behind: 0 + }) + ).resolves.toMatchObject({ + provider: 'gitea', + canCreate: true, + blockedReason: null, + nextAction: null, + head: 'feature/gitea' + }) + expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(glabExecFileAsyncMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index c03447d4043..04c751d7c3b 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -17,7 +17,8 @@ const { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock } = vi.hoisted(() => ({ createGitHubPullRequestMock: vi.fn(), createGitLabMergeRequestMock: vi.fn(), @@ -35,7 +36,8 @@ const { glabExecFileAsyncMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getUpstreamStatusMock: vi.fn(), - getSshGitProviderMock: vi.fn() + getSshGitProviderMock: vi.fn(), + getEnterpriseGitHubRepoSlugMock: vi.fn() })) vi.mock('../github/client', () => ({ @@ -44,6 +46,10 @@ vi.mock('../github/client', () => ({ getPRForBranch: vi.fn() })) +vi.mock('../github/github-enterprise-repository', () => ({ + getEnterpriseGitHubRepoSlug: getEnterpriseGitHubRepoSlugMock +})) + vi.mock('../gitlab/client', () => ({ getProjectSlug: getProjectSlugMock, getMergeRequestForBranch: vi.fn(), @@ -109,7 +115,7 @@ vi.mock('./hosted-review', () => ({ getHostedReviewForBranch: getHostedReviewForBranchMock })) -import { createHostedReview, getHostedReviewCreationEligibility } from './hosted-review-creation' +import { createHostedReview } from './hosted-review-creation' function resetMocks(): void { for (const mock of [ @@ -129,7 +135,8 @@ function resetMocks(): void { glabExecFileAsyncMock, gitExecFileAsyncMock, getUpstreamStatusMock, - getSshGitProviderMock + getSshGitProviderMock, + getEnterpriseGitHubRepoSlugMock ]) { mock.mockReset() } @@ -141,6 +148,22 @@ function mockGitHubProvider(): void { getBitbucketRepoSlugMock.mockResolvedValue(null) getAzureDevOpsRepoSlugMock.mockResolvedValue(null) getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue(null) +} + +// GHES: github.com-only slug parsing misses the custom host, so the enterprise +// resolver claims the repo and reports the host for the gh auth probe (#8312). +function mockGitHubEnterpriseProvider(): void { + getProjectSlugMock.mockResolvedValue(null) + getRepoSlugMock.mockResolvedValue(null) + getBitbucketRepoSlugMock.mockResolvedValue(null) + getAzureDevOpsRepoSlugMock.mockResolvedValue(null) + getGiteaRepoSlugMock.mockResolvedValue(null) + getEnterpriseGitHubRepoSlugMock.mockResolvedValue({ + owner: 'acme', + repo: 'orca', + host: 'github.acme-corp.com' + }) } function mockGitLabProvider(): void { @@ -200,6 +223,11 @@ describe('createHostedReview', () => { if (args[0] === 'status') { return { stdout: '', stderr: '' } } + // Why: base-on-remote probe (Change 2 enforcement) — the default base + // resolves to a remote-tracking branch so create-time validation passes. + if (args[0] === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -278,6 +306,32 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) + it('blocks creation with actionable copy when the submitted base is local-only', async () => { + // for-each-ref falls through to '' → the submitted stacked parent is not on + // the remote, so create-time enforcement blocks with actionable copy. + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'feature\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'stacked-parent', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: false, + code: 'validation', + error: + 'Create PR failed: the base branch "stacked-parent" hasn\'t been pushed to the remote. Choose a pushed base or push it first.' + }) + expect(createGitHubPullRequestMock).not.toHaveBeenCalled() + }) + it('creates the pull request after fresh main-process validation passes', async () => { await expect( createHostedReview('/repo', { @@ -349,6 +403,29 @@ describe('createHostedReview', () => { ) }) + it('creates a pull request on a GitHub Enterprise Server remote (#8312)', async () => { + mockGitHubEnterpriseProvider() + + await expect( + createHostedReview('/repo', { + provider: 'github', + base: 'main', + head: 'feature', + title: 'Feature' + }) + ).resolves.toEqual({ + ok: true, + number: 12, + url: 'https://github.com/acme/orca/pull/12' + }) + + // Detection already confirmed gh is authed to the GHES host, so the auth + // gate must not fire a second (rate-limited) gh probe. + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + expect(createGitHubPullRequestMock).toHaveBeenCalled() + expect(createGiteaPullRequestMock).not.toHaveBeenCalled() + }) + it('creates a GitLab merge request after fresh main-process validation passes', async () => { mockGitLabProvider() @@ -455,6 +532,10 @@ describe('createHostedReview', () => { if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { return { stdout: 'feature\n', stderr: '' } } + if (args[0] === 'for-each-ref') { + // Base-on-remote probe (Change 2) runs on the SSH host; base is pushed. + return { stdout: 'refs/remotes/origin/main\n', stderr: '' } + } if (args[0] === 'log' && args.includes('--pretty=%s')) { return { stdout: 'Feature title\n', stderr: '' } } @@ -542,223 +623,3 @@ describe('createHostedReview', () => { expect(createGitHubPullRequestMock).not.toHaveBeenCalled() }) }) - -describe('getHostedReviewCreationEligibility', () => { - beforeEach(() => { - resetMocks() - - mockGitHubProvider() - getHostedReviewForBranchMock.mockResolvedValue(null) - ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) - gitExecFileAsyncMock.mockResolvedValue({ stdout: 'Feature title\n', stderr: '' }) - isAzureDevOpsReviewCreationAuthenticatedMock.mockReturnValue(true) - isGiteaReviewCreationAuthenticatedMock.mockReturnValue(true) - }) - - it('treats short remote base refs as the default branch name', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'main', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'default_branch', - defaultBaseRef: 'origin/main' - }) - }) - - it('blocks dirty tracked GitHub branches before PR creation', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('keeps dirty feature branches eligible for PR preparation when review lookup fails', async () => { - getHostedReviewForBranchMock.mockRejectedValueOnce(new Error('gh lookup failed')) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: true, - hasUpstream: false, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: false, - blockedReason: 'dirty', - nextAction: 'commit', - head: 'feature/create-pr' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitHub feature branches', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'refs/heads/feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - blockedReason: null, - nextAction: null, - defaultBaseRef: 'origin/main', - head: 'feature/create-pr' - }) - }) - - it('resolves remote eligibility through SSH repo metadata without generating PR copy', async () => { - const remoteGit = { - exec: vi.fn(async () => ({ stdout: '', stderr: '' })) - } - getSshGitProviderMock.mockReturnValue(remoteGit) - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/remote/repo', - connectionId: 'ssh-1', - branch: 'feature/create-pr', - base: 'origin/main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'github', - canCreate: true, - head: 'feature/create-pr' - }) - - expect(getProjectSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getRepoSlugMock).toHaveBeenCalledWith('/remote/repo', 'ssh-1') - expect(getHostedReviewForBranchMock).toHaveBeenCalledWith( - expect.objectContaining({ repoPath: '/remote/repo', connectionId: 'ssh-1' }) - ) - expect(remoteGit.exec).not.toHaveBeenCalled() - }) - - it('offers push as the next action for authenticated branches with local-only commits', async () => { - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/create-pr', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 2, - behind: 0 - }) - ).resolves.toMatchObject({ - canCreate: false, - blockedReason: 'needs_push', - nextAction: 'push' - }) - }) - - it('enables creation for clean, in-sync, authenticated GitLab feature branches', async () => { - mockGitLabProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitlab', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitlab', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitlab' - }) - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).toHaveBeenCalledWith( - ['auth', 'status', '--hostname', 'gitlab.com'], - { cwd: '/repo' } - ) - }) - - it('enables creation for clean, in-sync, token-configured Azure DevOps feature branches', async () => { - mockAzureDevOpsProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/azure', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'azure-devops', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/azure' - }) - expect(isAzureDevOpsReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) - - it('enables creation for clean, in-sync, token-configured Gitea feature branches', async () => { - mockGiteaProvider() - - await expect( - getHostedReviewCreationEligibility({ - repoPath: '/repo', - branch: 'feature/gitea', - base: 'main', - hasUncommittedChanges: false, - hasUpstream: true, - ahead: 0, - behind: 0 - }) - ).resolves.toMatchObject({ - provider: 'gitea', - canCreate: true, - blockedReason: null, - nextAction: null, - head: 'feature/gitea' - }) - expect(isGiteaReviewCreationAuthenticatedMock).toHaveBeenCalledOnce() - expect(ghExecFileAsyncMock).not.toHaveBeenCalled() - expect(glabExecFileAsyncMock).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/source-control/hosted-review-creation.ts b/src/main/source-control/hosted-review-creation.ts index 7b6709d2dd3..1e19f1d4fa8 100644 --- a/src/main/source-control/hosted-review-creation.ts +++ b/src/main/source-control/hosted-review-creation.ts @@ -18,6 +18,7 @@ import { } from '../../shared/hosted-review-creation-providers' import { isAzureDevOpsReviewCreationAuthenticated } from '../azure-devops/pull-request-creation' import { isGiteaReviewCreationAuthenticated } from '../gitea/pull-request-creation' +import { getEnterpriseGitHubRepoSlug } from '../github/github-enterprise-repository' import { acquire, ghExecFileAsync, gitExecFileAsync, release } from '../github/gh-utils' import { isNoUpstreamError, normalizeGitErrorMessage } from '../../shared/git-remote-error' import type { GitUpstreamStatus } from '../../shared/types' @@ -41,6 +42,10 @@ import { type HostedReviewCreationEligibilityInput = HostedReviewCreationEligibilityArgs & { connectionId?: string | null + // Why: only the create-time preflight enforces base-on-remote as a hard block; + // the renderer's eligibility probe passes the base as a candidate and relies on + // Change 1 to correct a local-only parent, so it must never set this. + enforceBaseOnRemote?: boolean } & HostedReviewExecutionOptions function stripRefPrefix(ref: string): string { @@ -59,6 +64,14 @@ async function isGitHubAuthenticated( connectionId?: string | null, options: HostedReviewExecutionOptions = {} ): Promise { + // Why: a GHES remote is only routed to the GitHub provider once detection has + // confirmed gh is authenticated to its enterprise host, so a non-null slug + // already means authenticated — skip a redundant, rate-limited gh probe. + // Reaching the github.com check below therefore means the remote is github.com + // (its own custom host would have resolved above) (#8312). + if (await getEnterpriseGitHubRepoSlug(repoPath, connectionId, options)) { + return true + } await acquire() try { await ghExecFileAsync( @@ -124,6 +137,52 @@ async function getDefaultBaseRef( ) } +/** + * Whether the candidate base resolves to a remote-tracking branch on the + * executing host. + * + * Why: a stacked worktree's `worktree.baseRef` is typically a bare parent + * branch name with no remote qualifier, so the probe must match the branch + * under *any* configured remote rather than assume `origin` — otherwise fork + * workflows (`upstream/main`) would be missed. Runs through + * `runGitForHostedReview` so it evaluates on the same host that will run the + * provider create (native/WSL/SSH/relay). This reads the local remote-tracking + * snapshot, not the live remote; see the design doc's Open Questions for the + * ls-remote/staleness tradeoff left as a follow-up. + */ +async function baseRefExistsOnRemote( + candidate: string, + repoPath: string, + connectionId?: string | null, + options: HostedReviewExecutionOptions = {} +): Promise { + const base = normalizeHostedReviewBaseRef(candidate).trim() + if (!base) { + return false + } + const run = (argv: string[]): Promise<{ stdout: string }> => + runGitForHostedReview(repoPath, argv, connectionId, options) + + const patterns = [`refs/remotes/*/${base}`] + // Non-origin remote-qualified candidate (e.g. `fork/main`): the wildcard glob + // above only matches a branch literally named that because `*` does not cross `/`. + // Include the exact tracking ref directly. + if (base.includes('/')) { + patterns.push(`refs/remotes/${base}`) + } + + try { + // for-each-ref exits 0 whether or not the pattern matches, so a clean empty + // result is an authoritative "absent" while a thrown error is a transport + // failure. Never conflate the two: on an unreachable host, preserve the + // candidate rather than silently demoting a legitimately-pushed parent base. + const { stdout } = await run(['for-each-ref', '--count=1', '--format=%(refname)', ...patterns]) + return stdout.trim().length > 0 + } catch { + return true + } +} + async function getCurrentBranch( repoPath: string, connectionId?: string | null, @@ -246,9 +305,11 @@ async function isProviderAuthenticated( function blockedCreateResultForReason( reason: NonNullable, - provider: HostedReviewProvider + provider: HostedReviewProvider, + submittedBase?: string | null ): CreateHostedReviewResult | null { const copy = reviewCopy(provider) + const baseLabel = submittedBase?.trim() ? `"${submittedBase.trim()}" ` : '' const blockedCreateResultByReason = { auth_required: { ok: false, @@ -294,6 +355,11 @@ function blockedCreateResultForReason( ok: false, code: 'validation', error: `Create ${copy.shortLabel} failed: refresh source control status and try again.` + }, + base_not_on_remote: { + ok: false, + code: 'validation', + error: `Create ${copy.shortLabel} failed: the base branch ${baseLabel}hasn't been pushed to the remote. Choose a pushed base or push it first.` } } satisfies Partial< Record, CreateHostedReviewResult> @@ -302,7 +368,8 @@ function blockedCreateResultForReason( } function blockedEligibilityToCreateResult( - eligibility: HostedReviewCreationEligibility + eligibility: HostedReviewCreationEligibility, + submittedBase?: string | null ): CreateHostedReviewResult | null { if (eligibility.canCreate) { return null @@ -317,7 +384,11 @@ function blockedEligibilityToCreateResult( } } if (eligibility.blockedReason) { - return blockedCreateResultForReason(eligibility.blockedReason, eligibility.provider) + return blockedCreateResultForReason( + eligibility.blockedReason, + eligibility.provider, + submittedBase + ) } const copy = reviewCopy(eligibility.provider) return { @@ -349,20 +420,24 @@ async function validateCurrentBranchCanCreateReview( hasUncommittedChanges(repoPath, connectionId, options), getHostedReviewUpstreamStatus(repoPath, connectionId, options) ]) + const submittedBase = normalizeHostedReviewBaseRef(input.base) const eligibility = await getHostedReviewCreationEligibility({ repoPath, branch: requestedHead || currentBranch, - base: normalizeHostedReviewBaseRef(input.base), + base: submittedBase, hasUncommittedChanges: dirty, hasUpstream: upstreamStatus.hasUpstream, ahead: upstreamStatus.ahead, behind: upstreamStatus.behind, connectionId, + // Why: this is the last gate before the provider create, which targets the + // submitted base verbatim — enforce that the base exists on the remote here. + enforceBaseOnRemote: true, ...options }) // Why: renderer eligibility can be stale by submit time; the main process // is the last chance to avoid creating a PR from an out-of-date remote head. - return blockedEligibilityToCreateResult(eligibility) + return blockedEligibilityToCreateResult(eligibility, submittedBase) } catch (error) { console.warn('Hosted review creation preflight failed:', error) return { @@ -382,8 +457,22 @@ export async function getHostedReviewCreationEligibility( connectionId: args.connectionId, ...hostedReviewExecutionContext(args) }) - const defaultBaseRef = - args.base?.trim() || (await getDefaultBaseRef(args.repoPath, args.connectionId, args)) + // Why: an incoming base is only a *candidate* for the default merge target. A + // stacked worktree's parent base resolves on the remote only when it was + // actually pushed; a local-only parent must fall back to the repo default so + // the PR targets a ref the remote can resolve. Never regress to "no base" — + // keep the candidate if the repo default itself is unavailable. + const candidateBase = args.base?.trim() || null + const candidateBaseOnRemote = + candidateBase != null && + (await baseRefExistsOnRemote(candidateBase, args.repoPath, args.connectionId, args)) + let defaultBaseRef: string | null + if (candidateBase && candidateBaseOnRemote) { + defaultBaseRef = candidateBase + } else { + const repoDefaultBaseRef = await getDefaultBaseRef(args.repoPath, args.connectionId, args) + defaultBaseRef = repoDefaultBaseRef ?? candidateBase + } const baseBranch = defaultBaseRef ? normalizeHostedReviewBaseRef(defaultBaseRef) : null let review: Awaited> = null try { @@ -472,6 +561,20 @@ export async function getHostedReviewCreationEligibility( if ((args.ahead ?? 0) > 0) { return { ...baseResult, canCreate: false, blockedReason: 'needs_push', nextAction: 'push' } } + // Why: at create-time, `gh pr create` (and the other providers) target the + // submitted base verbatim — Change 1 only corrects the *default*, not a stale + // renderer's submitted value. Block a local-only submitted base here so it + // fails with actionable copy instead of the provider's opaque error. Only the + // create-time preflight enforces this; the renderer's eligibility probe leaves + // enforceBaseOnRemote unset so a local-only parent is silently auto-corrected. + if (args.enforceBaseOnRemote && candidateBase && !candidateBaseOnRemote) { + return { + ...baseResult, + canCreate: false, + blockedReason: 'base_not_on_remote', + nextAction: null + } + } return { ...baseResult, canCreate: Boolean(baseBranch), blockedReason: null, nextAction: null } } diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index 10123d56a25..16de2b94a46 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -305,6 +305,25 @@ describe('SshChannelMultiplexer', () => { expect(() => vi.advanceTimersByTime(5_000)).not.toThrow() expect(mux.isDisposed()).toBe(true) }) + + it('drives keepalive sends AND dead-link detection from a single interval', () => { + // The keepalive send and the liveness/timeout check were merged from two + // 5s intervals into one; there must be exactly one recurring timer. + expect(vi.getTimerCount()).toBe(1) + + // Send half: a keepalive is written on the tick. + const before = transport.written.length + vi.advanceTimersByTime(5_000) + expect(transport.written.length).toBeGreaterThan(before) + expect(transport.written.at(-1)![0]).toBe(MessageType.KeepAlive) + expect(vi.getTimerCount()).toBe(1) + + // Check half: with no inbound frames or acks, the same interval declares + // the link dead (no-data + oldest-unacked both exceed the 20s window). + expect(mux.isDisposed()).toBe(false) + vi.advanceTimersByTime(25_000) + expect(mux.isDisposed()).toBe(true) + }) }) describe('wake guard (timer pause across system sleep, #7773)', () => { diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 8cb0a24f302..25e6e2c6f00 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -54,8 +54,7 @@ export class SshChannelMultiplexer { // generic notification listener that already serves fs.changed. private methodNotificationHandlers = new Map>() private disposeHandlers: ((reason: 'shutdown' | 'connection_lost') => void)[] = [] - private keepaliveTimer: ReturnType | null = null - private timeoutTimer: ReturnType | null = null + private connectionHealthTimer: ReturnType | null = null private disposed = false // Track the oldest unacked outgoing message timestamp @@ -88,8 +87,7 @@ export class SshChannelMultiplexer { if (this.disposed) { return } - this.startKeepalive() - this.startTimeoutCheck() + this.startConnectionHealthTimer() } onNotification(handler: NotificationHandler): () => void { @@ -276,13 +274,9 @@ export class SshChannelMultiplexer { } this.disposed = true - if (this.keepaliveTimer) { - clearInterval(this.keepaliveTimer) - this.keepaliveTimer = null - } - if (this.timeoutTimer) { - clearInterval(this.timeoutTimer) - this.timeoutTimer = null + if (this.connectionHealthTimer) { + clearInterval(this.connectionHealthTimer) + this.connectionHealthTimer = null } // Why: the renderer uses the error code to distinguish temporary disconnects @@ -488,15 +482,17 @@ export class SshChannelMultiplexer { } } - private startKeepalive(): void { - this.keepaliveTimer = setInterval(() => { - this.sendKeepAlive() - }, KEEPALIVE_SEND_MS) - } - - private startTimeoutCheck(): void { + // Why: one 5s interval owns BOTH the periodic keepalive send and the + // liveness/dead-link check. They used to be two separate 5s intervals that + // always fired back-to-back (keepalive created first); folding them into one + // tick — send first (as the keepalive timer did), then run the check (as the + // timeout timer did) — halves the per-connection timer count with identical + // behavior, including the #7773 wake-gap recovery below. + private startConnectionHealthTimer(): void { let lastTickAt = Date.now() - this.timeoutTimer = setInterval(() => { + this.connectionHealthTimer = setInterval(() => { + this.sendKeepAlive() + if (this.disposed) { return } diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index 82cbfaee42c..da490ae7be0 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -424,7 +424,7 @@ describe('SshRelaySession', () => { expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything()) }) - it('installs a native Windows Orca CLI bridge without POSIX shell commands', async () => { + it('compiles a native Windows Orca CLI bridge without a cmd.exe shim', async () => { const { mockStore, mockPortForward, getMainWindow } = createMockDeps() const mockConn = { writeFile: vi.fn().mockResolvedValue(undefined) @@ -447,20 +447,20 @@ describe('SshRelaySession', () => { await session.establish(mockConn) - expect(execCommand).toHaveBeenCalledTimes(1) + expect(execCommand).toHaveBeenCalledTimes(2) expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe') expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false }) expect(mockConn.writeFile).toHaveBeenCalledWith( - 'C:/Users/me/.orca-relay/bin/orca.cmd', - expect.stringContaining('@echo off'), + 'C:/Users/me/.orca-relay/bin/orca-launcher.cs', + expect.stringContaining('ProcessStartInfo'), { hostPlatform: getRemoteHostPlatform('win32-x64') } ) - const shim = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string - expect(shim).toContain('C:/Users/me/.orca-remote/relay-v1') - expect(shim).toContain('\\\\.\\pipe\\orca-relay-123') - expect(shim).not.toContain('if not exist "%ORCA_RELAY_SOCKET_PATH%"') - expect(shim).not.toContain('Orca SSH CLI bridge cannot find the relay socket') - expect(shim).not.toContain('#!/usr/bin/env sh') + const launcherSource = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string + expect(launcherSource).toContain('ORCA_RELAY_SOCKET_PATH') + expect(launcherSource).not.toContain('cmd.exe') + expect(launcherSource).not.toContain('%*') + expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe') + expect(vi.mocked(execCommand).mock.calls[1]?.[2]).toEqual({ wrapCommand: false }) expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe( false ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 05b0068cade..b449daabb70 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -61,7 +61,8 @@ import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-w import type { SshPortForwardManager } from './ssh-port-forward' import type { SshConnection } from './ssh-connection' import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' -import { makeRemoteDirectoryCommand, makeRemoteExecutableCommand } from './ssh-remote-commands' +import { makeRemoteDirectoryCommand } from './ssh-remote-commands' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, type DetectedPort, @@ -653,14 +654,14 @@ export class SshRelaySession { } try { - await this.installRemoteOrcaCliShim() + await this.installRemoteOrcaCliLauncher() } catch (error) { - // Why: the remote `orca` CLI shim is a convenience bridge. On session- + // Why: the remote `orca` CLI launcher is a convenience bridge. On session- // limited remotes (MaxSessions=1) the relay bridge holds the only slot, // so this raw-connection install can fail — that must not fail the // whole connection, matching the managed-hook install above. console.warn( - `[ssh-relay-session] remote orca CLI shim install failed for ${this.targetId}: ${ + `[ssh-relay-session] remote orca CLI launcher install failed for ${this.targetId}: ${ error instanceof Error ? error.message : String(error) }` ) @@ -775,34 +776,38 @@ export class SshRelaySession { } } - private async installRemoteOrcaCliShim(): Promise { + private async installRemoteOrcaCliLauncher(): Promise { if (!this.remoteCliBridgeEnv) { return } const { binDir, hostPlatform } = this.remoteCliBridgeEnv - const shim = buildRemoteCliShim(this.remoteCliBridgeEnv) + const plan = createRemoteCliInstallPlan(this.remoteCliBridgeEnv) const conn = this.requireReadyConnection() await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) if (typeof conn.writeFile === 'function') { - await conn.writeFile(shim.path, shim.contents, { hostPlatform }) + for (const file of plan.files) { + await conn.writeFile(file.path, file.contents, { hostPlatform }) + } } else { const sftp = await conn.sftp() try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(shim.path) - sftp.once('error', reject) - ws.once('close', resolve) - ws.once('error', reject) - ws.end(shim.contents) - }) + for (const file of plan.files) { + await new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(file.path) + sftp.once('error', reject) + ws.once('close', resolve) + ws.once('error', reject) + ws.end(file.contents) + }) + } } finally { sftp.end() } } - if (!isWindowsRemoteHost(hostPlatform)) { - await execCommand(conn, makeRemoteExecutableCommand(hostPlatform, shim.path)) + for (const command of plan.postWriteCommands) { + await execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) }) } } @@ -1264,47 +1269,3 @@ export class SshRelaySession { } } } - -function quoteSh(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'` -} - -function buildRemoteCliShim(env: RemoteCliBridgeEnv): { - path: string - contents: string -} { - if (isWindowsRemoteHost(env.hostPlatform)) { - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') - return { - path: shimPath, - contents: [ - '@echo off', - 'setlocal', - `if not defined ORCA_RELAY_NODE_PATH set "ORCA_RELAY_NODE_PATH=${env.nodePath}"`, - `if not defined ORCA_RELAY_DIR set "ORCA_RELAY_DIR=${env.relayDir}"`, - `if not defined ORCA_RELAY_SOCKET_PATH set "ORCA_RELAY_SOCKET_PATH=${env.sockPath}"`, - '"%ORCA_RELAY_NODE_PATH%" "%ORCA_RELAY_DIR%/relay.js" --sock-path "%ORCA_RELAY_SOCKET_PATH%" --orca-cli %*', - 'exit /b %ERRORLEVEL%', - '' - ].join('\r\n') - } - } - - const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') - return { - path: shimPath, - contents: [ - '#!/usr/bin/env sh', - 'set -eu', - `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, - `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, - `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, - 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', - ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', - ' exit 1', - 'fi', - 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', - '' - ].join('\n') - } -} diff --git a/src/main/ssh/ssh-remote-cli-format.ts b/src/main/ssh/ssh-remote-cli-format.ts index c654af79b74..744c9f8e6cf 100644 --- a/src/main/ssh/ssh-remote-cli-format.ts +++ b/src/main/ssh/ssh-remote-cli-format.ts @@ -34,6 +34,7 @@ function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: stdout: `${[ `appRunning: ${status.app.running}`, `pid: ${status.app.pid ?? 'none'}`, + `desktopWindowStatus: ${status.app.desktopWindowStatus ?? 'unknown'}`, `runtimeState: ${status.runtime.state}`, `runtimeReachable: ${status.runtime.reachable}`, `runtimeId: ${status.runtime.runtimeId ?? 'none'}`, diff --git a/src/main/ssh/ssh-remote-cli-launcher.test.ts b/src/main/ssh/ssh-remote-cli-launcher.test.ts new file mode 100644 index 00000000000..ae0a2b18619 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.test.ts @@ -0,0 +1,143 @@ +import { Buffer } from 'node:buffer' +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createRemoteCliInstallPlan } from './ssh-remote-cli-launcher' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const itWindows = process.platform === 'win32' ? it : it.skip + +function decodePowerShellCommand(command: string): string { + const encoded = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)?.[1] + if (!encoded) { + throw new Error(`Expected an encoded PowerShell command: ${command}`) + } + return Buffer.from(encoded, 'base64').toString('utf16le') +} + +describe('SSH remote Orca CLI launcher', () => { + it('compiles a native Windows launcher without a cmd.exe argument bridge', () => { + const hostPlatform = getRemoteHostPlatform('win32-x64') + const plan = createRemoteCliInstallPlan({ + binDir: 'C:/Users/me user/.orca-relay/bin', + relayDir: 'C:/Users/me user/.orca-remote/relay-v1', + nodePath: 'C:/Program Files/nodejs/node.exe', + sockPath: '\\\\.\\pipe\\orca-relay-123', + hostPlatform + }) + + expect(plan.launcherPath).toBe('C:/Users/me user/.orca-relay/bin/orca.exe') + expect(plan.files).toHaveLength(1) + expect(plan.files[0]?.path).toBe('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(plan.files[0]?.contents).toContain('ProcessStartInfo') + expect(plan.files[0]?.contents).toContain('"--orca-cli"') + expect(plan.files[0]?.contents).toContain("value[index] == '\"'") + expect(plan.files[0]?.contents).toContain("character == '\\\\'") + expect(plan.files[0]?.contents).not.toContain('cmd.exe') + expect(plan.files[0]?.contents).not.toContain('%*') + + expect(plan.postWriteCommands).toHaveLength(1) + const compileScript = decodePowerShellCommand(plan.postWriteCommands[0] ?? '') + expect(compileScript).toContain('v4.0.30319\\csc.exe') + // Why: legacy csc.exe is invoked from the bin directory with bare, space-free + // file names so PowerShell 5.1 never mangles a space-bearing absolute path. + expect(compileScript).toContain( + "Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me user/.orca-relay/bin'" + ) + expect(compileScript).toContain('/out:orca.exe') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca-launcher.cs') + expect(compileScript).toContain('C:/Users/me user/.orca-relay/bin/orca.cmd') + expect(compileScript.indexOf('orca.cmd')).toBeLessThan(compileScript.indexOf('csc.exe')) + }) + + itWindows('preserves a multiline argument through the compiled remote launcher', () => { + const root = mkdtempSync(join(tmpdir(), 'orca remote cli ')) + try { + const binDir = join(root, 'bin').replaceAll('\\', '/') + const relayDir = join(root, 'relay').replaceAll('\\', '/') + const sockPath = '\\\\.\\pipe\\orca-relay-test' + const plan = createRemoteCliInstallPlan({ + binDir, + relayDir, + nodePath: process.execPath, + sockPath, + hostPlatform: getRemoteHostPlatform('win32-x64') + }) + for (const file of plan.files) { + mkdirSync(dirname(file.path), { recursive: true }) + writeFileSync(file.path, file.contents, 'utf8') + } + + const encoded = plan.postWriteCommands[0]?.match(/-EncodedCommand\s+(\S+)/)?.[1] + expect(encoded).toBeTruthy() + const compile = spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-EncodedCommand', + encoded! + ], + { encoding: 'utf8' } + ) + expect(compile.status, `${compile.stdout}\n${compile.stderr}`).toBe(0) + + mkdirSync(relayDir, { recursive: true }) + writeFileSync( + join(relayDir, 'relay.js'), + 'process.stdout.write(JSON.stringify(process.argv.slice(2)))\n', + 'utf8' + ) + const body = 'line one\nline two & whoami\n"quoted" C:\\tail\\' + const launched = spawnSync( + plan.launcherPath, + ['orchestration', 'send', '--body', body, '--json'], + { + encoding: 'utf8', + env: { + ...process.env, + ORCA_RELAY_NODE_PATH: process.execPath, + ORCA_RELAY_DIR: relayDir, + ORCA_RELAY_SOCKET_PATH: sockPath + } + } + ) + + expect(launched.status, launched.stderr).toBe(0) + expect(JSON.parse(launched.stdout)).toEqual([ + '--sock-path', + sockPath, + '--orca-cli', + 'orchestration', + 'send', + '--body', + body, + '--json' + ]) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('keeps the POSIX launcher as an argv-preserving shell exec', () => { + const plan = createRemoteCliInstallPlan({ + binDir: '/home/me/.orca-relay/bin', + relayDir: '/home/me/.orca-remote/relay-v1', + nodePath: '/usr/bin/node', + sockPath: '/home/me/.orca-remote/relay-v1/relay.sock', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + expect(plan.launcherPath).toBe('/home/me/.orca-relay/bin/orca') + expect(plan.files).toEqual([ + expect.objectContaining({ + path: '/home/me/.orca-relay/bin/orca', + contents: expect.stringContaining('--orca-cli "$@"') + }) + ]) + }) +}) diff --git a/src/main/ssh/ssh-remote-cli-launcher.ts b/src/main/ssh/ssh-remote-cli-launcher.ts new file mode 100644 index 00000000000..a246ce9c9b8 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-launcher.ts @@ -0,0 +1,238 @@ +import type { RemoteHostPlatform } from './ssh-remote-platform' +import { isWindowsRemoteHost, joinRemotePath } from './ssh-remote-platform' +import { powerShellCommand, powerShellLiteral, powerShellNativeArg } from './ssh-remote-powershell' + +type RemoteCliInstallEnv = { + binDir: string + relayDir: string + nodePath: string + sockPath: string + hostPlatform: RemoteHostPlatform +} + +type RemoteCliInstallFile = { + path: string + contents: string +} + +export type RemoteCliInstallPlan = { + launcherPath: string + files: RemoteCliInstallFile[] + postWriteCommands: string[] +} + +const WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE = String.raw`using System; +using System.Diagnostics; +using System.IO; +using System.Text; + +internal static class OrcaRemoteCliLauncher +{ + private static int Main(string[] args) + { + try + { + string nodePath = RequireEnvironmentVariable("ORCA_RELAY_NODE_PATH"); + string relayDirectory = RequireEnvironmentVariable("ORCA_RELAY_DIR"); + string socketPath = RequireEnvironmentVariable("ORCA_RELAY_SOCKET_PATH"); + string relayPath = Path.Combine(relayDirectory, "relay.js"); + + if (!File.Exists(nodePath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find Node.js at \"{0}\"", nodePath); + return 1; + } + if (!File.Exists(relayPath)) + { + Console.Error.WriteLine("Orca SSH CLI bridge cannot find the relay at \"{0}\"", relayPath); + return 1; + } + + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = nodePath, + Arguments = BuildArguments(relayPath, socketPath, args), + UseShellExecute = false + }; + + using (Process child = Process.Start(startInfo)) + { + child.WaitForExit(); + return child.ExitCode; + } + } + catch (Exception error) + { + Console.Error.WriteLine("Unable to start the Orca SSH CLI bridge: {0}", error.Message); + return 1; + } + } + + private static string RequireEnvironmentVariable(string name) + { + string value = Environment.GetEnvironmentVariable(name); + if (String.IsNullOrEmpty(value)) + { + throw new InvalidOperationException(name + " is not set."); + } + return value; + } + + private static string BuildArguments(string relayPath, string socketPath, string[] args) + { + StringBuilder commandLine = new StringBuilder(); + AppendArgument(commandLine, relayPath); + AppendArgument(commandLine, "--sock-path"); + AppendArgument(commandLine, socketPath); + AppendArgument(commandLine, "--orca-cli"); + foreach (string arg in args) + { + AppendArgument(commandLine, arg); + } + return commandLine.ToString(); + } + + private static void AppendArgument(StringBuilder commandLine, string value) + { + if (commandLine.Length > 0) + { + commandLine.Append(' '); + } + commandLine.Append(QuoteArgument(value)); + } + + private static string QuoteArgument(string value) + { + bool requiresQuotes = value.Length == 0; + for (int index = 0; index < value.Length && !requiresQuotes; index += 1) + { + requiresQuotes = value[index] == '"' || Char.IsWhiteSpace(value[index]); + } + if (!requiresQuotes) + { + return value; + } + + StringBuilder quoted = new StringBuilder("\""); + int backslashCount = 0; + foreach (char character in value) + { + if (character == '\\') + { + backslashCount += 1; + continue; + } + if (character == '"') + { + quoted.Append('\\', backslashCount * 2 + 1); + quoted.Append('"'); + } + else + { + quoted.Append('\\', backslashCount); + quoted.Append(character); + } + backslashCount = 0; + } + + quoted.Append('\\', backslashCount * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +` + +function quoteSh(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function createWindowsLauncherCompileCommand( + binDir: string, + sourceFileName: string, + launcherFileName: string, + launcherPath: string, + sourcePath: string, + legacyShimPath: string +): string { + // Why: legacy csc.exe mis-parses space-bearing absolute paths handed to it by + // Windows PowerShell 5.1's native-argument quoting, so compile from the bin + // directory and pass only the bare, space-free launcher file names. + const compilerArgs = [ + '/nologo', + '/target:exe', + '/optimize+', + '/warnaserror+', + `/out:${launcherFileName}`, + sourceFileName + ] + .map(powerShellNativeArg) + .join(' ') + return powerShellCommand( + [ + // Why: an upgrade must not leave the old %* batch bridge callable when + // compiler discovery fails; losing the convenience CLI is safer. + `Remove-Item -LiteralPath ${powerShellLiteral(legacyShimPath)} -Force -ErrorAction SilentlyContinue`, + `Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(binDir)}`, + '$windowsDirectory = if ($env:WINDIR) { $env:WINDIR } else { $env:SystemRoot }', + `$compilerCandidates = @((Join-Path $windowsDirectory 'Microsoft.NET\\Framework64\\v4.0.30319\\csc.exe'), (Join-Path $windowsDirectory 'Microsoft.NET\\Framework\\v4.0.30319\\csc.exe'))`, + '$compiler = $compilerCandidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1', + "if (-not $compiler) { Write-Error 'Unable to find the .NET Framework C# compiler required for the Orca SSH CLI launcher.'; exit 1 }", + `& $compiler ${compilerArgs}`, + 'if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }', + `if (-not (Test-Path -LiteralPath ${powerShellLiteral(launcherPath)} -PathType Leaf)) { Write-Error 'The Orca SSH CLI launcher compiler produced no executable.'; exit 1 }`, + `Remove-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Force` + ].join('; ') + ) +} + +export function createRemoteCliInstallPlan(env: RemoteCliInstallEnv): RemoteCliInstallPlan { + if (isWindowsRemoteHost(env.hostPlatform)) { + const launcherFileName = 'orca.exe' + const sourceFileName = 'orca-launcher.cs' + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, launcherFileName) + const sourcePath = joinRemotePath(env.hostPlatform, env.binDir, sourceFileName) + const legacyShimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd') + const binDir = joinRemotePath(env.hostPlatform, env.binDir) + return { + launcherPath, + files: [{ path: sourcePath, contents: WINDOWS_REMOTE_CLI_LAUNCHER_SOURCE }], + // Why: compiling on the Windows target avoids shipping an unsigned + // cross-host binary while ensuring argv never crosses cmd.exe's parser. + postWriteCommands: [ + createWindowsLauncherCompileCommand( + binDir, + sourceFileName, + launcherFileName, + launcherPath, + sourcePath, + legacyShimPath + ) + ] + } + } + + const launcherPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca') + return { + launcherPath, + files: [ + { + path: launcherPath, + contents: [ + '#!/usr/bin/env sh', + 'set -eu', + `ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`, + `ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`, + `ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`, + 'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then', + ' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2', + ' exit 1', + 'fi', + 'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"', + '' + ].join('\n') + } + ], + // Surface chmod failures: a non-executable launcher must fail install loudly, not silently. + postWriteCommands: [`chmod +x ${quoteSh(launcherPath)}`] + } +} diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 205723321da..e43956dfc1c 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -158,7 +158,11 @@ async function dispatchRemoteCli( } const status = response.result as RuntimeStatus const cliStatus: CliStatusResult = { - app: { running: true, pid: null }, + app: { + running: true, + pid: null, + ...(status.desktopWindowStatus ? { desktopWindowStatus: status.desktopWindowStatus } : {}) + }, runtime: { state: status.graphStatus === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, diff --git a/src/main/startup/serve-desktop-activation-wiring.test.ts b/src/main/startup/serve-desktop-activation-wiring.test.ts new file mode 100644 index 00000000000..9008ef4014e --- /dev/null +++ b/src/main/startup/serve-desktop-activation-wiring.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +describe('serve desktop activation wiring', () => { + const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') + + it('routes second-instance and app activation through one safety gate', () => { + expect(source).toContain('createServeDesktopActivationGate({') + expect(source).toContain('acquireSingleInstanceLock(app, requestDesktopActivation)') + expect(source).toContain("app.on('activate', requestDesktopActivation)") + expect(source).toContain('getDesktopWindowStatus: getDesktopWindowStatus') + }) + + it('settles the persistent provider before headless PTY registration', () => { + const appReadyIndex = source.indexOf('app.whenReady().then(async () => {') + const startupIndex = source.indexOf( + '\n startTerminalRuntimeStartupServices()\n', + appReadyIndex + ) + const serveIndex = source.indexOf('if (serveOptions) {', appReadyIndex) + const ptyReadyIndex = source.indexOf('await localPtyStartupReady', serveIndex) + const headlessRegistrationIndex = source.indexOf('registerHeadlessPtyRuntime(', serveIndex) + + expect(startupIndex).toBeGreaterThanOrEqual(0) + expect(startupIndex).toBeLessThan(serveIndex) + expect(ptyReadyIndex).toBeGreaterThan(serveIndex) + expect(headlessRegistrationIndex).toBeGreaterThan(ptyReadyIndex) + expect(source).not.toContain( + 'if (!isServeMode) {\n startDesktopFirstWindowStartupServices()' + ) + }) + + it('publishes the named headless sentinel and only enables promotion after RPC is ready', () => { + const serveIndex = source.indexOf('if (serveOptions) {') + const sentinelIndex = source.indexOf( + 'runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID', + serveIndex + ) + const rpcIndex = source.indexOf('await runtimeRpc.start()', serveIndex) + const settleIndex = source.indexOf('settleServeDesktopActivation()', rpcIndex) + + expect(serveIndex).toBeGreaterThanOrEqual(0) + expect(sentinelIndex).toBeGreaterThan(serveIndex) + expect(rpcIndex).toBeGreaterThan(sentinelIndex) + expect(settleIndex).toBeGreaterThan(rpcIndex) + expect(source).not.toContain('runtime.syncWindowGraph(0,') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.test.ts b/src/main/startup/serve-desktop-activation.test.ts new file mode 100644 index 00000000000..659e7b382d4 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { createServeDesktopActivationGate } from './serve-desktop-activation' + +describe('createServeDesktopActivationGate', () => { + it('coalesces activation requests while serve is initializing and drains once when ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(gate.getState()).toBe('initializing') + + gate.markReady() + + expect(activateWindow).toHaveBeenCalledOnce() + expect(gate.getState()).toBe('ready') + }) + + it('activates immediately after the persistent provider is ready', () => { + const activateWindow = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'ready', + activateWindow + }) + + gate.requestActivation() + gate.requestActivation() + + expect(activateWindow).toHaveBeenCalledTimes(2) + }) + + it('drops pending activation and fails closed when promotion is blocked', () => { + const activateWindow = vi.fn() + const onBlocked = vi.fn() + const gate = createServeDesktopActivationGate({ + initialState: 'initializing', + activateWindow, + onBlocked + }) + + gate.requestActivation() + gate.markBlocked('persistent PTY provider unavailable') + gate.requestActivation() + + expect(activateWindow).not.toHaveBeenCalled() + expect(onBlocked).toHaveBeenCalledTimes(2) + expect(gate.getState()).toBe('blocked') + }) +}) diff --git a/src/main/startup/serve-desktop-activation.ts b/src/main/startup/serve-desktop-activation.ts new file mode 100644 index 00000000000..8ee6a83a0e8 --- /dev/null +++ b/src/main/startup/serve-desktop-activation.ts @@ -0,0 +1,56 @@ +import type { RuntimeDesktopWindowStatus } from '../../shared/runtime-types' + +type ActivationGateState = Exclude | 'ready' + +export type ServeDesktopActivationGate = { + getState: () => ActivationGateState + requestActivation: () => void + markReady: () => void + markBlocked: (reason: string) => void +} + +export function createServeDesktopActivationGate(options: { + initialState: 'initializing' | 'ready' + activateWindow: () => void + onBlocked?: (reason: string) => void +}): ServeDesktopActivationGate { + let state: ActivationGateState = options.initialState + let pendingActivation = false + let blockedReason = 'desktop activation is unavailable' + + return { + getState: () => state, + requestActivation: () => { + if (state === 'ready') { + options.activateWindow() + return + } + if (state === 'initializing') { + pendingActivation = true + return + } + options.onBlocked?.(blockedReason) + }, + markReady: () => { + if (state !== 'initializing') { + return + } + state = 'ready' + if (pendingActivation) { + pendingActivation = false + options.activateWindow() + } + }, + markBlocked: (reason) => { + if (state !== 'initializing') { + return + } + state = 'blocked' + blockedReason = reason + if (pendingActivation) { + pendingActivation = false + options.onBlocked?.(blockedReason) + } + } + } +} diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index f5318b7b54b..5ccd70e6a4a 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -5,6 +5,7 @@ import { logSingleInstanceLockBypass, logSingleInstanceLockFailure, shouldBypassSingleInstanceLock, + shouldSkipSingleInstanceLock, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } from './single-instance-lock' @@ -73,6 +74,24 @@ describe('acquireSingleInstanceLock', () => { }) }) +describe('shouldSkipSingleInstanceLock', () => { + it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + }) + + it('lets isolated E2E exercise the production single-instance path', () => { + expect( + shouldSkipSingleInstanceLock({ + isDev: true, + isServeMode: false, + env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + }) + ).toBe(false) + }) +}) + describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 5fd9cc27610..078500284a2 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -4,6 +4,7 @@ import { writeStartupDiagnosticLine, type StartupDiagnosticSink } from './startu export const SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE = '[single-instance] Another Orca instance is already running for this userData profile; exiting this launch after requesting the existing window. If no Orca process is running, this may be an Electron/macOS single-instance lock failure.' export const SINGLE_INSTANCE_LOCK_BYPASS_ENV = 'ORCA_BYPASS_SINGLE_INSTANCE_LOCK' +export const SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV = 'ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK' export const SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE = '[single-instance] ORCA_BYPASS_SINGLE_INSTANCE_LOCK=1 is set; bypassing the packaged macOS single-instance lock for diagnostics. Do not use this with another Orca instance running for the same profile.' @@ -49,6 +50,15 @@ export function shouldBypassSingleInstanceLock(options: { ) } +export function shouldSkipSingleInstanceLock(options: { + env?: NodeJS.ProcessEnv + isDev: boolean + isServeMode: boolean +}): boolean { + const env = options.env ?? process.env + return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' +} + export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) } diff --git a/src/main/startup/window-all-closed-quit-policy.test.ts b/src/main/startup/window-all-closed-quit-policy.test.ts index b7aab9e13ae..6c451f7e4ad 100644 --- a/src/main/startup/window-all-closed-quit-policy.test.ts +++ b/src/main/startup/window-all-closed-quit-policy.test.ts @@ -41,4 +41,14 @@ describe('shouldQuitWhenAllWindowsClosed', () => { }) ).toBe(true) }) + + it('continues a committed quit after a serve owner was promoted to desktop', () => { + expect( + shouldQuitWhenAllWindowsClosed({ + platform: 'darwin', + isQuitting: true, + isServeMode: true + }) + ).toBe(true) + }) }) diff --git a/src/main/startup/window-all-closed-quit-policy.ts b/src/main/startup/window-all-closed-quit-policy.ts index 8b23f436140..f17881ea989 100644 --- a/src/main/startup/window-all-closed-quit-policy.ts +++ b/src/main/startup/window-all-closed-quit-policy.ts @@ -3,7 +3,7 @@ export function shouldQuitWhenAllWindowsClosed(options: { isQuitting: boolean isServeMode: boolean }): boolean { - if (options.isServeMode) { + if (options.isServeMode && !options.isQuitting) { return false } return options.platform !== 'darwin' || options.isQuitting diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 718711b629d..c2a730f9026 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -631,13 +631,20 @@ describe('attachMainWindowServices', () => { const notifier = runtime.setNotifier.mock.calls[0][0] as { revealTerminalSession: ( worktreeId: string, - opts: { ptyId: string; title?: string; cwd?: string; activate?: boolean } + opts: { + ptyId: string + title?: string + cwd?: string + viewMode?: 'terminal' | 'chat' + activate?: boolean + } ) => Promise<{ tabId: string; title?: string }> } const revealPromise = notifier.revealTerminalSession('wt-1', { ptyId: 'pty-1', title: 'SSH tmux', - cwd: '/repo/packages/web' + cwd: '/repo/packages/web', + viewMode: 'chat' }) const sentPayload = sendMock.mock.calls.find( ([channel]) => channel === 'ui:createTerminal' @@ -645,7 +652,7 @@ describe('attachMainWindowServices', () => { const handler = onMock.mock.calls.find( ([channel]) => channel === 'terminal:tabCreateReply' )?.[1] - expect(sentPayload.cwd).toBe('/repo/packages/web') + expect(sentPayload).toMatchObject({ cwd: '/repo/packages/web', viewMode: 'chat' }) handler?.( { sender: { send: vi.fn() } }, diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index cc03c40a904..0dd54c2c598 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -328,6 +328,7 @@ function registerRuntimeWindowLifecycle( ...(opts.launchConfig ? { launchConfig: opts.launchConfig } : {}), ...(opts.launchToken ? { launchToken: opts.launchToken } : {}), ...(opts.launchAgent ? { launchAgent: opts.launchAgent } : {}), + ...(opts.viewMode ? { viewMode: opts.viewMode } : {}), activate: opts.activate !== false, ...(opts.presentation ? { presentation: opts.presentation } : {}), // Why: pre-minted tabId from main keeps the renderer's tab id aligned diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 54b4728f7cd..8899ba81bbf 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -2837,11 +2837,39 @@ describe('createMainWindow', () => { }) }) - it('does not install the startup reveal fallback off Windows', () => { + it('reveals the startup window on Linux when ready-to-show never fires', () => { vi.useFakeTimers() const { browserWindowInstance } = createStartupRevealWindowFixture() withPlatform('linux', () => { + createMainWindow(null) + vi.advanceTimersByTime(9_999) + expect(browserWindowInstance.show).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + }) + + it('cancels the Linux startup reveal fallback after ready-to-show', () => { + vi.useFakeTimers() + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + withPlatform('linux', () => { + createMainWindow(null) + windowHandlers['ready-to-show']() + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).toHaveBeenCalledTimes(1) + }) + }) + + it('does not install the startup reveal fallback on macOS', () => { + vi.useFakeTimers() + const { browserWindowInstance } = createStartupRevealWindowFixture() + + withPlatform('darwin', () => { createMainWindow(null) vi.advanceTimersByTime(10_000) @@ -2901,6 +2929,57 @@ describe('createMainWindow', () => { }) }) + it('keeps the headless E2E window hidden when the Linux fallback fires', () => { + vi.useFakeTimers() + const previousHeadless = process.env.ORCA_E2E_HEADLESS + process.env.ORCA_E2E_HEADLESS = '1' + const { browserWindowInstance } = createStartupRevealWindowFixture() + + try { + withPlatform('linux', () => { + createMainWindow(createStartupRevealStore(true) as never) + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + } finally { + if (previousHeadless === undefined) { + delete process.env.ORCA_E2E_HEADLESS + } else { + process.env.ORCA_E2E_HEADLESS = previousHeadless + } + } + }) + + it('clears the Linux startup reveal fallback when the window is closed', () => { + vi.useFakeTimers() + const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture() + + withPlatform('linux', () => { + createMainWindow(createStartupRevealStore(true) as never) + windowHandlers.closed() + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + }) + + it('does not show or maximize a destroyed window when the Linux fallback fires', () => { + vi.useFakeTimers() + const { browserWindowInstance } = createStartupRevealWindowFixture() + + withPlatform('linux', () => { + createMainWindow(createStartupRevealStore(true) as never) + browserWindowInstance.isDestroyed.mockReturnValue(true) + vi.advanceTimersByTime(10_000) + + expect(browserWindowInstance.show).not.toHaveBeenCalled() + expect(browserWindowInstance.maximize).not.toHaveBeenCalled() + }) + }) + describe('system resume relay', () => { function setupResumeWindow() { const windowHandlers: Record void> = {} diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index ff66b5ebc16..52c84ec6048 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -337,10 +337,11 @@ export function createMainWindow( // the window back to full-screen after the user already resized it (#591). let handledInitialReadyToShow = false let initialRevealFallbackTimer: ReturnType | null = - process.platform === 'win32' + process.platform === 'win32' || process.platform === 'linux' ? setTimeout(() => { - // Why: GPU/driver failures on Windows can prevent ready-to-show forever, - // leaving the only app window hidden while the main process stays alive. + // Why: GPU/driver failures on Windows and Linux/X11 can prevent + // ready-to-show forever, leaving the only app window hidden while the + // main process stays alive (#8421). initialRevealFallbackTimer = null revealInitialWindow() }, 10_000) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 3cf190583c8..e2aa20a45c4 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2807,6 +2807,7 @@ export type PreloadApi = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean diff --git a/src/preload/index.ts b/src/preload/index.ts index 30b35b22436..679a4aa14c9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3445,6 +3445,7 @@ const api = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean @@ -3467,6 +3468,7 @@ const api = { launchConfig?: SleepingAgentLaunchConfig launchToken?: string launchAgent?: TuiAgent + viewMode?: 'terminal' | 'chat' title?: string ptyId?: string activate?: boolean diff --git a/src/renderer/src/components/AgentStateDot.test.ts b/src/renderer/src/components/AgentStateDot.test.ts index e02c5d496ce..2e17d0423df 100644 --- a/src/renderer/src/components/AgentStateDot.test.ts +++ b/src/renderer/src/components/AgentStateDot.test.ts @@ -23,6 +23,7 @@ describe('AgentStateDot', () => { expect(markup).toContain('border-yellow-500') expect(markup).toContain('border-t-transparent') expect(markup).toContain('[animation:spin_1s_steps(12,end)_infinite]') + expect(markup).toContain('motion-reduce:animate-none') expect(markup).not.toContain('animate-spin') }) diff --git a/src/renderer/src/components/AgentStateDot.tsx b/src/renderer/src/components/AgentStateDot.tsx index 951e3be36f1..9a4fe792e13 100644 --- a/src/renderer/src/components/AgentStateDot.tsx +++ b/src/renderer/src/components/AgentStateDot.tsx @@ -30,6 +30,7 @@ export type AgentDotState = // worktree-level permission dot. | 'permission' +/** Return the accessible label shared by every visual agent-state marker. */ export function agentStateLabel(state: AgentDotState): string { switch (state) { case 'working': @@ -57,6 +58,7 @@ type Props = { className?: string } +/** Render the compact state glyph used by agent rows and terminal tabs. */ export const AgentStateDot = React.memo(function AgentStateDot({ state, size = 'sm', @@ -76,7 +78,7 @@ export const AgentStateDot = React.memo(function AgentStateDot({ className={cn( // Why: match the sidebar worktree spinner's stepped cadence so // long-running visible agents do not keep a full-frame-rate loop. - 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite]', + 'block rounded-full border-2 border-yellow-500 border-t-transparent [animation:spin_1s_steps(12,end)_infinite] motion-reduce:animate-none', inner )} /> diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 3b538d44335..99fc42d7111 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -205,6 +205,7 @@ import { type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility' +import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination' import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns' import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team' import { @@ -6280,32 +6281,32 @@ export default function TaskPage(): React.JSX.Element { setPaginationLoading(true) setLoadingTargetPage(target) try { - let cursor = oldestItem.updatedAt - let loadedPages = pages.length - const newPages: GitHubWorkItem[][] = [] - - while (loadedPages <= target) { - const { items } = await fetchWorkItemsNextPage( - repoArgs, - PER_REPO_FETCH_LIMIT, - CROSS_REPO_DISPLAY_LIMIT, - q, - cursor - ) - if (paginationGenerationRef.current !== requestGeneration) { - return + const result = await accumulateWorkItemPages({ + existingPages: pages, + initialCursor: oldestItem.updatedAt, + targetPage: target, + // Why: uniform page size keeps deduped pages from shrinking below the + // size `totalPages` (count ÷ effectivePageSize) assumes — otherwise + // the per-page overlap loss would strand the tail items. + pageSize: effectivePageSize, + isCancelled: () => paginationGenerationRef.current !== requestGeneration, + fetchPage: async (cursor) => { + const { items } = await fetchWorkItemsNextPage( + repoArgs, + PER_REPO_FETCH_LIMIT, + CROSS_REPO_DISPLAY_LIMIT, + q, + cursor + ) + return { items } } - if (items.length === 0) { - break - } - newPages.push(items) - cursor = items.at(-1)!.updatedAt - loadedPages += 1 + }) + if (result.cancelled) { + return } - - if (newPages.length > 0) { - setPages((prev) => [...prev, ...newPages]) - setCurrentPage(target < loadedPages ? target : loadedPages - 1) + if (result.newPages.length > 0) { + setPages((prev) => [...prev, ...result.newPages]) + setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1) } } catch (err) { console.error('Failed to load next page:', err) @@ -6316,7 +6317,14 @@ export default function TaskPage(): React.JSX.Element { } } }, - [paginationLoading, selectedRepos, pages, appliedTaskSearch, fetchWorkItemsNextPage] + [ + paginationLoading, + selectedRepos, + pages, + appliedTaskSearch, + fetchWorkItemsNextPage, + effectivePageSize + ] ) useEffect(() => { diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 305e89ba35b..98a78ef9845 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -18,6 +18,7 @@ import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { ContextMenu, ContextMenuContent, @@ -1056,8 +1057,11 @@ export default function AutomationsPage(): React.JSX.Element { }, [fetchAllWorktrees, refresh]) useEffect(() => { - const timer = window.setInterval(() => setRelativeNow(Date.now()), 60 * 1000) - return () => window.clearInterval(timer) + // Pause the relative-time clock while the window is hidden. + return installWindowVisibilityInterval({ + run: () => setRelativeNow(Date.now()), + intervalMs: 60 * 1000 + }) }, []) useEffect(() => { diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx index 77c78d0a472..3251b43ac1a 100644 --- a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -5,7 +5,8 @@ import type { OpenFile } from '@/store/slices/editor' const lifecycle = vi.hoisted(() => ({ events: [] as string[], - models: new Map() + models: new Map(), + mountedProps: [] as { filePath: string; readOnly?: boolean; liveTail?: boolean }[] })) vi.mock('@/lib/lazy-with-retry', async () => { @@ -16,10 +17,20 @@ vi.mock('@/lib/lazy-with-retry', async () => { if (!factory.toString().includes('/MonacoEditor.tsx')) { return () => null } - return function MockRetainedMonaco(props: { filePath: string; content: string }) { + return function MockRetainedMonaco(props: { + filePath: string + content: string + readOnly?: boolean + liveTail?: boolean + }) { /* oxlint-disable react-hooks/exhaustive-deps -- Mount-only by design: a prop-effect would hide a missing outer React remount. */ React.useEffect(() => { lifecycle.events.push(`mount:${props.filePath}`) + lifecycle.mountedProps.push({ + filePath: props.filePath, + readOnly: props.readOnly, + liveTail: props.liveTail + }) const retained = lifecycle.models.get(props.filePath) ?? { content: '', undo: [] } const model = { getValue: () => retained.content, @@ -93,7 +104,7 @@ vi.mock('@/store', () => ({ import { EditorContent } from './EditorContent' -function file(filePath: string): OpenFile { +function file(filePath: string, overrides: Partial = {}): OpenFile { return { id: filePath, filePath, @@ -101,7 +112,8 @@ function file(filePath: string): OpenFile { worktreeId: 'repo::/repo', language: 'typescript', isDirty: false, - mode: 'edit' + mode: 'edit', + ...overrides } } @@ -136,6 +148,7 @@ afterEach(() => { cleanup() lifecycle.events.length = 0 lifecycle.models.clear() + lifecycle.mountedProps.length = 0 }) describe('EditorContent Monaco lifecycle boundary', () => { @@ -162,4 +175,14 @@ describe('EditorContent Monaco lifecycle boundary', () => { undo: ['first undo'] }) }) + + it('passes live-tail ownership only for a read-only live log', () => { + const liveLog = file('/repo/session.jsonl', { readOnly: true, liveTail: true }) + + render() + + expect(lifecycle.mountedProps).toEqual([ + { filePath: liveLog.filePath, readOnly: true, liveTail: true } + ]) + }) }) diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 3e56b7c58da..023c0b9fcae 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -327,6 +327,7 @@ export function EditorContent({ // the change/save callbacks so no draft, dirty state, or write can occur — // mirrors the conflict-review read-only rendering pattern. readOnly={activeFile.readOnly === true} + liveTail={activeFile.liveTail === true} onContentChange={activeFile.readOnly === true ? noopEditorContentChange : handleContentChange} onSave={activeFile.readOnly === true ? noopEditorSave : isMarkdown ? md.mdSave : handleSave} worktreeId={activeFile.worktreeId} diff --git a/src/renderer/src/components/editor/EditorPanel.tsx b/src/renderer/src/components/editor/EditorPanel.tsx index 21b116ea246..43a9276b15e 100644 --- a/src/renderer/src/components/editor/EditorPanel.tsx +++ b/src/renderer/src/components/editor/EditorPanel.tsx @@ -346,8 +346,9 @@ function EditorPanelInner({ activeMarkdownContent && extractFrontMatter(activeMarkdownContent) ) + // Why: front-matter shows by default; the map only carries per-file hide overrides. const isMarkdownFrontmatterVisible = - markdownFrontmatterVisible[markdownDocumentStateFileId] ?? false + markdownFrontmatterVisible[markdownDocumentStateFileId] ?? true const isMarkdownTableOfContentsVisible = markdownTableOfContentsVisible[markdownDocumentStateFileId] ?? false diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 38d146c1312..f47b3610f3c 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -619,12 +619,11 @@ export default function MarkdownPreview({ .replace(/\r?\n(?:---|\+\+\+)\r?\n?$/, '') .trim() }, [frontMatter]) - // Why: front matter is hidden by default (#4468) and controlled from the - // markdown preview actions menu, keeping metadata out of the reading surface - // unless the user explicitly asks for it. + // Why: front matter shows by default and is toggled off from the markdown + // preview actions menu; the store map only carries per-file hide overrides. const toggleableSourceFileId: string | null = sourceFileId ?? null const frontmatterVisible = toggleableSourceFileId - ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? false) + ? (frontmatterVisibleByFile[toggleableSourceFileId] ?? true) : true const [activeAnnotationBlockKey, setActiveAnnotationBlockKey] = useState(null) const [reviewNotesCopied, setReviewNotesCopied] = useState(false) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index d8f92a00cf3..daf288d8ce9 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -15,7 +15,11 @@ import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-select import { useContextualCopySetup } from './useContextualCopySetup' import { MAX_REVEAL_CONTENT_WAIT_FRAMES, performReveal } from './monaco-reveal' -import { syncContentOnMount, syncContentUpdate } from './monaco-content-sync' +import { + syncContentOnMount, + syncContentUpdate, + type MonacoContentSyncMode +} from './monaco-content-sync' import { getMonacoCodebaseSearchQuery } from './monaco-codebase-search' import { beginProgrammaticContentSync, @@ -77,6 +81,7 @@ type MonacoEditorProps = { markdownAnnotationsEnabled?: boolean conflictDecorationsEnabled?: boolean readOnly?: boolean + liveTail?: boolean autoHeight?: boolean } @@ -101,6 +106,7 @@ export default function MonacoEditor({ markdownAnnotationsEnabled = false, conflictDecorationsEnabled = false, readOnly = false, + liveTail = false, autoHeight = false }: MonacoEditorProps): React.JSX.Element { const editorRef = useRef(null) @@ -131,6 +137,8 @@ export default function MonacoEditor({ propsRef.current = { relativePath, language, onSave, onContentChange } const readOnlyRef = useRef(readOnly) readOnlyRef.current = readOnly + const contentSyncModeRef = useRef('undoable') + contentSyncModeRef.current = readOnly && liveTail ? 'read-only-live-tail' : 'undoable' const settings = useAppStore((s) => s.settings) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) @@ -370,7 +378,11 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - const didSyncOnMount = syncContentOnMount(editorInstance, contentRef.current) + const didSyncOnMount = syncContentOnMount( + editorInstance, + contentRef.current, + contentSyncModeRef.current + ) if (didSyncOnMount) { lastSyncedContentRef.current = contentRef.current } @@ -672,7 +684,7 @@ export default function MonacoEditor({ beginProgrammaticContentSync(filePath) isApplyingProgrammaticContentRef.current = true try { - syncContentUpdate(ed, content) + syncContentUpdate(ed, content, contentSyncModeRef.current) lastSyncedContentRef.current = content } finally { isApplyingProgrammaticContentRef.current = false diff --git a/src/renderer/src/components/editor/monaco-content-sync.test.ts b/src/renderer/src/components/editor/monaco-content-sync.test.ts index ccf958f122e..30f833d379c 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.test.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.test.ts @@ -9,6 +9,7 @@ function createHarness( editorInstance: editor.IStandaloneCodeEditor getValue: ReturnType getFullModelRange: ReturnType + applyEdits: ReturnType pushEditOperations: ReturnType pushUndoStop: ReturnType } { @@ -20,18 +21,27 @@ function createHarness( endColumn: 5 })) const pushEditOperations = vi.fn() + const applyEdits = vi.fn() const model = { getValue, getEOL: () => eol, getFullModelRange, - pushEditOperations + pushEditOperations, + applyEdits } as unknown as editor.ITextModel const pushUndoStop = vi.fn() const editorInstance = { getModel: () => model, pushUndoStop } as unknown as editor.IStandaloneCodeEditor - return { editorInstance, getValue, getFullModelRange, pushEditOperations, pushUndoStop } + return { + editorInstance, + getValue, + getFullModelRange, + applyEdits, + pushEditOperations, + pushUndoStop + } } describe('monaco content sync', () => { @@ -60,6 +70,26 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) }) + it('inserts a read-only live-tail suffix without recording undo history', () => { + const harness = createHarness('first\nsecond\nlast') + + syncContentUpdate(harness.editorInstance, 'first\nsecond\nlast\nnext', 'read-only-live-tail') + + expect(harness.applyEdits).toHaveBeenCalledWith([ + { + range: { + startLineNumber: 3, + startColumn: 5, + endLineNumber: 3, + endColumn: 5 + }, + text: '\nnext' + } + ]) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('does nothing for identical content', () => { const harness = createHarness('unchanged') @@ -127,6 +157,22 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) }) + it.each([ + ['same-length rewrite', 'before', 'after!'], + ['prefix mismatch', 'before', 'changed content'], + ['truncation', 'longer content', 'short'] + ])('non-undoingly replaces a read-only live-tail %s', (_label, initialContent, nextContent) => { + const harness = createHarness(initialContent) + + syncContentUpdate(harness.editorInstance, nextContent, 'read-only-live-tail') + + expect(harness.applyEdits).toHaveBeenCalledWith([ + { range: harness.getFullModelRange.mock.results[0]?.value, text: nextContent } + ]) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('reconciles a stale retained model on mount without undo stops', () => { const harness = createHarness('stale') @@ -136,6 +182,16 @@ describe('monaco content sync', () => { expect(harness.pushUndoStop).not.toHaveBeenCalled() }) + it('reconciles a stale read-only live-tail model on mount without undo history', () => { + const harness = createHarness('stale') + + expect(syncContentOnMount(harness.editorInstance, 'fresh', 'read-only-live-tail')).toBe(true) + + expect(harness.applyEdits).toHaveBeenCalledTimes(1) + expect(harness.pushEditOperations).not.toHaveBeenCalled() + expect(harness.pushUndoStop).not.toHaveBeenCalled() + }) + it('does nothing on mount when content already matches', () => { const harness = createHarness('same') diff --git a/src/renderer/src/components/editor/monaco-content-sync.ts b/src/renderer/src/components/editor/monaco-content-sync.ts index b7894af432c..fa19928b89d 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.ts @@ -1,5 +1,7 @@ import type { editor } from 'monaco-editor' +export type MonacoContentSyncMode = 'undoable' | 'read-only-live-tail' + function normalizeToModelEol(content: string, model: editor.ITextModel): string { const eol = model.getEOL() // Why: Monaco normalizes model line endings, while filesystem content keeps @@ -10,28 +12,41 @@ function normalizeToModelEol(content: string, model: editor.ITextModel): string return content.replace(/\r\n|\r|\n/g, eol) } -/** - * Overwrite a Monaco model's text via pushEditOperations so the change - * participates in the undo stack (unlike setValue, which blows it away). - */ +function applyModelEdit( + editorInstance: editor.IStandaloneCodeEditor, + model: editor.ITextModel, + edit: editor.IIdentifiedSingleEditOperation, + mode: MonacoContentSyncMode, + withUndoStops: boolean +): void { + if (mode === 'read-only-live-tail') { + // Why: live-tail updates are machine-owned and cannot be undone by users; + // recording them would retain the growing log again in Monaco's undo service. + model.applyEdits([edit]) + return + } + if (withUndoStops) { + editorInstance.pushUndoStop() + } + model.pushEditOperations([], [edit], () => null) + if (withUndoStops) { + editorInstance.pushUndoStop() + } +} + function replaceModelContent( editorInstance: editor.IStandaloneCodeEditor, model: editor.ITextModel, currentContent: string, content: string, + mode: MonacoContentSyncMode, withUndoStops: boolean ): void { if (currentContent === content) { return } const fullRange = model.getFullModelRange() - if (withUndoStops) { - editorInstance.pushUndoStop() - } - model.pushEditOperations([], [{ range: fullRange, text: content }], () => null) - if (withUndoStops) { - editorInstance.pushUndoStop() - } + applyModelEdit(editorInstance, model, { range: fullRange, text: content }, mode, withUndoStops) } /** @@ -46,7 +61,8 @@ function replaceModelContent( */ export function syncContentOnMount( editorInstance: editor.IStandaloneCodeEditor, - content: string + content: string, + mode: MonacoContentSyncMode = 'undoable' ): boolean { const model = editorInstance.getModel() if (!model) { @@ -60,7 +76,7 @@ export function syncContentOnMount( // Why: no undo stop on mount — the retained model's text was already the // user's last-known state, and adding an undo entry here would make Cmd+Z // revert to the pre-remount text, which is confusing. - replaceModelContent(editorInstance, model, currentContent, normalizedContent, false) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, false) return true } @@ -74,7 +90,8 @@ export function syncContentOnMount( */ export function syncContentUpdate( editorInstance: editor.IStandaloneCodeEditor, - content: string + content: string, + mode: MonacoContentSyncMode = 'undoable' ): void { const model = editorInstance.getModel() if (!model) { @@ -83,7 +100,7 @@ export function syncContentUpdate( const currentContent = model.getValue() const normalizedContent = normalizeToModelEol(content, model) if (currentContent.length === normalizedContent.length) { - replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true) return } if ( @@ -93,24 +110,22 @@ export function syncContentUpdate( // Why: preserving the existing prefix lets Monaco retain viewport, // selection, find-widget, and tokenization state above a live-file append. const fullRange = model.getFullModelRange() - editorInstance.pushUndoStop() - model.pushEditOperations( - [], - [ - { - range: { - startLineNumber: fullRange.endLineNumber, - startColumn: fullRange.endColumn, - endLineNumber: fullRange.endLineNumber, - endColumn: fullRange.endColumn - }, - text: normalizedContent.slice(currentContent.length) - } - ], - () => null + applyModelEdit( + editorInstance, + model, + { + range: { + startLineNumber: fullRange.endLineNumber, + startColumn: fullRange.endColumn, + endLineNumber: fullRange.endLineNumber, + endColumn: fullRange.endColumn + }, + text: normalizedContent.slice(currentContent.length) + }, + mode, + true ) - editorInstance.pushUndoStop() return } - replaceModelContent(editorInstance, model, currentContent, normalizedContent, true) + replaceModelContent(editorInstance, model, currentContent, normalizedContent, mode, true) } diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts new file mode 100644 index 00000000000..7118634a3b1 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts @@ -0,0 +1,51 @@ +// @vitest-environment happy-dom +import * as monaco from 'monaco-editor' +import { afterEach, describe, expect, it } from 'vitest' +import { syncContentUpdate } from './monaco-content-sync' + +const models: monaco.editor.ITextModel[] = [] + +function createEditor(initialContent: string): { + editorInstance: monaco.editor.IStandaloneCodeEditor + model: monaco.editor.ITextModel +} { + const model = monaco.editor.createModel(initialContent, 'plaintext') + models.push(model) + return { + model, + editorInstance: { + getModel: () => model, + pushUndoStop: () => { + model.pushStackElement() + return true + } + } as unknown as monaco.editor.IStandaloneCodeEditor + } +} + +afterEach(() => { + for (const model of models.splice(0)) { + model.dispose() + } +}) + +describe('Monaco external-content undo history', () => { + it('does not make a read-only live-tail append undoable', () => { + const { editorInstance, model } = createEditor('first line') + + syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail') + + expect(model.getValue()).toBe('first line\nappended') + expect(model.canUndo()).toBe(false) + }) + + it('keeps ordinary external updates undoable', async () => { + const { editorInstance, model } = createEditor('editable') + + syncContentUpdate(editorInstance, 'external update') + + expect(model.canUndo()).toBe(true) + await model.undo() + expect(model.getValue()).toBe('editable') + }) +}) diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts new file mode 100644 index 00000000000..a5fdbe1de34 --- /dev/null +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts @@ -0,0 +1,78 @@ +// @vitest-environment happy-dom +import { bench, expect } from 'vitest' +import * as monaco from 'monaco-editor' +import { syncContentUpdate, type MonacoContentSyncMode } from './monaco-content-sync' + +const MEBIBYTE = 1024 * 1024 +const BATCH_COUNT = 5 +const BATCH_BYTES = 10 * MEBIBYTE + +type UndoStackElement = { heapSize: () => number } +type UndoRedoService = { + getElements: (resource: monaco.Uri) => { past: UndoStackElement[]; future: UndoStackElement[] } +} + +function undoHistoryBytes(model: monaco.editor.ITextModel): number { + const service = (model as unknown as { _undoRedoService: UndoRedoService })._undoRedoService + const elements = service.getElements(model.uri) + return [...elements.past, ...elements.future].reduce( + (total, element) => total + element.heapSize(), + 0 + ) +} + +async function forceGcAndSettle(): Promise { + const gc = (globalThis as { gc?: () => void }).gc + if (!gc) { + throw new Error('Forced GC unavailable; run the benchmark with node --expose-gc') + } + gc() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function measureUndoRetention(mode: MonacoContentSyncMode): Promise<{ + arrayBufferDelta: number + canUndo: boolean + undoBytes: number +}> { + await forceGcAndSettle() + const beforeArrayBuffers = process.memoryUsage().arrayBuffers + const model = monaco.editor.createModel('', 'plaintext') + const editorInstance = { + getModel: () => model, + pushUndoStop: () => { + model.pushStackElement() + return true + } + } as unknown as monaco.editor.IStandaloneCodeEditor + let content = '' + for (let batch = 0; batch < BATCH_COUNT; batch++) { + content += String(batch % 10).repeat(BATCH_BYTES) + syncContentUpdate(editorInstance, content, mode) + } + const canUndo = model.canUndo() + const undoBytes = undoHistoryBytes(model) + await forceGcAndSettle() + const arrayBufferDelta = process.memoryUsage().arrayBuffers - beforeArrayBuffers + model.dispose() + await forceGcAndSettle() + return { arrayBufferDelta, canUndo, undoBytes } +} + +bench( + '50 MiB read-only live-tail undo retention', + async () => { + const undoable = await measureUndoRetention('undoable') + const readOnlyLiveTail = await measureUndoRetention('read-only-live-tail') + console.log( + `[monaco-content-sync] 50 MiB undo retention ${JSON.stringify({ undoable, readOnlyLiveTail })}` + ) + expect(undoable.canUndo).toBe(true) + expect(undoable.undoBytes).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES) + expect(undoable.arrayBufferDelta).toBeGreaterThanOrEqual(BATCH_COUNT * BATCH_BYTES) + expect(readOnlyLiveTail.canUndo).toBe(false) + expect(readOnlyLiveTail.undoBytes).toBe(0) + expect(readOnlyLiveTail.arrayBufferDelta).toBe(0) + }, + { iterations: 1, time: 1, warmupIterations: 0, warmupTime: 0 } +) diff --git a/src/renderer/src/components/editor/monaco-e2e-probe.ts b/src/renderer/src/components/editor/monaco-e2e-probe.ts index f6284a51771..788a2204794 100644 --- a/src/renderer/src/components/editor/monaco-e2e-probe.ts +++ b/src/renderer/src/components/editor/monaco-e2e-probe.ts @@ -1,6 +1,7 @@ import type { editor, IRange, ISelection } from 'monaco-editor' export type MonacoE2ESnapshot = { + canUndo: boolean contentHeight: number scrollHeight: number scrollTop: number @@ -74,6 +75,7 @@ export function installMonacoE2EProbe( ? lastLine : (model?.getLineContent(lastLineNumber - 1) ?? '') return { + canUndo: model?.canUndo() ?? false, contentHeight: editorInstance.getContentHeight(), scrollHeight: editorInstance.getScrollHeight(), scrollTop: editorInstance.getScrollTop(), diff --git a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx index cbf041f62e3..9b394ada907 100644 --- a/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatApprovalCard.tsx @@ -19,35 +19,37 @@ export function NativeChatApprovalCard({ onChoose }: NativeChatApprovalCardProps): React.JSX.Element { return ( -
-
-
- -
-

{approval.title}

- {approval.detail ? ( -

- {approval.detail} -

- ) : null} +
+
+
+
+ +
+

{approval.title}

+ {approval.detail ? ( +

+ {approval.detail} +

+ ) : null} +
+
+
+ {approval.options.map((opt, i) => ( + + ))}
-
-
- {approval.options.map((opt, i) => ( - - ))}
diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx new file mode 100644 index 00000000000..c8be699f8f6 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + cancelPendingSends: vi.fn(), + fieldProps: null as { onSend?: () => void; onStop?: () => void } | null, + sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, + sendNativeChatMessage: vi.fn(), + trackPendingSend: vi.fn(), + setDraft: vi.fn() +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ dictationState: 'idle', settings: { voice: { enabled: false } } }) +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + isRemoteRuntimePtyId: () => false, + sendRuntimePtyInput: vi.fn() +})) +vi.mock('@/lib/agent-paste-draft', () => ({ + getSettingsForAgentTabRuntimeOwner: () => ({}) +})) +vi.mock('./native-chat-runtime-send', () => ({ + sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatMessageWithImageAttachments: vi.fn(), + submitNativeChatPrompt: vi.fn() +})) +vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] })) +vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('./use-native-chat-draft', () => ({ + useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft }) +})) +vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' })) +vi.mock('./NativeChatComposerField', () => ({ + NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { + mocks.fieldProps = props + return null + } +})) +vi.mock('./use-native-chat-skills', () => ({ useNativeChatSkills: () => [] })) +vi.mock('./use-native-chat-composer-attachments', () => ({ + useNativeChatComposerAttachments: () => ({ + imageAttachments: [], + attachResolvedPaths: vi.fn(), + clearImageAttachments: vi.fn(), + removeImageAttachment: vi.fn() + }) +})) +vi.mock('./use-native-chat-composer-paste', () => ({ + useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() }) +})) +vi.mock('./use-native-chat-external-attachments', () => ({ + useNativeChatExternalAttachments: () => ({ + attachExternalPaths: vi.fn(), + resolveAttachmentOwner: vi.fn() + }) +})) +vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) +vi.mock('./use-native-chat-composer-keydown', () => ({ + useNativeChatComposerKeyDown: () => vi.fn() +})) +vi.mock('./use-native-chat-send-lifecycle', () => ({ + useNativeChatSendLifecycle: () => ({ + cancelPendingSends: mocks.cancelPendingSends, + trackPendingSend: mocks.trackPendingSend + }) +})) + +import { NativeChatComposer } from './NativeChatComposer' + +describe('NativeChatComposer', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.fieldProps = null + mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + Object.defineProperty(window, 'api', { + configurable: true, + value: { ui: { onFileDrop: () => vi.fn() } } + }) + }) + + afterEach(() => cleanup()) + + it('cancels delayed composer writes before the Stop button interrupts the agent', () => { + const onStop = vi.fn() + render( + + ) + + act(() => mocks.fieldProps?.onStop?.()) + + expect(mocks.cancelPendingSends).toHaveBeenCalledOnce() + expect(onStop).toHaveBeenCalledOnce() + expect(mocks.cancelPendingSends.mock.invocationCallOrder[0]).toBeLessThan( + onStop.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + + it('associates a delayed submit with its optimistic cache entry', () => { + const onOptimisticSend = vi.fn(() => 'pending-1') + render( + + ) + + act(() => mocks.fieldProps?.onSend?.()) + + expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) + expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index d85a3a59bbe..41b254639dc 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -17,6 +17,7 @@ import { sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt } from './native-chat-runtime-send' +import type { NativeChatSendHandle } from './native-chat-runtime-send' import { getAgentSlashCommands } from './native-chat-agent-commands' import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' import { @@ -44,6 +45,7 @@ import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' +import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' // Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a // PTY (matching how xterm forwards Escape). The richer interrupt-intent @@ -70,7 +72,9 @@ export type NativeChatComposerProps = { onStop?: () => void /** Optional optimistic-send hook: called with the sent text so the view can * render a "queued" echo until the real transcript turn lands (mobile parity). */ - onOptimisticSend?: (text: string, imagePaths?: string[]) => void + onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined + /** Remove an optimistic echo when its delayed submit is canceled. */ + onOptimisticSendCanceled?: (pendingId: string) => void /** Called with a dispatched slash command (e.g. `/clear`) so the view can show * a small "Ran /clear" system line — slash commands aren't chat turns and * otherwise leave no visible trace that anything happened. */ @@ -111,6 +115,7 @@ export const NativeChatComposer = forwardRef(null) + const { cancelPendingSends, trackPendingSend } = useNativeChatSendLifecycle( + terminalTabId, + targetPtyId, + onOptimisticSendCanceled + ) const dictationState = useAppStore((store) => store.dictationState) const voiceSettings = useAppStore((store) => store.settings?.voice) const isDictationHoldMode = voiceSettings?.dictationMode === 'hold' @@ -293,21 +303,33 @@ export const NativeChatComposer = forwardRef 0) { - sendNativeChatMessageWithImageAttachments(target.settings, target.ptyId, text, imagePaths) + pendingHandle = sendNativeChatMessageWithImageAttachments( + target.settings, + target.ptyId, + text, + imagePaths + ) } else if (text.trim().length > 0) { - sendNativeChatMessage(target.settings, target.ptyId, text) + pendingHandle = sendNativeChatMessage(target.settings, target.ptyId, text) } else { submitNativeChatPrompt(target.settings, target.ptyId) } // Slash commands don't echo a user bubble, but DO surface a small // "Ran /clear" system line so the command leaves a visible trace. if (isSlashCommand) { + if (pendingHandle) { + trackPendingSend(pendingHandle) + } onSlashCommand?.(text.trim()) } else { - onOptimisticSend?.(text, imagePaths) + const pendingId = onOptimisticSend?.(text, imagePaths) + if (pendingHandle) { + trackPendingSend(pendingHandle, pendingId) + } } // Why: U10 telemetry — record adoption + local-vs-remote runtime split. The // agent prop is the loose AgentType; the emitter narrows unknowns to 'other'. @@ -329,10 +351,12 @@ export const NativeChatComposer = forwardRef { + cancelPendingSends() if (isWorking && onStop) { onStop() return @@ -342,7 +366,7 @@ export const NativeChatComposer = forwardRef { @@ -362,7 +386,7 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx index d3149c8c4ca..5a7b2a90255 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerField.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerField.tsx @@ -85,8 +85,9 @@ export function NativeChatComposerField({ }: NativeChatComposerFieldProps): React.JSX.Element { return (
-
-
+ {/* Extra bottom padding keeps the input box off the window rim. */} +
+
{autocomplete.mode === 'slash' && autocomplete.suggestions.length > 0 ? ( {imageAttachments.length > 0 ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 39bbba9a75e..947d5f65c3e 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import { parseInteractivePrompt } from './native-chat-interactive-prompt' import { nativeChatCardDismissKey } from './native-chat-dismiss-key' @@ -23,11 +23,19 @@ import type { NativeChatInteractiveSend } from './use-native-chat-interactive-se export function NativeChatInteractiveCard({ paneKey, send, - canSend + canSend, + onShowingQuestionChange, + answerInputRef }: { paneKey: string send: NativeChatInteractiveSend canSend: boolean + /** Reports whether a question card is on screen so the view can replace the + * composer with it (the card's free-text row is the answer input). */ + onShowingQuestionChange?: (showing: boolean) => void + /** Forwarded to the question card's free-text row so pane-level Paste keeps + * a target while the composer is unmounted. */ + answerInputRef?: React.RefObject }): React.JSX.Element | null { const interactivePrompt = useAppStore( (s) => s.agentStatusByPaneKey[paneKey]?.interactivePrompt ?? null @@ -43,15 +51,40 @@ export function NativeChatInteractiveCard({ ) const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) + // A question answer is a paced multi-step write (body→Enter per question); keep + // the card up until it settles instead of dismissing on the click, so it doesn't + // vanish mid-send. `submitting` also gates a second submit racing the first. + const dismissTimerRef = useRef | null>(null) + const submittingRef = useRef(false) + const clearDismissTimer = (): void => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current) + dismissTimerRef.current = null + } + submittingRef.current = false + } + // Keyed on cardKey (and unmount): a new prompt can replace the current one + // before the settle timer fires, and a stale `submitting` gate would swallow + // the new card's first answer. + useEffect(() => clearDismissTimer, [cardKey]) // Forget the dismissal once the prompt clears so a fresh prompt can show. const present = card != null useEffect(() => { if (!present) { setDismissedKey(null) + clearDismissTimer() } }, [present]) + // Tell the view when a question card is up so it can hide the composer (this + // card supplies its own input). Reset on unmount so the composer comes back. + const showingQuestion = card?.kind === 'question' && canSend && cardKey !== dismissedKey + useEffect(() => { + onShowingQuestionChange?.(showingQuestion) + return () => onShowingQuestionChange?.(false) + }, [showingQuestion, onShowingQuestionChange]) + if (!card || !canSend || cardKey === dismissedKey) { return null } @@ -60,11 +93,23 @@ export function NativeChatInteractiveCard({ { - setDismissedKey(cardKey) - sendAnswer(text) + if (submittingRef.current) { + return + } + submittingRef.current = true + const settleMs = sendAnswer(text) + // Hold the card until the paced write finishes, then mark it answered + // (which hides it and restores the composer). + dismissTimerRef.current = setTimeout(() => { + setDismissedKey(cardKey) + submittingRef.current = false + dismissTimerRef.current = null + }, settleMs) }} onCancel={() => { + clearDismissTimer() setDismissedKey(cardKey) cancel() }} diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index 313845c2030..71aee362083 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -167,7 +167,9 @@ function MessageRow({ // transcript caught up.) return (
-
+ {/* User turns get a distinct muted fill (not the card/canvas color) so + the prompt reads apart from the assistant's body copy. */} +
{markdown ? ( <> @@ -213,7 +215,7 @@ function MessageRow({ ) : null} @@ -251,6 +253,7 @@ export function NativeChatMessageList({ failedDeliveryMessageIds?: ReadonlySet }): React.JSX.Element { const scrollRef = useRef(null) + const contentRef = useRef(null) const [stuckToBottom, setStuckToBottom] = useState(true) const [showJump, setShowJump] = useState(false) @@ -312,6 +315,11 @@ export function NativeChatMessageList({ if (!container) { return } + // Detach synchronously (not just via the pending onScroll) so an in-place + // streaming growth can't re-pin to the bottom mid-flight and fight this + // deliberate scroll. The ref is what the resize observer reads. + stuckToBottomRef.current = false + setStuckToBottom(false) const delta = el.getBoundingClientRect().top - container.getBoundingClientRect().top container.scrollTo({ top: container.scrollTop + delta, behavior: 'smooth' }) }, []) @@ -334,17 +342,31 @@ export function NativeChatMessageList({ } }, [messages.length, isWorking, showTypingIndicator, scrollToBottom]) - // Keep the affordances in sync if the container resizes (e.g. composer mounts, - // viewport reflow) without a scroll event. + // Content growing without a message-count change (a streaming assistant turn + // extends its own message in place) never re-fires the layout effect above. + // Observe the container so those in-place growths still re-pin: stay glued to + // the bottom while stuck, otherwise just refresh the jump affordance. This is + // what removes most "Jump to latest" clicks during a live response. useEffect(() => { const el = scrollRef.current if (!el || typeof ResizeObserver === 'undefined') { return } - const observer = new ResizeObserver(handleScroll) + const observer = new ResizeObserver(() => { + if (stuckToBottomRef.current) { + scrollToBottom() + } else { + handleScroll() + } + }) + // Observe the growing content, not just the fixed-height viewport, so an + // in-place streaming growth is seen; also watch the viewport for reflows. observer.observe(el) + if (contentRef.current) { + observer.observe(contentRef.current) + } return () => observer.disconnect() - }, [handleScroll]) + }, [handleScroll, scrollToBottom]) return (
@@ -354,7 +376,8 @@ export function NativeChatMessageList({ className="scrollbar-sleek h-full overflow-y-auto px-3 pt-10 pb-4 sm:px-4" >
void /** Dismiss the prompt (sends Escape to the agent). */ onCancel: () => void + /** Exposes the free-text row so pane-level Paste can target it while the + * card replaces the composer. */ + answerInputRef?: RefObject } -// Synthetic option value for the "Other…" free-text row, kept out of the -// answer text and replaced by the typed value when selected. -const OTHER = '__other__' - /** - * Native renderer for an agent's AskUserQuestion prompt as a wizard: one - * question per step with tabs across the top (tap to jump, a check once - * answered), single- or multi-select option rows, an "Other…" row that reveals a - * free-text input, and a Next button that advances and becomes "Send answer" on - * the last step. Matches the desktop chat's neutral shadcn styling. + * Native renderer for an agent's AskUserQuestion prompt: a numbered pick-list + * (mobile/Claude-Code parity) with a header + close, a hover-highlighted row per + * option, and an always-present free-text row for a custom answer. Single-select + * commits on click; multi-select toggles and confirms via the trailing action. + * Multi-question prompts step through tabs across the top. Neutral shadcn tokens. */ export function NativeChatQuestionCard({ prompt, onAnswer, - onCancel + onCancel, + answerInputRef }: NativeChatQuestionCardProps): React.JSX.Element { const [index, setIndex] = useState(0) const [selections, setSelections] = useState(() => prompt.questions.map(() => [])) const [otherText, setOtherText] = useState(() => prompt.questions.map(() => '')) - const toggle = (qi: number, label: string, multi: boolean): void => { - setSelections((prev) => { - const next = prev.map((s) => [...s]) - const cur = next[qi] ?? [] - if (multi) { - next[qi] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] - } else { - next[qi] = cur.includes(label) ? [] : [label] - } - return next - }) - } + const total = prompt.questions.length + const isLast = index === total - 1 + const q = prompt.questions[index]! const setOther = (qi: number, value: string): void => { setOtherText((prev) => { @@ -53,28 +44,19 @@ export function NativeChatQuestionCard({ }) } - // The resolved answer for a question: picked labels plus the typed "Other" - // value (which replaces the synthetic OTHER marker). - const answerFor = (qi: number): string => { - const picked = (selections[qi] ?? []).filter((l) => l !== OTHER) - const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : '' - return [...picked, other].filter((p) => p.length > 0).join(', ') + // The resolved answer for a question: picked labels plus any typed free-text. + const answerFor = (qi: number, sel = selections, oth = otherText): string => { + const picked = sel[qi] ?? [] + const other = (oth[qi] ?? '').trim() + return [...picked, ...(other ? [other] : [])].join(', ') } - const total = prompt.questions.length - const isLast = index === total - 1 - const currentAnswered = useMemo( - () => answerFor(index).length > 0, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selections, otherText, index] - ) + const currentAnswered = answerFor(index).length > 0 - const submit = (): void => { - // Build per-question label lists, substituting the typed Other value, then - // format to one line per answered question. + const submitAll = (sel: string[][], oth: string[]): void => { const resolved = prompt.questions.map((_, i) => { - const picked = (selections[i] ?? []).filter((l) => l !== OTHER) - const other = (selections[i] ?? []).includes(OTHER) ? (otherText[i] ?? '').trim() : '' + const picked = sel[i] ?? [] + const other = (oth[i] ?? '').trim() return [...picked, ...(other ? [other] : [])] }) const text = formatAskAnswer(prompt, resolved) @@ -83,22 +65,60 @@ export function NativeChatQuestionCard({ } } - const advance = (): void => { + // Advance to the next question, or submit on the last one — always from an + // explicit snapshot so a just-committed single-select pick isn't lost to the + // async setState. + const advanceOrSubmit = (sel: string[][], oth: string[]): void => { if (isLast) { - submit() + submitAll(sel, oth) } else { setIndex((i) => Math.min(i + 1, total - 1)) } } - const q = prompt.questions[index]! - const otherSelected = (selections[index] ?? []).includes(OTHER) + // Selecting only highlights the row; submitting is an explicit step via the + // trailing Send/Next button. (Auto-submitting on the first click dismissed the + // card before the user saw any feedback, which read as "nothing happened".) + const pickOption = (label: string): void => { + setSelections((prev) => { + const next = prev.map((s) => [...s]) + const cur = next[index] ?? [] + if (q.multiSelect) { + next[index] = cur.includes(label) ? cur.filter((l) => l !== label) : [...cur, label] + } else { + next[index] = cur.includes(label) ? [] : [label] + } + return next + }) + } + + // Trailing action (also fired by Enter). On any non-final question this just + // advances — "Next" when answered, "Skip" when not — so skipping one question + // never discards answers already given on the others. Only the final question + // submits; an explicit Skip click there with nothing answered anywhere + // dismisses, but a reflexive Enter in the empty field is a no-op so it can't + // throw away the whole prompt. + const confirm = (fromKeyboard = false): void => { + if (!isLast) { + advanceOrSubmit(selections, otherText) + return + } + const anyAnswered = prompt.questions.some((_, i) => answerFor(i).length > 0) + if (anyAnswered) { + submitAll(selections, otherText) + } else if (!fromKeyboard) { + onCancel() + } + } return ( -
-
+ // Part of the composer: docked in the bottom input region, matching the + // composer's width and padding, rendered as the "ask" dialog card directly + // above the text input. Its free-text row is the answer input. +
+
{total > 1 ? ( -
+
{prompt.questions.map((qq, i) => ( +
+ + {/* Scroll only kicks in on long option lists; the sleek scrollbar rides + the card's right edge instead of crowding the choices. */} +
+ {q.options.map((opt, i) => ( toggle(index, opt.label, q.multiSelect)} + onSelect={() => pickOption(opt.label)} /> ))} - toggle(index, OTHER, q.multiSelect)} - /> - {otherSelected ? ( -