Merge origin/main (9a324bf33) into gsxdsm/native-chat

Reconcile the desktop native-chat refactor (functions moved to src/shared)
with main's in-place improvements:
- carry NATIVE_CHAT_ADVANCE_BUFFER_MS 300->500 (#8568) into the shared
  answer-stepping module; the mobile stepping module derives from it, so
  sync mobile-native-chat-answer-stepping.test.ts to 500/1000
- add formatToolInput (#8654) to the shared tool-summary module and re-export
  it from the desktop barrel (consumed by NativeChatToolRun)
- keep main's settleAfterMs send-handle + useLayoutEffect teardown fix, layered
  onto the branch's shouldStepNativeChatAskAnswer gating
This commit is contained in:
Brennan Benson
2026-07-13 23:28:39 -07:00
311 changed files with 18518 additions and 4476 deletions
+6
View File
@@ -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
+4 -4
View File
@@ -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
+206 -6
View File
@@ -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."
@@ -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, '../..')
@@ -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 {
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.9m">
<title>downloads: 4.9m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.0m">
<title>downloads: 5.0m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.9m</text>
<text x="93.5" y="14">4.9m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.0m</text>
<text x="93.5" y="14">5.0m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+13
View File
@@ -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<string | null>(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)}
/>
<UsageBar
label="7d"
usedPercent={activeWeeklyBar.usedPercent}
unavailable={activeWeeklyBar.unavailable}
loading={activeWeeklyBar.loading}
resetText={getWindowResetLabel(activeUsage, 'weekly', now)}
/>
</View>
) : null}
@@ -211,12 +222,14 @@ export default function AccountsScreen() {
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
resetText={getWindowResetLabel(usage, 'session', now)}
/>
<UsageBar
label="7d"
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
resetText={getWindowResetLabel(usage, 'weekly', now)}
/>
</View>
{usage?.error ? (
+1
View File
@@ -1428,6 +1428,7 @@ export function HostScreen({
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={worktrees}
onVisibleChange={(visible) => {
newWorktreeModalVisibleRef.current = visible
}}
+1 -1
View File
@@ -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
+97 -39
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
esbuild: true
overrides:
xcode>uuid: 11.1.1
+5
View File
@@ -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'], {
+44 -20
View File
@@ -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 (
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${used ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
<View style={styles.usageBarColumn}>
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${used ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
</View>
{loading ? (
<ActivityIndicator
size="small"
color={colors.textSecondary}
style={styles.usageSpinner}
/>
) : (
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
)}
</View>
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
) : (
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
)}
{resetText ? (
<Text style={styles.usageResetText} numberOfLines={1}>
{resetText}
</Text>
) : null}
</View>
)
}
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
}
})
+108 -96
View File
@@ -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 = (
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
)
// 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 (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
{overlay}
</Modal>
)
}
+329 -288
View File
@@ -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<Repo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [showRepoPicker, setShowRepoPicker] = useState(false)
const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true)
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const createInFlightRef = useRef(false)
const setupTrustActionInFlightRef = useRef(false)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
null
)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
const [tasksSupported, setTasksSupported] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
@@ -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<NewWorktreeDrawerView, 'transition'>): 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<PasteRepoCandidate[]>(
() =>
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<RpcResponse>): 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<void> {
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<string, unknown> = {
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<void> {
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 (
<>
<BottomDrawer visible={visible} onClose={onClose}>
// 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.
<BottomDrawerModalHost
visible={visible}
onRequestClose={() => {
if (drawerView === 'form') {
onClose()
} else if (drawerView === 'trust') {
closeSetupTrust()
} else {
transitionDrawer('form')
}
}}
>
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
@@ -702,7 +856,7 @@ function NewWorktreeModalContent({
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
setShowRepoPicker(true)
transitionDrawer('repo')
}}
>
{selectedRepo ? (
@@ -720,6 +874,18 @@ function NewWorktreeModalContent({
</Pressable>
</View>
<SmartWorkspaceSourceField
composer={composer}
label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
disabled={sshGate.requiresConnection}
onBeforeOpen={() => setError('')}
onOpenDrawer={() => transitionDrawer('source')}
/>
{composer.forkPushWarning ? (
<Text style={styles.sourceWarning}>{composer.forkPushWarning}</Text>
) : null}
{selectedRepoConnectionId ? (
<View style={styles.field}>
<Text style={styles.label}>SSH Connection</Text>
@@ -763,28 +929,6 @@ function NewWorktreeModalContent({
</View>
) : null}
<View style={styles.field}>
<Text style={styles.label}>
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<MobileWorkspaceNameInput
style={styles.input}
value={name}
onChangeText={(t) => {
setName(t)
setError('')
}}
placeholderTextColor={colors.textMuted}
shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0}
returnKeyType="done"
onSubmitEditing={() => {
if (canCreate) {
void handleCreate()
}
}}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Agent</Text>
<Pressable
@@ -792,7 +936,7 @@ function NewWorktreeModalContent({
disabled={sshGate.requiresConnection}
onPress={() => {
prepareSelectionPickerOpen()
setShowAgentPicker(true)
transitionDrawer('agent')
}}
>
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
@@ -814,6 +958,11 @@ function NewWorktreeModalContent({
{showAdvanced && (
<>
<SmartWorkspaceAdvancedFields
composer={composer}
selectedRepoIsGit={selectedRepoIsGit}
/>
<View style={styles.field}>
<Text style={styles.label}>Note</Text>
<TextInput
@@ -903,22 +1052,39 @@ function NewWorktreeModalContent({
)}
</BottomDrawer>
{/* 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. */}
<SmartWorkspaceSourceDrawer
visible={visible && drawerView === 'source'}
client={client}
composer={composer}
availability={sourceAvailability}
repoId={selectedRepo?.id ?? null}
repos={pasteRepos}
sshReady={!sshGate.requiresConnection}
onRepoChange={(repoId) => {
const nextRepo = repos.find((repo) => repo.id === repoId)
if (nextRepo) {
setSelectedRepo(nextRepo)
}
}}
onClose={() => transitionDrawer('form')}
/>
<PickerListDrawer
visible={visible && showRepoPicker}
visible={visible && drawerView === 'repo'}
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => setSelectedRepo(item.repo)}
onClose={() => setShowRepoPicker(false)}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={(item) => {
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
}}
/>
<PickerListDrawer
visible={visible && showAgentPicker}
visible={visible && drawerView === 'agent'}
title="Agent"
items={pickerAgentOptions}
selectedId={selectedAgent.id}
@@ -926,105 +1092,20 @@ function NewWorktreeModalContent({
setAgentOverridden(true)
setSelectedAgent(agent)
}}
onClose={() => setShowAgentPicker(false)}
onClose={() => transitionDrawer('form')}
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
/>
<BottomDrawer
visible={visible && setupTrustPrompt != null}
onClose={() => setSetupTrustPrompt(null)}
>
{setupTrustPrompt ? (
<View>
<View style={styles.trustHeader}>
<Text style={styles.title}>
{setupTrustPrompt.previouslyApproved
? `${setupTrustPrompt.repoName}'s setup script changed`
: `Run setup from ${setupTrustPrompt.repoName}?`}
</Text>
<Text style={styles.subtitle}>
This repository's orca.yaml runs before the workspace starts. Only run it if you
trust this repository.
</Text>
</View>
<View style={styles.trustScriptBox}>
<Text style={styles.trustScriptLabel}>
{setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'}
</Text>
<Text style={styles.trustScriptText}>{setupTrustPrompt.scriptContent}</Text>
</View>
<View style={styles.trustActionGroup}>
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() =>
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.')
}
})()
}
>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Run hooks</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() =>
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.')
}
})()
}
>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Always trust and run</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() => {
setSetupTrustPrompt(null)
void handleCreate({ setupOverride: 'skip' })
}}
>
<Text style={styles.trustActionText}>Don't run</Text>
</Pressable>
</View>
</View>
) : null}
</BottomDrawer>
</>
<SetupHookTrustDrawer
visible={visible && drawerView === 'trust' && setupTrustPrompt != null}
prompt={setupTrustPrompt}
busy={creating}
onRunOnce={() => void approveSetupTrust(false)}
onAlwaysTrust={() => void approveSetupTrust(true)}
onDontRun={skipSetupTrust}
onClose={closeSetupTrust}
/>
</BottomDrawerModalHost>
)
}
@@ -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',
@@ -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<NewWorktreeModalControllerH
client,
hostId,
existingWorktreePaths,
existingWorktrees,
onVisibleChange,
onRouteVisibleChange,
onCreated
@@ -58,6 +60,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
onCreated={onCreated}
onClose={close}
/>
@@ -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 (
<BottomDrawer visible={visible && prompt != null} onClose={onClose}>
{prompt ? (
<View>
<View style={styles.trustHeader}>
<Text style={styles.title}>
{prompt.previouslyApproved
? `${prompt.repoName}'s setup script changed`
: `Run setup from ${prompt.repoName}?`}
</Text>
<Text style={styles.subtitle}>
This repository's orca.yaml runs before the workspace starts. Only run it if you trust
this repository.
</Text>
</View>
<View style={styles.trustScriptBox}>
<Text style={styles.trustScriptLabel}>
{prompt.previouslyApproved ? 'New setup script' : 'Setup script'}
</Text>
<Text style={styles.trustScriptText}>{prompt.scriptContent}</Text>
</View>
<View style={styles.trustActionGroup}>
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onRunOnce}>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Run hooks</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onAlwaysTrust}>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Always trust and run</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onDontRun}>
<Text style={styles.trustActionText}>Don't run</Text>
</Pressable>
</View>
</View>
) : null}
</BottomDrawer>
)
}
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
}
})
@@ -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 <TaskProviderLogo provider={icon.provider} size={14} color={color} />
}
if (icon.name === 'sparkles') {
return <Sparkles size={14} color={color} />
}
if (icon.name === 'git-branch') {
return <GitBranch size={14} color={color} />
}
return <CaseSensitive size={14} color={color} />
}
@@ -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 ? (
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
value={composer.name}
onChangeText={composer.setName}
placeholder="Workspace name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{showBranchOverride ? (
<View style={styles.field}>
<Text style={styles.label}>Branch name</Text>
<TextInput
style={styles.input}
value={composer.branchNameOverride ?? ''}
onChangeText={composer.handleBranchNameOverrideChange}
placeholder="Derived from name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{composer.reuseEligibleBranch ? (
<View style={styles.field}>
<View style={styles.reuseRow}>
<Text style={styles.reuseLabel} numberOfLines={1}>
Reuse branch {composer.reuseEligibleBranch}
</Text>
<Switch
value={composer.reuseSelectedBranch}
onValueChange={composer.setReuseSelectedBranch}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
style={styles.reuseSwitch}
/>
</View>
</View>
) : 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 }]
}
})
@@ -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<SmartNameMode>(() => resolveDefaultSmartMode(availability))
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('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<void> {
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 (
<BottomDrawer
visible={visible}
onClose={onClose}
dragContentToDismiss={false}
contentScrollable={false}
>
<View style={styles.header}>
<Text style={styles.title}>Name or 'Create From'</Text>
<Pressable onPress={closeSoon} hitSlop={8}>
<Text style={styles.done}>Done</Text>
</Pressable>
</View>
<TextInput
style={styles.search}
value={composer.name}
onChangeText={composer.setName}
placeholder="Type a name or search a source"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<View style={styles.tabRow}>
{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 (
<Pressable
key={option.id}
style={[styles.tab, selected && styles.tabSelected]}
onPress={() => setMode(option.id)}
>
<SmartSourceModeIcon icon={option.icon} color={tint} />
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
{effectiveMode === 'gitlab' ? (
<View style={styles.chipRow}>
{MR_STATE_FILTER_OPTIONS.map((option) => {
const selected = option.id === mrStateFilter
return (
<Pressable
key={option.id}
style={[styles.chip, selected && styles.chipSelected]}
onPress={() => setMrStateFilter(option.id)}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
) : null}
{crossRepoPrompt ? (
<View style={styles.crossRepo}>
<Text style={styles.crossRepoText}>
This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
</Text>
<View style={styles.crossRepoActions}>
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
<Text style={styles.crossRepoDismissText}>Cancel</Text>
</Pressable>
<Pressable style={styles.crossRepoSwitch} onPress={() => void handleAcceptCrossRepo()}>
<Text style={styles.crossRepoSwitchText}>
Switch to {crossRepoPrompt.matchingRepo.displayName}
</Text>
</Pressable>
</View>
</View>
) : null}
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
<Text style={styles.notice}>Connect the repository to search sources.</Text>
) : needsGitHubRemote ? (
<Text style={styles.notice}>
This SSH repo needs a GitHub remote to list issues and PRs.
</Text>
) : error ? (
<Text style={styles.errorNotice}>{error}</Text>
) : null}
<FlatList
data={rows}
keyExtractor={(row) => row.value}
style={styles.list}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
ListFooterComponent={
loading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : showEmpty ? (
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
) : null
}
renderItem={({ item }) => (
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
)}
/>
</BottomDrawer>
)
}
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
}
})
@@ -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 <GitPullRequest size={15} color={colors.textSecondary} />
}
if (kind === 'gitlab-mr') {
return <GitMerge size={15} color={colors.textSecondary} />
}
if (kind === 'github-issue' || kind === 'gitlab-issue') {
return <CircleDot size={15} color={colors.textSecondary} />
}
if (kind === 'branch') {
return <GitBranch size={15} color={colors.textSecondary} />
}
return <TaskProviderLogo provider="linear" size={15} color={colors.textSecondary} />
}
export function SmartWorkspaceSourceField({
composer,
label,
disabled,
onBeforeOpen,
onOpenDrawer
}: Props) {
const selection = composer.smartNameSelection
function openDrawer(): void {
if (disabled) {
return
}
onBeforeOpen?.()
onOpenDrawer()
}
return (
<View style={styles.field}>
<Text style={styles.label}>
{label} <Text style={styles.labelHint}>[Optional]</Text>
</Text>
{selection ? (
<View style={styles.pill}>
<SelectionIcon kind={selection.kind} />
<Text style={styles.pillLabel} numberOfLines={1}>
{selection.label}
</Text>
{selection.url ? (
<Pressable
hitSlop={6}
onPress={() => selection.url && void Linking.openURL(selection.url).catch(() => {})}
>
<ExternalLink size={15} color={colors.textMuted} />
</Pressable>
) : null}
<Pressable hitSlop={6} onPress={composer.handleClearSmartNameSelection}>
<X size={15} color={colors.textMuted} />
</Pressable>
</View>
) : (
<Pressable
style={[styles.input, disabled && styles.disabled]}
disabled={disabled}
onPress={openDrawer}
>
<Text
style={[styles.inputText, !composer.name && styles.inputPlaceholder]}
numberOfLines={1}
>
{composer.name || 'Type a name or search a source'}
</Text>
</Pressable>
)}
</View>
)
}
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
}
})
@@ -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: <Sparkles size={16} color={colors.textSecondary} />,
title: `Use "${row.name}"`,
subtitle: 'Name this workspace'
}
case 'create-branch':
return {
icon: <GitBranch size={16} color={colors.accentBlue} />,
title: `Create branch "${row.name}"`,
subtitle: 'New branch'
}
case 'github':
return {
icon: <TaskProviderLogo provider="github" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'gitlab':
return {
icon: <TaskProviderLogo provider="gitlab" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'branch':
return {
icon: <GitBranch size={16} color={colors.textSecondary} />,
title: row.localBranchName || row.refName,
subtitle: row.refName
}
case 'linear':
return {
icon: <TaskProviderLogo provider="linear" size={16} color={colors.textSecondary} />,
title: row.issue.title,
subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`,
status: row.issue.state?.name
}
default:
return { icon: <CaseSensitive size={16} color={colors.textSecondary} />, title: '' }
}
}
export function SmartWorkspaceSourceRow({ row, onPress }: Props) {
const content = resolveRowContent(row)
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.icon}>{content.icon}</View>
<View style={styles.copy}>
<Text style={styles.title} numberOfLines={1}>
{content.title}
</Text>
{content.subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{content.subtitle}
</Text>
) : null}
</View>
{content.status ? (
<View style={styles.pill}>
<Text style={styles.pillText} numberOfLines={1}>
{content.status}
</Text>
</View>
) : null}
</Pressable>
)
}
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'
}
})
@@ -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(
@@ -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.
@@ -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 (
<Modal
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onRequestClose}
>
<BottomDrawerModalHostContext.Provider value={true}>
{children}
</BottomDrawerModalHostContext.Provider>
</Modal>
)
}
@@ -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 })
})
})
@@ -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:
@@ -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<string, unknown>
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<string, unknown>
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)
})
})
@@ -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<WorktreeCreateResult> {
return createWorktreeWithNameRetry({
client: args.client,
baseName: args.baseName,
buildParams: (name) => {
const params: Record<string, unknown> = {
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
}
})
}
@@ -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<string, unknown>) => ({
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]
@@ -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 }
@@ -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<GitHubPrStartPoint> {
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<ComposerHostedBase> {
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
}
@@ -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<WorkspaceSourceLinkedItem, 'provider'> & {
provider: Exclude<WorkspaceSourceLinkedItem['provider'], 'jira'>
}
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<WorkspaceSourceSelection, 'kind'> & {
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 }
@@ -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')
})
})
@@ -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'
@@ -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'
+23
View File
@@ -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(
+16
View File
@@ -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<PersistedTrustedOrcaHooks> {
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 {
@@ -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<string, unknown> }
function fakeClient(byMethod: Record<string, unknown>, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
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: []
})
})
})
+141
View File
@@ -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<SmartFanOutResult, 'needsGitHubRemote' | 'error'> = {
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<SmartFanOutResult> {
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<GitHubWorkItem[]>([]),
tasks.gitlab ?? Promise.resolve<GitLabWorkItem[]>([]),
tasks.linear ?? Promise.resolve<LinearIssue[]>([]),
tasks.branches ?? Promise.resolve<BaseRefSearchResult[]>([])
])
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
}
}
@@ -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<string, { owner: string; repo: string } | null>()
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()
})
})
@@ -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<ReturnType<typeof parseGitLabIssueOrMRLink>>
}
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<string, RepoSlug | null>
): Promise<PasteRepoCandidate | null> {
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<GitHubWorkItem | null> {
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<GitHubWorkItem | null> {
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<ReturnType<typeof parseGitLabIssueOrMRLink>>
): Promise<GitLabWorkItem | null> {
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
}
@@ -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<string, unknown> }
function fakeClient(result: unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
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' })
})
})
@@ -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<GitHubWorkItem[]> {
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<GitLabWorkItem[]> {
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<LinearIssue[]> {
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<BaseRefSearchResult[]> {
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 }))
)
}
@@ -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<string, unknown> }
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<string, unknown> })
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 "<type>-<number>" 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'
})
})
})
+250
View File
@@ -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<WorktreeCreateResult> {
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<MobileComposerCreateSelection, { kind: 'work-item' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
}): Promise<WorktreeCreateResult> {
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<MobileComposerCreateSelection, { kind: 'branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
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<string, unknown>): Record<string, unknown> => {
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<string, unknown> = {
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<MobileComposerCreateSelection, { kind: 'new-branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
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<string, unknown> = {
repo: `id:${targetRepoId}`,
name: candidate,
setupDecision,
branchNameOverride: candidate,
startupCommand: agent.startupCommand
}
if (createdWithAgentId) {
params.createdWithAgent = createdWithAgentId
}
if (comment) {
params.comment = comment
}
return params
}
})
}
@@ -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<MobileLinkedWorkItem | null>(null)
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
const [forkPushWarning, setForkPushWarning] = useState<string | null>(null)
const [resolvingBase, setResolvingBase] = useState(false)
// Set when the "Create branch <name>" 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<ComposerHostedBase>) => {
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>": 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<SmartNameSelection | null>(
() => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }),
[base.baseBranch, linkedWorkItem]
)
const createSelection = useMemo<MobileComposerCreateSelection | null>(
() =>
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<typeof useMobileComposerSource>
@@ -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<SmartFanOutResult>(EMPTY_FAN)
const [paste, setPaste] = useState<PasteResolved>({ github: null, gitlab: null })
const [loading, setLoading] = useState(false)
const [crossRepoPrompt, setCrossRepoPrompt] = useState<SmartCrossRepoPrompt | null>(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<string>('')
const repoSlugCacheRef = useRef<Map<string, { owner: string; repo: string } | null>>(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<SmartWorkspaceSourceRow[]>(
() =>
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<string, { owner: string; repo: string } | null>
}): 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 }
}
@@ -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)
})
})
@@ -0,0 +1 @@
export * from '../../../src/shared/new-workspace/work-item-lookup-text'
@@ -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'
+43 -18
View File
@@ -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
}
}
+46
View File
@@ -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<string, unknown>
maxAttempts?: number
}): Promise<WorktreeCreateResult> {
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' }
}
@@ -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' })
})
})
-296
View File
@@ -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 015 written as `38;5;N` using classic SGR 3037/9097, 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=<n>`: re-run exactly one seed for a repro (both suites).
-532
View File
@@ -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.815.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=<path>` 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 <build>`
— 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 10k50k silently lost older history
on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback
option and clamps it through the shared 050,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.
-152
View File
@@ -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.
-583
View File
@@ -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 (12 GB).
5. Battery usage is high.
Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling
latency down significantly; lower memory with 01 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 535 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 510 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 <machine>-<date>
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 35
(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 ~210 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 ~350770 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 27117 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
"<bench> --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 ≈ 700790 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 27117 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.27.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.22.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 3948 MB/s ingest runs executed while a dev
app was still running. On a quiet machine the revive branch ingests at
**82109 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 01 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.
-306
View File
@@ -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] <event>` 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 <userData> /grant:r <user>:(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 <userData> /grant:r <user>:(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 <userData>\* /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] <event>` 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=<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.
+1 -1
View File
@@ -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",
+1
View File
@@ -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'}`,
+133
View File
@@ -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<string, string | undefined>
// 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<void> {
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')
}
)
})
+7 -1
View File
@@ -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<string, string> {
// 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)
)
}
+89 -2
View File
@@ -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<ReturnType<typeof createServer>>()
const sockets = new Set<Socket>()
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<void>((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<void>((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 () => {
+30 -5
View File
@@ -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<RuntimeRpcSuccess<CliStatusResult>> {
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,
+1 -1
View File
@@ -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
+16 -1
View File
@@ -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<CliStatusResult> {
return {
id: 'local-status',
+31 -1
View File
@@ -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<TestRuntime> {
const serverKeyPair = generateKeyPair()
@@ -177,6 +206,7 @@ async function startTestRuntime(
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: null,
desktopWindowStatus: statusOverrides.desktopWindowStatus,
liveTabCount: 0,
liveLeafCount: 0,
runtimeProtocolVersion:
+5 -10
View File
@@ -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
@@ -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:<uuid>` 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)
@@ -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:<uuid>` 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')
}
@@ -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> = {}): 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)
@@ -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<boolean> {
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
const parsed = splitWorktreeId(worktreeId)
// Why: oldWorktreePath feeds an on-disk folder move. Resolve the synthetic
// `::workspace:<uuid>` 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,
@@ -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')
})
+3 -1
View File
@@ -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)
+8 -10
View File
@@ -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) => {
+3 -1
View File
@@ -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
+31 -2
View File
@@ -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', () => {
+28 -8
View File
@@ -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
`
}
@@ -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<string, string> }).ptyBuffers
+23 -3
View File
@@ -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<string, string>()
private readonly ptyBuffers = new Map<string, string>()
private readonly seenExternalKeys = new Set<string>()
@@ -81,8 +87,12 @@ export class ServeSimStateWatcher {
private stateWatcher: FSWatcher | null = null
private stateDirPoll: ReturnType<typeof setInterval> | 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.
}
}
+80
View File
@@ -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({
+48 -8
View File
@@ -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' }
)
+47 -18
View File
@@ -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<GitHubWorkItem, 'repoId'>
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:<DATE to
// fetch items older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor.
// Why: cursor-based pagination. GitHub search supports updated:<=DATE to
// fetch items at or older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor. The bound is inclusive (`<=`) so items
// sharing the boundary row's exact updatedAt aren't skipped between pages; the
// renderer dedupes the re-fetched boundary rows by id (#8649).
if (before) {
searchParts.push(`updated:<${before}`)
searchParts.push(`updated:<=${before}`)
}
if (kind === 'pr' && query.reviewRequested) {
searchParts.push(`review-requested:${query.reviewRequested}`)
@@ -1010,9 +1019,14 @@ function buildWorkItemListArgs(args: {
if (query.freeText) {
searchParts.push(query.freeText)
}
if (searchParts.length > 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,
@@ -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 <host>` 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)
})
})
@@ -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<string, HostAuthCacheEntry>()
// 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 <host>` 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<boolean> {
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<GitHubEnterpriseRepoSlug | null> {
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
}
+67 -58
View File
@@ -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<void> = 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 <unpacked CLI entry>) 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<void> {
function startTerminalRuntimeStartupServices(): Promise<void> {
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<void> {
// 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<void> {
return firstWindowStartupServicesReady
}
async function startServeAgentHookServer(): Promise<void> {
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', () => {
@@ -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<string, (_event: unknown, args: unknown) => Promise<unknown> | 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<unknown>
const second = handlers['fs:watchWorktree']({ sender: senderTwo }, args) as Promise<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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<unknown>
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)
})
})
+6 -2
View File
@@ -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' }
+95 -26
View File
@@ -652,22 +652,31 @@ type RemoteWatcherState = {
type RemoteWatcherInstallToken = {
cancelled: boolean
listeners: Map<number, WebContents>
abortController: AbortController
abortScheduled: boolean
}
// Key: `${connectionId}:${worktreePath}`, Value: shared remote watch state.
const remoteWatchers = new Map<string, RemoteWatcherState>()
const loggedUnavailableRemoteWatchers = new Set<string>()
const pendingRemoteWatcherRetries = new Map<string, ReturnType<typeof setTimeout>>()
// 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<string, RemoteWatcherInstallToken>()
// 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<string, Promise<RemoteWatcherInstallResult>>()
// 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<RemoteWatcherInstallResult> {
// 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<RemoteWatcherInstallResult> {
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<void> => {
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<void> {
}
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()
@@ -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,
+28 -16
View File
@@ -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
@@ -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 ?? ''}`
}
}
+6 -2
View File
@@ -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:<uuid>` 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
}
@@ -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()
})
})
+4 -1
View File
@@ -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 {
+230 -10
View File
@@ -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<string, { commandCodeDetector: unknown }>
}
).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 {
+159 -13
View File
@@ -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<string, string>) | 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<string, string>
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<RuntimeTerminalCreate> {
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()
+75
View File
@@ -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({
+8 -5
View File
@@ -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[]
}

Some files were not shown because too many files have changed in this diff Show More